Repository: kiali/kiali

Issue:
Add configuration option to specify Kiali's public port (OIDC support)
**Describe the bug**

When using Kiali behind an Istio VirtualService (or likely a Kubernetes Ingress too), the openid login button redirects me to `<schema>://<web_fqdn>:20001`. Since I'm terminating all ingress on port 443, this behaviour breaks my ability to use openid.

**Versions used**
Kiali: 1.22
Istio: 1.6
Kubernetes flavour and version: (e.g. OpenShift Origin 3.9) Kubernetes 1.18.6 bare-meal

**To Reproduce**
Steps to reproduce the behavior:
1. Configure Kiali behind an Ingress with a public FQDN (in my case, `kiali.elpenguino.net`)
2. Configure openid auth
3. At the initial screen, click "Log in with OpenID"
4. You are redirected to `https://kiali.elpenguino.net:20001/kiali/api/auth/openid_redirect`

**Expected behavior**

I should be able to override the URL to remove the `:20001` component. This should also apply to the redirect_url passed during the OIDC login process.


Discussion:
@israel-hdez: @funkypenguin Can you provide an example of the VirtualService configuration?
I'm interested in all the related CRs, if you are using Gateways, DestinationRules, etc as part of the config, please also provide them.@israel-hdez: @funkypenguin As a workaround, if you are using VirtualServices, you could add a `X-Forwarded-Port` header with the right port you need. See: https://istio.io/latest/docs/reference/config/networking/virtual-service/#Headers

If you are using an Ingress, it *may* also offer header injection.@funkypenguin: Thanks @israel-hdez, here's the VirtualService:

```
spec:
  gateways:
  - istio-ingressgateway.istio-system.svc.cluster.local
  hosts:
  - kiali.elpenguino.net
  http:
  - route:
    - destination:
        host: kiali
        port:
          number: 20001
    timeout: 30s
```

Re the workaround with the header, I'll give it a try ;)

D@funkypenguin: OK, looks like this fixed the hard-coded port issue:

```
spec:
  gateways:
  - istio-ingressgateway.istio-system.svc.cluster.local
  hosts:
  - kiali.elpenguino.net
  http:
  - headers:
      request:
        set:
          X-Forwarded-Port: "443"
    route:
    - destination:
        host: kiali
        port:
          number: 20001
    timeout: 30s
```

I can now log into KeyCloak, and I get successfully redirected back to Kiali's login page.

Next step to work out how to correlate the returning user details with what kube-api expects (kube-api is using `"oidc-username-prefix": "oidc:"`). Another day, another issue. Thanks! :)@jmazzitelli: @israel-hdez this sounds like something we should put in the FAQ: https://kiali.io/documentation/latest/faq/#token-strategy
I'll add that, unless you think we should solve this in the code and not require `X-Forwarded-Port`@funkypenguin: I think the `X-Forwarded-Port` trick is a neat workaround, but I'd appreciate being able to set this value, just like I can set `web_fqdn`, via the config :)

(re-opening this issue since it's still under discussion)@jmazzitelli: FAQ PR: https://github.com/kiali/kiali.io/pull/291
See: https://deploy-preview-291--kiali.netlify.app/documentation/staging/faq/#x-forwarded-port@israel-hdez: IMO, it's a little odd to see that you can configure the hostname via the `web_fqdn` configuration and also configure the protocol via the `web_schema`, but not the port. So, I think we can leave this open with the aim for a more complete config.

I'll relabel as `enhancement`. For now, I think the workaround and the new FAQ entry would be quite fine.

Don't expect this to be implemented soon, though, as there are other priorities ;)@jshaughn: @israel-hdez I'll add to the backlog, let me know if and when we should prioritize.@israel-hdez: @cfcosta wanna help with this?@cfcosta: @israel-hdez sounds good, let me take a look.@juliusoh: I'm still running into issue 

```
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: kiali
  namespace: gateway
spec:
  parentRefs:
    - name: 'istio-public'
      namespace: gateway
      sectionName: https
  hostnames:
    - ${hostname}
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /kiali
      filters:
        - type: RequestHeaderModifier
          requestHeaderModifier:
            set:
              - name: X-Forwarded-Port
                value: '443'
              - name: X-Forwarded-Proto
                value: 'https'
              - name: X-Forwarded-Host
                value: '${hostname}'
      backendRefs:
        - name: kiali
          namespace: istio-system
          port: 20001
    - matches:
        - path:
            type: Exact
            value: /
      filters:
        - type: RequestRedirect
          requestRedirect:
            path:
              type: ReplaceFullPath
              replaceFullPath: /kiali/console
            statusCode: 302

```

Solution patch:
diff --git a/Makefile b/Makefile
index dd7a40892..fc9d1014d 100644
--- a/Makefile
+++ b/Makefile
@@ -7,7 +7,7 @@ OUTDIR=${ROOTDIR}/_output
 
 # Identifies the current build.
 # These will be embedded in the app and displayed when it starts.
-VERSION ?= v1.26.0-SNAPSHOT
+VERSION ?= v1.27.0-SNAPSHOT
 COMMIT_HASH ?= $(shell git rev-parse HEAD)
 
 # Indicates which version of the UI console is to be embedded
@@ -22,7 +22,7 @@ CONSOLE_LOCAL_DIR ?= ${ROOTDIR}/../../../../../kiali-ui
 
 # Version label is used in the OpenShift/K8S resources to identify
 # their specific instances. Kiali resources will have labels of
-# "app: kiali" and "version: ${VERSION_LABEL}"
+# "app.kubernetes.io/name: kiali" and "app.kubernetes.io/version: ${VERSION_LABEL}"
 # The default is the VERSION itself.
 VERSION_LABEL ?= ${VERSION}
 
@@ -110,7 +110,6 @@ AUTH_STRATEGY ?= openshift
 endif
 KIALI_IMAGE_PULL_POLICY ?= Always
 SERVICE_TYPE ?= ClusterIP
-VERBOSE_MODE ?= 3
 KIALI_CR_SPEC_VERSION ?= default
 
 # Determine if Maistra/ServiceMesh is deployed. If not, assume we are working with upstream Istio.
diff --git a/README.adoc b/README.adoc
index 61c77996d..e88c6ccbb 100644
--- a/README.adoc
+++ b/README.adoc
@@ -477,7 +477,7 @@ spec:
 
 === Observing a Remote Cluster
 
-There are certain use cases where Kiali needs to be deployed in one cluster (Control Plane) and observe a different cluster (Data Plane). link:https://user-images.githubusercontent.com/6889074/87819080-ad099980-c839-11ea-834b-56eec038ce4d.png:[Diagram].
+There are certain use cases where Kiali needs to be deployed in one cluster (Control Plane) and observe a different cluster (Data Plane). link:https://user-images.githubusercontent.com/6889074/87819080-ad099980-c839-11ea-834b-56eec038ce4d.png[Diagram].
 
 Follow these steps:
 
diff --git a/business/apps.go b/business/apps.go
index 8a2a4d67f..649005185 100644
--- a/business/apps.go
+++ b/business/apps.go
@@ -122,8 +122,7 @@ func (in *AppService) GetApp(namespace string, appName string) (models.App, erro
 	for _, workload := range appDetails.Workloads {
 		pods = append(pods, workload.Pods...)
 	}
-	dash := NewDashboardsService(in.prom)
-	(*appInstance).Runtimes = dash.GetCustomDashboardRefs(namespace, appName, "", pods)
+	(*appInstance).Runtimes = NewDashboardsService().GetCustomDashboardRefs(namespace, appName, "", pods)
 
 	return *appInstance, nil
 }
diff --git a/business/dashboards.go b/business/dashboards.go
index b9c831807..4ade91720 100644
--- a/business/dashboards.go
+++ b/business/dashboards.go
@@ -22,17 +22,16 @@ import (
 // DashboardsService deals with fetching dashboards from k8s client
 type DashboardsService struct {
 	delegate *kbus.DashboardsService
-	prom     prometheus.ClientInterface
 }
 
 // NewDashboardsService initializes this business service
-func NewDashboardsService(prom prometheus.ClientInterface) DashboardsService {
+func NewDashboardsService() *DashboardsService {
 	cfg, lg, enabled := DashboardsConfig()
 	if !enabled {
-		return DashboardsService{delegate: nil, prom: prom}
+		return &DashboardsService{delegate: nil}
 	}
 	delegate := kbus.NewDashboardsService(cfg, lg)
-	return DashboardsService{delegate: &delegate, prom: prom}
+	return &DashboardsService{delegate: &delegate}
 }
 
 func DashboardsConfig() (kconf.Config, klog.LogAdapter, bool) {
@@ -181,16 +180,15 @@ func getIstioCharts() []istioChart {
 }
 
 // GetIstioDashboard returns Istio dashboard (currently hard-coded) filled-in with metrics
-func (in *DashboardsService) GetIstioDashboard(params prometheus.IstioMetricsQuery) (*kmodel.MonitoringDashboard, error) {
+func (in *DashboardsService) BuildIstioDashboard(metrics models.Metrics, direction string) (*kmodel.MonitoringDashboard, error) {
 	var dashboard kmodel.MonitoringDashboard
 	// Copy dashboard
-	if params.Direction == "inbound" {
+	if direction == "inbound" {
 		dashboard = models.PrepareIstioDashboard("Inbound", "destination", "source")
 	} else {
 		dashboard = models.PrepareIstioDashboard("Outbound", "source", "destination")
 	}
 
-	metrics := in.prom.GetMetrics(&params)
 	istioCharts := getIstioCharts()
 
 	for _, chartTpl := range istioCharts {
diff --git a/business/iter8.go b/business/iter8.go
index 65ad369d9..77693c702 100644
--- a/business/iter8.go
+++ b/business/iter8.go
@@ -359,7 +359,7 @@ func (in *Iter8Service) ParseJsonForCreate(body []byte) (string, error) {
 	return string(b), nil
 }
 
-func (in *Iter8Service) buildStringMatch(mr models.HTTPMarchRule) *kubernetes.StringMatch {
+func (in *Iter8Service) buildStringMatch(mr models.HTTPMatchRule) *kubernetes.StringMatch {
 	uri := kubernetes.StringMatch{}
 	stringMatch := mr.StringMatch
 	switch mr.Match {
@@ -380,7 +380,7 @@ func (in *Iter8Service) ParseMatchRule(http []models.HTTPMatchRequest) []*kubern
 	for i, m := range http {
 		nm := kubernetes.HTTPMatchRequest{}
 
-		if (m.URI != models.HTTPMarchRule{}) {
+		if (m.URI != models.HTTPMatchRule{}) {
 			nm.URI = in.buildStringMatch(m.URI) // &uri
 		}
 		if len(m.Headers) > 0 {
diff --git a/business/metrics.go b/business/metrics.go
new file mode 100644
index 000000000..7169d4b02
--- /dev/null
+++ b/business/metrics.go
@@ -0,0 +1,207 @@
+package business
+
+import (
+	"math"
+	"sort"
+	"strings"
+	"sync"
+
+	"github.com/kiali/kiali/models"
+	"github.com/kiali/kiali/prometheus"
+)
+
+// MetricsService deals with fetching metrics from prometheus
+type MetricsService struct {
+	prom prometheus.ClientInterface
+}
+
+// NewMetricsService initializes this business service
+func NewMetricsService(prom prometheus.ClientInterface) *MetricsService {
+	return &MetricsService{prom: prom}
+}
+
+func (in *MetricsService) GetMetrics(q models.IstioMetricsQuery) models.Metrics {
+	lb := createMetricsLabelsBuilder(&q)
+	grouping := strings.Join(q.ByLabels, ",")
+	return in.fetchAllMetrics(q, lb, grouping)
+}
+
+func createMetricsLabelsBuilder(q *models.IstioMetricsQuery) *MetricsLabelsBuilder {
+	lb := NewMetricsLabelsBuilder(q.Direction)
+	lb.Reporter(q.Reporter)
+
+	namespaceSet := false
+	if q.Service != "" {
+		lb.Service(q.Service, q.Namespace)
+		namespaceSet = true
+	}
+	if q.Workload != "" {
+		lb.Workload(q.Workload, q.Namespace)
+		namespaceSet = true
+	}
+	if q.App != "" {
+		lb.App(q.App, q.Namespace)
+		namespaceSet = true
+	}
+	if !namespaceSet && q.Namespace != "" {
+		lb.Namespace(q.Namespace)
+	}
+	if q.RequestProtocol != "" {
+		lb.Protocol(q.RequestProtocol)
+	}
+	if q.Aggregate != "" {
+		lb.Aggregate(q.Aggregate, q.AggregateValue)
+	}
+	return lb
+}
+
+func (in *MetricsService) fetchAllMetrics(q models.IstioMetricsQuery, lb *MetricsLabelsBuilder, grouping string) models.Metrics {
+	labels := lb.Build()
+	labelsError := lb.BuildForErrors()
+
+	var wg sync.WaitGroup
+	fetchRate := func(p8sFamilyName string, metric **prometheus.Metric, lbl []string) {
+		defer wg.Done()
+		m := in.prom.FetchRateRange(p8sFamilyName, lbl, grouping, &q.RangeQuery)
+		*metric = m
+	}
+
+	fetchHisto := func(p8sFamilyName string, histo *prometheus.Histogram) {
+		defer wg.Done()
+		h := in.prom.FetchHistogramRange(p8sFamilyName, labels, grouping, &q.RangeQuery)
+		*histo = h
+	}
+
+	type resultHolder struct {
+		metric     *prometheus.Metric
+		histo      prometheus.Histogram
+		definition istioMetric
+	}
+	maxResults := len(istioMetrics)
+	if len(q.Filters) != 0 {
+		maxResults = len(q.Filters)
+	}
+	results := make([]*resultHolder, maxResults)
+
+	for _, istioMetric := range istioMetrics {
+		// if filters is empty, fetch all anyway
+		doFetch := len(q.Filters) == 0
+		if !doFetch {
+			for _, filter := range q.Filters {
+				if filter == istioMetric.kialiName {
+					doFetch = true
+					break
+				}
+			}
+		}
+		if doFetch {
+			wg.Add(1)
+			result := resultHolder{definition: istioMetric}
+			results = append(results, &result)
+			if istioMetric.isHisto {
+				go fetchHisto(istioMetric.istioName, &result.histo)
+			} else {
+				labelsToUse := istioMetric.labelsToUse(labels, labelsError)
+				go fetchRate(istioMetric.istioName, &result.metric, labelsToUse)
+			}
+		}
+	}
+	wg.Wait()
+
+	// Return results as two maps per reporter
+	metrics := make(map[string]*prometheus.Metric)
+	histograms := make(map[string]prometheus.Histogram)
+	for _, result := range results {
+		if result != nil {
+			if result.definition.isHisto {
+				histograms[result.definition.kialiName] = result.histo
+			} else {
+				metrics[result.definition.kialiName] = result.metric
+			}
+		}
+	}
+	return models.Metrics{
+		Metrics:    metrics,
+		Histograms: histograms,
+	}
+}
+
+// GetStats computes metrics stats, currently response times, for a set of queries
+func (in *MetricsService) GetStats(queries []models.MetricsStatsQuery) (map[string]models.MetricsStats, error) {
+	type statsChanResult struct {
+		key   string
+		stats *models.MetricsStats
+		err   error
+	}
+
+	statsChan := make(chan statsChanResult, len(queries))
+	var wg sync.WaitGroup
+
+	for _, q := range queries {
+		wg.Add(1)
+		go func(q models.MetricsStatsQuery) {
+			defer wg.Done()
+			stats, err := in.getSingleQueryStats(&q)
+			statsChan <- statsChanResult{key: q.GenKey(), stats: stats, err: err}
+		}(q)
+	}
+	wg.Wait()
+	// All stats are fetched, close channel
+	close(statsChan)
+	// Read channel
+	result := make(map[string]models.MetricsStats)
+	for r := range statsChan {
+		if r.err != nil {
+			return nil, r.err
+		}
+		if r.stats != nil {
+			result[r.key] = *r.stats
+		}
+	}
+	return result, nil
+}
+
+func (in *MetricsService) getSingleQueryStats(q *models.MetricsStatsQuery) (*models.MetricsStats, error) {
+	lb := createStatsMetricsLabelsBuilder(q)
+	labels := lb.Build()
+	stats, err := in.prom.FetchHistogramValues("istio_request_duration_milliseconds", labels, "", q.Interval, q.Avg, q.Quantiles, q.QueryTime)
+	if err != nil {
+		return nil, err
+	}
+	metricsStats := models.MetricsStats{}
+	for stat, vec := range stats {
+		for _, sample := range vec {
+			value := float64(sample.Value)
+			if math.IsNaN(value) {
+				continue
+			}
+			metricsStats.ResponseTimes = append(metricsStats.ResponseTimes, models.Stat{Name: stat, Value: value})
+		}
+	}
+	sort.Slice(metricsStats.ResponseTimes, func(i, j int) bool {
+		return metricsStats.ResponseTimes[i].Name < metricsStats.ResponseTimes[j].Name
+	})
+	return &metricsStats, nil
+}
+
+func createStatsMetricsLabelsBuilder(q *models.MetricsStatsQuery) *MetricsLabelsBuilder {
+	lb := NewMetricsLabelsBuilder(q.Direction)
+	lb.SelfReporter()
+	if q.Target.Kind == "app" {
+		lb.App(q.Target.Name, q.Target.Namespace)
+	} else if q.Target.Kind == "workload" {
+		lb.Workload(q.Target.Name, q.Target.Namespace)
+	} else if q.Target.Kind == "service" {
+		lb.Service(q.Target.Name, q.Target.Namespace)
+	}
+	if q.PeerTarget != nil {
+		if q.PeerTarget.Kind == "app" {
+			lb.PeerApp(q.PeerTarget.Name, q.PeerTarget.Namespace)
+		} else if q.PeerTarget.Kind == "workload" {
+			lb.PeerWorkload(q.PeerTarget.Name, q.PeerTarget.Namespace)
+		} else if q.PeerTarget.Kind == "service" {
+			lb.PeerService(q.PeerTarget.Name, q.PeerTarget.Namespace)
+		}
+	}
+	return lb
+}
diff --git a/prometheus/metrics_definitions.go b/business/metrics_definitions.go
similarity index 98%
rename from prometheus/metrics_definitions.go
rename to business/metrics_definitions.go
index a36a53cfd..0aae5282d 100644
--- a/prometheus/metrics_definitions.go
+++ b/business/metrics_definitions.go
@@ -1,4 +1,4 @@
-package prometheus
+package business
 
 type istioMetric struct {
 	kialiName      string
diff --git a/business/metrics_labels_builder.go b/business/metrics_labels_builder.go
new file mode 100644
index 000000000..75ab4dc0b
--- /dev/null
+++ b/business/metrics_labels_builder.go
@@ -0,0 +1,142 @@
+package business
+
+import (
+	"fmt"
+	"strings"
+)
+
+const (
+	destination                = "destination"
+	source                     = "source"
+	regexGrpcResponseStatusErr = "^[1-9]$|^1[0-6]$"
+	regexResponseCodeErr       = "^0$|^[4-5]\\\\d\\\\d$"
+)
+
+type MetricsLabelsBuilder struct {
+	side     string
+	peerSide string
+	protocol string
+	labelsKV []string
+}
+
+func NewMetricsLabelsBuilder(direction string) *MetricsLabelsBuilder {
+	side := destination
+	peerSide := source
+	if direction == "outbound" {
+		side = source
+		peerSide = destination
+	}
+	return &MetricsLabelsBuilder{
+		side:     side,
+		peerSide: peerSide,
+	}
+}
+
+func (lb *MetricsLabelsBuilder) Add(key, value string) *MetricsLabelsBuilder {
+	lb.labelsKV = append(lb.labelsKV, fmt.Sprintf(`%s="%s"`, key, value))
+	return lb
+}
+
+func (lb *MetricsLabelsBuilder) addSided(partialKey, value, side string) *MetricsLabelsBuilder {
+	lb.labelsKV = append(lb.labelsKV, fmt.Sprintf(`%s_%s="%s"`, side, partialKey, value))
+	return lb
+}
+
+func (lb *MetricsLabelsBuilder) Reporter(name string) *MetricsLabelsBuilder {
+	return lb.Add("reporter", name)
+}
+
+func (lb *MetricsLabelsBuilder) SelfReporter() *MetricsLabelsBuilder {
+	return lb.Add("reporter", lb.side)
+}
+
+func (lb *MetricsLabelsBuilder) Service(name, namespace string) *MetricsLabelsBuilder {
+	if lb.side == destination {
+		lb.Add("destination_service_name", name)
+		if namespace != "" {
+			lb.Add("destination_service_namespace", namespace)
+		}
+	}
+	return lb
+}
+
+func (lb *MetricsLabelsBuilder) Namespace(namespace string) *MetricsLabelsBuilder {
+	return lb.addSided("workload_namespace", namespace, lb.side)
+}
+
+func (lb *MetricsLabelsBuilder) Workload(name, namespace string) *MetricsLabelsBuilder {
+	if namespace != "" {
+		lb.addSided("workload_namespace", namespace, lb.side)
+	}
+	return lb.addSided("workload", name, lb.side)
+}
+
+func (lb *MetricsLabelsBuilder) App(name, namespace string) *MetricsLabelsBuilder {
+	if namespace != "" {
+		// workload_namespace works for app as well
+		lb.addSided("workload_namespace", namespace, lb.side)
+	}
+	return lb.addSided("canonical_service", name, lb.side)
+}
+
+func (lb *MetricsLabelsBuilder) PeerService(name, namespace string) *MetricsLabelsBuilder {
+	if lb.peerSide == destination {
+		lb.Add("destination_service_name", name)
+		if namespace != "" {
+			lb.Add("destination_service_namespace", namespace)
+		}
+	}
+	return lb
+}
+
+func (lb *MetricsLabelsBuilder) PeerNamespace(namespace string) *MetricsLabelsBuilder {
+	return lb.addSided("workload_namespace", namespace, lb.peerSide)
+}
+
+func (lb *MetricsLabelsBuilder) PeerWorkload(name, namespace string) *MetricsLabelsBuilder {
+	if namespace != "" {
+		lb.addSided("workload_namespace", namespace, lb.peerSide)
+	}
+	return lb.addSided("workload", name, lb.peerSide)
+}
+
+func (lb *MetricsLabelsBuilder) PeerApp(name, namespace string) *MetricsLabelsBuilder {
+	if namespace != "" {
+		// workload_namespace works for app as well
+		lb.addSided("workload_namespace", namespace, lb.peerSide)
+	}
+	return lb.addSided("canonical_service", name, lb.peerSide)
+}
+
+func (lb *MetricsLabelsBuilder) Protocol(name string) *MetricsLabelsBuilder {
+	lb.protocol = strings.ToLower(name)
+	return lb.Add("request_protocol", name)
+}
+
+func (lb *MetricsLabelsBuilder) Aggregate(key, value string) *MetricsLabelsBuilder {
+	return lb.Add(key, value)
+}
+
+func (lb *MetricsLabelsBuilder) Build() string {
+	return "{" + strings.Join(lb.labelsKV, ",") + "}"
+}
+
+func (lb *MetricsLabelsBuilder) BuildForErrors() []string {
+	errors := []string{}
+
+	// both http and grpc requests can suffer from no response (response_code=0) or an http error
+	// (response_code=4xx,5xx), and so we always perform a query against response_code:
+	httpLabels := append(lb.labelsKV, fmt.Sprintf(`response_code=~"%s"`, regexResponseCodeErr))
+	errors = append(errors, "{"+strings.Join(httpLabels, ",")+"}")
+
+	// if necessary also look for grpc errors. note that the grpc test intentionally avoids
+	// `grpc_response_status!="0"`. We need to be backward compatible and handle the case where
+	// grpc_response_status does not exist, or if it is simply unset. In Prometheus, negative tests on a
+	// non-existent label match everything, but positive tests match nothing. So, we stay positive.
+	// furthermore, make sure we only count grpc errors with successful http status.
+	if lb.protocol != "http" {
+		grpcLabels := append(lb.labelsKV, fmt.Sprintf(`grpc_response_status=~"%s",response_code!~"%s"`, regexGrpcResponseStatusErr, regexResponseCodeErr))
+		errors = append(errors, ("{" + strings.Join(grpcLabels, ",") + "}"))
+	}
+	return errors
+}
diff --git a/business/openid_auth.go b/business/openid_auth.go
index 5dc6db661..9a57cddc6 100644
--- a/business/openid_auth.go
+++ b/business/openid_auth.go
@@ -16,9 +16,12 @@ import (
 	"time"
 
 	"github.com/dgrijalva/jwt-go"
+	"golang.org/x/sync/singleflight"
+	"gopkg.in/square/go-jose.v2"
 
 	"github.com/kiali/kiali/config"
 	"github.com/kiali/kiali/log"
+	"github.com/kiali/kiali/util"
 )
 
 const (
@@ -50,7 +53,9 @@ type OpenIdCallbackParams struct {
 	Subject       string
 }
 
+var cachedOpenIdKeySet *jose.JSONWebKeySet
 var cachedOpenIdMetadata *OpenIdMetadata
+var openIdFlightGroup singleflight.Group
 
 func BuildOpenIdJwtClaims(openIdParams *OpenIdCallbackParams) *config.IanaClaims {
 	return &config.IanaClaims{
@@ -141,28 +146,15 @@ func ParseOpenIdToken(openIdParams *OpenIdCallbackParams) error {
 	idTokenClaims := parsedIdToken.Claims.(jwt.MapClaims)
 
 	// Set a default value for expiration date
-	openIdParams.ExpiresOn = time.Now().Add(time.Second * time.Duration(config.Get().LoginToken.ExpirationSeconds))
-
-	// If the expiration date is present on the claim, we use that
-	expiresInNumber := int64(0)
-
-	// As it turns out, the response from the exp claim can be either a f64 and
-	// a json.Number. With this, we take care of it, converting to the int64
-	// that we need to use timestamps in go.
-	switch exp := idTokenClaims["exp"].(type) {
-	case float64:
-		// This can not fail
-		expiresInNumber = int64(exp)
-	case json.Number:
-		// This can fail, so we short-circuit if we get an invalid value.
-		expiresInNumber, err = exp.Int64()
-
+	if expClaim, ok := idTokenClaims["exp"]; !ok {
+		return errors.New("the received id_token from the OpenId provider has missing the required 'exp' claim")
+	} else {
+		// If the expiration date is present on the claim, we use that
+		expiresInNumber, err := parseTimeClaim(expClaim)
 		if err != nil {
 			return fmt.Errorf("token exp claim is present, but invalid: %w", err)
 		}
-	}
 
-	if expiresInNumber != 0 {
 		openIdParams.ExpiresOn = time.Unix(expiresInNumber, 0)
 	}
 
@@ -196,6 +188,39 @@ func GetConfiguredOpenIdScopes() []string {
 	return scopes
 }
 
+func GetJwkFromKeySet(keyId string) (*jose.JSONWebKey, error) {
+	// Helper function to find a key with a certain key id in a key-set.
+	var findJwkFunc = func(kid string, jwks *jose.JSONWebKeySet) *jose.JSONWebKey {
+		for _, key := range jwks.Keys {
+			if key.KeyID == kid {
+				return &key
+			}
+		}
+		return nil
+	}
+
+	if cachedOpenIdKeySet != nil {
+		// If key-set is cached, try to find the key in the cached key-set
+		foundKey := findJwkFunc(keyId, cachedOpenIdKeySet)
+		if foundKey != nil {
+			return foundKey, nil
+		}
+	}
+
+	// If key-set is not cached, or if the requested key was not found in the
+	// cached key-set, then fetch/refresh the key-set from the OpenId provider
+	keySet, err := GetOpenIdJwks()
+	if err != nil {
+		return nil, err
+	}
+
+	// Try to find the key in the fetched key-set
+	foundKey := findJwkFunc(keyId, keySet)
+
+	// "foundKey" can be nil. That's acceptable if the key-set does not contain the requested key id
+	return foundKey, nil
+}
+
 func GetOpenIdAesSession(r *http.Request) (*config.IanaClaims, error) {
 	authCookie, err := r.Cookie(config.TokenCookieName + "-aes")
 	if err != nil {
@@ -247,86 +272,150 @@ func GetOpenIdMetadata() (*OpenIdMetadata, error) {
 		return cachedOpenIdMetadata, nil
 	}
 
-	cfg := config.Get().Auth.OpenId
+	fetchedMetadata, fetchError, _ := openIdFlightGroup.Do("metadata", func() (interface{}, error) {
+		cfg := config.Get().Auth.OpenId
 
-	// Remove trailing slash from issuer URI, if needed
-	trimmedIssuerUri := strings.TrimRight(cfg.IssuerUri, "/")
+		// Remove trailing slash from issuer URI, if needed
+		trimmedIssuerUri := strings.TrimRight(cfg.IssuerUri, "/")
 
-	// Create HTTP client
-	httpTransport := &http.Transport{}
-	if cfg.InsecureSkipVerifyTLS {
-		httpTransport.TLSClientConfig = &tls.Config{
-			InsecureSkipVerify: true,
+		// Create HTTP client
+		httpTransport := &http.Transport{}
+		if cfg.InsecureSkipVerifyTLS {
+			httpTransport.TLSClientConfig = &tls.Config{
+				InsecureSkipVerify: true,
+			}
 		}
-	}
 
-	httpClient := http.Client{
-		Timeout:   time.Second * 10,
-		Transport: httpTransport,
-	}
+		httpClient := http.Client{
+			Timeout:   time.Second * 10,
+			Transport: httpTransport,
+		}
 
-	// Fetch IdP metadata
-	response, err := httpClient.Get(trimmedIssuerUri + "/.well-known/openid-configuration")
-	if err != nil {
-		return nil, err
-	}
+		// Fetch IdP metadata
+		response, err := httpClient.Get(trimmedIssuerUri + "/.well-known/openid-configuration")
+		if err != nil {
+			return nil, err
+		}
 
-	defer response.Body.Close()
-	if response.StatusCode != 200 {
-		return nil, fmt.Errorf("cannot fetch OpenId Metadata (HTTP response status = %s)", response.Status)
-	}
+		defer response.Body.Close()
+		if response.StatusCode != 200 {
+			return nil, fmt.Errorf("cannot fetch OpenId Metadata (HTTP response status = %s)", response.Status)
+		}
 
-	// Parse JSON document
-	var metadata OpenIdMetadata
+		// Parse JSON document
+		var metadata OpenIdMetadata
 
-	rawMetadata, err := ioutil.ReadAll(response.Body)
-	if err != nil {
-		return nil, fmt.Errorf("failed to read OpenId Metadata: %s", err.Error())
-	}
+		rawMetadata, err := ioutil.ReadAll(response.Body)
+		if err != nil {
+			return nil, fmt.Errorf("failed to read OpenId Metadata: %s", err.Error())
+		}
 
-	err = json.Unmarshal(rawMetadata, &metadata)
-	if err != nil {
-		return nil, fmt.Errorf("cannot parse OpenId Metadata: %s", err.Error())
-	}
+		err = json.Unmarshal(rawMetadata, &metadata)
+		if err != nil {
+			return nil, fmt.Errorf("cannot parse OpenId Metadata: %s", err.Error())
+		}
 
-	// Validate issuer == issuerUri
-	if metadata.Issuer != cfg.IssuerUri {
-		return nil, fmt.Errorf("mismatch between the configured issuer_uri (%s) and the exposed Issuer URI in OpenId provider metadata (%s)", cfg.IssuerUri, metadata.Issuer)
-	}
+		// Validate issuer == issuerUri
+		if metadata.Issuer != cfg.IssuerUri {
+			return nil, fmt.Errorf("mismatch between the configured issuer_uri (%s) and the exposed Issuer URI in OpenId provider metadata (%s)", cfg.IssuerUri, metadata.Issuer)
+		}
 
-	// Validate there is an authorization endpoint
-	if len(metadata.AuthURL) == 0 {
-		return nil, errors.New("the OpenID provider does not expose an authorization endpoint")
-	}
+		// Validate there is an authorization endpoint
+		if len(metadata.AuthURL) == 0 {
+			return nil, errors.New("the OpenID provider does not expose an authorization endpoint")
+		}
 
-	// Log warning if OpenId provider Metadata does not expose "id_token" in it's supported response types.
-	// It's possible to try authentication. If metadata is right, the error will be evident to the user when trying to login.
-	responseTypes := strings.Join(metadata.ResponseTypesSupported, " ")
-	if !strings.Contains(responseTypes, "id_token") {
-		log.Warning("Configured OpenID provider informs response_type=id_token is unsupported. Users may not be able to login.")
-	}
+		// Log warning if OpenId provider Metadata does not expose "id_token" in it's supported response types.
+		// It's possible to try authentication. If metadata is right, the error will be evident to the user when trying to login.
+		responseTypes := strings.Join(metadata.ResponseTypesSupported, " ")
+		if !strings.Contains(responseTypes, "id_token") {
+			log.Warning("Configured OpenID provider informs response_type=id_token is unsupported. Users may not be able to login.")
+		}
 
-	// Log warning if OpenId provider informs that some of the configured scopes are not supported
-	// It's possible to try authentication. If metadata is right, the error will be evident to the user when trying to login.
-	scopes := GetConfiguredOpenIdScopes()
-	for _, scope := range scopes {
-		isScopeSupported := false
-		for _, supportedScope := range metadata.ScopesSupported {
-			if scope == supportedScope {
-				isScopeSupported = true
+		// Log warning if OpenId provider informs that some of the configured scopes are not supported
+		// It's possible to try authentication. If metadata is right, the error will be evident to the user when trying to login.
+		scopes := GetConfiguredOpenIdScopes()
+		for _, scope := range scopes {
+			isScopeSupported := false
+			for _, supportedScope := range metadata.ScopesSupported {
+				if scope == supportedScope {
+					isScopeSupported = true
+					break
+				}
+			}
+
+			if !isScopeSupported {
+				log.Warning("Configured OpenID provider informs some of the configured scopes are unsupported. Users may not be able to login.")
 				break
 			}
 		}
 
-		if !isScopeSupported {
-			log.Warning("Configured OpenID provider informs some of the configured scopes are unsupported. Users may not be able to login.")
-			break
+		// Return parsed metadata
+		cachedOpenIdMetadata = &metadata
+		return cachedOpenIdMetadata, nil
+	})
+
+	if fetchError != nil {
+		return nil, fetchError
+	}
+
+	return fetchedMetadata.(*OpenIdMetadata), nil
+}
+
+func GetOpenIdJwks() (*jose.JSONWebKeySet, error) {
+	fetchedKeySet, fetchError, _ := openIdFlightGroup.Do("jwks", func() (interface{}, error) {
+		oidcMetadata, err := GetOpenIdMetadata()
+		if err != nil {
+			return nil, err
+		}
+
+		// Create HTTP client
+		cfg := config.Get().Auth.OpenId
+		httpTransport := &http.Transport{}
+		if cfg.InsecureSkipVerifyTLS {
+			httpTransport.TLSClientConfig = &tls.Config{
+				InsecureSkipVerify: true,
+			}
+		}
+
+		httpClient := http.Client{
+			Timeout:   time.Second * 10,
+			Transport: httpTransport,
+		}
+
+		// Fetch Keys document
+		response, err := httpClient.Get(oidcMetadata.JWKSURL)
+		if err != nil {
+			return nil, err
+		}
+
+		defer response.Body.Close()
+		if response.StatusCode != 200 {
+			return nil, fmt.Errorf("cannot fetch OpenId JWKS document (HTTP response status = %s)", response.Status)
+		}
+
+		// Parse the Keys document
+		var oidcKeys jose.JSONWebKeySet
+
+		rawMetadata, err := ioutil.ReadAll(response.Body)
+		if err != nil {
+			return nil, fmt.Errorf("failed to read OpenId JWKS document: %s", err.Error())
 		}
+
+		err = json.Unmarshal(rawMetadata, &oidcKeys)
+		if err != nil {
+			return nil, fmt.Errorf("cannot parse OpenId JWKS document: %s", err.Error())
+		}
+
+		cachedOpenIdKeySet = &oidcKeys // Store the keyset in a "cache"
+		return cachedOpenIdKeySet, nil
+	})
+
+	if fetchError != nil {
+		return nil, fetchError
 	}
 
-	// Return parsed metadata
-	cachedOpenIdMetadata = &metadata
-	return cachedOpenIdMetadata, nil
+	return fetchedKeySet.(*jose.JSONWebKeySet), nil
 }
 
 func IsOpenIdCodeFlowPossible() bool {
@@ -459,6 +548,134 @@ func ValidateOpenIdState(openIdParams *OpenIdCallbackParams) (validationFailure
 	return
 }
 
+func ValidateOpenTokenInHouse(openIdParams *OpenIdCallbackParams) error {
+	oidCfg := config.Get().Auth.OpenId
+	oidMetadata, err := GetOpenIdMetadata()
+	if err != nil {
+		return err
+	}
+
+	log.Debugf("Raw token to validate: %s", openIdParams.IdToken)
+
+	idTokenClaims := openIdParams.ParsedIdToken.Claims.(jwt.MapClaims)
+
+	// Check iss claim matches fetched metadata at discovery
+	if issuerClaim, ok := idTokenClaims["iss"].(string); !ok || issuerClaim != oidMetadata.Issuer {
+		return fmt.Errorf("the OpenId token has unexpected issuer claim; got iss = '%s'", issuerClaim)
+	}
+
+	// Check the aud claim contains our client-id
+	checkAzpClaim := false
+	if audienceClaim, ok := idTokenClaims["aud"]; !ok {
+		return errors.New("the OpenId token has no aud claim")
+	} else {
+		switch ac := audienceClaim.(type) {
+		case string:
+			if oidCfg.ClientId != ac {
+				return fmt.Errorf("the OpenId token is not targeted for Kiali; got aud = '%s'", audienceClaim)
+			}
+		case []string:
+			audFound := false
+			for _, audItem := range ac {
+				if oidCfg.ClientId == audItem {
+					audFound = true
+				}
+			}
+			if !audFound {
+				return fmt.Errorf("the OpenId token is not targeted for Kiali; got aud = %v", audienceClaim)
+			}
+
+			// The OIDC Spec says that if the aud claim contains multiple audiences, we "SHOULD" check
+			// the azp claim is present. In Kiali, there is currently no known reason to omit this
+			// check, so we do it.
+			checkAzpClaim = true
+		default:
+			return fmt.Errorf("the OpenId token has an unexpected audience claim; got '%v'", audienceClaim)
+		}
+	}
+
+	if checkAzpClaim {
+		// Check that the azp claim is present and contains our client_id
+		if authorizedPartyClaim, ok := idTokenClaims["azp"].(string); !ok {
+			return fmt.Errorf("the OpenId token has an invalid 'azp' claim")
+		} else if oidCfg.ClientId != authorizedPartyClaim {
+			return fmt.Errorf("the OpenId token is not targeted for Kiali; got azp = %v", authorizedPartyClaim)
+		}
+	}
+
+	// Currently, we only support tokens with an RSA signature with SHA-256, which is the default in the OIDC spec
+	if algHeader, ok := openIdParams.ParsedIdToken.Header["alg"].(string); !ok || algHeader != "RS256" {
+		return fmt.Errorf("the OpenId token has unexpected alg header claim; got alg = '%s'", algHeader)
+	}
+
+	// Check iat (issued at) claim
+	if iatClaim, ok := idTokenClaims["iat"]; !ok {
+		return errors.New("the OpenId token has no iat claim or is invalid")
+	} else {
+		parsedIat, parseErr := parseTimeClaim(iatClaim)
+		if parseErr != nil {
+			return fmt.Errorf("the OpenId token has an invalid iat claim: %w", parseErr)
+		}
+		if parsedIat == 0 {
+			// This is weird. This would mean an invalid type
+			return fmt.Errorf("the OpenId token has an invalid value in the iat claim; got '%v'", iatClaim)
+		}
+
+		// Let's do the minimal check to ensure that the token wasn't issued in the future
+		// we add a little offset to "now" to add one minute tolerance
+		iatTime := time.Unix(parsedIat, 0)
+		nowTime := util.Clock.Now().Add(60 * time.Second)
+		if iatTime.After(nowTime) {
+			return fmt.Errorf("we don't like people living in the future - enjoy the present!; iat = '%d'", parsedIat)
+		}
+	}
+
+	// Check exp (expiration time) claim
+	// The OIDC spec says: "The current time MUST be before the time represented by the exp Claim"
+	// No tolerance for this check.
+	if !util.Clock.Now().Before(openIdParams.ExpiresOn) {
+		return fmt.Errorf("the OpenId token has expired; exp = '%s'", openIdParams.ExpiresOn.String())
+	}
+
+	// There are other claims that could be checked, but are not verified here:
+	//   - nonce: This should be verified regardless if RBAC is on/off. So, it's verified in
+	//       another part of the authentication flow.
+	//   - acr: we are not asking for this claim at authorization, so the IdP doesn't
+	//       need to provide it nor we need to verify it.
+	//   - auth_time: we are not asking for this claim at authorization, so the IdP doesn't
+	//	     need to provide it nor we need to verify it.
+
+	// If execution flow reached this point, all claims look valid, but that won't guarantee that
+	// the id_token hasn't been tampered. So, we check the signature to find if
+	// the token is genuine
+	if kidHeader, ok := openIdParams.ParsedIdToken.Header["kid"]; !ok {
+		return errors.New("the OpenId token is missing the kid header claim")
+	} else {
+		if jws, parseErr := jose.ParseSigned(openIdParams.IdToken); parseErr != nil {
+			return fmt.Errorf("error when parsing the OpenId token: %w", parseErr)
+		} else {
+			if len(jws.Signatures) == 0 {
+				return errors.New("an unsigned OpenId token is not acceptable")
+			}
+
+			matchingKey, findKeyErr := GetJwkFromKeySet(kidHeader.(string))
+			if findKeyErr != nil {
+				return fmt.Errorf("something went wrong when trying to find the key that signed the OpenId token: %w", findKeyErr)
+			}
+			if matchingKey == nil {
+				return errors.New("the OpenId token is signed with an unknown key")
+			}
+
+			_, signVerifyErr := jws.Verify(matchingKey)
+			if signVerifyErr != nil {
+				return fmt.Errorf("the signature of the OpenId token is invalid: %w", signVerifyErr)
+			}
+		}
+	}
+
+	return nil
+}
+
 func VerifyOpenIdUserAccess(token string) (int, string, error) {
 	// Create business layer using the id_token
 	business, err := Get(token)
@@ -480,3 +697,28 @@ func VerifyOpenIdUserAccess(token string) (int, string, error) {
 
 	return http.StatusOK, "", nil
 }
+
+// As it turns out, the response from time claims can be either a f64 and
+// a json.Number. With this, we take care of it, converting to the int64
+// that we need to use timestamps in go.
+func parseTimeClaim(claimValue interface{}) (int64, error) {
+	var err error
+	parsedTime := int64(0)
+
+	switch exp := claimValue.(type) {
+	case float64:
+		// This can not fail
+		parsedTime = int64(exp)
+	case json.Number:
+		// This can fail, so we short-circuit if we get an invalid value.
+		parsedTime, err = exp.Int64()
+
+		if err != nil {
+			return 0, err
+		}
+	default:
+		return 0, errors.New("the 'exp' claim of the OpenId token has invalid type")
+	}
+
+	return parsedTime, nil
+}
diff --git a/business/openshift_oauth.go b/business/openshift_oauth.go
index cf74a59e9..839bca7eb 100644
--- a/business/openshift_oauth.go
+++ b/business/openshift_oauth.go
@@ -9,6 +9,7 @@ import (
 	"net/http"
 	"net/url"
 	"strings"
+	"time"
 
 	"github.com/kiali/kiali/config"
 	"github.com/kiali/kiali/kubernetes"
@@ -56,6 +57,8 @@ type OAuthRouteTLSSpec struct {
 
 const serverPrefix = "https://kubernetes.default.svc/"
 
+const defaultRequestTimeout = 10 * time.Second
+
 var kialiNamespace string
 
 func (in *OpenshiftOAuthService) Metadata() (metadata *OAuthMetadata, err error) {
@@ -107,7 +110,7 @@ func getOAuthAuthorizationServer() (*OAuthAuthorizationServer, error) {
 
 	if err != nil {
 		log.Error(err)
-		message := fmt.Errorf("could not send request to the Openshift OAuth API: %v", err)
+		message := fmt.Errorf("could not get OAuthAuthorizationServer: %v", err)
 		return nil, message
 	}
 
@@ -115,7 +118,7 @@ func getOAuthAuthorizationServer() (*OAuthAuthorizationServer, error) {
 
 	if err != nil {
 		log.Error(err)
-		message := fmt.Errorf("could not parse data from the Openshift API: %v", err)
+		message := fmt.Errorf("could not parse OAuthAuthorizationServer: %v", err)
 		return nil, message
 	}
 
@@ -166,7 +169,7 @@ func getKialiRoutePath(routeName string) (*string, error) {
 	conf, err := kubernetes.ConfigClient()
 	if err != nil {
 		log.Error(err)
-		return nil, fmt.Errorf("could not connect to Openshift: %v", err)
+		return nil, fmt.Errorf("could not get openshift config client: %v", err)
 	}
 
 	response, err := request("GET", fmt.Sprintf("apis/route.openshift.io/v1/namespaces/%v/routes/%s", namespace, routeName), &conf.BearerToken)
@@ -211,6 +214,10 @@ func (in *OpenshiftOAuthService) Logout(token string) error {
 }
 
 func request(method string, url string, auth *string) ([]byte, error) {
+	return requestWithTimeout(method, url, auth, time.Duration(defaultRequestTimeout))
+}
+
+func requestWithTimeout(method string, url string, auth *string, timeout time.Duration) ([]byte, error) {
 	certPool := x509.NewCertPool()
 	cert, err := ioutil.ReadFile("/run/secrets/kubernetes.io/serviceaccount/ca.crt")
 
@@ -223,10 +230,13 @@ func request(method string, url string, auth *string) ([]byte, error) {
 	tlsConfig := &tls.Config{RootCAs: certPool}
 
 	client := &http.Client{
+		Timeout: timeout,
 		Transport: &http.Transport{
 			TLSClientConfig: tlsConfig,
 		}}
 
+	defer client.CloseIdleConnections()
+
 	request, err := http.NewRequest(method, strings.Join([]string{serverPrefix, url}, ""), nil)
 
 	if err != nil {
diff --git a/business/workloads.go b/business/workloads.go
index 5e39eedf8..1f6e9bdd3 100644
--- a/business/workloads.go
+++ b/business/workloads.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"regexp"
 	"sort"
+	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -15,6 +16,7 @@ import (
 	batch_v1beta1 "k8s.io/api/batch/v1beta1"
 	core_v1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/api/errors"
+	meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
 
 	"github.com/kiali/kiali/config"
@@ -109,8 +111,7 @@ func (in *WorkloadService) GetWorkload(namespace string, workloadName string, wo
 		conf := config.Get()
 		app := workload.Labels[conf.IstioLabels.AppLabelName]
 		version := workload.Labels[conf.IstioLabels.VersionLabelName]
-		dash := NewDashboardsService(in.prom)
-		runtimes = dash.GetCustomDashboardRefs(namespace, app, version, workload.Pods)
+		runtimes = NewDashboardsService().GetCustomDashboardRefs(namespace, app, version, workload.Pods)
 	}()
 
 	if includeServices {
@@ -195,6 +196,47 @@ func (in *WorkloadService) GetPod(namespace, name string) (*models.Pod, error) {
 	return &pod, nil
 }
 
+func (in *WorkloadService) BuildLogOptionsCriteria(container string, duration string, sinceTime string, tailLines string) (*LogOptions, error) {
+	opts := &LogOptions{}
+	opts.PodLogOptions = core_v1.PodLogOptions{Timestamps: true}
+
+	if container != "" {
+		opts.Container = container
+	}
+
+	if duration != "" {
+		duration, err := time.ParseDuration(duration)
+
+		if err != nil {
+			return nil, fmt.Errorf("Invalid duration [%s]: %v", duration, err)
+		}
+
+		opts.Duration = &duration
+	}
+
+	if sinceTime != "" {
+		numTime, err := strconv.ParseInt(sinceTime, 10, 64)
+
+		if err != nil {
+			return nil, fmt.Errorf("Invalid sinceTime [%s]: %v", sinceTime, err)
+		}
+
+		opts.SinceTime = &meta_v1.Time{Time: time.Unix(numTime, 0)}
+	}
+
+	if tailLines != "" {
+		if numLines, err := strconv.ParseInt(tailLines, 10, 64); err == nil {
+			if numLines > 0 {
+				opts.TailLines = &numLines
+			}
+		} else {
+			return nil, fmt.Errorf("Invalid tailLines [%s]: %v", tailLines, err)
+		}
+	}
+
+	return opts, nil
+}
+
 func (in *WorkloadService) getParsedLogs(namespace, name string, opts *LogOptions) (*PodLog, error) {
 	k8sOpts := opts.PodLogOptions
 	// the k8s API does not support "endTime/beforeTime". So for bounded time ranges we need to
diff --git a/config/config.go b/config/config.go
index 0e4a638de..524bac073 100644
--- a/config/config.go
+++ b/config/config.go
@@ -72,6 +72,7 @@ type Server struct {
 	Port                       int    `yaml:",omitempty"`
 	StaticContentRootDirectory string `yaml:"static_content_root_directory,omitempty"`
 	WebFQDN                    string `yaml:"web_fqdn,omitempty"`
+	WebPort                    string `yaml:"web_port,omitempty"`
 	WebRoot                    string `yaml:"web_root,omitempty"`
 	WebHistoryMode             string `yaml:"web_history_mode,omitempty"`
 	WebSchema                  string `yaml:"web_schema,omitempty"`
@@ -271,6 +272,7 @@ type OpenIdConfig struct {
 	AuthorizationEndpoint string   `yaml:"authorization_endpoint,omitempty"`
 	ClientId              string   `yaml:"client_id,omitempty"`
 	ClientSecret          string   `yaml:"client_secret,omitempty"`
+	DisableRBAC           bool     `yaml:"disable_rbac,omitempty"`
 	InsecureSkipVerifyTLS bool     `yaml:"insecure_skip_verify_tls,omitempty"`
 	IssuerUri             string   `yaml:"issuer_uri,omitempty"`
 	Scopes                []string `yaml:"scopes,omitempty"`
@@ -359,6 +361,7 @@ func NewConfig() (c *Config) {
 				AuthorizationEndpoint: "",
 				ClientId:              "",
 				ClientSecret:          "",
+				DisableRBAC:           false,
 				InsecureSkipVerifyTLS: false,
 				IssuerUri:             "",
 				Scopes:                []string{"openid", "profile", "email"},
diff --git a/doc.go b/doc.go
index 3b4dc2e4e..82220cc36 100644
--- a/doc.go
+++ b/doc.go
@@ -9,7 +9,6 @@ import (
 	"github.com/kiali/kiali/handlers"
 	"github.com/kiali/kiali/jaeger"
 	"github.com/kiali/kiali/models"
-	"github.com/kiali/kiali/prometheus"
 	"github.com/kiali/kiali/status"
 )
 
@@ -591,7 +590,7 @@ type WorkloadDetailsResponse struct {
 // swagger:response metricsResponse
 type MetricsResponse struct {
 	// in:body
-	Body prometheus.Metrics
+	Body models.Metrics
 }
 
 // Dashboard response model
@@ -710,3 +709,17 @@ type IstioStatusResponse struct {
 	// in: body
 	Body business.IstioComponentStatus
 }
+
+// Posted parameters for a metrics stats query
+// swagger:parameters metricsStats
+type MetricsStatsQueryBody struct {
+	// in: body
+	Body models.MetricsStatsQueries
+}
+
+// Response of the metrics stats query
+// swagger:response metricsStatsResponse
+type MetricsStatsResponse struct {
+	// in: body
+	Body models.MetricsStats
+}
diff --git a/go.mod b/go.mod
index b095200ba..4a74c635a 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 	github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b
+	github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
 	github.com/golang/protobuf v1.3.5 // indirect
 	github.com/googleapis/gnostic v0.4.0 // indirect
 	github.com/gorilla/mux v1.7.4
@@ -20,10 +21,13 @@ require (
 	github.com/prometheus/client_model v0.2.0 // indirect
 	github.com/prometheus/common v0.4.1
 	github.com/prometheus/procfs v0.0.10 // indirect
+	github.com/rs/zerolog v1.20.0
 	github.com/stretchr/testify v1.4.0
+	golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e
 	golang.org/x/text v0.3.3 // indirect
 	google.golang.org/appengine v1.6.6 // indirect
 	gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect
+	gopkg.in/square/go-jose.v2 v2.5.1
 	gopkg.in/yaml.v2 v2.2.8
 	k8s.io/api v0.0.0-20190313235455-40a48860b5ab
 	k8s.io/apimachinery v0.0.0-20190816221834-a9f1d8a9c101
diff --git a/go.sum b/go.sum
index 78d3825cb..a32cdc17b 100644
--- a/go.sum
+++ b/go.sum
@@ -10,6 +10,7 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -39,6 +40,7 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekf
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903 h1:LbsanbbD6LieFkXbj9YNNBupiGHJgFeLpO0j0Fza1h8=
 github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -119,6 +121,7 @@ github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1Cpa
 github.com/openshift/api v0.0.0-20200221181648-8ce0047d664f h1:ATPK7UhEwglONJc8qGsq41TbPk0XA4Kpm7XZZ3mlhAY=
 github.com/openshift/api v0.0.0-20200221181648-8ce0047d664f/go.mod h1:dh9o4Fs58gpFXGSYfnVxGR9PnV53I8TW84pQaJDdGiY=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
@@ -136,6 +139,9 @@ github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R
 github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
 github.com/prometheus/procfs v0.0.10 h1:QJQN3jYQhkamO4mhfUWqdDH2asK7ONOI9MTWjyAxNKM=
 github.com/prometheus/procfs v0.0.10/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
+github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
+github.com/rs/zerolog v1.20.0 h1:38k9hgtUBdxFwE34yS8rTHmHBa4eN16E4DJlv177LNs=
+github.com/rs/zerolog v1.20.0/go.mod h1:IzD0RJ65iWH0w97OQQebJEvTZYvsCUm9WVLWBQrJRjo=
 github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
 github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
 github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
@@ -161,6 +167,8 @@ golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73r
 golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190603091049-60506f45cf65 h1:+rhAzEzT3f4JtomfC371qB+0Ola2caSKcY69NUBZrRQ=
 golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20191004110552-13f9640d40b9 h1:rjwSpXsdiK0dV8/Naq3kAw9ymfAeJIyd0upUIElB+lI=
 golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/oauth2 v0.0.0-20170412232759-a6bd8cefa181 h1:/4OaQ4bC66Oq9JDhUnxTjBGt8XBhDuwgMRXHgvfcCUY=
@@ -170,6 +178,8 @@ golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4Iltr
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY=
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -192,6 +202,8 @@ golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxb
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.6.6-0.20191219230319-b6ce0843b556/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
 google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc=
@@ -205,6 +217,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
 gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
 gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
 gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w=
+gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/graph/telemetry/istio/istio.go b/graph/telemetry/istio/istio.go
index f772ee9fd..df9c677b9 100644
--- a/graph/telemetry/istio/istio.go
+++ b/graph/telemetry/istio/istio.go
@@ -33,13 +33,13 @@ import (
 
 // BuildNamespacesTrafficMap is required by the graph/TelemtryVendor interface
 func BuildNamespacesTrafficMap(o graph.TelemetryOptions, client *prometheus.Client, globalInfo *graph.AppenderGlobalInfo) graph.TrafficMap {
-	log.Tracef("Build [%s] graph for [%v] namespaces [%s]", o.GraphType, len(o.Namespaces), o.Namespaces)
+	log.Tracef("Build [%s] graph for [%d] namespaces [%v]", o.GraphType, len(o.Namespaces), o.Namespaces)
 
 	appenders := appender.ParseAppenders(o)
 	trafficMap := graph.NewTrafficMap()
 
 	for _, namespace := range o.Namespaces {
-		log.Tracef("Build traffic map for namespace [%s]", namespace)
+		log.Tracef("Build traffic map for namespace [%v]", namespace)
 		namespaceTrafficMap := buildNamespaceTrafficMap(namespace.Name, o, client)
 		namespaceInfo := graph.NewAppenderNamespaceInfo(namespace.Name)
 		for _, a := range appenders {
diff --git a/hack/README.adoc b/hack/README.adoc
index 2aea9bd72..eccee52c1 100644
--- a/hack/README.adoc
+++ b/hack/README.adoc
@@ -25,7 +25,7 @@ Runs the Kiali molecule tests with the latest version of the Kiali quay.io image
 
 * `link:ci-openshift-molecule-tests.sh[]`
 
-Runs the Kiali molecule tests with the latest version of the Kiali quay.io images on an OpenShift AWS cluster. This utilizes the `aws-openshift-istio.sh` script to start and stop an OpenShift cluster on AWS installed with either Service Mesh or upstream Istio. Also utilizes the `run-molecule-tests.sh` script to run the molecule tests. Use this in a cron job to periodically run the molecule tests on minikube.
+Runs the Kiali molecule tests with the latest version of the Kiali quay.io images on an existing OpenShift cluster. Utilizes the `run-molecule-tests.sh` script to run the molecule tests. Use this in a cron job to periodically run the molecule tests.
 
 * `link:crc-openshift.sh[]`
 
@@ -53,46 +53,22 @@ Runs a local Prometheus in docker and scrapes the Kiali server and operator that
 
 == Running Molecule Tests Periodically
 
-If you have a cluster running (in this example, one that was installed locally via link:https://github.com/kxr/ocp4_setup_upi_kvm[ocp4_setup_upi_kvm]), you can use a script like this for a cron job in order to periodically run the Molecule tests:
+=== Testing on Minikube
+
+You can do this for a cron job in order to periodically run the Molecule tests on Minikube. If Minikube is not yet started, this script will start one for you (using `k8s-minikube.sh`) with Dex installed so the OIDC tests can run:
+
+```
+hack/ci-minikube-molecule-tests.sh
+```
+
+There are several options you can pass to that script - pass in `--help` to see what is applicable for you.
+
+=== Testing on OpenShift
+
+If you have a cluster running - one that was installed locally via link:https://github.com/kxr/ocp4_setup_upi_kvm[ocp4_setup_upi_kvm] - you can do this for a cron job in order to periodically run the Molecule tests:
 
 ```
-#!/bin/bash
-
-# abort on any error
-set -e
-
-ID="/root/ocp4_setup_ocp4/install_dir"
-OC="/root/ocp4_setup_ocp4/oc"
-SRC="/tmp/KIALI-GIT"
-DORP="podman"
-
-# If you want to test code from different forks and/or branches, set them here
-KIALI_FORK="kiali"
-KIALI_BRANCH="master"
-KIALI_OPERATOR_FORK="kiali"
-KIALI_OPERATOR_BRANCH="master"
-
-echo "Create a clean github repo location"
-rm -rf /tmp/KIALI-GIT
-mkdir -p $SRC
-
-echo "Make sure everything exists"
-test -d $ID || (echo "$ID is missing"; exit 1)
-test -x $OC || (echo "$OC is missing"; exit 1)
-test -d $SRC || (echo "$SRC is missing"; exit 1)
-which $DORP > /dev/null || (echo "$DORP is not in the PATH"; exit 1)
-
-echo "Clone github repos to make sure we have the latest tests and scripts"
-cd $SRC
-git clone --single-branch --branch ${KIALI_BRANCH} git@github.com:${KIALI_FORK}/kiali
-git clone --single-branch --branch ${KIALI_OPERATOR_BRANCH} git@github.com:${KIALI_OPERATOR_FORK}/kiali-operator kiali/operator
-cd kiali
-
-echo "Log into the cluster"
-$OC login -u kubeadmin -p $(cat $ID/auth/kubeadmin-password) https://api.ocp4.local:6443
-
-echo "Run the tests"
-hack/run-molecule-tests.sh --client-exe "$OC"
+hack/ci-openshift-molecule-tests.sh
 ```
 
-Note that if you do not have a cluster readily available, you could tweek that script to use `ci-openshift-molecule-tests.sh` or `ci-minikube-molecule-tests.sh` to do everything (install the cluster and then run the molecule tests).
+There are several options you can pass to that script - pass in `--help` to see what is applicable for you.
diff --git a/hack/ci-openshift-molecule-tests.sh b/hack/ci-openshift-molecule-tests.sh
index eacee9335..ad8b63d8d 100755
--- a/hack/ci-openshift-molecule-tests.sh
+++ b/hack/ci-openshift-molecule-tests.sh
@@ -1,79 +1,367 @@
 #!/bin/bash
 
 #
-# This script is designed to be run via a cronjob so the latest Kiali images
-# can be tested periodically using the molecule tests.
-#
-# If you want to run the molecule tests against a specific OpenShift cluster, you first
-# must "oc login" to that cluster and then run this script.
-#
-# If you do not have an OpenShift cluster, then when this script runs it will attempt
-# to install one on AWS. In this case, you must have an AWS account set up and an OpenShift
-# pull secret available. See the aws-openshift.sh and aws-openshift-istio.sh hack scripts for details.
-# WARNING! If you are not logged into an AWS cluster but you already have an AWS cluster deployed,
-# this script will destroy that cluster and rebuild a new one.
+# Refer to the --help output for a description of this script and its available options.
 #
 
+infomsg() {
+  echo "[INFO] ${1}"
+}
+
+helpmsg() {
+  cat <<HELP
+This script will run the Kiali molecule tests within an existing OpenShift cluster.
+It tests the latest published images, but has options to allow you to test dev images
+built from specified branches, thus allowing you to test PRs and other dev builds.
+
+You can use this as a cronjob to test Kiali periodically.
+
+Some of the defaults used by this script assume you started the cluster via the script
+provided by https://github.com/kxr/ocp4_setup_upi_kvm - see that project for more details.
+
+To use your own existing OpenShift cluster, you will want to look at the
+options -kp (or -kpf), -ku, and -oapi.
+
+Options:
+
+-at|--all-tests <tests>
+    Space-separated list of all the molecule tests to be run.
+    Note that this list may not be the tests that are actually run - see --skip-tests.
+    Default: <all tests in kiali-operator repo /molecule directory>
+
+-dorp|--docker-or-podman <docker|podman>
+    What to use when building images.
+    Default: docker
+
+-hb|--helm-branch <branch name>
+    The helm-chart branch to clone.
+    Default: master
+
+-hf|--helm-fork <name>
+    The helm-chart fork/org to clone.
+    Default: kiali
+
+-ii|--install-istio <true|false>
+    If true, an Istio control plane will be installed
+    in 'istio-system' namespace if one does not already exist.
+    If false, and no Istio is installed, this script aborts.
+    Default: true
+
+-ir|--irc-room <irc room name>
+    The freenode IRC room to send the results message.
+    Set to "" to not send any message.
+    Default: kiali-molecule-tests
+
+-kb|--kiali-branch <branch name>
+    The kiali branch to clone.
+    Default: master
+
+-kf|--kiali-fork <name>
+    The kiali fork/org to clone.
+    Default: kiali
+
+-kob|--kiali-operator-branch <branch name>
+    The kiali-operator branch to clone.
+    Default: master
+
+-kof|--kiali-operator-fork <name>
+    The kiali-operator fork/org to clone.
+    Default: kiali
+
+-kp|--kubeadmin-pw <password>
+    The password for the kubeadmin user.
+    If you want this more secure, put the password in a file and use -kpf.
+    Default: <unset>
+
+-kpf|--kubeadmin-pw-file <filename>
+    The file containing the kubeadmin password. If the file is not readable by the user
+    running this script, the script will attempt to read it via "sudo".
+    Default: /root/ocp4_setup_ocp4/install_dir/auth/kubeadmin-password
+
+-ku|--kubeadmin-user <username>
+    A username that has kubeadmin permissions.
+    Default: kubeadmin
+
+-lb|--logs-branch <branch name>
+    The logs branch to clone.
+    Default: master
+
+-lf|--logs-fork <name>
+    The logs fork/org to clone.
+    Default: jmazzitelli
+
+-lpn|--logs-project-name <name>
+    The project name within the logs fork/org to clone.
+    Default: kiali-molecule-test-logs
+
+-oapi|--openshift-api <OpenShift API URL>
+    The URL to the OpenShift API. This is the endpoint used by "oc login".
+    Default: https://api.ocp4.local:6443
+
+-oc <path to oc>
+    The full path to the 'oc' command.
+    If 'oc' is in your PATH, you can pass the option as '-oc \$(which oc)'
+    Default: /root/ocp4_setup_ocp4/oc
+
+-sd|--src-dir <directory>
+    Where the git source repositories will be cloned.
+    Default: /tmp/KIALI-GIT
+
+-st|--skip-tests <tests>
+    Space-separated list of all the molecule tests to be skipped.
+    Default: <tests that are unable to be run on OpenShift>
+
+-udi|--use-dev-images <true|false>
+    If true, the tests will use locally built dev images of Kiali and the operator.
+    When using dev images, this script will attempt to build and push dev images
+    into your cluster. If your environment is not set up for development
+    (e.g. you do not have Go installed), then this script will fail.
+    If false, the cluster will simply pull the 'latest' images published on quay.io.
+    Default: false
+
+-ul|--upload-logs <true|false>
+    If you want to upload the logs to the git repo, set this to true.
+    Otherwise, set this to false. The logs will remain on the local machine,
+    but will not be committed to the remote git repo.
+    If the IRC message will be sent, you can at least be told if there were
+    failures during any one of the tests.
+    Default: false
+HELP
+}
+
+# process command line arguments
 while [[ $# -gt 0 ]]; do
   key="$1"
   case $key in
-    -ism|--install-service-mesh)
-      INSTALL_SERVICE_MESH="$2"
-      shift;shift
-      ;;
-    -iui|--install-upstream-istio)
-      INSTALL_UPSTREAM_ISTIO="$2"
-      shift;shift
-      ;;
-    -h|--help)
-      cat <<HELPMSG
-
-$0 [option...] command
-
--ism|--install-service-mesh: If Service Mesh should be installed. default: "false"
--iui|--install-upstream-istio: If upstream Istio should be installed. default: "false"
-HELPMSG
-      exit 1
-      ;;
-    *)
-      echo "Unknown option: [$key]. Aborting."
-      exit 1
-      ;;
+    -at|--all-tests)              ALL_TESTS="$2";             shift;shift; ;;
+    -dorp|--docker-or-podman)     DORP="$2";                  shift;shift; ;;
+    -hb|--helm-branch)            HELM_BRANCH="$2";           shift;shift; ;;
+    -h|--help)                    helpmsg;                    exit 1       ;;
+    -hf|--helm-fork)              HELM_FORK="$2";             shift;shift; ;;
+    -ii|--install-istio)          INSTALL_ISTIO="$2";         shift;shift; ;;
+    -ir|--irc-room)               IRC_ROOM="$2";              shift;shift; ;;
+    -kb|--kiali-branch)           KIALI_BRANCH="$2";          shift;shift; ;;
+    -kf|--kiali-fork)             KIALI_FORK="$2";            shift;shift; ;;
+    -kob|--kiali-operator-branch) KIALI_OPERATOR_BRANCH="$2"; shift;shift; ;;
+    -kof|--kiali-operator-fork)   KIALI_OPERATOR_FORK="$2";   shift;shift; ;;
+    -kp|--kubeadmin-pw)           KUBEADMIN_PW="$2";          shift;shift; ;;
+    -kpf|--kubeadmin-pw-file)     KUBEADMIN_PW_FILE="$2";     shift;shift; ;;
+    -ku|--kubeadmin-user)         KUBEADMIN_USER="$2";        shift;shift; ;;
+    -lb|--logs-branch)            LOGS_BRANCH="$2";           shift;shift; ;;
+    -lf|--logs-fork)              LOGS_FORK="$2";             shift;shift; ;;
+    -lpn|--logs-project-name)     LOGS_PROJECT_NAME="$2";     shift;shift; ;;
+    -oapi|--openshift-api)        OPENSHIFT_API="$2";         shift;shift; ;;
+    -oc)                          OC="$2";                    shift;shift; ;;
+    -sd|--src-dir)                SRC="$2";                   shift;shift; ;;
+    -st|--skip-tests)             SKIP_TESTS="$2";            shift;shift; ;;
+    -udi|--use-dev-images)        USE_DEV_IMAGES="$2";        shift;shift; ;;
+    -ul|--upload-logs)            UPLOAD_LOGS="$2";           shift;shift; ;;
+    *) echo "Unknown argument: [$key]. Aborting."; helpmsg; exit 1 ;;
   esac
 done
 
-INSTALL_SERVICE_MESH="${INSTALL_SERVICE_MESH:-false}"
-INSTALL_UPSTREAM_ISTIO="${INSTALL_UPSTREAM_ISTIO:-false}"
+# abort on any error
+set -e
 
-# Where this script is - all our hack files are assumed to be in here
-script_root="$( cd "$(dirname "$0")" ; pwd -P )"
-hack_dir="$script_root"
+# set up some of our defaults
+KUBEADMIN_USER="${KUBEADMIN_USER:-kubeadmin}"
+KUBEADMIN_PW_FILE="${KUBEADMIN_PW_FILE:-/root/ocp4_setup_ocp4/install_dir/auth/kubeadmin-password}"
+OC=${OC:-/root/ocp4_setup_ocp4/oc}
+OPENSHIFT_API=${OPENSHIFT_API:-https://api.ocp4.local:6443}
+SRC="${SRC:-/tmp/KIALI-GIT}"
+DORP="${DORP:-docker}"
 
-if ! oc whoami > /dev/null 2>&1; then
-  if [ "${INSTALL_SERVICE_MESH}" == "false" -a "${INSTALL_UPSTREAM_ISTIO}" == "false" ]; then
-    echo "Molecule tests require either Service Mesh or upstream Istio to be installed."
-    exit 1
-  fi
+# if you want to test code from different forks and/or branches, set them here
+HELM_FORK="${HELM_FORK:-kiali}"
+HELM_BRANCH="${HELM_BRANCH:-master}"
+KIALI_FORK="${KIALI_FORK:-kiali}"
+KIALI_BRANCH="${KIALI_BRANCH:-master}"
+KIALI_OPERATOR_FORK="${KIALI_OPERATOR_FORK:-kiali}"
+KIALI_OPERATOR_BRANCH="${KIALI_OPERATOR_BRANCH:-master}"
 
-  ${hack_dir}/aws-openshift-istio.sh \
-    --install-service-mesh "${INSTALL_SERVICE_MESH}" \
-    --install-upstream-istio "${INSTALL_UPSTREAM_ISTIO}" \
-    --install-bookinfo "false"
-  if [ "$?" != "0" ]; then
-    echo "Failed to install the AWS cluster."
-    exit 1
-  fi
+# details about the github repo where the logs are to be stored
+LOGS_PROJECT_NAME="${LOGS_PROJECT_NAME:-kiali-molecule-test-logs}"
+LOGS_FORK="${LOGS_FORK:-jmazzitelli}"
+LOGS_BRANCH="${LOGS_BRANCH:-master}"
 
-  # If we are installing Service Mesh, uninstall Kiali since the molecule tests will want to install its own
-  if [ "${INSTALL_SERVICE_MESH}" == "true" ]; then
-    ${hack_dir}/istio/install-sm11.sh k-uninstall
-  fi
+LOGS_LOCAL_DIRNAME="${LOGS_PROJECT_NAME}"
+LOGS_LOCAL_DIRNAME_ABS="${SRC}/${LOGS_LOCAL_DIRNAME}"
+LOGS_LOCAL_SUBDIR="molecule-tests-$(date +'%Y-%m-%d_%H-%M-%S')"
+LOGS_LOCAL_SUBDIR_ABS="${LOGS_LOCAL_DIRNAME_ABS}/${LOGS_LOCAL_SUBDIR}"
+LOGS_LOCAL_RESULTS="${LOGS_LOCAL_SUBDIR_ABS}/results.log"
+LOGS_GITHUB_HTTPS_BASE="https://github.com/${LOGS_FORK}/${LOGS_PROJECT_NAME}/tree/${LOGS_BRANCH}"
+LOGS_GITHUB_HTTPS_SUBDIR="${LOGS_GITHUB_HTTPS_BASE}/${LOGS_LOCAL_SUBDIR}"
+LOGS_GITHUB_HTTPS_RESULTS="${LOGS_GITHUB_HTTPS_SUBDIR}/results.log"
+LOGS_GITHUB_GITCLONE="git@github.com:${LOGS_FORK}/${LOGS_PROJECT_NAME}.git"
+
+# the freenode IRC room where notifications are to be sent (allow the user to set this to "" via -ir option)
+IRC_ROOM="${IRC_ROOM-kiali-molecule-tests}"
 
-  oc login -u kiali -p kiali https://$(${hack_dir}/aws-openshift.sh api-host)
-  if [ "$?" != "0" ]; then
-    echo "Failed to log into the AWS cluster as the kiali user."
+# Only if this is set to "true" will the logs be committed and pushed to the git repo
+UPLOAD_LOGS="${UPLOAD_LOGS:-false}"
+
+# Only if this is set to "true" will Istio be installed if it is missing
+INSTALL_ISTIO="${INSTALL_ISTIO:-true}"
+
+# Determines if we should build and push dev images
+USE_DEV_IMAGES="${USE_DEV_IMAGES:-false}"
+
+# print out our settings for debug purposes
+cat <<EOM
+=== SETTINGS ===
+ALL_TESTS=$ALL_TESTS
+DORP=$DORP
+HELM_BRANCH=$HELM_BRANCH
+HELM_FORK=$HELM_FORK
+INSTALL_ISTIO=$INSTALL_ISTIO
+IRC_ROOM=$IRC_ROOM
+KIALI_BRANCH=$KIALI_BRANCH
+KIALI_FORK=$KIALI_FORK
+KIALI_OPERATOR_BRANCH=$KIALI_OPERATOR_BRANCH
+KIALI_OPERATOR_FORK=$KIALI_OPERATOR_FORK
+KUBEADMIN_PW_FILE=$KUBEADMIN_PW_FILE
+KUBEADMIN_USER=$KUBEADMIN_USER
+LOGS_GITHUB_GITCLONE=$LOGS_GITHUB_GITCLONE
+LOGS_GITHUB_HTTPS_RESULTS=$LOGS_GITHUB_HTTPS_RESULTS
+LOGS_GITHUB_HTTPS_SUBDIR=$LOGS_GITHUB_HTTPS_SUBDIR
+LOGS_LOCAL_RESULTS=$LOGS_LOCAL_RESULTS
+LOGS_LOCAL_SUBDIR=$LOGS_LOCAL_SUBDIR
+LOGS_LOCAL_SUBDIR_ABS=$LOGS_LOCAL_SUBDIR_ABS
+OC=$OC
+SKIP_TESTS=$SKIP_TESTS
+SRC=$SRC
+UPLOAD_LOGS=$UPLOAD_LOGS
+USE_DEV_IMAGES=$USE_DEV_IMAGES
+=== SETTINGS ===
+EOM
+
+infomsg "Create a clean github repo location"
+if [ "${SRC}" == "" ]; then
+  infomsg "SRC is empty - aborting"
+  exit 1
+fi
+if [ "${LOGS_PROJECT_NAME}" == "" ]; then
+  infomsg "LOGS_PROJECT_NAME is empty - aborting"
+  exit 1
+fi
+test -d ${SRC}/helm-charts && rm -rf ${SRC}/helm-charts
+test -d ${SRC}/kiali-operator && rm -rf ${SRC}/kiali-operator
+test -d ${SRC}/kiali && rm -rf ${SRC}/kiali
+test -d ${SRC}/${LOGS_PROJECT_NAME:-invalid} && [ "${SRC}/${LOGS_PROJECT_NAME}" != "/" ] && rm -rf ${SRC}/${LOGS_PROJECT_NAME:-invalid}
+mkdir -p ${SRC}
+
+infomsg "Make sure everything exists"
+if [ -z "${KUBEADMIN_PW}" ]; then
+  infomsg "Will read the kubeadmin pw file now [${KUBEADMIN_PW_FILE}] - if it cannot be read as the current user [$USER] another attempt via 'sudo' will be made"
+  test -f ${KUBEADMIN_PW_FILE} || sudo test -f ${KUBEADMIN_PW_FILE} || (infomsg "kubeadmin password file [$KUBEADMIN_PW_FILE] is missing"; exit 1)
+  KUBEADMIN_PW="$(cat ${KUBEADMIN_PW_FILE} || sudo cat ${KUBEADMIN_PW_FILE})"
+fi
+
+test -x $OC || (infomsg "OC executable [$OC] is missing"; exit 1)
+test -d $SRC || (infomsg "Directory to git clone the repos [$SRC] is missing"; exit 1)
+which $DORP > /dev/null || (infomsg "[$DORP] is not in the PATH"; exit 1)
+
+infomsg "Clone github repos in [$SRC] to make sure we have the latest tests and scripts"
+
+cd ${SRC}
+
+infomsg "Cloning logs repo [${LOGS_FORK}/${LOGS_PROJECT_NAME}:${LOGS_BRANCH}]..."
+git clone --single-branch --branch ${LOGS_BRANCH} ${LOGS_GITHUB_GITCLONE}
+
+infomsg "Cloning helm-charts [${HELM_FORK}/helm-charts:${HELM_BRANCH}]..."
+git clone --single-branch --branch ${HELM_BRANCH} git@github.com:${HELM_FORK}/helm-charts
+
+infomsg "Cloning kiali [${KIALI_FORK}/kiali:${KIALI_BRANCH}]..."
+git clone --single-branch --branch ${KIALI_BRANCH} git@github.com:${KIALI_FORK}/kiali
+
+infomsg "Cloning kiali-operator [${KIALI_OPERATOR_FORK}/kiali-operator:${KIALI_OPERATOR_BRANCH}]..."
+git clone --single-branch --branch ${KIALI_OPERATOR_BRANCH} git@github.com:${KIALI_OPERATOR_FORK}/kiali-operator
+
+ln -s ${SRC}/kiali-operator kiali/operator
+cd kiali
+
+infomsg "Log into the cluster [${OPENSHIFT_API}] as kubeadmin user named [${KUBEADMIN_USER}]"
+$OC login -u ${KUBEADMIN_USER} -p ${KUBEADMIN_PW} ${OPENSHIFT_API}
+
+if [ "${USE_DEV_IMAGES}" == "true" ]; then
+  infomsg "Dev images are to be tested. Will prepare them now."
+
+  infomsg "Building dev image..."
+  OC="${OC}" DORP="${DORP}" make clean build test
+
+  infomsg "Logging into the image registry..."
+  eval $(OC="${OC}" DORP="${DORP}" make cluster-status | grep "Image Registry login:" | sed 's/Image Registry login: \(.*\)$/\1/')
+
+  infomsg "Pushing the images into the cluster..."
+  OC="${OC}" DORP="${DORP}" make cluster-push
+else
+  infomsg "Will test the latest published images"
+fi
+
+infomsg "Cleaning any residual Kiali installs that might be hanging around"
+hack/purge-kiali-from-cluster.sh --client-exe "$OC"
+
+if ! $OC get namespace istio-system > /dev/null; then
+  if [ "${INSTALL_ISTIO}" == "true" ]; then
+    hack/istio/install-istio-via-istioctl.sh --client-exe-path "$OC"
+  else
+    infomsg "There is no 'istio-system' namespace, and this script was told not to install Istio. Aborting."
     exit 1
   fi
+else
+  infomsg "There is an 'istio-system' namespace - assuming Istio is installed and ready."
+fi
+
+infomsg "Building the Molecule test docker image using [${DORP}]"
+DORP=${DORP} make molecule-build
+
+mkdir -p "${LOGS_LOCAL_SUBDIR_ABS}"
+infomsg "Running the tests - logs are going here: ${LOGS_LOCAL_SUBDIR_ABS}"
+eval hack/run-molecule-tests.sh $(test ! -z "$ALL_TESTS" && echo "--all-tests \"$ALL_TESTS\"") $(test ! -z "$SKIP_TESTS" && echo "--skip-tests \"$SKIP_TESTS\"") --use-dev-images "${USE_DEV_IMAGES}" --helm-charts-repo "${SRC}/helm-charts" --client-exe "$OC" --color false --test-logs-dir "${LOGS_LOCAL_SUBDIR_ABS}" > "${LOGS_LOCAL_RESULTS}"
+
+cd ${LOGS_LOCAL_SUBDIR_ABS}
+
+# compress large log files
+MAX_LOG_FILE_SIZE="50M"
+for bigfile in $(find ${LOGS_LOCAL_SUBDIR_ABS} -maxdepth 1 -type f -size +${MAX_LOG_FILE_SIZE})
+do
+  infomsg "This file is large and needs to be compressed: $(basename ${bigfile})"
+  tar -czf ${bigfile}.tgz -C ${LOGS_LOCAL_SUBDIR_ABS} --remove-files $(basename ${bigfile})
+done
+
+if [ "${UPLOAD_LOGS}" == "true" ]; then
+  infomsg "Committing the logs to github: ${LOGS_GITHUB_HTTPS_SUBDIR}"
+  git add -A
+  git commit -m "Test results for ${LOGS_LOCAL_SUBDIR}"
+  git push
+else
+  infomsg "The logs will not be uploaded. Test results can be found here: ${LOGS_LOCAL_SUBDIR_ABS}"
 fi
 
-${hack_dir}/run-molecule-tests.sh
+# determine what message to send to IRC based on test results
+if grep FAILURE "${LOGS_LOCAL_RESULTS}"; then
+  irc_msg="a FAILURE occurred in one or more tests"
+else
+  irc_msg="all tests passed"
+fi
+
+if [ "${UPLOAD_LOGS}" == "true" ]; then
+  irc_msg="kiali tests are done [${irc_msg}]: ${LOGS_GITHUB_HTTPS_RESULTS} (test logs directory: ${LOGS_GITHUB_HTTPS_SUBDIR})"
+else
+  irc_msg="kiali tests are done [${irc_msg}]: Logs were not uploaded. See the local machine directory: ${LOGS_LOCAL_SUBDIR_ABS}"
+fi
+
+if [ "${IRC_ROOM}" == "" ]; then
+  infomsg "Not sending IRC notification - results are: ${irc_msg}"
+else
+  infomsg "Sending IRC notification to room [#${IRC_ROOM}]. msg=${irc_msg}"
+  (
+  echo 'NICK kiali-test-bot'
+  echo 'USER kiali-test-bot 8 * : kiali-test-bot'
+  sleep 10
+  echo "JOIN #${IRC_ROOM}"
+  sleep 5
+  echo "PRIVMSG #${IRC_ROOM} : ${irc_msg}"
+  echo QUIT
+  ) | nc irc.freenode.net 6667
+fi
diff --git a/hack/cluster-openshift.sh b/hack/cluster-openshift.sh
deleted file mode 100755
index f8773f31d..000000000
--- a/hack/cluster-openshift.sh
+++ /dev/null
@@ -1,682 +0,0 @@
-#!/bin/bash
-
-##############################################################################
-# cluster-openshift.sh
-#
-# Run this script to start/stop OpenShift cluster with Istio.
-# This can also optionally install Kiali.
-#
-# This script takes one command whose value is one of the following:
-#       up: starts the OpenShift environment
-#     down: stops the OpenShift environment
-#   status: outputs the current status of the OpenShift environment
-#
-# This script accepts several options - see --help for details.
-#
-# This script utilizes openshift-istio/origin and its istiooc tool.
-# If you do not have it, this script will download it for you.
-#
-##############################################################################
-
-debug() {
-  if [ "$_VERBOSE" == "true" ]; then
-    echo "DEBUG: $1"
-  fi
-}
-
-get_downloader() {
-  # Use wget command if available, otherwise try curl
-  if which wget > /dev/null ; then
-    DOWNLOADER="wget -O"
-  fi
-  if [ ! "$DOWNLOADER" ] ; then
-    if which curl > /dev/null ; then
-      DOWNLOADER="curl -L -o"
-    fi
-  fi
-  if [ ! "$DOWNLOADER" ] ; then
-    echo "ERROR: You must install either curl or wget to allow downloading"
-    exit 1
-  fi
-  debug "Downloader command to be used: ${DOWNLOADER}"
-}
-
-# Change to the directory where this script is and set our env
-SCRIPT_ROOT="$( cd "$(dirname "$0")" ; pwd -P )"
-cd ${SCRIPT_ROOT}
-
-# The default version of the istiooc command to be downloaded
-DEFAULT_MAISTRA_ISTIO_OC_DOWNLOAD_VERSION="v3.11.0+maistra-0.11.0"
-
-# The default installation custom resource used to define what to install
-DEFAULT_MAISTRA_INSTALL_YAML="${SCRIPT_ROOT}/maistra_v1_servicemeshcontrolplane_cr_basic.yaml"
-
-# set the default openshift address here so that it can be used for the usage text
-#
-# This is the IP address where OpenShift will bind its master.
-# This should be a valid IP address for the machine where OpenShift is installed.
-if which ip > /dev/null ; then
-  DEFAULT_OPENSHIFT_IP_ADDRESS=`echo $(ip -f inet addr | grep 'state UP' -A1 | tail -n1 | awk '{print $2}' | cut -f1 -d'/')`
-else
-  DEFAULT_OPENSHIFT_IP_ADDRESS="127.0.0.1"
-fi
-
-# process command line args to override environment
-_CMD=""
-while [[ $# -gt 0 ]]; do
-  key="$1"
-  case $key in
-    up)
-      _CMD="up"
-      shift
-      ;;
-    down)
-      _CMD="down"
-      shift
-      ;;
-    status)
-      _CMD="status"
-      shift
-      ;;
-    -v|--verbose)
-      _VERBOSE=true
-      shift
-      ;;
-    -b|--bin-dir)
-      OPENSHIFT_BIN_PATH="$2"
-      shift;shift
-      ;;
-    -a|--address)
-      OPENSHIFT_IP_ADDRESS="$2"
-      shift;shift
-      ;;
-    -ie|--istio-enabled)
-      ISTIO_ENABLED="$2"
-      shift;shift
-      ;;
-    -iv|--istio-version)
-      ISTIO_VERSION="$2"
-      shift;shift
-      ;;
-    -iov|--istiooc-version)
-      MAISTRA_ISTIO_OC_DOWNLOAD_VERSION="$2"
-      shift;shift
-      ;;
-    -iop|--istiooc-platform)
-      MAISTRA_ISTIO_OC_DOWNLOAD_PLATFORM="$2"
-      shift;shift
-      ;;
-    -ioy|--istiooc-install-yaml)
-      MAISTRA_INSTALL_YAML="$2"
-      shift;shift
-      ;;
-    -p|--persistence-dir)
-      OPENSHIFT_PERSISTENCE_DIR="$2"
-      shift;shift
-      ;;
-    -kn|--knative)
-      KNATIVE_ENABLED="$2"
-      shift;shift
-      ;;
-    -ke|--kiali-enabled)
-      KIALI_ENABLED="$2"
-      shift;shift
-      ;;
-    -kas|--kiali-auth-strategy)
-      KIALI_AUTH_STRATEGY="$2"
-      shift;shift
-      ;;
-    -kv|--kiali-version)
-      KIALI_VERSION="$2"
-      shift;shift
-      ;;
-    -ku|--kiali-username)
-      KIALI_USERNAME="$2"
-      shift;shift
-      ;;
-    -kp|--kiali-passphrase)
-      KIALI_PASSPHRASE="$2"
-      shift;shift
-      ;;
-    --cluster-options)
-      CLUSTER_OPTIONS="$2"
-      shift;shift
-      ;;
-    -nw|--no-wait-for-istio)
-      WAIT_FOR_ISTIO=false
-      shift
-      ;;
-    -nj|--no-jaeger)
-	TRACING_ENABLED=false
-	shift
-	;;
-    -h|--help)
-      cat <<HELPMSG
-
-$0 [option...] command
-
-Valid options:
-  -v|--verbose
-      Enable logging of debug messages from this script.
-  -b|--bin-dir <dir>
-      Directory where the OpenShift binaries are or will be stored when downloaded.
-      Default: ${HOME}/bin
-  -h|--help : this message
-  -a|--address <address>
-      The public IP or named address bound to by the OpenShift cluster.
-      Default: ${DEFAULT_OPENSHIFT_IP_ADDRESS}
-      Used only for the 'up' command.
-  -ie|--istio-enabled (true|false)
-      When set to true, Istio will be installed in OpenShift.
-      Default: true
-      Used only for the 'up' command.
-  -iv|--istio-version <version>
-      The version of Istio that the istiooc binary will install.
-      If this is not specified, this will be whatever the istiooc binary installs by default.
-      Default: none
-  -iov|--istiooc-version <version>
-      The version of the istiooc binary to use.
-      If one does not exist in the bin directory, it will be downloaded there.
-      Default: ${DEFAULT_MAISTRA_ISTIO_OC_DOWNLOAD_VERSION}
-  -iop|--istiooc-platform (linux|darwin)
-      The platform indicator to determine what istiooc binary executable to download.
-      Default: linux (darwin if Mac is detected)
-  -ioy|--istiooc-install-yaml <file or url>
-      Points to the YAML file that defines the Installation custom resource which declares what to install.
-      Default: ${DEFAULT_MAISTRA_INSTALL_YAML}
-  -ke|--kiali-enabled (true|false)
-      When set to true, Kiali will be installed in OpenShift.
-      Default: false
-      Used only for the 'up' command.
-  -kas|--kiali-auth-strategy (openshift,login,anonymous)
-      Determines what authentication strategy Kiali will use. See docs for what each auth-strategy does.
-      Default: openshift
-      Used only for the 'up' command.
-  -kn|--knative (true|false)
-      When set to true, Knative will be installed in OpenShift.
-      Default: false
-      Used only for the 'up' command.
-  -ku|--kiali-username <username>
-      The username needed when logging into Kiali.
-      Default: admin
-      Used only for the 'up' command.
-  -kp|--kiali-passphrase <passphrase>
-      The passphrase needed when logging into Kiali.
-      Default: admin
-      Used only for the 'up' command.
-  -kv|--kiali-version <version>
-      The Kiali version to be installed in OpenShift.
-      Used only for the 'up' command.
-  -nw|--no-wait-for-istio
-      When specified, this script will not wait for Istio to be up and running before exiting.
-      Note that waiting is required when --kiali-enabled is true - the Kiali install
-      will not start until after all Istio components are up and running. Thus this option
-      is ignored when --kiali-enabled is true.
-      This will also be ignored when --istio-enabled is false.
-      Used only for the 'up' command.
-  -nj|--no-jaeger
-      When specified, this script will remove Jaeger and Elasticsearch installations from the
-      cluster after start-up.
-      Used only for the 'up' command.
-  -p|--persistence-dir <dir>
-      When set, OpenShift will persist data to this directory.
-      Restarting OpenShift will restore its previous state when this is set.
-      If not set, OpenShift will start clean every time.
-      Default: /var/lib/origin/persistent.data
-  --cluster-options <options>
-      These are additional custom options you want to pass to the cluster.
-      Used only for the 'up' command.
-      Default: none
-
-The command must be either: up, down, or status
-HELPMSG
-      exit 1
-      ;;
-    *)
-      echo "Unknown argument [$key]. Aborting."
-      exit 1
-      ;;
-  esac
-done
-
-########################################
-# Environment setup section starts here.
-
-#--------------------------------------------------------------
-# Variables below have values that can be overridden by
-# command line options (see above) or by environment variables.
-#--------------------------------------------------------------
-
-# This is where you want the OpenShift binaries to go
-OPENSHIFT_BIN_PATH="${OPENSHIFT_BIN_PATH:=${HOME}/bin}"
-
-# This is the IP address where OpenShift will bind its master.
-# This should be a valid IP address for the machine where OpenShift is installed.
-
-if [ ! "$OPENSHIFT_IP_ADDRESS" ] ; then
-  OPENSHIFT_IP_ADDRESS=${DEFAULT_OPENSHIFT_IP_ADDRESS}
-fi
-
-# The version is the tag from the openshift-istio/origin release builds.
-# The platform is either "linux" or "darwin".
-MAISTRA_ISTIO_OC_DOWNLOAD_VERSION="${MAISTRA_ISTIO_OC_DOWNLOAD_VERSION:-${DEFAULT_MAISTRA_ISTIO_OC_DOWNLOAD_VERSION}}"
-DEFAULT_OS_VERSION=linux
-DETECTED_OS_VERSION=`uname | tr '[:upper:]' '[:lower:]'`
-if [ "${DETECTED_OS_VERSION}" = "linux" -o "${DETECTED_OS_VERSION}" = "darwin" ] ; then
-  DEFAULT_OS_VERSION=${DETECTED_OS_VERSION}
-  debug "The operating system has been detected as ${DEFAULT_OS_VERSION}"
-fi
-MAISTRA_ISTIO_OC_DOWNLOAD_PLATFORM="${MAISTRA_ISTIO_OC_DOWNLOAD_PLATFORM:-${DEFAULT_OS_VERSION}}"
-
-# Defines where the Installation yaml is to be found.
-MAISTRA_INSTALL_YAML="${MAISTRA_INSTALL_YAML:-${DEFAULT_MAISTRA_INSTALL_YAML}}"
-
-# if sed is gnu-sed then set option to work in posix mode to be compatible with non-gnu-sed versions
-if sed --posix 's/ / /' < /dev/null > /dev/null 2>&1 ; then
-  SEDOPTIONS="--posix"
-fi
-
-# If you want to persist data across restarts of OpenShift, set to the persistence directory.
-# If you set this to "" then no persistence will be used
-# Note: ${v=d} is used on purpose so we do not persist if the directory was explicitly set to "".
-OPENSHIFT_PERSISTENCE_DIR="${OPENSHIFT_PERSISTENCE_DIR=/var/lib/origin/persistent.data}"
-
-# If ISTIO_ENABLED=true, then the istiooc command will install a version of Istio for you.
-ISTIO_ENABLED="${ISTIO_ENABLED:-true}"
-
-# By default, wait for Istio to be up and running before the script ends.
-WAIT_FOR_ISTIO="${WAIT_FOR_ISTIO:-true}"
-
-# If you set KIALI_ENABLED=true, then the istiooc command will install a version of Kiali for you.
-# If that is set to false, the other KIALI_ environment variables will be ignored.
-KIALI_ENABLED="${KIALI_ENABLED:-false}"
-KIALI_VERSION="${KIALI_VERSION:-lastrelease}"
-KIALI_AUTH_STRATEGY="${KIALI_AUTH_STRATEGY:-openshift}"
-KIALI_USERNAME="${KIALI_USERNAME:-admin}"
-KIALI_PASSPHRASE="${KIALI_PASSPHRASE:-admin}"
-
-#--------------------------------------------------------------
-# Variables below have values derived from the variables above.
-# These variables below are not meant for users to change.
-#--------------------------------------------------------------
-
-# See if sudo is required. It is required if the user is not in the docker group.
-if groups ${USER} | grep >/dev/null 2>&1 '\bdocker\b'; then
-  DOCKER_SUDO=
-else
-  DOCKER_SUDO=sudo
-fi
-
-# If latest Kiali release is to be installed, figure out which version that is
-if [ "${KIALI_ENABLED}" == "true" -a "${KIALI_VERSION}" == "lastrelease" ]; then
-  get_downloader
-  eval ${DOWNLOADER} /tmp/kiali-release-latest.json https://api.github.com/repos/kiali/kiali/releases/latest
-  KIALI_VERSION=$(cat /tmp/kiali-release-latest.json |\
-    grep  "tag_name" | \
-    sed -e 's/.*://' -e 's/ *"//' -e 's/",//')
-  if [ "${KIALI_VERSION}" == "" ]; then
-    echo "ERROR: Cannot determine the latest Kiali version to install. Set KIALI_VERSION env var to the version you want."
-    exit 1
-  fi
-  echo "The latest Kiali release is: ${KIALI_VERSION}"
-fi
-
-# Determine where to get the binary executable and its full path and how to execute it.
-# This download URL is where to the binary is on the github release page.
-MAISTRA_ISTIO_OC_DOWNLOAD_LOCATION="https://github.com/Maistra/origin/releases/download/${MAISTRA_ISTIO_OC_DOWNLOAD_VERSION}/istiooc_${MAISTRA_ISTIO_OC_DOWNLOAD_PLATFORM}"
-MAISTRA_ISTIO_OC_EXE_NAME=istiooc
-MAISTRA_ISTIO_OC_EXE_PATH="${OPENSHIFT_BIN_PATH}/${MAISTRA_ISTIO_OC_EXE_NAME}"
-MAISTRA_ISTIO_OC_COMMAND="${MAISTRA_ISTIO_OC_EXE_PATH}"
-
-# If Istio is to be installed, set the proper istiooc enable option value that will be needed.
-# Note that the --enable option is only used if the cluster was never initialized. Do not use it otherwise.
-if [ ! -d "${OPENSHIFT_PERSISTENCE_DIR}" ]; then
-  if [ "${ISTIO_ENABLED}" == "true" ]; then
-    ENABLE_ARG="--enable=*,istio"
-  else
-    ENABLE_ARG="--enable=*,-istio"
-  fi
-fi
-
-# If we are to persist data across restarts, set the proper arguments
-if [ "${OPENSHIFT_PERSISTENCE_DIR}" != "" ]; then
-  OPENSHIFT_PERSISTENCE_ARGS="--base-dir=${OPENSHIFT_PERSISTENCE_DIR}"
-fi
-
-# If Kiali is to be installed, set up some things that may be needed
-if [ "${KIALI_ENABLED}" == "true" ]; then
-  echo Kiali is enabled and will be installed.
-fi
-
-# Operator Tempate Variables - export these so the template can see them
-export OPENSHIFT_ISTIO_MASTER_PUBLIC_URL="https://${OPENSHIFT_IP_ADDRESS}:8443"
-if [ "${ISTIO_VERSION}" != "" ]; then
-  export OPENSHIFT_ISTIO_VERSION="${ISTIO_VERSION}"
-fi
-
-# Environment setup section stops here.
-########################################
-
-debug "ENVIRONMENT:
-  command=$_CMD
-  OPENSHIFT_BIN_PATH=$OPENSHIFT_BIN_PATH
-  OPENSHIFT_IP_ADDRESS=$OPENSHIFT_IP_ADDRESS
-  OPENSHIFT_PERSISTENCE_DIR=$OPENSHIFT_PERSISTENCE_DIR
-  MAISTRA_ISTIO_OC_DOWNLOAD_VERSION=$MAISTRA_ISTIO_OC_DOWNLOAD_VERSION
-  MAISTRA_ISTIO_OC_DOWNLOAD_PLATFORM=$MAISTRA_ISTIO_OC_DOWNLOAD_PLATFORM
-  MAISTRA_ISTIO_OC_DOWNLOAD_LOCATION=$MAISTRA_ISTIO_OC_DOWNLOAD_LOCATION
-  MAISTRA_ISTIO_OC_EXE_NAME=$MAISTRA_ISTIO_OC_EXE_NAME
-  MAISTRA_ISTIO_OC_EXE_PATH=$MAISTRA_ISTIO_OC_EXE_PATH
-  MAISTRA_ISTIO_OC_COMMAND=$MAISTRA_ISTIO_OC_COMMAND
-  MAISTRA_INSTALL_YAML=$MAISTRA_INSTALL_YAML
-  DOCKER_SUDO=$DOCKER_SUDO
-  KIALI_ENABLED=$KIALI_ENABLED
-  KIALI_VERSION=$KIALI_VERSION
-  KIALI_AUTH_STRATEGY=$KIALI_AUTH_STRATEGY
-  KIALI_USERNAME=$KIALI_USERNAME
-  KIALI_PASSPHRASE=$KIALI_PASSPHRASE
-  ISTIO_ENABLED=$ISTIO_ENABLED
-  ISTIO_VERSION=$ISTIO_VERSION
-  ENABLE_ARG=$ENABLE_ARG
-  OPENSHIFT_PERSISTENCE_ARGS=$OPENSHIFT_PERSISTENCE_ARGS
-  CLUSTER_OPTIONS=$CLUSTER_OPTIONS
-  OPENSHIFT_ISTIO_MASTER_PUBLIC_URL=$OPENSHIFT_ISTIO_MASTER_PUBLIC_URL
-  OPENSHIFT_ISTIO_VERSION=$OPENSHIFT_ISTIO_VERSION
-  TRACING_ENABLED=$TRACING_ENABLED
-  "
-
-# Fail fast if we don't even have the correct location where the oc client should be
-if [ ! -d "${OPENSHIFT_BIN_PATH}" ]; then
-  echo "ERROR: You must define OPENSHIFT_BIN_PATH to an existing location where you want the oc client tool to be. It is currently set to: ${OPENSHIFT_BIN_PATH}"
-  exit 1
-fi
-
-# Download the oc client if we do not have it yet
-if [[ -f "${MAISTRA_ISTIO_OC_EXE_PATH}" ]]; then
-  _existingVersion=$(${MAISTRA_ISTIO_OC_EXE_PATH} --request-timeout=2s version | head -n 1 | sed ${SEDOPTIONS}  "s/^oc \([A-Za-z0-9.-]*+[A-Za-z0-9.-]*\)\+[a-z0-9 ]*$/\1/")
-  if [ "$_existingVersion" != "${MAISTRA_ISTIO_OC_DOWNLOAD_VERSION}" ]; then
-    echo "===== WARNING ====="
-    echo "You already have the client binary but it does not match the version you want."
-    echo "Either delete your existing client binary and let this script download another one,"
-    echo "or change the version passed to this script to match the version of your client binary."
-    echo "Client binary is here: ${MAISTRA_ISTIO_OC_EXE_PATH}"
-    echo "The version of the client binary is: ${_existingVersion}"
-    echo "You asked for version: ${MAISTRA_ISTIO_OC_DOWNLOAD_VERSION}"
-    echo "===== WARNING ====="
-    exit 1
-  fi
-else
-  echo "Downloading binary to ${MAISTRA_ISTIO_OC_EXE_PATH}"
-
-  get_downloader
-  eval ${DOWNLOADER} ${MAISTRA_ISTIO_OC_EXE_PATH} ${MAISTRA_ISTIO_OC_DOWNLOAD_LOCATION}
-  if [ "$?" != "0" ]; then
-    echo "===== WARNING ====="
-    echo "Could not download the client binary for the version you want."
-    echo "Make sure this is valid: ${MAISTRA_ISTIO_OC_DOWNLOAD_LOCATION}"
-    echo "===== WARNING ====="
-    rm ${MAISTRA_ISTIO_OC_EXE_PATH}
-    exit 1
-  fi
-  chmod +x ${MAISTRA_ISTIO_OC_EXE_PATH}
-fi
-
-debug "oc command that will be used: ${MAISTRA_ISTIO_OC_COMMAND}"
-debug "$(${MAISTRA_ISTIO_OC_COMMAND} version)"
-
-cd ${OPENSHIFT_BIN_PATH}
-
-if [ "$_CMD" = "up" ]; then
-
-  # Create and set ownership of the persistence dir, if there is one
-  if [ "${OPENSHIFT_PERSISTENCE_DIR}" != "" ]; then
-    echo "SUDO ACCESS: Creating persistence dir and giving ownership to $(whoami):"
-    sudo mkdir -p ${OPENSHIFT_PERSISTENCE_DIR} && sudo chown $(whoami):$(whoami) ${OPENSHIFT_PERSISTENCE_DIR}
-    ls -ld ${OPENSHIFT_PERSISTENCE_DIR}
-  fi
-
-  # The OpenShift docs say to define docker with an insecure registry setting. This checks such a setting is enabled.
-  pgrep -a dockerd | grep '[-]-insecure-registry' > /dev/null 2>&1
-  if [ "$?" != "0" ]; then
-    grep 'OPTIONS=.*--insecure-registry' /etc/sysconfig/docker > /dev/null 2>&1
-    if [ "$?" != "0" ]; then
-      grep 'insecure-registries' /etc/docker/daemon.json > /dev/null 2>&1
-      if [ "$?" != "0" ]; then
-        echo 'WARNING: You must run Docker with the --insecure-registry argument with an appropriate value (usually "--insecure-registry 172.30.0.0/16"). See the OpenShift Origin documentation for more details: https://github.com/openshift/origin/blob/master/docs/cluster_up_down.md#linux'
-      else
-        debug "/etc/docker/daemon.json has the insecure-registry setting. This is good."
-      fi
-    else
-      debug "/etc/sysconfig/docker has defined the insecure-registry setting. This is good."
-    fi
-  else
-    debug "Docker daemon is running with --insecure-registry setting. This is good."
-  fi
-
-  # The OpenShift docs say to disable firewalld for now. Just in case it is running, stop it now.
-  # If firewalld was running and is shutdown, it changes the iptable rules and screws up docker,
-  # so we must restart docker in order for it to rebuild its iptable rules.
-  echo "SUDO ACCESS: Determine status of firewalld"
-  sudo systemctl status firewalld > /dev/null 2>&1
-  if [ "$?" == "0" ]; then
-    echo "SUDO ACCESS: Turning off firewalld as per OpenShift recommendation and then restarting docker to rebuild iptable rules"
-    sudo systemctl stop firewalld
-    sudo systemctl restart docker.service
-  fi
-
-  echo "Writing openshift config..."
-  ${MAISTRA_ISTIO_OC_COMMAND} cluster up ${ENABLE_ARG} --public-hostname=${OPENSHIFT_IP_ADDRESS} ${OPENSHIFT_PERSISTENCE_ARGS} ${CLUSTER_OPTIONS} --write-config
-
-  if [ "$KNATIVE_ENABLED" == "true" ]; then
-    debug "Preparing the cluster for Knative"
-    sed -i -e 's/"admissionConfig":{"pluginConfig":null}/"admissionConfig": {\
-        "pluginConfig": {\
-            "ValidatingAdmissionWebhook": {\
-                "configuration": {\
-                    "apiVersion": "v1",\
-                    "kind": "DefaultAdmissionConfig",\
-                    "disable": false\
-                }\
-            },\
-            "MutatingAdmissionWebhook": {\
-                "configuration": {\
-                    "apiVersion": "v1",\
-                    "kind": "DefaultAdmissionConfig",\
-                    "disable": false\
-                }\
-            }\
-        }\
-    }/' ${OPENSHIFT_PERSISTENCE_DIR}/kube-apiserver/master-config.yaml
-  fi
-
-  echo "Starting the OpenShift cluster..."
-  debug "${MAISTRA_ISTIO_OC_COMMAND} cluster up ${ENABLE_ARG} --public-hostname=${OPENSHIFT_IP_ADDRESS} ${OPENSHIFT_PERSISTENCE_ARGS} ${CLUSTER_OPTIONS}"
-  ${MAISTRA_ISTIO_OC_COMMAND} cluster up --public-hostname=${OPENSHIFT_IP_ADDRESS} ${OPENSHIFT_PERSISTENCE_ARGS} ${CLUSTER_OPTIONS}
-
-  if [ "$?" != "0" ]; then
-    echo "ERROR: failed to start OpenShift"
-    exit 1
-  fi
-
-  echo 'Do you want the admin user to be assigned the cluster-admin role?'
-  echo 'NOTE: This could expose your machine to root access!'
-  echo '      If you elect not to do this and Istio is not already installed,'
-  echo '      you will be required to perform additional steps later.'
-  echo 'Select "1" for Yes and "2" for No:'
-  select yn in "Yes" "No"; do
-    case $yn in
-      Yes )
-        echo Will assign the cluster-admin role to the admin user.
-        ${MAISTRA_ISTIO_OC_COMMAND} login -u system:admin
-        ${MAISTRA_ISTIO_OC_COMMAND} adm policy add-cluster-role-to-user cluster-admin admin
-        _CREATE_INSTALLATION_RESOURCE="true"
-        break;;
-      No )
-        echo Admin user will not be assigned the cluster-admin role.
-        echo If Istio is not already installed then you will be required to perform additional steps later.
-        _CREATE_INSTALLATION_RESOURCE="false"
-        break;;
-    esac
-  done
-
-  ${MAISTRA_ISTIO_OC_COMMAND} get -n istio-operator Installation istio-installation > /dev/null 2>&1
-  if [ "$?" != "0" ]; then
-    if [ "${ISTIO_ENABLED}" == "true" ] ; then
-      if [ "${_CREATE_INSTALLATION_RESOURCE}" == "true" ] ; then
-        echo "Installing Istio via Installation Custom Resource"
-        echo "Creating a istio-system namespace"
-        debug "${MAISTRA_ISTIO_OC_COMMAND} new-project istio-system"
-        ${MAISTRA_ISTIO_OC_COMMAND} new-project istio-system
-        # Note, Maistra requires to install the CR in the target namespace not in the operator one
-        # https://maistra.io/docs/getting_started/install/
-        echo "Creating a CR under istio-system namespace"
-        cat ${MAISTRA_INSTALL_YAML} | TRACING_ENABLED=${TRACING_ENABLED:-true} envsubst | ${MAISTRA_ISTIO_OC_COMMAND} create -n istio-system -f -
-      else
-        echo "It appears Istio has not yet been installed - after you have ensured that your OpenShift user has the proper"
-        echo "permissions, you will need to install Istio manually."
-      fi
-    else
-      echo "You asked that Istio not be enabled - will not create the Installation Custom Resource."
-    fi
-  else
-    if [ "${ISTIO_ENABLED}" == "true" ] ; then
-      echo "It appears Istio has already been installed - will not create the Installation Custom Resource again."
-    else
-      echo "You asked that Istio not be enabled, but it appears Istio has already been installed. You might want to uninstall it."
-    fi
-  fi
-
-  checkApp () {
-    local expected="$1"
-    apps=$(${MAISTRA_ISTIO_OC_COMMAND} get deployment.apps -n istio-system -o jsonpath='{range .items[*]}{.metadata.name}{" "}{end}' 2> /dev/null)
-    for app in ${apps[@]}
-    do
-	  if [[ "$expected" == "$app" ]]; then
-	    return 0
-	  fi
-    done
-    return 1
-  }
-
-  # If Istio is enabled, it should be installing now - if we need to, wait for it to finish
-  if [ "${ISTIO_ENABLED}" == "true" ] ; then
-    if [ "${KIALI_ENABLED}" == "true" -o "${WAIT_FOR_ISTIO}" == "true" ]; then
-      echo "Wait for Istio to fully start (this is going to take a while)..."
-
-      ## Check if jaeger is part of the expected apps
-      if [ "${TRACING_ENABLED}" == "false" ]; then
-        expected_apps=(istio-citadel prometheus istio-galley istio-policy istio-telemetry istio-pilot istio-egressgateway istio-ingressgateway istio-sidecar-injector grafana)
-      else
-        expected_apps=(istio-citadel prometheus jaeger-query jaeger-collector istio-galley istio-policy istio-telemetry istio-pilot istio-egressgateway istio-ingressgateway istio-sidecar-injector grafana)
-      fi
-
-      for expected in ${expected_apps[@]}
-      do
-        echo -n "Waiting for $expected ..."
-        while ! checkApp $expected
-        do
-             sleep 5
-             echo -n '.'
-        done
-        echo "done."
-      done
-
-      echo "Waiting for Istio Deployments to be created..."
-      for app in $(${MAISTRA_ISTIO_OC_COMMAND} get deployment.apps -n istio-system -o jsonpath='{range .items[*]}{.metadata.name}{" "}{end}' 2> /dev/null)
-      do
-         echo -n "Waiting for ${app} to be ready..."
-         readyReplicas="0"
-         while [ "$?" != "0" -o "$readyReplicas" == "0" ]
-         do
-            sleep 1
-            echo -n '.'
-            readyReplicas="$(${MAISTRA_ISTIO_OC_COMMAND} get deployment.app/${app} -n istio-system -o jsonpath='{.status.readyReplicas}' 2> /dev/null)"
-         done
-         echo "done."
-      done
-    fi
-  fi
-
-  if [ "${KIALI_ENABLED}" == "true" ]; then
-    echo "Deleting any previously existing Kiali..."
-    ${MAISTRA_ISTIO_OC_COMMAND} delete all,secrets,sa,templates,configmaps,deployments,clusterroles,clusterrolebindings,virtualservices,destinationrules --selector=app=kiali -n istio-system
-    echo "Deploying Kiali..."
-    get_downloader
-    eval ${DOWNLOADER} /tmp/deploy-kiali-operator.sh https://raw.githubusercontent.com/kiali/kiali/${KIALI_VERSION}/operator/deploy/deploy-kiali-operator.sh
-    chmod +x /tmp/deploy-kiali-operator.sh
-    OPERATOR_VERSION_LABEL=${KIALI_VERSION} \
-    OPERATOR_IMAGE_VERSION=${KIALI_VERSION}  \
-    VERSION_LABEL=${KIALI_VERSION} \
-    IMAGE_VERSION=${KIALI_VERSION}  \
-    AUTH_STRATEGY=${KIALI_AUTH_STRATEGY}  \
-    CREDENTIALS_USERNAME=${KIALI_USERNAME}  \
-    CREDENTIALS_PASSPHRASE=${KIALI_PASSPHRASE} /tmp/deploy-kiali-operator.sh
-  fi
-
-  if [ "${KNATIVE_ENABLED}" == "true" ]; then
-    echo "Setting up security policy for knative..."
-
-    ${MAISTRA_ISTIO_OC_COMMAND} adm policy add-scc-to-user anyuid -z build-controller -n knative-build
-    ${MAISTRA_ISTIO_OC_COMMAND} adm policy add-scc-to-user anyuid -z controller -n knative-serving
-    ${MAISTRA_ISTIO_OC_COMMAND} adm policy add-scc-to-user anyuid -z autoscaler -n knative-serving
-    ${MAISTRA_ISTIO_OC_COMMAND} adm policy add-scc-to-user anyuid -z kube-state-metrics -n knative-monitoring
-    ${MAISTRA_ISTIO_OC_COMMAND} adm policy add-scc-to-user anyuid -z node-exporter -n knative-monitoring
-    ${MAISTRA_ISTIO_OC_COMMAND} adm policy add-scc-to-user anyuid -z prometheus-system -n knative-monitoring
-    ${MAISTRA_ISTIO_OC_COMMAND} adm policy add-cluster-role-to-user cluster-admin -z build-controller -n knative-build
-    ${MAISTRA_ISTIO_OC_COMMAND} adm policy add-cluster-role-to-user cluster-admin -z controller -n knative-serving
-
-    curl -L https://github.com/knative/serving/releases/download/v0.4.1/serving.yaml \
-      | sed 's/LoadBalancer/NodePort/' \
-      | ${MAISTRA_ISTIO_OC_COMMAND} apply -f -
-
-    echo "Waiting for Knative to become ready"
-    sleep 5; while echo && ${MAISTRA_ISTIO_OC_COMMAND} get pods -n knative-serving | grep -v -E "(Running|Completed|STATUS)"; do sleep 5; done
-
-    echo "Creating a new project for knative examples"
-    ${MAISTRA_ISTIO_OC_COMMAND} new-project knative-examples || true
-
-    echo "Applying default domain to knative pods"
-
-    export DOMAIN=$(${MAISTRA_ISTIO_OC_COMMAND} get route -n istio-system istio-ingressgateway --output=custom-columns=ROUTE:.spec.host | grep -v ROUTE | sed "s/istio-ingressgateway-istio-system.//g")
-    cat ${SCRIPT_ROOT}/knative/config-domain.yaml | envsubst | oc apply -f -
-
-    echo "Using domain: *.knative.${DOMAIN}"
-
-    ${MAISTRA_ISTIO_OC_COMMAND} adm policy add-scc-to-user privileged -z default -n knative-examples
-    ${MAISTRA_ISTIO_OC_COMMAND} label --overwrite namespace knative-examples istio-injection=enabled
-
-    echo "Knative is installed!"
-
-    echo "Installing a sample application for knative..."
-    ${MAISTRA_ISTIO_OC_COMMAND} delete -n knative-examples -f ${SCRIPT_ROOT}/knative/service.yaml || true
-    ${MAISTRA_ISTIO_OC_COMMAND} apply -n knative-examples -f ${SCRIPT_ROOT}/knative/service.yaml
-  fi
-
-elif [ "$_CMD" = "down" ];then
-
-  echo "Will shutdown the OpenShift cluster"
-  ${MAISTRA_ISTIO_OC_COMMAND} cluster down
-  echo "SUDO ACCESS: unmounting openshift local volumes"
-  mount | grep "openshift.local.volumes" | awk '{ print $3}' | while read FILESYSTEM
-  do
-    if [ "${FILESYSTEM}" ] ; then
-      sudo umount "${FILESYSTEM}"
-    fi
-  done
-  # only purge these if we do not want persistence
-  if [ "${OPENSHIFT_PERSISTENCE_ARGS}" == "" ]; then
-    echo "SUDO ACCESS: Purging /var/lib/origin files"
-    sudo rm -rf /var/lib/origin/* && sudo rmdir /var/lib/origin
-  else
-    echo "OpenShift has left your persisted data here: ${OPENSHIFT_PERSISTENCE_DIR}"
-  fi
-
-elif [ "$_CMD" = "status" ];then
-
-  ${MAISTRA_ISTIO_OC_COMMAND} version
-  ${MAISTRA_ISTIO_OC_COMMAND} cluster status
-
-else
-  echo "ERROR: Required command must be either: up, down, or status"
-  exit 1
-fi
diff --git a/hack/istio/install-istio-kiali-via-helm.sh b/hack/istio/install-istio-kiali-via-helm.sh
deleted file mode 100755
index dd2e13c3c..000000000
--- a/hack/istio/install-istio-kiali-via-helm.sh
+++ /dev/null
@@ -1,379 +0,0 @@
-#!/bin/bash
-
-##############################################################################
-# install-istio-kiali-via-helm.sh
-#
-# Installs the Istio into your cluster (either Kubernetes or OpenShift)
-# using Helm.
-#
-# If you do not yet have it, this script will download a copy of Istio.
-#
-# You should have "helm" version 2.10 or higher installed and in your PATH.
-# If you do not, a temporary helm will be installed locally.
-#
-# See --help for more details on options to this script.
-#
-##############################################################################
-
-# ISTIO_DIR is where the Istio download is installed and thus where the Helm charts are found.
-# CLIENT_EXE_NAME must be either "oc", "kubectl".
-ISTIO_DIR=
-CLIENT_EXE_NAME="oc"
-NAMESPACE="istio-system"
-USERNAME="admin"
-PASSPHRASE="admin"
-MTLS="false"
-DELETE_ISTIO="false"
-KIALI_ENABLED="false"
-KIALI_CREATE_SECRET="false"
-DASHBOARDS_ENABLED="false"
-USE_DEMO_VALUES="false"
-USE_DEMO_AUTH_VALUES="false"
-ISTIO_EGRESSGATEWAY_ENABLED="true"
-#HELM_REPO_TO_ADD="https://gcsweb.istio.io/gcs/istio-prerelease/daily-build/release-1.1-latest-daily/charts/"
-
-# process command line args
-while [[ $# -gt 0 ]]; do
-  key="$1"
-  case $key in
-    -c|--client-exe)
-      CLIENT_EXE_NAME="$2"
-      shift;shift
-      ;;
-    -cp|--client-exe-path)
-      CLIENT_EXE="$2"
-      shift;shift
-      ;;
-    -di|--delete-istio)
-      if [ "${2}" == "true" ] || [ "${2}" == "false" ]; then
-        DELETE_ISTIO="$2"
-      else
-        echo "ERROR: The --delete-istio flag must be 'true' or 'false'"
-        exit 1
-      fi
-      shift;shift
-      ;;
-    -de|--dashboards-enabled)
-      if [ "${2}" == "true" ] || [ "${2}" == "false" ]; then
-        DASHBOARDS_ENABLED="$2"
-      else
-        echo "ERROR: The --dashboards-enabled flag must be 'true' or 'false'"
-        exit 1
-      fi
-      shift;shift
-      ;;
-    -hr|--helm-repo)
-      HELM_REPO_TO_ADD="$2"
-      shift;shift
-      ;;
-    -id|--istio-dir)
-      ISTIO_DIR="$2"
-      shift;shift
-      ;;
-    -iee|--istio-egressgateway-enabled)
-      if [ "${2}" == "true" ] || [ "${2}" == "false" ]; then
-        ISTIO_EGRESSGATEWAY_ENABLED="$2"
-      else
-        echo "ERROR: The --istio-egressgateway-enabled flag must be 'true' or 'false'"
-        exit 1
-      fi
-      shift;shift
-      ;;
-    -ke|--kiali-enabled)
-      if [ "${2}" == "true" ] || [ "${2}" == "false" ]; then
-        KIALI_ENABLED="$2"
-        KIALI_CREATE_SECRET="$2"
-      else
-        echo "ERROR: The --kiali-enabled flag must be 'true' or 'false'"
-        exit 1
-      fi
-      shift;shift
-      ;;
-    -kt|--kiali-tag)
-      KIALI_TAG="$2"
-      shift;shift
-      ;;
-    -m|--mtls)
-      if [ "${2}" == "true" ] || [ "${2}" == "false" ]; then
-        MTLS="$2"
-      else
-        echo "ERROR: The --mtls flag must be 'true' or 'false'"
-        exit 1
-      fi
-      shift;shift
-      ;;
-    -n|--namespace)
-      NAMESPACE="$2"
-      shift;shift
-      ;;
-    -p|--passphrase)
-      PASSPHRASE="$2"
-      shift;shift
-      ;;
-    -u|--username)
-      USERNAME="$2"
-      shift;shift
-      ;;
-    -s|--set)
-      CUSTOM_HELM_VALUES="${CUSTOM_HELM_VALUES} --set $2"
-      shift;shift
-      ;;
-    -ud|--use-demo)
-      if [ "${2}" == "true" ] || [ "${2}" == "false" ]; then
-        USE_DEMO_VALUES="$2"
-      else
-        echo "ERROR: The --use-demo flag must be 'true' or 'false'"
-        exit 1
-      fi
-      shift;shift
-      ;;
-    -uda|--use-demo-auth)
-      if [ "${2}" == "true" ] || [ "${2}" == "false" ]; then
-        USE_DEMO_AUTH_VALUES="$2"
-      else
-        echo "ERROR: The --use-demo-auth flag must be 'true' or 'false'"
-        exit 1
-      fi
-      shift;shift
-      ;;
-    -h|--help)
-      cat <<HELPMSG
-Valid command line arguments:
-  -c|--client-exe <name>:
-       Cluster client executable name - valid values are "kubectl" or "oc".
-       Default: oc
-  -cp|--client-exe-path <full path to client exec>:
-       Cluster client executable path - e.g. "/bin/kubectl" or "minikube kubectl --"
-       This value overrides any other value set with --client-exe
-  -di|--delete-istio (true|false):
-       Set to 'true' if you want to delete Istio, rather than install it.
-       Default: false
-  -de|--dashboards-enabled (true|false):
-       Set to 'true' if you want Jaeger and Grafana installed.
-       Ignored if --use-demo or --use-demo-auth is true.
-       Default: false
-  -hr|--helm-repo <url>:
-       Will add this Istio Helm repo which contains remote Istio dependencies.
-       Default: ${HELM_REPO_TO_ADD}
-  -id|--istio-dir <dir>:
-       Where Istio has already been downloaded. If not found, this script aborts.
-  -iee|--istio-egressgateway-enabled (true|false)
-       When set to true, istio-egressgateway will be installed.
-       Default: true
-  -ke|--kiali-enabled (true|false):
-       When set to true, Kiali will be installed.
-       Ignored if --use-demo or --use-demo-auth is true.
-       Default: false
-  -kt|--kiali-tag <tag>:
-       Defines the docker tag that will identify the image to be pulled when Kiali is deployed.
-       If you want the latest-and-greatest image, set this to "latest".
-       If you have locally built your own development version of Kiali, set this to "dev".
-       Ignored if --use-demo or --use-demo-auth is true.
-       Default: the tag default defined by the Istio Helm chart
-  -m|--mtls (true|false):
-       Indicate if you want global MTLS enabled.
-       Ignored if --use-demo or --use-demo-auth is true.
-       Default: false
-  -n|--namespace <name>:
-       Install Istio in this namespace.
-       Default: istio-system
-  -p|--passphrase <pass>:
-       The passphrase for the Kiali secret - this is the password to use when logging into Kiali.
-       Default: admin
-  -u|--username <uname>:
-       The username for the Kiali secret - this is the name to use when logging into Kiali.
-       Default: admin
-  -s|--set <name=value>:
-       Sets a name/value pair for a custom helm value.
-  -ud|--use-demo (true|false):
-       If true, use install settings equal to the istio-demo.yaml.
-       Only one of --use-demo or --use-demo-auth is allowed to be true.
-       Default: false
-  -uda|--use-demo-auth (true|false):
-       If true, use install settings equal to the istio-demo-auth.yaml.
-       Only one of --use-demo or --use-demo-auth is allowed to be true.
-       Default: false
-  -h|--help:
-       this message
-HELPMSG
-      exit 1
-      ;;
-    *)
-      echo "ERROR: Unknown argument [$key]. Aborting."
-      exit 1
-      ;;
-  esac
-done
-
-if [ "${USE_DEMO_VALUES}" == "true" -a "${USE_DEMO_AUTH_VALUES}" == "true" ]; then
-  echo "ERROR: Setting both --use-demo and --use-demo-auth to true is not allowed. Aborting."
-  exit 1
-fi
-if [ "${USE_DEMO_VALUES}" == "true" -o "${USE_DEMO_AUTH_VALUES}" == "true" ]; then
-  # we know Kiali is always enabled in the demo yamls but we do not need to create the secret
-  KIALI_ENABLED="true"
-  KIALI_CREATE_SECRET="false"
-fi
-
-if [ "${CLIENT_EXE}" = "" ]; then
-  CLIENT_EXE=`which "${CLIENT_EXE_NAME}"`
-  if [ "$?" = "0" ]; then
-    echo "The cluster client executable is found here: ${CLIENT_EXE}"
-  else
-    echo "ERROR: You must install the cluster client ${CLIENT_EXE_NAME} in your PATH before you can continue."
-    exit 1
-  fi
-fi
-
-HELM_EXE=`which helm`
-if [ "$?" = "0" ]; then
-  echo "The helm executable is found here: ${HELM_EXE}"
-else
-  TMP_HELM_DIR=${TMP_HELM_DIR:-/tmp/helm-install}
-  mkdir -p ${TMP_HELM_DIR}
-  export PATH=${PATH}:${TMP_HELM_DIR}
-  echo "You do not have helm installed in your PATH. A temporary helm installation will be downloaded to ${TMP_HELM_DIR}..."
-  curl https://raw.githubusercontent.com/helm/helm/master/scripts/get > ${TMP_HELM_DIR}/get_helm.sh
-  chmod 700 ${TMP_HELM_DIR}/get_helm.sh
-  HELM_INSTALL_DIR=${TMP_HELM_DIR} ${TMP_HELM_DIR}/get_helm.sh --no-sudo
-  HELM_EXE=${TMP_HELM_DIR}/helm
-  echo "Helm temporarily installed at ${HELM_EXE}. Version is: $(${HELM_EXE} version)"
-fi
-
-if [ "${ISTIO_DIR}" == "" ]; then
-  # Go to the main output directory and try to find an Istio there.
-  HACK_SCRIPT_DIR="$(cd $(dirname "${BASH_SOURCE[0]}") && pwd)"
-  OUTPUT_DIR="${OUTPUT_DIR:-${HACK_SCRIPT_DIR}/../../_output}"
-  ALL_ISTIOS=$(ls -dt1 ${OUTPUT_DIR}/istio-*)
-  if [ "$?" != "0" ]; then
-    ${HACK_SCRIPT_DIR}/download-istio.sh
-    if [ "$?" != "0" ]; then
-      echo "ERROR: You do not have Istio installed and it cannot be downloaded."
-      exit 1
-    fi
-  fi
-  # install the Istio release that was last downloaded (that's the -t option to ls)
-  ISTIO_DIR=$(ls -dt1 ${OUTPUT_DIR}/istio-* | head -n1)
-fi
-
-if [ ! -d "${ISTIO_DIR}" ]; then
-   echo "ERROR: Istio cannot be found at: ${ISTIO_DIR}"
-   exit 1
-fi
-
-echo "Istio is found here: ${ISTIO_DIR}"
-
-# When installing Istio (i.e. not deleting it) perform some preparation steps
-if [ "${DELETE_ISTIO}" != "true" ]; then
-  # Create the istio-system namespace
-  # If OpenShift, we need to do some additional things - see:
-  #   https://istio.io/docs/setup/kubernetes/platform-setup/openshift/
-  echo Creating the namespace: ${NAMESPACE}
-  if [[ "${CLIENT_EXE}" = *"oc" ]]; then
-    ${CLIENT_EXE} new-project ${NAMESPACE}
-    echo Performing additional commands for OpenShift
-    ${CLIENT_EXE} adm policy add-scc-to-group anyuid system:serviceaccounts -n ${NAMESPACE}
-  else
-    ${CLIENT_EXE} create namespace ${NAMESPACE}
-  fi
-
-  # Create the kiali secret
-  if [ "${KIALI_CREATE_SECRET}" == "true" ]; then
-    _ENCODED_USERNAME=$(echo -n "${USERNAME}" | base64)
-    _ENCODED_PASSPHRASE=$(echo -n "${PASSPHRASE}" | base64)
-
-    echo Creating the Kiali secret
-    cat <<EOF | ${CLIENT_EXE} apply -f -
-apiVersion: v1
-kind: Secret
-metadata:
-  name: kiali
-  namespace: ${NAMESPACE}
-  labels:
-    app: kiali
-type: Opaque
-data:
-  username: ${_ENCODED_USERNAME}
-  passphrase: ${_ENCODED_PASSPHRASE}
-EOF
-  fi
-fi
-
-# Determine if we should be using the demo values. If so, see if the demo yaml already exists, otherwise,
-# we will use the demo values file to build it.
-rm -f /tmp/istio.yaml
-if [ -f "/tmp/istio.yaml" ]; then
-  echo "ERROR: There is an existing /tmp/istio.yaml installation file in the way that cannot be deleted"
-  exit 1
-fi
-
-if [ "${USE_DEMO_VALUES}" == "true" ]; then
-  _HELM_VALUES="--values ${ISTIO_DIR}/install/kubernetes/helm/istio/values-istio-demo.yaml"
-elif [ "${USE_DEMO_AUTH_VALUES}" == "true" ]; then
-  _HELM_VALUES="--values ${ISTIO_DIR}/install/kubernetes/helm/istio/values-istio-demo-auth.yaml"
-else
-  if [ "${KIALI_TAG}" != "" ]; then
-    _KIALI_TAG_ARG="--set kiali.tag=${KIALI_TAG}"
-  fi
-  _HELM_VALUES="--set kiali.enabled=${KIALI_ENABLED} ${_KIALI_TAG_ARG} --set tracing.enabled=${DASHBOARDS_ENABLED} --set grafana.enabled=${DASHBOARDS_ENABLED} --set global.mtls.enabled=${MTLS} --set global.controlPlaneSecurityEnabled=${MTLS} --set gateways.istio-egressgateway.enabled=${ISTIO_EGRESSGATEWAY_ENABLED}"
-fi
-
-# Create the install yaml via the helm template command
-if [ ! -f "/tmp/istio.yaml" ]; then
-  echo Initializing client-side helm...
-  ${HELM_EXE} init --client-only
-  if [ ! -z "${HELM_REPO_TO_ADD}" ]; then
-    echo Adding Helm repo: ${HELM_REPO_TO_ADD}
-    ${HELM_EXE} repo add istio.io ${HELM_REPO_TO_ADD}
-    echo Updating Helm dependencies...
-    ${HELM_EXE} dep update "${ISTIO_DIR}/install/kubernetes/helm/istio"
-  fi
-  echo Building Helm yaml for Istio...
-  ${HELM_EXE} template ${_HELM_VALUES} ${CUSTOM_HELM_VALUES} "${ISTIO_DIR}/install/kubernetes/helm/istio" --name istio --namespace ${NAMESPACE} --set istio_cni.enabled=true --set cni.components.cni.enabled=true > /tmp/istio.yaml
-fi
-
-if [ "${DELETE_ISTIO}" == "true" ]; then
-  echo DELETING ISTIO!
-  ${CLIENT_EXE} delete -f /tmp/istio.yaml
-  ${HELM_EXE} template "${ISTIO_DIR}/install/kubernetes/helm/istio-init" --name istio-init --namespace ${NAMESPACE} | ${CLIENT_EXE} delete -f -
-  ${CLIENT_EXE} delete -f "${ISTIO_DIR}/install/kubernetes/helm/istio-init/files"
-  ${HELM_EXE} template "${ISTIO_DIR}/install/kubernetes/helm/istio-cni" --name istio-cni --namespace kube-system --set cniBinDir=/var/lib/cni/bin --set cniConfDir=/var/run/multus/cni/net.d | ${CLIENT_EXE} delete -f -
-  ${CLIENT_EXE} delete namespace ${NAMESPACE}
-
-  if [[ "${CLIENT_EXE}" = *"oc" ]]; then
-    echo "NOTICE! You need to do this for all app namespaces:"
-    echo " ${CLIENT_EXE} adm policy remove-scc-from-group privileged system:serviceaccounts -n bookinfo"
-    echo " ${CLIENT_EXE} adm policy remove-scc-from-group anyuid system:serviceaccounts -n bookinfo"
-  fi
-else
-  echo "Installing CNI..."
-  ${HELM_EXE} template "${ISTIO_DIR}/install/kubernetes/helm/istio-cni" --name istio-cni --namespace kube-system --set cniBinDir=/var/lib/cni/bin --set cniConfDir=/var/run/multus/cni/net.d | ${CLIENT_EXE} apply -f -
-  echo "Initializing Istio..."
-  ${HELM_EXE} template "${ISTIO_DIR}/install/kubernetes/helm/istio-init" --name istio-init --namespace ${NAMESPACE} | ${CLIENT_EXE} apply -f -
-  echo "Waiting for Istio Init jobs to finish - this might take a while..."
-  ${CLIENT_EXE} -n ${NAMESPACE} wait --for=condition=complete job --all --timeout=-1s
-  echo "Installing Istio..."
-  ${CLIENT_EXE} apply -f /tmp/istio.yaml
-
-  if [[ "${CLIENT_EXE}" = *"oc" ]]; then
-    # If OpenShift, expose some routes
-    ${CLIENT_EXE} -n ${NAMESPACE} expose svc/istio-ingressgateway --port=80
-    ${CLIENT_EXE} -n ${NAMESPACE} expose svc/kiali
-    ${CLIENT_EXE} -n ${NAMESPACE} expose svc/prometheus
-
-    echo "NOTICE! You need to do this for all app namespaces:"
-    echo " ${CLIENT_EXE} adm policy add-scc-to-group privileged system:serviceaccounts -n bookinfo"
-    echo " ${CLIENT_EXE} adm policy add-scc-to-group anyuid system:serviceaccounts -n bookinfo"
-  fi
-
-  if [ "${KIALI_ENABLED}" == "true" ]; then
-    ${CLIENT_EXE} patch clusterrole kiali -p '[{"op":"add", "path":"/rules/-", "value":{"apiGroups":["apps.openshift.io"], "resources":["deploymentconfigs"],"verbs": ["get", "list", "watch"]}}]' --type json
-    ${CLIENT_EXE} patch clusterrole kiali -p '[{"op":"add", "path":"/rules/-", "value":{"apiGroups":["project.openshift.io"], "resources":["projects"],"verbs": ["get"]}}]' --type json
-    ${CLIENT_EXE} patch clusterrole kiali -p '[{"op":"add", "path":"/rules/-", "value":{"apiGroups":["route.openshift.io"], "resources":["routes"],"verbs": ["get"]}}]' --type json
-  fi
-
-  # make sure CNI is enabled, this should return "true"
-  if [ "$($CLIENT_EXE -n ${NAMESPACE} get cm istio-sidecar-injector -ojsonpath='{.data.values}' | jq '.istio_cni.enabled')" != "true" ]; then
-    echo "WARNING: CNI IS NOT ENABLED - THINGS WILL NOT WORK ON OPENSHIFT"
-  fi
-fi
diff --git a/hack/istio/install-istio-via-istioctl.sh b/hack/istio/install-istio-via-istioctl.sh
index 91ce80bf2..b1d37ada3 100755
--- a/hack/istio/install-istio-via-istioctl.sh
+++ b/hack/istio/install-istio-via-istioctl.sh
@@ -29,7 +29,7 @@ while [[ $# -gt 0 ]]; do
   key="$1"
   case $key in
     -a|--addons)
-      ADDONS="prometheus $2"
+      ADDONS="$2"
       shift;shift
       ;;
     -c|--client-exe)
@@ -94,7 +94,6 @@ Valid command line arguments:
        The names of the addons you want to install along with the core Istio components.
        Make sure this value is space-separated. Valid addon names can be found in your Istio
        distribution directory samples/addons.
-       Note that "prometheus" will always be added to the list of addons, so you do not have to specify it.
        Default: prometheus grafana jaeger
   -c|--client-exe <name>:
        Cluster client executable name - valid values are "kubectl" or "oc".
@@ -127,8 +126,6 @@ Valid command line arguments:
   -s|--set <name=value>:
        Sets a name/value pair for a custom install setting. Some examples you may want to use:
        --set installPackagePath=/git/clone/istio.io/installer
-       --set components.telemetry.k8s.resources.requests.memory=100Mi
-       --set components.telemetry.k8s.resources.requests.cpu=50m
   -h|--help:
        this message
 HELPMSG
@@ -184,22 +181,14 @@ echo "istioctl is found here: ${ISTIOCTL}"
 
 # If OpenShift, install CNI
 if [[ "${CLIENT_EXE}" = *"oc" ]]; then
-  # Istio 1.4 had different option names than 1.5+
-  if ${ISTIOCTL} --remote=false version | grep -q "1\.4" ; then
-    CNI_OPTIONS="--set cni.enabled=true --set cni.components.cni.enabled=true --set cni.components.cni.namespace=kube-system --set values.cni.cniBinDir=/var/lib/cni/bin --set values.cni.cniConfDir=/var/run/multus/cni/net.d"
-    TELEMETRY_OPTIONS="--set telemetry.components.telemetry.k8s.resources.requests.memory=100Mi --set telemetry.components.telemetry.k8s.resources.requests.cpu=50m"
-  else
-    CNI_OPTIONS="--set components.cni.enabled=true --set components.cni.namespace=kube-system --set values.cni.cniBinDir=/var/lib/cni/bin --set values.cni.cniConfDir=/etc/cni/multus/net.d --set values.cni.chained=false --set values.cni.cniConfFileName=istio-cni.conf --set values.sidecarInjectorWebhook.injectedAnnotations.k8s\.v1\.cni\.cncf\.io/networks=istio-cni"
-    TELEMETRY_OPTIONS="--set components.telemetry.k8s.resources.requests.memory=100Mi --set components.telemetry.k8s.resources.requests.cpu=50m"
+  CNI_OPTIONS="--set components.cni.enabled=true --set components.cni.namespace=kube-system --set values.cni.cniBinDir=/var/lib/cni/bin --set values.cni.cniConfDir=/etc/cni/multus/net.d --set values.cni.chained=false --set values.cni.cniConfFileName=istio-cni.conf --set values.sidecarInjectorWebhook.injectedAnnotations.k8s\.v1\.cni\.cncf\.io/networks=istio-cni"
 
-    # Istio 1.5 used global.mtls.auto, 1.6 renamed it
-    if ${ISTIOCTL} --remote=false version | grep -q "1\.5" ; then
-      MTLS_OPTIONS="--set values.global.mtls.auto=${MTLS}"
-    else
-      MTLS_OPTIONS="--set values.meshConfig.enableAutoMtls=${MTLS}"
-    fi
-    MTLS_OPTIONS="${MTLS_OPTIONS} --set values.global.controlPlaneSecurityEnabled=${MTLS}"
+  # Istio 1.8 removed mixer - so components.telemetry settings are only valid from 1.7 and earlier.
+  if ${ISTIOCTL} --remote=false version | grep -q "1\.6\|1\.7" ; then
+    TELEMETRY_OPTIONS="--set components.telemetry.k8s.resources.requests.memory=100Mi --set components.telemetry.k8s.resources.requests.cpu=50m"
   fi
+
+  MTLS_OPTIONS="--set values.meshConfig.enableAutoMtls=${MTLS}"
 fi
 
 # When installing Istio (i.e. not deleting it) perform some preparation steps
@@ -250,9 +239,12 @@ if [ "${DELETE_ISTIO}" == "true" ]; then
     echo "  oc -n <target-namespace> delete network-attachment-definition istio-cni"
     echo "===== IMPORTANT ====="
   fi
+
+  echo "Deleting the istio namespace [${NAMESPACE}]"
+  ${CLIENT_EXE} delete namespace ${NAMESPACE}
 else
   echo Installing Istio...
-  ${ISTIOCTL} manifest install --set profile=${CONFIG_PROFILE} ${MANIFEST_CONFIG_SETTINGS_TO_APPLY}
+  ${ISTIOCTL} manifest install --skip-confirmation=true --set profile=${CONFIG_PROFILE} ${MANIFEST_CONFIG_SETTINGS_TO_APPLY}
   if [ "$?" != "0" ]; then
     echo "Failed to install Istio with profile [${CONFIG_PROFILE}]"
     exit 1
diff --git a/hack/istio/install-rh-tutorial.sh b/hack/istio/install-rh-tutorial.sh
deleted file mode 100755
index a70d8e4aa..000000000
--- a/hack/istio/install-rh-tutorial.sh
+++ /dev/null
@@ -1,195 +0,0 @@
-#!/bin/bash
-
-##############################################################################
-# install-rh-tutorial.sh
-#
-# Installs the Red Hat Istio Tutorial into your cluster.
-#
-# This requires java and maven in your PATH.
-#
-# See --help for more details on options to this script.
-#
-##############################################################################
-
-# ISTIO_DIR is where the Istio download is installed
-# CLIENT_EXE_NAME is going to either be "oc", "kubectl", or "istiooc"
-# RATE is the rate of requests pushed into the mesh via the traffic generator (if enabled)
-# TUTORIAL_DIR is the parent directory where the tutorial files will be git cloned
-ISTIO_DIR=
-CLIENT_EXE_NAME="oc"
-NAMESPACE="tutorial"
-RATE=1
-TUTORIAL_DIR="/tmp"
-
-# process command line args
-while [[ $# -gt 0 ]]; do
-  key="$1"
-  case $key in
-    -id|--istio-dir)
-      ISTIO_DIR="$2"
-      shift;shift
-      ;;
-    -td|--tutorial-dir)
-      TUTORIAL_DIR="$2"
-      shift;shift
-      ;;
-    -c|--client-exe)
-      CLIENT_EXE_NAME="$2"
-      shift;shift
-      ;;
-    -n|--namespace)
-      NAMESPACE="$2"
-      shift;shift
-      ;;
-    -tg|--traffic-generator)
-      TRAFFIC_GENERATOR_ENABLED="true"
-      shift;
-      ;;
-    -h|--help)
-      cat <<HELPMSG
-Valid command line arguments:
-  -id|--istio-dir <dir>: Where Istio has already been downloaded. If not found, this script aborts.
-  -td|--tutorial-dir <dir>: Where the tutorial files will be git cloned. Default: /tmp
-  -c|--client-exe <name>: Cluster client executable name - valid values are "kubectl" or "oc" or "istiooc"
-  -n|--namespace <name>: Install the demo in this namespace (default: tutorial)
-  -tg|--traffic-generator: Install Kiali Traffic Generator
-  -h|--help : this message
-HELPMSG
-      exit 1
-      ;;
-    *)
-      echo "Unknown argument [$key]. Aborting."
-      exit 1
-      ;;
-  esac
-done
-
-if [ "${ISTIO_DIR}" == "" ]; then
-  # Go to the main output directory and try to find an Istio there.
-  HACK_SCRIPT_DIR="$(cd $(dirname "${BASH_SOURCE[0]}") && pwd)"
-  OUTPUT_DIR="${OUTPUT_DIR:-${HACK_SCRIPT_DIR}/../../_output}"
-  ALL_ISTIOS=$(ls -d1 ${OUTPUT_DIR}/istio-*)
-  if [ "$?" != "0" ]; then
-    ${HACK_SCRIPT_DIR}/download-istio.sh
-    if [ "$?" != "0" ]; then
-      echo "ERROR: You do not have Istio installed and it cannot be downloaded"
-      exit 1
-    fi
-  fi
-  ISTIO_DIR=$(ls -d1 ${OUTPUT_DIR}/istio-* | head -n1)
-fi
-
-if [ ! -d "${ISTIO_DIR}" ]; then
-   echo "ERROR: Istio cannot be found at: ${ISTIO_DIR}"
-   exit 1
-fi
-
-echo "Istio is found here: ${ISTIO_DIR}"
-if [[ -x "${ISTIO_DIR}/bin/istioctl" ]]; then
-  echo "istioctl is found here: ${ISTIO_DIR}/bin/istioctl"
-  ISTIOCTL="${ISTIO_DIR}/bin/istioctl"
-  ${ISTIOCTL} version
-else
-  echo "ERROR: istioctl is NOT found at ${ISTIO_DIR}/bin/istioctl"
-  exit 1
-fi
-
-CLIENT_EXE=`which ${CLIENT_EXE_NAME}`
-if [ "$?" = "0" ]; then
-  echo "The cluster client executable is found here: ${CLIENT_EXE}"
-else
-  echo "You must install the cluster client ${CLIENT_EXE_NAME} in your PATH before you can continue"
-  exit 1
-fi
-
-# If OpenShift, we need to do some additional things
-if [[ "$CLIENT_EXE" = *"oc" ]]; then
-  $CLIENT_EXE new-project ${NAMESPACE}
-  $CLIENT_EXE adm policy add-scc-to-user anyuid -z default -n ${NAMESPACE}
-  $CLIENT_EXE adm policy add-scc-to-user privileged -z default -n ${NAMESPACE}
-else
-  $CLIENT_EXE create namespace ${NAMESPACE}
-fi
-
-# Git clone the tutorial
-cd ${TUTORIAL_DIR}
-git clone https://github.com/redhat-developer-demos/istio-tutorial.git
-cd istio-tutorial
-git checkout book-1.0.4
-
-# BUILD IMAGES
-
-# customer
-echo BUILDING THE CUSTOMER MICROSERVICE
-cd customer/java/springboot
-mvn clean package && docker build -t example/customer .
-if [ "$?" != "0" ]; then
-  echo "Failed to build the customer image"
-  exit 1
-fi
-cd ../../..
-
-# preference
-echo BUILDING THE PREFERENCE MICROSERVICE
-cd preference/java/springboot
-mvn clean package && docker build -t example/preference:v1 .
-if [ "$?" != "0" ]; then
-  echo "Failed to build the preference image"
-  exit 1
-fi
-cd ../../..
-
-# recommendation
-echo BUILDING THE RECOMMENDATION MICROSERVICE
-cd recommendation/java/vertx
-mvn clean package && docker build -t example/recommendation:v1 .
-if [ "$?" != "0" ]; then
-  echo "Failed to build the recommendation image"
-  exit 1
-fi
-cd ../../..
-
-# DEPLOY IMAGES
-
-echo DEPLOYING THE IMAGES
-
-#customer
-$CLIENT_EXE apply -f <(${ISTIOCTL} kube-inject -f customer/kubernetes/Deployment.yml) -n ${NAMESPACE}
-$CLIENT_EXE create -f customer/kubernetes/Service.yml -n ${NAMESPACE}
-
-# preference
-$CLIENT_EXE apply -f <(${ISTIOCTL} kube-inject -f preference/kubernetes/Deployment.yml) -n ${NAMESPACE}
-$CLIENT_EXE create -f preference/kubernetes/Service.yml -n ${NAMESPACE}
-
-# recommendation
-$CLIENT_EXE apply -f <(${ISTIOCTL} kube-inject -f recommendation/kubernetes/Deployment.yml) -n ${NAMESPACE}
-$CLIENT_EXE create -f recommendation/kubernetes/Service.yml -n ${NAMESPACE}
-
-sleep 4
-
-echo "Tutorial Demo should be installed and starting up - here are the pods and services"
-$CLIENT_EXE get services -n ${NAMESPACE}
-$CLIENT_EXE get pods -n ${NAMESPACE}
-
-# If OpenShift, we need to do some additional things
-if [[ "$CLIENT_EXE" = *"oc" ]]; then
-  $CLIENT_EXE expose svc customer -n ${NAMESPACE}
-  $CLIENT_EXE expose svc istio-ingressgateway -n istio-system
-fi
-
-if [ "${TRAFFIC_GENERATOR_ENABLED}" == "true" ]; then
-  echo "Installing Traffic Generator"
-  if [[ "$CLIENT_EXE" = *"oc" ]]; then
-    INGRESS_ROUTE=$(${CLIENT_EXE} get route customer -o jsonpath='{.spec.host}{"\n"}' -n ${NAMESPACE})
-    echo "Traffic Generator will use the OpenShift ingress route of: ${INGRESS_ROUTE}"
-  else
-    echo "Traffic Generator not configured for use with Kubernetes (yet)"
-  fi
-
-  if [ "${INGRESS_ROUTE}" != "" ] ; then
-    # TODO - these access the "openshift" yaml files - but there are no kubernetes specific versions. using --validate=false
-    curl https://raw.githubusercontent.com/kiali/kiali-test-mesh/master/traffic-generator/openshift/traffic-generator-configmap.yaml | DURATION='0s' ROUTE="http://${INGRESS_ROUTE}" RATE="${RATE}"  envsubst | $CLIENT_EXE create -n ${NAMESPACE} -f -
-    curl https://raw.githubusercontent.com/kiali/kiali-test-mesh/master/traffic-generator/openshift/traffic-generator.yaml | $CLIENT_EXE create --validate=false -n ${NAMESPACE} -f -
-  fi
-fi
-
diff --git a/hack/istio/install-sm10.sh b/hack/istio/install-sm2.sh
similarity index 65%
rename from hack/istio/install-sm10.sh
rename to hack/istio/install-sm2.sh
index 31d441614..e645c1598 100755
--- a/hack/istio/install-sm10.sh
+++ b/hack/istio/install-sm2.sh
@@ -1,9 +1,9 @@
 #!/bin/bash
 
 ##############################################################################
-# install-sm10.sh
+# install-sm2
 #
-# Run this script to install Service Mesh 1.0.
+# Run this script to install Service Mesh 2.x.
 #
 # This script takes one command whose value is one of the following:
 #   sm-install: installs service mesh into the cluster
@@ -51,27 +51,6 @@ get_downloader() {
   debug "Downloader command to be used: ${DOWNLOADER}"
 }
 
-get_registry_names() {
-  local ext=$(${OC} get image.config.openshift.io/cluster -o custom-columns=EXT:.status.externalRegistryHostnames[0] --no-headers 2>/dev/null)
-  local int=$(${OC} get image.config.openshift.io/cluster -o custom-columns=INT:.status.internalRegistryHostname --no-headers 2>/dev/null)
-  EXTERNAL_IMAGE_REGISTRY=${ext:-<unknown>}
-  INTERNAL_IMAGE_REGISTRY=${int:-<unknown>}
-}
-
-get_route_url() {
-  # takes as input "routeName:routeNamespace"
-  local routename=$(echo ${1} | cut -d: -f1)
-  local routenamespace=$(echo ${1} | cut -d: -f2)
-  local protocol="https"
-  local termination=$(${OC} get route ${routename} -n ${routenamespace} -o custom-columns=T:spec.tls.termination --no-headers)
-  if [ "${termination}" == "<none>" ]; then
-    protocol="http"
-  fi
-  local host=$(${OC} get route ${routename} -n ${routenamespace} -o custom-columns=H:spec.host --no-headers)
-
-  ROUTE_URL="${protocol}://${host}"
-}
-
 check_istio_app() {
   local expected="$1"
   apps=$(${OC} get deployment.apps -n ${CONTROL_PLANE_NAMESPACE} -o jsonpath='{range .items[*]}{.metadata.name}{" "}{end}' 2> /dev/null)
@@ -84,22 +63,123 @@ check_istio_app() {
   return 1
 }
 
+apply_smcp() {
+  while ! ${OC} apply -n ${1} -f ${2}
+  do
+    warnmsg "Failed to apply SMCP [${2}] - will retry in 5 seconds to see if the error condition clears up..."
+    sleep 5
+  done
+  infomsg "SMCP [${2}] has been successfully applied to namespace [${1}]."
+}
+
 install_service_mesh() {
+
+  OPENSHIFT_MAJOR_MINOR_VERSION="$(${OC} version | grep "Server" | sed 's/.*\([[:digit:]]\+\.[[:digit:]]\+\)\.[[:digit:]]\+/\1/')"
+
+  # START CODE THAT IS NECESSARY TO PULL CONTENT FROM PRIVATE MAISTRA QUAY REPO
+  if [ "${USE_QUAY}" == "true" ]; then
+
+    if ${OC} get namespace registry-puller; then
+      echo "registry-puller seems to be installed. Good."
+    else
+      echo "It appears you do not have the registry-puller installed. That must be installed. Aborting."
+      exit 1
+    fi
+
+    # Disable all other Operator Sources - we probably don't need to do this
+    ${OC} patch operatorhub cluster -n openshift-marketplace -p '{"spec":{"disableAllDefaultSources": true}}' --type=merge
+
+    if ${OC} get secret sm-pull-secret -n openshift-marketplace; then
+      echo "Operator source secret already created"
+    else
+      # Get the quay token that has access to the private maistra quay.io repo
+      echo -n 'Your quay.io username: ' && read QUAY_USERNAME && echo -n 'Your quay.io password: ' && export QUAY_TOKEN=$(curl --silent -H "Content-Type: application/json" -XPOST https://quay.io/cnr/api/v1/users/login -d '{"user":{"username":"'"${QUAY_USERNAME}"'","password":"'"$(read -s PW && echo -n $PW)"'"}}' | sed -E 's/.*\"(basic .*)\".*/\1/')
+
+      # create operator source secret used to get content from private maistra quay repo
+      cat <<EOM1 | ${OC} apply -f -
+apiVersion: v1
+kind: Secret
+metadata:
+  name: sm-pull-secret
+  namespace: openshift-marketplace
+stringData:
+  token: "$QUAY_TOKEN"
+EOM1
+    fi
+
+    # create operator source to get private maistra content
+    OPERATOR_SOURCE_NAME="maistra-redhat-operators"
+    cat <<EOM2 | ${OC} apply -f -
+apiVersion: operators.coreos.com/v1
+kind: OperatorSource
+metadata:
+  name: $OPERATOR_SOURCE_NAME
+  namespace: openshift-marketplace
+spec:
+  type: appregistry
+  endpoint: https://quay.io/cnr
+  registryNamespace: maistra
+  displayName: "Maistra Operators"
+  publisher: "Maistra Team"
+  authorizationToken:
+    secretName: sm-pull-secret
+EOM2
+  fi
+  # END CODE THAT IS NECESSARY TO PULL CONTENT FROM PRIVATE MAISTRA QUAY REPO
+
   local create_smcp="$1"
-  infomsg "Installing the Service Mesh operator..."
+  OPERATOR_SOURCE_NAME=${OPERATOR_SOURCE_NAME:-redhat-operators}
+  infomsg "Installing the Service Mesh operators..."
   cat <<EOM | ${OC} apply -f -
 apiVersion: operators.coreos.com/v1alpha1
 kind: Subscription
+metadata:
+  name: elasticsearch-operator
+  namespace: openshift-operators
+spec:
+  channel: "${OPENSHIFT_MAJOR_MINOR_VERSION}"
+  installPlanApproval: Automatic
+  name: elasticsearch-operator
+  source: $OPERATOR_SOURCE_NAME
+  sourceNamespace: openshift-marketplace
+---
+apiVersion: operators.coreos.com/v1alpha1
+kind: Subscription
+metadata:
+  name: jaeger-product
+  namespace: openshift-operators
+spec:
+  channel: stable
+  installPlanApproval: Automatic
+  name: jaeger-product
+  source: $OPERATOR_SOURCE_NAME
+  sourceNamespace: openshift-marketplace
+---
+apiVersion: operators.coreos.com/v1alpha1
+kind: Subscription
+metadata:
+  name: kiali-ossm
+  namespace: openshift-operators
+spec:
+  channel: stable
+  installPlanApproval: Automatic
+  name: kiali-ossm
+  source: $OPERATOR_SOURCE_NAME
+  sourceNamespace: openshift-marketplace
+---
+apiVersion: operators.coreos.com/v1alpha1
+kind: Subscription
 metadata:
   name: servicemeshoperator
   namespace: openshift-operators
 spec:
-  channel: '1.0'
+  channel: stable
   installPlanApproval: Automatic
   name: servicemeshoperator
-  source: redhat-operators
+  source: $OPERATOR_SOURCE_NAME
   sourceNamespace: openshift-marketplace
 EOM
+
   if [ "${create_smcp}" == "true" ] ; then
 
     infomsg "Waiting for the operator CRDs to come online"
@@ -157,15 +237,43 @@ EOM
 
     infomsg "Creating control plane namespace: ${CONTROL_PLANE_NAMESPACE}"
     ${OC} create namespace ${CONTROL_PLANE_NAMESPACE}
+
+    infomsg "Wait for the servicemesh validating webhook to be created."
+    while [ "$(${OC} get validatingwebhookconfigurations -o name | grep servicemesh)" == "" ]
+    do
+      echo -n "."
+      sleep 5
+    done
+    echo "done."
+
+    infomsg "Wait for the servicemesh mutating webhook to be created."
+    while [ "$(${OC} get mutatingwebhookconfigurations -o name | grep servicemesh)" == "" ]
+    do
+      echo -n "."
+      sleep 5
+    done
+    echo "done."
+
     infomsg "Installing Maistra via ServiceMeshControlPlane Custom Resource."
     if [ "${MAISTRA_SMCP_YAML}" != "" ]; then
-      ${OC} create -n ${CONTROL_PLANE_NAMESPACE} -f ${MAISTRA_SMCP_YAML}
+      apply_smcp "${CONTROL_PLANE_NAMESPACE}" "${MAISTRA_SMCP_YAML}"
     else
       debug "Using example SMCP/SMMR"
       rm -f /tmp/maistra-smcp.yaml
       get_downloader
-      eval ${DOWNLOADER} /tmp/maistra-smcp.yaml "https://raw.githubusercontent.com/Maistra/istio-operator/maistra-1.0/deploy/examples/maistra_v1_servicemeshcontrolplane_cr_full.yaml"
-      ${OC} create -n ${CONTROL_PLANE_NAMESPACE} -f /tmp/maistra-smcp.yaml
+      eval ${DOWNLOADER} /tmp/maistra-smcp.yaml "https://raw.githubusercontent.com/Maistra/istio-operator/maistra-2.0/deploy/examples/maistra_v2_servicemeshcontrolplane_cr_full.yaml"
+
+      # The example we just downloaded doesn't specify a version. We could set it explicitly to v2.0
+      # but the webhook will set the value to v2.0 for us automagically.
+      #sed -i 's/istio:/version: v2.0\n  istio:/' /tmp/maistra-smcp.yaml
+
+      apply_smcp "${CONTROL_PLANE_NAMESPACE}" "/tmp/maistra-smcp.yaml"
+
+      # START CODE THAT IS NECESSARY TO PULL CONTENT FROM PRIVATE MAISTRA QUAY REPO
+      if [ "${USE_QUAY}" == "true" ]; then
+        ${OC} patch smcp full-install -n ${CONTROL_PLANE_NAMESPACE} -p '{"spec": {"istio": {"global": {"tag": "2.0.0", "hub":"quay.io/maistra"}}}}' --type=merge
+      fi
+      # END CODE THAT IS NECESSARY TO PULL CONTENT FROM PRIVATE MAISTRA QUAY REPO
     fi
   else
     infomsg "The operators should be available but the Maistra SMCP CR will not be created."
@@ -242,7 +350,10 @@ while [[ $# -gt 0 ]]; do
       MAISTRA_SMCP_YAML="$2"
       shift;shift
       ;;
-
+    -uq|--use-quay)
+      USE_QUAY="$2"
+      shift;shift
+      ;;
     # HELP
 
     -h|--help)
@@ -272,6 +383,10 @@ Valid options that configure the service mesh components:
   -smcp|--maistra-smcp-yaml <file or url>
       Points to the YAML file that defines the ServiceMeshControlPlane custom resource which declares what to install.
       If not defined, a basic one will be used.
+  -uq|--use-quay <true|false>
+      If true, perform additional things so the images are pulled from quay.io rather than registry.redhat.io.
+      You can only use this if you have been granted access to the pre-release quay.io repository for Maistra images.
+      Default: false
 
 The command must be one of:
 
@@ -338,8 +453,8 @@ if [ "$_CMD" = "sm-install" ]; then
   if [ "${WAIT_FOR_ISTIO}" == "true" ]; then
     infomsg "Wait for Maistra to fully start (this is going to take a while)..."
 
-    infomsg "Waiting for Maistra Deployments to be created."
-    _EXPECTED_APPS=(istio-citadel prometheus istio-galley istio-policy istio-telemetry istio-pilot istio-egressgateway istio-ingressgateway istio-sidecar-injector)
+    infomsg "Waiting for Maistra Deployments to be created. NOTE: this assumes the SMCP is called 'full'"
+    _EXPECTED_APPS=(istiod-full prometheus istio-egressgateway istio-ingressgateway)
     for expected in ${_EXPECTED_APPS[@]}
     do
       echo -n "Waiting for $expected ..."
@@ -368,43 +483,44 @@ if [ "$_CMD" = "sm-install" ]; then
 
 elif [ "$_CMD" = "sm-uninstall" ]; then
 
-  # remove the SMCP and SMMR CRs which uninstalls all the Service Mesh components
-  debug "Deleting the ServiceMesh SMCP and SMMR CRs"
+  infomsg "Delete the ServiceMesh SMCP and SMMR CRs (if they exist) which uninstalls all the Service Mesh components"
   ${OC} delete -n ${CONTROL_PLANE_NAMESPACE} $(${OC} get smcp -n ${CONTROL_PLANE_NAMESPACE} -o name)
   ${OC} delete -n ${CONTROL_PLANE_NAMESPACE} $(${OC} get smmr -n ${CONTROL_PLANE_NAMESPACE} -o name)
 
-  # Make sure the Kiail CR is deleted (probably not needed, ServiceMesh should be doing this)
+  infomsg "Make sure the Kiail CR is deleted (probably not needed, ServiceMesh should be doing this)"
   _kialicr=$(${OC} get kiali -n ${CONTROL_PLANE_NAMESPACE} -o name 2>/dev/null)
   if [ "${_kialicr}" != "" ]; then
     debug "Deleting the Kiali CR"
-    ${OC} patch ${_kialicr} -n ${CONTROL_PLANE_NAMESPACE} -p '{"metadata":{"finalizers": []}}' --type=merge
+    #${OC} patch ${_kialicr} -n ${CONTROL_PLANE_NAMESPACE} -p '{"metadata":{"finalizers": []}}' --type=merge
     ${OC} delete ${_kialicr} -n ${CONTROL_PLANE_NAMESPACE}
   fi
 
-  debug "Cleaning up the rest of ServiceMesh"
+  infomsg "Cleaning up the rest of ServiceMesh"
 
-  # clean up the control plane namespace
+  debug "Clean up the control plane namespace [${CONTROL_PLANE_NAMESPACE}]"
   ${OC} delete namespace ${CONTROL_PLANE_NAMESPACE}
 
-  # clean up OLM Subscriptions
+  debug "Clean up OLM Subscriptions"
   for sub in $(${OC} get subscriptions -n openshift-operators -o name | grep -E 'servicemesh|kiali|jaeger|elasticsearch')
   do
     ${OC} delete -n openshift-operators ${sub}
   done
 
-  # clean up OLM CSVs for all the different operators which deletes the operators and their related resources
+  debug "Clean up OLM CSVs for all the different operators which deletes the operators and their related resources"
   for csv in $(${OC} get csv --all-namespaces --no-headers -o custom-columns=NS:.metadata.namespace,N:.metadata.name | sed ${SEDOPTIONS} 's/  */:/g' | grep -E 'servicemesh|kiali|jaeger|elasticsearch')
   do
     ${OC} delete csv -n $(echo -n $csv | cut -d: -f1) $(echo -n $csv | cut -d: -f2)
   done
 
-  # Delete Istio resources that are getting left behind for some reason
+  debug "Delete Istio clusterroles/bindings that are getting left behind"
   for r in \
     $(${OC} get clusterrolebindings -o name | grep -E 'istio') \
     $(${OC} get clusterroles -o name | grep -E 'istio')
   do
     ${OC} delete ${r}
   done
+
+  debug "Delete Istio service accounts, configmaps, secrets that are getting left behind"
   for r in \
     $(${OC} get sa -n openshift-operators -o name | grep -E 'istio') \
     $(${OC} get configmaps -n openshift-operators -o name | grep -E 'istio') \
@@ -413,11 +529,21 @@ elif [ "$_CMD" = "sm-uninstall" ]; then
     ${OC} delete -n openshift-operators ${r}
   done
 
-  # clean up additional leftover items
   # see: https://docs.openshift.com/container-platform/4.1/service_mesh/service_mesh_install/removing-ossm.html#ossm-remove-cleanup_removing-ossm
+  debug "Clean up validating webhooks"
   ${OC} delete validatingwebhookconfiguration/openshift-operators.servicemesh-resources.maistra.io
+  ${OC} delete validatingwebhookconfiguration/istiod-istio-system
+  debug "Clean up mutating webhooks"
+  ${OC} delete mutatingwebhookconfigurations/openshift-operators.servicemesh-resources.maistra.io
+  ${OC} delete mutatingwebhookconfigurations/istio-sidecar-injector
+  debug "Clean up deamonsets"
   ${OC} delete -n openshift-operators daemonset/istio-node
-  ${OC} delete clusterrole/istio-admin
+  debug "Clean up some more clusterroles/bindings"
+  ${OC} delete clusterrole/istio-admin clusterrole/istio-cni clusterrolebinding/istio-cni
+  debug "Clean up some security related things from the operator"
+  ${OC} delete -n openshift-operators configmap/maistra-operator-cabundle
+  ${OC} delete -n openshift-operators secret/maistra-operator-serving-cert
+  debug "Delete the CRDs"
   ${OC} get crds -o name | grep '.*\.istio\.io' | xargs -r -n 1 ${OC} delete
   ${OC} get crds -o name | grep '.*\.maistra\.io' | xargs -r -n 1 ${OC} delete
   ${OC} get crds -o name | grep '.*\.kiali\.io' | xargs -r -n 1 ${OC} delete
@@ -431,8 +557,8 @@ elif [ "$_CMD" = "bi-install" ]; then
   # see: https://maistra.io/docs/examples/bookinfo/
   ${OC} new-project ${BOOKINFO_NAMESPACE}
   ${OC} patch -n ${CONTROL_PLANE_NAMESPACE} --type='json' smmr default -p '[{"op": "add", "path": "/spec/members", "value":["'"${BOOKINFO_NAMESPACE}"'"]}]'
-  ${OC} apply -n ${BOOKINFO_NAMESPACE} -f https://raw.githubusercontent.com/Maistra/bookinfo/maistra-1.0/bookinfo.yaml
-  ${OC} apply -n ${BOOKINFO_NAMESPACE} -f https://raw.githubusercontent.com/Maistra/bookinfo/maistra-1.0/bookinfo-gateway.yaml
+  ${OC} apply -n ${BOOKINFO_NAMESPACE} -f https://raw.githubusercontent.com/maistra/istio/maistra-2.0/samples/bookinfo/platform/kube/bookinfo.yaml
+  ${OC} apply -n ${BOOKINFO_NAMESPACE} -f https://raw.githubusercontent.com/maistra/istio/maistra-2.0/samples/bookinfo/platform/kube/bookinfo-ingress.yaml
 
   BOOKINFO_PRODUCTPAGE_URL="http://$(${OC} get route istio-ingressgateway -n ${CONTROL_PLANE_NAMESPACE} -o jsonpath='{.spec.host}')/productpage"
   infomsg "Bookinfo URL: ${BOOKINFO_PRODUCTPAGE_URL}"
@@ -446,31 +572,39 @@ elif [ "$_CMD" = "k-uninstall" ]; then
   # Tell ServiceMesh to disable Kiali so it doesn't try to manage it
   _smcp=$(${OC} get smcp -n ${CONTROL_PLANE_NAMESPACE} -o name 2>/dev/null)
   if [ "${_smcp}" != "" ]; then
-    debug "Telling ServiceMesh to disable Kiali"
-    ${OC} patch ${_smcp} -n ${CONTROL_PLANE_NAMESPACE} -p '{"spec":{"istio":{"kiali":{"enabled": "false"}}}}' --type=merge
+    infomsg "Telling ServiceMesh to disable Kiali"
+    ${OC} patch ${_smcp} -n ${CONTROL_PLANE_NAMESPACE} -p '{"spec":{"addons":{"kiali":{"enabled": false}}}}' --type=merge
   fi
 
   # Make sure the Kiail CR is deleted (probably not needed, ServiceMesh should be doing this)
   _kialicr=$(${OC} get kiali -n ${CONTROL_PLANE_NAMESPACE} -o name 2>/dev/null)
   if [ "${_kialicr}" != "" ]; then
-    debug "Deleting the Kiali CR"
+    infomsg "Deleting the Kiali CR"
     ${OC} patch ${_kialicr} -n ${CONTROL_PLANE_NAMESPACE} -p '{"metadata":{"finalizers": []}}' --type=merge
     ${OC} delete ${_kialicr} -n ${CONTROL_PLANE_NAMESPACE}
   fi
 
-  # clean up OLM subscriptions
+  infomsg "Waiting for Kiali CR to disappear..."
+  _kialicr=$(${OC} get kiali -n ${CONTROL_PLANE_NAMESPACE} -o name 2>/dev/null)
+  while [ "${kiali_deployment}" != "" ]
+  do
+    sleep 2
+    _kialicr=$(${OC} get kiali -n ${CONTROL_PLANE_NAMESPACE} -o name 2>/dev/null)
+  done
+
+  infomsg "Delete OLM subscriptions"
   for sub in $(${OC} get subscriptions -n openshift-operators -o name | grep kiali)
   do
     ${OC} delete -n openshift-operators ${sub}
   done
 
-  # clean up OLM CSVs which deletes the operator and its related resources
-  for csv in $(${OC} get csv --all-namespaces --no-headers -o custom-columns=NS:.metadata.namespace,N:.metadata.name | sed ${SEDOPTIONS} 's/  */:/g' | grep kiali)
+  infomsg "Delete OLM CSVs which deletes the operator and its related resources"
+  for csv in $(${OC} get csv --all-namespaces --no-headers -o custom-columns=NS:.metadata.namespace,N:.metadata.name | sed ${SEDOPTIONS} 's/  */:/g' | grep kiali-operator)
   do
     ${OC} delete csv -n $(echo -n $csv | cut -d: -f1) $(echo -n $csv | cut -d: -f2)
   done
 
-  # clean up additional leftover items
+  infomsg "Delete Kiali CRDs"
   ${OC} get crds -o name | grep '.*\.kiali\.io' | xargs -r -n 1 ${OC} delete
 
 else
diff --git a/hack/istio/playbooks/README.adoc b/hack/istio/playbooks/README.adoc
deleted file mode 100644
index 4d79ada52..000000000
--- a/hack/istio/playbooks/README.adoc
+++ /dev/null
@@ -1,72 +0,0 @@
-== Requirements
-
-- pip
-- openshift-python (pip install openshift) (openshift should be 0.6.2+)
-- ansible 2.5+
-
-
-== Installing Bookinfo without Traffic Generator
-
-
-[NOTE]
-All the Bookinfo Demo pods and services are installed in the "bookinfo" namespace by default. If you want to define a different namespace from bookinfo, please set `-e bookinfo_namespace='your_namespace'`
-
-
-[NOTE]
-Bookinfo Version should be defined according to Istio Release. Check https://github.com/istio/istio/blob/master/samples/bookinfo/platform/kube/bookinfo.yaml
-
-- In order to uninstall it, simply delete that namespace via something like `kubectl delete namespace bookinfo` or
-`oc delete project bookinfo` (or namespace that you used)
-
-- In order quick install, you may use the following command
-
-```
-ansible-playbook install_bookinfo.yml
-```
-
-- If you want to include traffic generator to bookinfo project, see sections below
-
-== Installing Bookinfo with Kiali Traffic Generator
-
-[NOTE]
-The variable `-e traffic_generator=true` must be present on the command line, if you want traffic generator to be deployed
-
-[NOTE]
-Rate is the number of request per seconds that traffic generator will hit the application and this variable is mandatory. It is recommend to use `-e rate=1`
-
-[NOTE]
-Rename label is for renamming bookinfo resources. It is recommend to use `-e rename_label=-app`
-[NOTE]
-You must choose between Ingress Route or Openshift Route there is a traffic generator included
-
-=== Kiali Traffic Generator with Ingress Route
-- If you want to use ingress route (all traffic is going to hit ingress-gateway), you may use the following command:
-
-```
-ansible-playbook install_bookinfo.yml -e ingress_route=true -e traffic_generator=true -e rate=1 -vv
-```
-
-=== Kiali Traffic Generator with Openshift Route
-
-- If you want to use openshift route (kiali will report as "unknown"), you may use the following command:
-
-```
-ansible-playbook install_bookinfo.yml -e ingress_route=false -e traffic_generator=true -e rate=1 -vv
-```
-
-
-=== Kiali Traffic Generator with MongoDB
-
-- If you want to enable MongoDB for ratings, you may use the following command
-
-```
-ansible-playbook install_bookinfo.yml -e ingress_route=false -e traffic_generator=true -e rate=1 -e mongodb=true -vv
-```
-
-=== Kiali Traffic Generator with MySQL
-
-- If you want to enable MySQL for ratings, you may use the following command
-
-```
-ansible-playbook install_bookinfo.yml -e ingress_route=false -e traffic_generator=true -e rate=1 -e mysql=true -vv
-```
diff --git a/hack/istio/playbooks/files/bookinfo-gateway.yaml b/hack/istio/playbooks/files/bookinfo-gateway.yaml
deleted file mode 100644
index b6eff6079..000000000
--- a/hack/istio/playbooks/files/bookinfo-gateway.yaml
+++ /dev/null
@@ -1,39 +0,0 @@
-apiVersion: networking.istio.io/v1alpha3
-kind: Gateway
-metadata:
-  name: bookinfo-gateway
-spec:
-  selector:
-    istio: ingressgateway # use istio default controller
-  servers:
-  - port:
-      number: 80
-      name: http
-      protocol: HTTP
-    hosts:
-    - "*"
----
-apiVersion: networking.istio.io/v1alpha3
-kind: VirtualService
-metadata:
-  name: bookinfo-vs
-spec:
-  hosts:
-  - "*"
-  gateways:
-  - bookinfo-gateway
-  http:
-  - match:
-    - uri:
-        exact: /productpage
-    - uri:
-        exact: /login
-    - uri:
-        exact: /logout
-    - uri:
-        prefix: /api/v1/products
-    route:
-    - destination:
-        host: productpage
-        port:
-          number: 9080
diff --git a/hack/istio/playbooks/files/bookinfo-mongodb.yaml b/hack/istio/playbooks/files/bookinfo-mongodb.yaml
deleted file mode 100644
index b5949b1ba..000000000
--- a/hack/istio/playbooks/files/bookinfo-mongodb.yaml
+++ /dev/null
@@ -1,46 +0,0 @@
-# Copyright 2017 Istio Authors
-#
-#   Licensed under the Apache License, Version 2.0 (the "License");
-#   you may not use this file except in compliance with the License.
-#   You may obtain a copy of the License at
-#
-#       http://www.apache.org/licenses/LICENSE-2.0
-#
-#   Unless required by applicable law or agreed to in writing, software
-#   distributed under the License is distributed on an "AS IS" BASIS,
-#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-#   See the License for the specific language governing permissions and
-#   limitations under the License.
-
-apiVersion: v1
-kind: Service
-metadata:
-  name: mongodb
-  labels:
-    app: mongodb${LABEL}
-spec:
-  ports:
-  - port: 27017
-    name: mongo
-  selector:
-    app: mongodb${LABEL}
----
-apiVersion: extensions/v1beta1
-kind: Deployment
-metadata:
-  name: mongodb-v1
-spec:
-  replicas: 1
-  template:
-    metadata:
-      labels:
-        app: mongodb${LABEL}
-        version: v1
-    spec:
-      containers:
-      - name: mongodb
-        image: istio/examples-bookinfo-mongodb:${VERSION}
-        imagePullPolicy: IfNotPresent
-        ports:
-        - containerPort: 27017
----
diff --git a/hack/istio/playbooks/files/bookinfo-mysql.yaml b/hack/istio/playbooks/files/bookinfo-mysql.yaml
deleted file mode 100644
index 31ff6b8b9..000000000
--- a/hack/istio/playbooks/files/bookinfo-mysql.yaml
+++ /dev/null
@@ -1,64 +0,0 @@
-# Copyright 2017 Istio Authors
-#
-#   Licensed under the Apache License, Version 2.0 (the "License");
-#   you may not use this file except in compliance with the License.
-#   You may obtain a copy of the License at
-#
-#       http://www.apache.org/licenses/LICENSE-2.0
-#
-#   Unless required by applicable law or agreed to in writing, software
-#   distributed under the License is distributed on an "AS IS" BASIS,
-#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-#   See the License for the specific language governing permissions and
-#   limitations under the License.
-
-##################################################################################################
-# Mysql db services
-# credentials: root/password
-##################################################################################################
-apiVersion: v1
-kind: Secret
-metadata:
-  name: mysql-credentials
-type: Opaque
-data:
-  rootpasswd: cGFzc3dvcmQ=
----
-apiVersion: v1
-kind: Service
-metadata:
-  name: mysqldb
-  labels:
-    app: mysqldb${LABEL}
-spec:
-  ports:
-  - port: 3306
-    name: mysql
-  selector:
-    app: mysqldb${LABEL}
----
-apiVersion: extensions/v1beta1
-kind: Deployment
-metadata:
-  name: mysqldb-v1
-spec:
-  replicas: 1
-  template:
-    metadata:
-      labels:
-        app: mysqldb${LABEL}
-        version: v1
-    spec:
-      containers:
-      - name: mysqldb
-        image: istio/examples-bookinfo-mysqldb:${VERSION}
-        imagePullPolicy: IfNotPresent
-        ports:
-        - containerPort: 3306
-        env:
-          - name: MYSQL_ROOT_PASSWORD
-            valueFrom:
-              secretKeyRef:
-                name: mysql-credentials
-                key: rootpasswd
----
diff --git a/hack/istio/playbooks/files/bookinfo-ratings-v2-mongodb.yaml b/hack/istio/playbooks/files/bookinfo-ratings-v2-mongodb.yaml
deleted file mode 100644
index ac5bdd6aa..000000000
--- a/hack/istio/playbooks/files/bookinfo-ratings-v2-mongodb.yaml
+++ /dev/null
@@ -1,50 +0,0 @@
-# Copyright 2017 Istio Authors
-#
-#   Licensed under the Apache License, Version 2.0 (the "License");
-#   you may not use this file except in compliance with the License.
-#   You may obtain a copy of the License at
-#
-#       http://www.apache.org/licenses/LICENSE-2.0
-#
-#   Unless required by applicable law or agreed to in writing, software
-#   distributed under the License is distributed on an "AS IS" BASIS,
-#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-#   See the License for the specific language governing permissions and
-#   limitations under the License.
-
-apiVersion: extensions/v1beta1
-kind: Deployment
-metadata:
-  name: ratings-v2-mongodb
-spec:
-  replicas: 1
-  template:
-    metadata:
-      labels:
-        app: ratings${LABEL}
-        version: v2-mongodb
-    spec:
-      containers:
-      - name: ratings
-        image: istio/examples-bookinfo-ratings-v2:${VERSION}
-        imagePullPolicy: IfNotPresent
-        env:
-          # ratings-v2 will use mongodb as the default db backend.
-          # if you would like to use mysqldb then set DB_TYPE = 'mysql', set
-          # the rest of the parameters shown here and also create the
-          # mysqldb service using bookinfo-mysql.yaml
-          # - name: DB_TYPE #default to
-          #   value: "mysql"
-          # - name: MYSQL_DB_HOST
-          #   value: mysqldb
-          # - name: MYSQL_DB_PORT
-          #   value: "3306"
-          # - name: MYSQL_DB_USER
-          #   value: root
-          # - name: MYSQL_DB_PASSWORD
-          #  value: password
-          - name: MONGO_DB_URL
-            value: mongodb://mongodb:27017/test
-        ports:
-        - containerPort: 9080
----
diff --git a/hack/istio/playbooks/files/bookinfo-ratings-v2-mysql.yaml b/hack/istio/playbooks/files/bookinfo-ratings-v2-mysql.yaml
deleted file mode 100644
index e3d45a07a..000000000
--- a/hack/istio/playbooks/files/bookinfo-ratings-v2-mysql.yaml
+++ /dev/null
@@ -1,49 +0,0 @@
-# Copyright 2017 Istio Authors
-#
-#   Licensed under the Apache License, Version 2.0 (the "License");
-#   you may not use this file except in compliance with the License.
-#   You may obtain a copy of the License at
-#
-#       http://www.apache.org/licenses/LICENSE-2.0
-#
-#   Unless required by applicable law or agreed to in writing, software
-#   distributed under the License is distributed on an "AS IS" BASIS,
-#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-#   See the License for the specific language governing permissions and
-#   limitations under the License.
-
-apiVersion: extensions/v1beta1
-kind: Deployment
-metadata:
-  name: ratings-v2-mysql
-spec:
-  replicas: 1
-  template:
-    metadata:
-      labels:
-        app: ratings${LABEL}
-        version: v2-mysql
-    spec:
-      containers:
-      - name: ratings
-        image: istio/examples-bookinfo-ratings-v2:${VERSION}
-        imagePullPolicy: IfNotPresent
-        env:
-          # ratings-v2 will use mongodb as the default db backend.
-          # if you would like to use mysqldb then you can use this file
-          # which sets DB_TYPE = 'mysql' and the rest of the parameters shown
-          # here and also create the # mysqldb service using bookinfo-mysql.yaml
-          # NOTE: This file is mutually exclusive to bookinfo-ratings-v2.yaml
-          - name: DB_TYPE
-            value: "mysql"
-          - name: MYSQL_DB_HOST
-            value: mysqldb
-          - name: MYSQL_DB_PORT
-            value: "3306"
-          - name: MYSQL_DB_USER
-            value: root
-          - name: MYSQL_DB_PASSWORD
-            value: password
-        ports:
-        - containerPort: 9080
----
diff --git a/hack/istio/playbooks/files/bookinfo.yaml b/hack/istio/playbooks/files/bookinfo.yaml
deleted file mode 100644
index 119ffb14a..000000000
--- a/hack/istio/playbooks/files/bookinfo.yaml
+++ /dev/null
@@ -1,192 +0,0 @@
-# Copyright 2017 Istio Authors
-#
-#   Licensed under the Apache License, Version 2.0 (the "License");
-#   you may not use this file except in compliance with the License.
-#   You may obtain a copy of the License at
-#
-#       http://www.apache.org/licenses/LICENSE-2.0
-#
-#   Unless required by applicable law or agreed to in writing, software
-#   distributed under the License is distributed on an "AS IS" BASIS,
-#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-#   See the License for the specific language governing permissions and
-#   limitations under the License.
-
-##################################################################################################
-# Details service
-##################################################################################################
-apiVersion: v1
-kind: Service
-metadata:
-  name: details
-  labels:
-    app: details${LABEL}
-spec:
-  ports:
-  - port: 9080
-    name: http
-  selector:
-    app: details${LABEL}
----
-apiVersion: extensions/v1beta1
-kind: Deployment
-metadata:
-  name: details-v1
-spec:
-  replicas: 1
-  template:
-    metadata:
-      labels:
-        app: details${LABEL}
-        version: v1
-    spec:
-      containers:
-      - name: details-container
-        image: istio/examples-bookinfo-details-v1:${VERSION}
-        imagePullPolicy: IfNotPresent
-        ports:
-        - containerPort: 9080
----
-##################################################################################################
-# Ratings service
-##################################################################################################
-apiVersion: v1
-kind: Service
-metadata:
-  name: ratings
-  labels:
-    app: ratings${LABEL}
-spec:
-  ports:
-  - port: 9080
-    name: http
-  selector:
-    app: ratings${LABEL}
----
-apiVersion: extensions/v1beta1
-kind: Deployment
-metadata:
-  name: ratings-v1
-spec:
-  replicas: 1
-  template:
-    metadata:
-      labels:
-        app: ratings${LABEL}
-        version: v1
-    spec:
-      containers:
-      - name: ratings-container
-        image: istio/examples-bookinfo-ratings-v1:${VERSION}
-        imagePullPolicy: IfNotPresent
-        ports:
-        - containerPort: 9080
----
-##################################################################################################
-# Reviews service
-##################################################################################################
-apiVersion: v1
-kind: Service
-metadata:
-  name: reviews
-  labels:
-    app: reviews${LABEL}
-spec:
-  ports:
-  - port: 9080
-    name: http
-  selector:
-    app: reviews${LABEL}
----
-apiVersion: extensions/v1beta1
-kind: Deployment
-metadata:
-  name: reviews-v1
-spec:
-  replicas: 1
-  template:
-    metadata:
-      labels:
-        app: reviews${LABEL}
-        version: v1
-    spec:
-      containers:
-      - name: reviews-container
-        image: istio/examples-bookinfo-reviews-v1:${VERSION}
-        imagePullPolicy: IfNotPresent
-        ports:
-        - containerPort: 9080
----
-apiVersion: extensions/v1beta1
-kind: Deployment
-metadata:
-  name: reviews-v2
-spec:
-  replicas: 1
-  template:
-    metadata:
-      labels:
-        app: reviews${LABEL}
-        version: v2
-    spec:
-      containers:
-      - name: reviews-container
-        image: istio/examples-bookinfo-reviews-v2:${VERSION}
-        imagePullPolicy: IfNotPresent
-        ports:
-        - containerPort: 9080
----
-apiVersion: extensions/v1beta1
-kind: Deployment
-metadata:
-  name: reviews-v3
-spec:
-  replicas: 1
-  template:
-    metadata:
-      labels:
-        app: reviews${LABEL}
-        version: v3
-    spec:
-      containers:
-      - name: reviews-container
-        image: istio/examples-bookinfo-reviews-v3:${VERSION}
-        imagePullPolicy: IfNotPresent
-        ports:
-        - containerPort: 9080
----
-##################################################################################################
-# Productpage services
-##################################################################################################
-apiVersion: v1
-kind: Service
-metadata:
-  name: productpage
-  labels:
-    app: productpage${LABEL}
-spec:
-  ports:
-  - port: 9080
-    name: http
-  selector:
-    app: productpage${LABEL}
----
-apiVersion: extensions/v1beta1
-kind: Deployment
-metadata:
-  name: productpage-v1
-spec:
-  replicas: 1
-  template:
-    metadata:
-      labels:
-        app: productpage${LABEL}
-        version: v1
-    spec:
-      containers:
-      - name: productpage-container
-        image: istio/examples-bookinfo-productpage-v1:${VERSION}
-        imagePullPolicy: IfNotPresent
-        ports:
-        - containerPort: 9080
----
diff --git a/hack/istio/playbooks/install_bookinfo.yml b/hack/istio/playbooks/install_bookinfo.yml
deleted file mode 100644
index 7a8790596..000000000
--- a/hack/istio/playbooks/install_bookinfo.yml
+++ /dev/null
@@ -1,172 +0,0 @@
-- name: Install Bookinfo with or without Kiali Traffic Generator
-  hosts: localhost
-  tasks:
-
-    - name: Initialize Facts
-      set_fact:
-        istio_bookinfo_mongodb_deployment_path: files/bookinfo-mongodb.yaml
-        istio_bookinfo_mongodb_service_path: files/bookinfo-ratings-v2-mongodb.yaml
-        istio_bookinfo_mysql_deployment_path: files/bookinfo-mysql.yaml
-        istio_bookinfo_mysql_service_path: files/bookinfo-ratings-v2-mysql.yaml
-        istio_bookinfo_deployment_path: files/bookinfo.yaml
-        istio_bookinfo_gateway_path: files/bookinfo-gateway.yaml
-        check_retries: "{{ check_retries | default(50) }}"
-
-    - name: Define Bookinfo Namespace if is not defined
-      set_fact:
-        bookinfo_namespace: 'bookinfo'
-      when: bookinfo_namespace is not defined
-
-    - name: Define Bookinfo Version if is not defined
-      set_fact:
-        bookinfo_version: '1.9.0'
-      when: bookinfo_version is not defined
-
-    - name: Define Rename Label if is not defined
-      set_fact:
-        rename_label: ''
-      when: rename_label is not defined
-
-    - name: Delete Test Bookinfo Project
-      openshift_raw:
-        api_version: v1
-        kind: Project
-        name: "{{ item }}"
-        state: absent
-        verify_ssl: false
-      ignore_errors: true
-      with_items:
-        - "{{ bookinfo_namespace }}"
-
-    - name: Create Bookinfo Project
-      openshift_raw:
-        api_version: v1
-        kind: Project
-        name: "{{ item }}"
-        state: present
-        verify_ssl: false
-      register: projectStatus
-      until: projectStatus.changed == true
-      retries: "{{ hostvars['localhost']['check_retries'] }}"
-      delay: 10
-      with_items:
-        - "{{ bookinfo_namespace }}"
-
-    - name: Give the scc-to-user to Bookinfo
-      shell: "oc adm policy add-scc-to-user privileged -z default -n {{bookinfo_namespace}}"
-
-    - name: Deploy Bookinfo
-      shell: "cat {{item}} | LABEL={{ rename_label }} VERSION={{ bookinfo_version }} envsubst   | istioctl kube-inject -f - | oc apply -n {{bookinfo_namespace}} -f -  "
-      with_items:
-        - "{{ istio_bookinfo_deployment_path }}"
-
-    - name: Deploy Bookinfo Gateway
-      shell: "cat {{item}}  | oc apply -n {{bookinfo_namespace}} -f -  "
-      with_items:
-        - "{{ istio_bookinfo_gateway_path }}"
-
-    - name: Deploy MongoDB
-      shell: "cat {{item}} |  LABEL={{ rename_label }} VERSION={{ bookinfo_version }} envsubst   | istioctl kube-inject -f - | oc apply -n {{bookinfo_namespace}} -f -  "
-      when: "mongodb is defined and mongodb == 'true'"
-      with_items:
-        - "{{ istio_bookinfo_mongodb_deployment_path }}"
-        - "{{ istio_bookinfo_mongodb_service_path }}"
-
-    - name: Deploy MySQL
-      shell: "cat {{item}} |  LABEL={{ rename_label }} VERSION={{ bookinfo_version }} envsubst    | istioctl kube-inject -f - | oc apply -n {{bookinfo_namespace}} -f -  "
-      when: "mysql is defined and mysql == 'true'"
-      with_items:
-        - "{{ istio_bookinfo_mysql_deployment_path }}"
-        - "{{ istio_bookinfo_mysql_service_path }}"
-
-    - name: Expose Ingress Gateway
-      shell: "oc expose svc istio-ingressgateway -n istio-system"
-      ignore_errors: true
-
-    - name: Expose Openshift Route for Bookinfo (useful when you trying to deploy with external traffic)
-      shell: "oc expose svc productpage -n {{bookinfo_namespace}}"
-
-    - name: Wait until all pods are running on bookinfo
-      openshift_raw:
-        definition:
-          apiVersion: v1
-          kind: Deployment
-          metadata:
-            name: "{{item}}"
-            namespace: "{{ bookinfo_namespace }}"
-      register: replicaResult
-      until: (replicaResult.result.status.ready_replicas is defined and replicaResult.result.status.ready_replicas == 1) or
-           (replicaResult.result.status.readyReplicas is defined and replicaResult.result.status.readyReplicas == 1)
-      with_items:
-        - productpage-v1
-        - details-v1
-        - ratings-v1
-        - reviews-v1
-        - reviews-v2
-        - reviews-v3
-      retries: "{{ hostvars['localhost']['check_retries'] }}"
-      delay: 10
-
-    - name: Wait until mongodb pods are running
-      openshift_raw:
-        definition:
-          apiVersion: v1
-          kind: Deployment
-          metadata:
-            name: "{{item}}"
-            namespace: "{{ bookinfo_namespace }}"
-      register: replicaResult
-      when: "mongodb is defined and mongodb == 'true'"
-      until: (replicaResult.result.status.ready_replicas is defined and replicaResult.result.status.ready_replicas == 1) or
-           (replicaResult.result.status.readyReplicas is defined and replicaResult.result.status.readyReplicas == 1)
-      with_items:
-        - ratings-v2-mongodb
-        - mongodb-v1
-      retries: "{{ hostvars['localhost']['check_retries'] }}"
-      delay: 10
-
-    - name: Wait until mysql pods are running
-      openshift_raw:
-        definition:
-          apiVersion: v1
-          kind: Deployment
-          metadata:
-            name: "{{item}}"
-            namespace: "{{ bookinfo_namespace }}"
-      register: replicaResult
-      when: "mysql is defined and mysql == 'true'"
-      until: (replicaResult.result.status.ready_replicas is defined and replicaResult.result.status.ready_replicas == 1) or
-           (replicaResult.result.status.readyReplicas is defined and replicaResult.result.status.readyReplicas == 1)
-      with_items:
-        - ratings-v2-mysql
-        - mysqldb-v1
-      retries: "{{ hostvars['localhost']['check_retries'] }}"
-      delay: 10
-
-    - name: Get Openshift Route
-      shell: oc get route productpage -o jsonpath='{.spec.host}{"\n"}' -n {{bookinfo_namespace}}
-      when: ingress_route is defined and ingress_route == 'false' and traffic_generator is defined and traffic_generator == 'true'
-      register: openshift_route
-
-
-    - name: Get Ingress Route
-      shell: oc get route istio-ingressgateway -o jsonpath='{.spec.host}{"\n"}' -n istio-system
-      when: ingress_route is defined and ingress_route == 'true' and traffic_generator is defined and traffic_generator == 'true'
-      register: internal_route
-
-    - name: Define duration of Traffic Generator
-      set_fact:
-        duration: 0
-      when: duration is not defined
-
-    - name: Deploy Kiali Traffic Generator Configmap to Openshift Route
-      shell: "curl https://raw.githubusercontent.com/kiali/kiali-test-mesh/master/traffic-generator/openshift/traffic-generator-configmap.yaml | DURATION='0s' ROUTE='http://{{ openshift_route ['stdout'] }}/productpage' RATE='{{rate}}'  envsubst | oc apply -n {{bookinfo_namespace}} -f -"
-      when: ingress_route is defined and ingress_route == 'false' and traffic_generator is defined and traffic_generator == 'true'
-
-    - name: Deploy Kiali Traffic Generator Configmap to Ingress Route
-      shell: "curl https://raw.githubusercontent.com/kiali/kiali-test-mesh/master/traffic-generator/openshift/traffic-generator-configmap.yaml | DURATION='0' ROUTE='http://{{ internal_route ['stdout'] }}/productpage' RATE='{{rate}}'  envsubst | oc apply -n {{bookinfo_namespace}} -f -"
-      when: ingress_route is defined and ingress_route == 'true' and traffic_generator is defined and traffic_generator == 'true'
-
-    - name: Deploy Kiali Traffic Generator
-      shell: "curl https://raw.githubusercontent.com/kiali/kiali-test-mesh/master/traffic-generator/openshift/traffic-generator.yaml | oc apply -n {{bookinfo_namespace}} -f -"
-      when: traffic_generator is defined and traffic_generator == 'true'
diff --git a/hack/k8s-minikube.sh b/hack/k8s-minikube.sh
index 35d611716..cd59975a9 100755
--- a/hack/k8s-minikube.sh
+++ b/hack/k8s-minikube.sh
@@ -486,7 +486,7 @@ elif [ "$_CMD" = "port-forward" ]; then
   ensure_minikube_is_running
   echo 'Forwarding port 20001 to the Kiali server. This runs in foreground, press Control-C to kill it.'
   echo 'To access Kiali, point your browser to https://localhost:20001/kiali/console'
-  ${MINIKUBE_EXEC_WITH_PROFILE} kubectl -- -n istio-system port-forward $(${MINIKUBE_EXEC_WITH_PROFILE} kubectl -- -n istio-system get pod -l app=kiali -o jsonpath='{.items[0].metadata.name}') 20001:20001
+  ${MINIKUBE_EXEC_WITH_PROFILE} kubectl -- -n istio-system port-forward $(${MINIKUBE_EXEC_WITH_PROFILE} kubectl -- -n istio-system get pod -l app.kubernetes.io/name=kiali -o jsonpath='{.items[0].metadata.name}') 20001:20001
 
 elif [ "$_CMD" = "ingress" ]; then
   ensure_minikube_is_running
diff --git a/hack/knative/config-domain.yaml b/hack/knative/config-domain.yaml
deleted file mode 100644
index 71b983de0..000000000
--- a/hack/knative/config-domain.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-apiVersion: v1
-kind: ConfigMap
-metadata:
-  name: config-domain
-  namespace: knative-serving
-data:
-  knative.${DOMAIN}: ""
diff --git a/hack/knative/service.yaml b/hack/knative/service.yaml
deleted file mode 100644
index a0a9c32cf..000000000
--- a/hack/knative/service.yaml
+++ /dev/null
@@ -1,16 +0,0 @@
-apiVersion: serving.knative.dev/v1alpha1
-kind: Service
-metadata:
-  name: helloworld-go
-  istio-injection: enabled
-spec:
-  runLatest:
-    configuration:
-      revisionTemplate:
-        spec:
-          container:
-            image: gcr.io/knative-samples/helloworld-go
-            imagePullPolicy: IfNotPresent
-            env:
-            - name: TARGET
-              value: "Go Sample v1"
diff --git a/hack/maistra_v1_servicemeshcontrolplane_cr_basic.yaml b/hack/maistra_v1_servicemeshcontrolplane_cr_basic.yaml
deleted file mode 100644
index 7d0a1eb5f..000000000
--- a/hack/maistra_v1_servicemeshcontrolplane_cr_basic.yaml
+++ /dev/null
@@ -1,69 +0,0 @@
-apiVersion: maistra.io/v1
-kind: ServiceMeshControlPlane
-metadata:
-  name: basic-install
-spec:
-  # NOTE, if you remove all children from an element, you should remove the
-  # element too.  An empty element is interpreted as null and will override all
-  # default values (i.e. no values will be specified for that element, not even
-  # the defaults baked into the chart values.yaml).
-  istio:
-    global:
-      proxy:
-        # constrain resources for use in smaller environments
-        resources:
-          requests:
-            cpu: 100m
-            memory: 128Mi
-          limits:
-            cpu: 500m
-            memory: 128Mi
-
-    gateways:
-      istio-egressgateway:
-        # disable autoscaling for use in smaller environments
-        autoscaleEnabled: false
-      istio-ingressgateway:
-        # disable autoscaling for use in smaller environments
-        autoscaleEnabled: false
-        # set to true to enable IOR
-        ior_enabled: false
-
-    mixer:
-      policy:
-        # disable autoscaling for use in smaller environments
-        autoscaleEnabled: false
-
-      telemetry:
-        # disable autoscaling for use in smaller environments
-        autoscaleEnabled: false
-        # constrain resources for use in smaller environments
-        resources:
-          requests:
-            cpu: 100m
-            memory: 1G
-          limits:
-            cpu: 500m
-            memory: 4G
-
-    pilot:
-      # disable autoscaling for use in smaller environments
-      autoscaleEnabled: false
-      # increase random sampling rate for development/testing
-      traceSampling: 100.0
-
-    kiali:
-      ## Changed on kiali/hack folder to false
-      ## Kiali will be installed using cluster-openshift.sh steps not via the istio-operator
-      # change to false to disable kiali
-      enabled: false
-    
-      # to use oauth, remove the following 'dashboard' section (note, oauth is broken on OCP 4.0 with kiali 0.16.2)
-      # create a secret for accessing kiali dashboard with the following credentials
-      dashboard:
-        user: admin
-        passphrase: admin
-
-    tracing:
-      # change to false to disable tracing (i.e. jaeger)
-      enabled: $TRACING_ENABLED
diff --git a/hack/os-minishift.sh b/hack/os-minishift.sh
deleted file mode 100755
index f350a1e81..000000000
--- a/hack/os-minishift.sh
+++ /dev/null
@@ -1,335 +0,0 @@
-#/bin/bash
-
-##############################################################################
-# os-minishift.sh
-#
-# This script can be used to help run OpenShift via minishift.
-# The typical order of commands used is the following:
-#   up - starts the OpenShift cluster via minishift
-#   oc - shows how to put oc in your environment
-#   admin - give cluster admin rights to admin user
-#   login - log in using the admin credentials
-#   istio - installs Istio using Kiali's install hack script
-#   docker - shows what is needed to put images in minishift's docker daemon
-#   (at this point, you can install Kiali into your OpenShift environment)
-#   dashboard - shows the OpenShift GUI console
-#   bookinfo - installs bookinfo demo into your cluster
-#   down - shuts down the OpenShift cluster, you can start it up again
-#   delete - if you don't want your cluster anymore, this deletes it
-#   expose - exposes services
-#   knative - installs Knative into your cluster
-#
-##############################################################################
-
-debug() {
-  if [ "$_VERBOSE" == "true" ]; then
-    echo "DEBUG: $1"
-  fi
-}
-
-ensure_minishift_is_running() {
-  if ! minishift ssh -- ls > /dev/null 2>&1 ; then
-    echo 'minishift must be running in order to continue. Aborting.'
-    exit 1
-  fi
-
-  # put minishift 'oc' in PATH
-  get_oc_env
-}
-
-get_oc_env() {
-  eval $(minishift oc-env)
-  debug "Using this 'oc': $(which oc)"
-}
-
-oc_login() {
-  oc login $(minishift ip):8443 -u admin -p admin
-  debug "Currently logged in as: $(oc whoami)"
-}
-
-get_gateway_url() {
-  if [ "$1" == "" ] ; then
-    INGRESS_PORT="<port>"
-  else
-    jsonpath="{.spec.ports[?(@.name==\"$1\")].nodePort}"
-    INGRESS_PORT=$(oc -n istio-system get service istio-ingressgateway -o jsonpath=${jsonpath})
-  fi
-
-  INGRESS_HOST=$(minishift ip)
-  GATEWAY_URL=$INGRESS_HOST:${INGRESS_PORT:-?}
-}
-
-print_all_gateway_urls() {
-  echo "Gateway URLs for all known ports are:"
-  allnames=$(oc -n istio-system get service istio-ingressgateway -o jsonpath={.spec.ports['*'].name})
-  for n in ${allnames}
-  do
-    get_gateway_url ${n}
-    echo ${n}: ${GATEWAY_URL}
-  done
-}
-
-# Change to the directory where this script is and set our env
-cd "$(dirname "${BASH_SOURCE[0]}")"
-
-_CMD=""
-while [[ $# -gt 0 ]]; do
-  key="$1"
-  case $key in
-    up)
-      _CMD="up"
-      shift
-      ;;
-    down)
-      _CMD="down"
-      shift
-      ;;
-    status)
-      _CMD="status"
-      shift
-      ;;
-    delete)
-      _CMD="delete"
-      shift
-      ;;
-    docker)
-      _CMD="docker"
-      shift
-      ;;
-    oc)
-      _CMD="oc"
-      shift
-      ;;
-    dashboard)
-      _CMD="dashboard"
-      shift
-      ;;
-    admin)
-      _CMD="admin"
-      shift
-      ;;
-    expose)
-      _CMD="expose"
-      shift
-      ;;
-    ingress)
-      _CMD="ingress"
-      shift
-      ;;
-    istio)
-      _CMD="istio"
-      shift
-      ;;
-    bookinfo)
-      _CMD="bookinfo"
-      shift
-      ;;
-    knative)
-      _CMD="knative"
-      shift
-      ;;
-    gwurl)
-      _CMD="gwurl"
-      if [ "$2" != "" ]; then
-        _CMD_OPT="$2"
-        shift
-      fi
-      shift
-      ;;
-    login)
-      _CMD="login"
-      shift
-      ;;
-    -v|--verbose)
-      _VERBOSE=true
-      shift
-      ;;
-    -h|--help)
-      cat <<HELPMSG
-
-$0 [option...] command
-
-Valid options:
-  -v|--verbose
-      Enable logging of debug messages from this script.
-
-The command must be either:
-  up:        starts the minishift cluster
-  down:      stops the minishift cluster
-  status:    gets the status of the minishift cluster
-  delete:    completely removes the minishift cluster VM destroying all state
-  docker:    information on the minishift docker environment
-  oc:        information on the minishift oc environment
-  dashboard: enables access to the OpenShift GUI within minishift
-  ingress:   enables access to the OpenShift ingress URL within minishift
-  admin:     gives cluster admin rights to admin user
-  expose:    exposes some Istio services
-  istio:     installs Istio into the minishift cluster
-  bookinfo:  installs Istio's bookinfo demo (make sure Istio is installed first)
-  login:     logs into OpenShift using admin credentials
-  gwurl [<portName>|'all']:
-             displays the Ingress Gateway URL. If a port name is given, the gateway port is also shown.
-             If the port name is "all" then all the URLs for all known ports are shown.
-HELPMSG
-      exit 1
-      ;;
-    *)
-      echo "Unknown argument [$key]. Aborting."
-      exit 1
-      ;;
-  esac
-done
-
-# If minishift is not in PATH, abort.
-if ! which minishift > /dev/null 2>&1 ; then
-  echo 'You do not have minishift installed in your $PATH. Aborting.'
-  exit 1
-fi
-
-debug "This script is located at $(pwd)"
-debug "minishift is located at $(which minishift)"
-
-debug "Setting the minishift profile to 'kialihack'"
-minishift profile set kialihack
-
-if [ "$_CMD" = "up" ]; then
-  echo 'Configurating Minishift and enabling addons'
-  minishift config set disk-size 40GB
-  minishift config set memory 8GB
-  minishift config set cpus 3
-  minishift config set vm-driver virtualbox
-  minishift config set image-caching true
-  minishift addon enable admin-user
-  minishift addon enable anyuid
-  minishift addon enable admissions-webhook
-  minishift config set openshift-version v3.11.0
-
-  echo 'Starting minishift with 8gig RAM and 40gig disk space (disk space for docker images)'
-  minishift start
-
-elif [ "$_CMD" = "down" ]; then
-  ensure_minishift_is_running
-  echo 'Stopping minishift'
-  minishift stop
-
-elif [ "$_CMD" = "status" ]; then
-  ensure_minishift_is_running
-  echo 'Status report for minishift'
-  minishift status
-
-elif [ "$_CMD" = "delete" ]; then
-  echo 'Deleting the entire minishift VM'
-  minishift delete
-
-elif [ "$_CMD" = "admin" ]; then
-  echo 'Giving cluster admin rights to admin user'
-  ensure_minishift_is_running
-  oc login -u system:admin
-  oc adm policy add-cluster-role-to-user cluster-admin admin
-  # log in via the admin user now that it has cluster admin rights
-  oc_login
-
-elif [ "$_CMD" = "expose" ]; then
-  ensure_minishift_is_running
-  oc_login
-  echo 'Exposing Services'
-  oc expose svc prometheus -n istio-system
-  oc expose svc tracing -n istio-system
-  oc expose svc grafana -n istio-system
-
-  # display all the routes we created
-  oc get routes -n istio-system
-
-elif [ "$_CMD" = "dashboard" ]; then
-  ensure_minishift_is_running
-  echo 'Accessing the OpenShift console GUI.'
-  minishift dashboard
-
-elif [ "$_CMD" = "ingress" ]; then
-  ensure_minishift_is_running
-  echo 'Accessing the OpenShift Ingress URL.'
-  xdg-open "http://$(minishift ip)"
-
-elif [ "$_CMD" = "istio" ]; then
-  ensure_minishift_is_running
-  oc_login
-  echo 'Installing Istio'
-  ./istio/install-istio-kiali-via-helm.sh -ud true -c oc
-
-elif [ "$_CMD" = "bookinfo" ]; then
-  ensure_minishift_is_running
-  oc_login
-  echo 'Installing Bookinfo'
-  ./istio/install-bookinfo-demo.sh --mongo -tg -c oc
-  get_gateway_url http2
-  echo 'To access the Bookinfo application, access this URL:'
-  echo "http://${GATEWAY_URL}/productpage"
-  echo 'To push requests into the Bookinfo application, execute this command:'
-  echo "watch -n 1 curl -o /dev/null -s -w '%{http_code}' http://${GATEWAY_URL}/productpage"
-
-elif [ "$_CMD" = "gwurl" ]; then
-  ensure_minishift_is_running
-  oc_login
-  if [ "$_CMD_OPT" == "all" ]; then
-    print_all_gateway_urls
-  else
-    get_gateway_url $_CMD_OPT
-    echo 'The Gateway URL is:'
-    echo "${GATEWAY_URL}"
-  fi
-
-elif [ "$_CMD" = "docker" ]; then
-  ensure_minishift_is_running
-  echo 'Your current minishift docker environment is the following:'
-  minishift docker-env
-  echo 'Run the above command in your shell before building docker images so your images will go in the minishift docker daemon'
-
-elif [ "$_CMD" = "oc" ]; then
-  ensure_minishift_is_running
-  echo 'Your current minishift oc environment is the following:'
-  minishift oc-env
-  echo 'Run the above command in your shell to obtain the minishift oc command in your PATH'
-
-elif [ "$_CMD" = "login" ]; then
-  ensure_minishift_is_running
-  oc_login
-
-elif [ "$_CMD" = "knative" ]; then
-  ensure_minishift_is_running
-  oc_login
-
-  echo 'Configuring necessary privileges to the service accounts used by Knative'
-  oc adm policy add-scc-to-user anyuid -z controller -n knative-serving
-  oc adm policy add-scc-to-user anyuid -z autoscaler -n knative-serving
-  oc adm policy add-cluster-role-to-user cluster-admin -z controller -n knative-serving
-
-  echo 'Installing Knative Serving'
-  oc apply -f https://github.com/knative/serving/releases/download/v0.4.1/serving.yaml
-  oc apply -f https://raw.githubusercontent.com/knative/serving/v0.4.1/third_party/config/build/clusterrole.yaml
-
-  echo "Waiting for Knative to become ready"
-  sleep 5; while oc get pods -n knative-serving | grep -v -E "(Running|Completed|STATUS)"; do sleep 5; done
-
-  echo "Knative is installed!"
-
-  echo "Creating a new project for knative examples"
-  oc new-project knative-examples || true
-
-  echo "Applying default domain to knative pods"
-
-  oc expose svc istio-ingressgateway -n istio-system || true
-  export DOMAIN=$(oc get route -n istio-system istio-ingressgateway --output=custom-columns=ROUTE:.spec.host | grep -v ROUTE | sed "s/istio-ingressgateway-istio-system.//g")
-  cat ./knative/config-domain.yaml | envsubst | oc apply -f -
-
-  echo "Using domain: *.knative.${DOMAIN}"
-
-  oc adm policy add-scc-to-user privileged -z default -n knative-examples
-  oc label --overwrite namespace knative-examples istio-injection=enabled
-
-  echo "Installing a sample application for knative..."
-  oc delete -n knative-examples -f ./knative/service.yaml || true
-  oc apply -n knative-examples -f ./knative/service.yaml
-else
-  echo "ERROR: Missing required command"
-  exit 1
-fi
diff --git a/hack/purge-kiali-from-cluster.sh b/hack/purge-kiali-from-cluster.sh
index a35cf213b..a18b7a77e 100755
--- a/hack/purge-kiali-from-cluster.sh
+++ b/hack/purge-kiali-from-cluster.sh
@@ -97,15 +97,18 @@ do
   fi
 done
 
+# purge using the k8s labels
+delete_namespace_resources "app.kubernetes.io/name=kiali"
+delete_cluster_resources "app.kubernetes.io/name=kiali"
+delete_namespace_resources "app.kubernetes.io/name=kiali-operator"
+delete_cluster_resources "app.kubernetes.io/name=kiali-operator"
+
+# purge anything using the old labels
 delete_namespace_resources "app=kiali"
 delete_cluster_resources "app=kiali"
-
 delete_namespace_resources "app=kiali-operator"
 delete_cluster_resources "app=kiali-operator"
 
-delete_namespace_resources "app.kubernetes.io/name=kiali-operator"
-delete_cluster_resources "app.kubernetes.io/name=kiali-operator"
-
 msg "Deleting Kiali CRDs..."
 for c in $(${CLIENT_EXE} get crds --ignore-not-found=true monitoringdashboards.monitoring.kiali.io kialis.kiali.io -o custom-columns=N:.metadata.name --no-headers)
 do
diff --git a/handlers/apps.go b/handlers/apps.go
index ab724eba2..b9d3ed2ff 100644
--- a/handlers/apps.go
+++ b/handlers/apps.go
@@ -7,7 +7,7 @@ import (
 	khttp "github.com/kiali/k-charted/http"
 
 	"github.com/kiali/kiali/business"
-	"github.com/kiali/kiali/prometheus"
+	"github.com/kiali/kiali/models"
 )
 
 // AppList is the API handler to fetch all the apps to be displayed, related to a single namespace
@@ -54,34 +54,6 @@ func AppDetails(w http.ResponseWriter, r *http.Request) {
 	RespondWithJSON(w, http.StatusOK, appDetails)
 }
 
-// AppMetrics is the API handler to fetch metrics to be displayed, related to an app-label grouping
-func AppMetrics(w http.ResponseWriter, r *http.Request) {
-	getAppMetrics(w, r, defaultPromClientSupplier)
-}
-
-// getAppMetrics (mock-friendly version)
-func getAppMetrics(w http.ResponseWriter, r *http.Request, promSupplier promClientSupplier) {
-	vars := mux.Vars(r)
-	namespace := vars["namespace"]
-	app := vars["app"]
-
-	prom, namespaceInfo := initClientsForMetrics(w, r, promSupplier, namespace)
-	if prom == nil {
-		// any returned value nil means error & response already written
-		return
-	}
-
-	params := prometheus.IstioMetricsQuery{Namespace: namespace, App: app}
-	err := extractIstioMetricsQueryParams(r, &params, namespaceInfo)
-	if err != nil {
-		RespondWithError(w, http.StatusBadRequest, err.Error())
-		return
-	}
-
-	metrics := prom.GetMetrics(&params)
-	RespondWithJSON(w, http.StatusOK, metrics)
-}
-
 // CustomDashboard is the API handler to fetch runtime metrics to be displayed, related to a single app
 func CustomDashboard(w http.ResponseWriter, r *http.Request) {
 	cfg, log, enabled := business.DashboardsConfig()
@@ -98,21 +70,21 @@ func AppDashboard(w http.ResponseWriter, r *http.Request) {
 	namespace := vars["namespace"]
 	app := vars["app"]
 
-	prom, namespaceInfo := initClientsForMetrics(w, r, defaultPromClientSupplier, namespace)
-	if prom == nil {
+	metricsService, namespaceInfo := createMetricsServiceForNamespace(w, r, defaultPromClientSupplier, namespace)
+	if metricsService == nil {
 		// any returned value nil means error & response already written
 		return
 	}
 
-	params := prometheus.IstioMetricsQuery{Namespace: namespace, App: app}
+	params := models.IstioMetricsQuery{Namespace: namespace, App: app}
 	err := extractIstioMetricsQueryParams(r, &params, namespaceInfo)
 	if err != nil {
 		RespondWithError(w, http.StatusBadRequest, err.Error())
 		return
 	}
 
-	svc := business.NewDashboardsService(prom)
-	dashboard, err := svc.GetIstioDashboard(params)
+	metrics := metricsService.GetMetrics(params)
+	dashboard, err := business.NewDashboardsService().BuildIstioDashboard(metrics, params.Direction)
 	if err != nil {
 		RespondWithError(w, http.StatusInternalServerError, err.Error())
 		return
diff --git a/handlers/authentication.go b/handlers/authentication.go
index 1a295db62..b16687636 100644
--- a/handlers/authentication.go
+++ b/handlers/authentication.go
@@ -148,6 +148,8 @@ func performOpenshiftAuthentication(w http.ResponseWriter, r *http.Request) bool
 }
 
 func performOpenIdAuthentication(w http.ResponseWriter, r *http.Request) bool {
+	conf := config.Get()
+
 	// Read received HTTP params and check for data completeness
 	openIdParams, err := business.ExtractOpenIdCallbackParams(r)
 	if err != nil {
@@ -178,14 +180,18 @@ func performOpenIdAuthentication(w http.ResponseWriter, r *http.Request) bool {
 		return false
 	}
 
-	// Check if user trying to login has enough privileges to login
-	httpStatus, errMsg, detailedError := business.VerifyOpenIdUserAccess(openIdParams.IdToken)
-	if detailedError != nil {
-		RespondWithDetailedError(w, httpStatus, errMsg, detailedError.Error())
-		return false
-	} else if httpStatus != http.StatusOK {
-		RespondWithError(w, httpStatus, errMsg)
-		return false
+	if !conf.Auth.OpenId.DisableRBAC {
+		// Check if user trying to login has enough privileges to login. This check is only done if
+		// config indicates that RBAC is on. For cases where RBAC is off, we simply assume that the
+		// Kiali ServiceAccount token should have enough privileges and skip this privilege check.
+		httpStatus, errMsg, detailedError := business.VerifyOpenIdUserAccess(openIdParams.IdToken)
+		if detailedError != nil {
+			RespondWithDetailedError(w, httpStatus, errMsg, detailedError.Error())
+			return false
+		} else if httpStatus != http.StatusOK {
+			RespondWithError(w, httpStatus, errMsg)
+			return false
+		}
 	}
 
 	// Now that we know that the OpenId token is valid, build our session cookie
@@ -202,7 +208,7 @@ func performOpenIdAuthentication(w http.ResponseWriter, r *http.Request) bool {
 		Value:    tokenString,
 		Expires:  openIdParams.ExpiresOn,
 		HttpOnly: true,
-		Path:     config.Get().Server.WebRoot,
+		Path:     conf.Server.WebRoot,
 		SameSite: http.SameSiteStrictMode,
 	}
 	http.SetCookie(w, &tokenCookie)
@@ -390,16 +396,18 @@ func checkOpenIdSession(w http.ResponseWriter, r *http.Request) (int, string) {
 		return http.StatusUnauthorized, ""
 	}
 
-	_, err = business.Namespace.GetNamespaces()
-	if err == nil {
-		// Internal header used to propagate the subject of the request for audit purposes
-		r.Header.Add("Kiali-User", claims.Subject)
-		return http.StatusOK, claims.SessionId
+	if !config.Get().Auth.OpenId.DisableRBAC {
+		// If RBAC is ENABLED, check that the user has privilges on the cluster.
+		_, err = business.Namespace.GetNamespaces()
+		if err != nil {
+			log.Warning("Token error: ", err)
+			return http.StatusUnauthorized, ""
+		}
 	}
 
-	log.Warning("Token error: ", err)
-
-	return http.StatusUnauthorized, ""
+	// Internal header used to propagate the subject of the request for audit purposes
+	r.Header.Add("Kiali-User", claims.Subject)
+	return http.StatusOK, claims.SessionId
 }
 
 func checkTokenSession(w http.ResponseWriter, r *http.Request) (int, string) {
@@ -453,6 +461,12 @@ func (aHandler AuthenticationHandler) Handle(next http.Handler) http.Handler {
 			statusCode, token = checkOpenshiftSession(w, r)
 		case config.AuthStrategyOpenId:
 			statusCode, token = checkOpenIdSession(w, r)
+			if conf.Auth.OpenId.DisableRBAC {
+				// If RBAC is off, it's assumed that the kubernetes cluster will reject the OpenId token.
+				// Instead, we use the Kiali token an this has the side effect that all users will share the
+				// same privileges.
+				token = aHandler.saToken
+			}
 		case config.AuthStrategyToken:
 			statusCode, token = checkTokenSession(w, r)
 		case config.AuthStrategyAnonymous:
@@ -663,6 +677,8 @@ func OpenIdRedirect(w http.ResponseWriter, r *http.Request) {
 }
 
 func OpenIdCodeFlowHandler(w http.ResponseWriter, r *http.Request) bool {
+	conf := config.Get()
+
 	// Read received HTTP params and check for data completeness
 	openIdParams, err := business.ExtractOpenIdCallbackParams(r)
 	if err != nil {
@@ -701,14 +717,27 @@ func OpenIdCodeFlowHandler(w http.ResponseWriter, r *http.Request) bool {
 		return true
 	}
 
-	// Check if user trying to login has enough privileges to login
-	httpStatus, errMsg, detailedError := business.VerifyOpenIdUserAccess(openIdParams.IdToken)
-	if detailedError != nil {
-		RespondWithDetailedError(w, httpStatus, errMsg, detailedError.Error())
-		return true
-	} else if httpStatus != http.StatusOK {
-		RespondWithError(w, httpStatus, errMsg)
-		return true
+	if conf.Auth.OpenId.DisableRBAC {
+		// When RBAC is on, we delegate some validations to the Kubernetes cluster. However, if RBAC is off
+		// the token must be fully validated, as we no longer pass the OpenId token to the cluster API server.
+		// Since the configuration indicates RBAC is off, we do the validations:
+		err = business.ValidateOpenTokenInHouse(openIdParams)
+		if err != nil {
+			RespondWithDetailedError(w, http.StatusForbidden, "the OpenID token was rejected", err.Error())
+			return true
+		}
+	} else {
+		// Check if user trying to login has enough privileges to login. This check is only done if
+		// config indicates that RBAC is on. For cases where RBAC is off, we simply assume that the
+		// Kiali ServiceAccount token should have enough privileges and skip this privilege check.
+		httpStatus, errMsg, detailedError := business.VerifyOpenIdUserAccess(openIdParams.IdToken)
+		if detailedError != nil {
+			RespondWithDetailedError(w, httpStatus, errMsg, detailedError.Error())
+			return true
+		} else if httpStatus != http.StatusOK {
+			RespondWithError(w, httpStatus, errMsg)
+			return true
+		}
 	}
 
 	// Create Kiali's session cookie and set it in the response
@@ -753,13 +782,12 @@ func OpenIdCodeFlowHandler(w http.ResponseWriter, r *http.Request) bool {
 		Value:    base64.StdEncoding.EncodeToString(cipherSessionData),
 		Expires:  openIdParams.ExpiresOn,
 		HttpOnly: true,
-		Path:     config.Get().Server.WebRoot,
+		Path:     conf.Server.WebRoot,
 		SameSite: http.SameSiteStrictMode,
 	}
 	http.SetCookie(w, &authCookie)
 
 	// Let's redirect (remove the openid params) to let the Kiali-UI to boot
-	conf := config.Get()
 	webRoot := conf.Server.WebRoot
 	webRootWithSlash := webRoot + "/"
 	http.Redirect(w, r, webRootWithSlash, http.StatusFound)
diff --git a/handlers/metrics.go b/handlers/metrics.go
index 9bc2b1f88..084301469 100644
--- a/handlers/metrics.go
+++ b/handlers/metrics.go
@@ -1,8 +1,10 @@
 package handlers
 
 import (
+	"encoding/json"
 	"errors"
 	"fmt"
+	"io/ioutil"
 	"net/http"
 	"net/url"
 	"strconv"
@@ -10,12 +12,97 @@ import (
 
 	"github.com/gorilla/mux"
 
+	"github.com/kiali/kiali/business"
 	"github.com/kiali/kiali/log"
 	"github.com/kiali/kiali/models"
 	"github.com/kiali/kiali/prometheus"
 	"github.com/kiali/kiali/util"
 )
 
+// AppMetrics is the API handler to fetch metrics to be displayed, related to an app-label grouping
+func AppMetrics(w http.ResponseWriter, r *http.Request) {
+	getAppMetrics(w, r, defaultPromClientSupplier)
+}
+
+// getAppMetrics (mock-friendly version)
+func getAppMetrics(w http.ResponseWriter, r *http.Request, promSupplier promClientSupplier) {
+	vars := mux.Vars(r)
+	namespace := vars["namespace"]
+	app := vars["app"]
+
+	metricsService, namespaceInfo := createMetricsServiceForNamespace(w, r, promSupplier, namespace)
+	if metricsService == nil {
+		// any returned value nil means error & response already written
+		return
+	}
+
+	params := models.IstioMetricsQuery{Namespace: namespace, App: app}
+	err := extractIstioMetricsQueryParams(r, &params, namespaceInfo)
+	if err != nil {
+		RespondWithError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	metrics := metricsService.GetMetrics(params)
+	RespondWithJSON(w, http.StatusOK, metrics)
+}
+
+// WorkloadMetrics is the API handler to fetch metrics to be displayed, related to a single workload
+func WorkloadMetrics(w http.ResponseWriter, r *http.Request) {
+	getWorkloadMetrics(w, r, defaultPromClientSupplier)
+}
+
+// getWorkloadMetrics (mock-friendly version)
+func getWorkloadMetrics(w http.ResponseWriter, r *http.Request, promSupplier promClientSupplier) {
+	vars := mux.Vars(r)
+	namespace := vars["namespace"]
+	workload := vars["workload"]
+
+	metricsService, namespaceInfo := createMetricsServiceForNamespace(w, r, promSupplier, namespace)
+	if metricsService == nil {
+		// any returned value nil means error & response already written
+		return
+	}
+
+	params := models.IstioMetricsQuery{Namespace: namespace, Workload: workload}
+	err := extractIstioMetricsQueryParams(r, &params, namespaceInfo)
+	if err != nil {
+		RespondWithError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	metrics := metricsService.GetMetrics(params)
+	RespondWithJSON(w, http.StatusOK, metrics)
+}
+
+// ServiceMetrics is the API handler to fetch metrics to be displayed, related to a single service
+func ServiceMetrics(w http.ResponseWriter, r *http.Request) {
+	getServiceMetrics(w, r, defaultPromClientSupplier)
+}
+
+// getServiceMetrics (mock-friendly version)
+func getServiceMetrics(w http.ResponseWriter, r *http.Request, promSupplier promClientSupplier) {
+	vars := mux.Vars(r)
+	namespace := vars["namespace"]
+	service := vars["service"]
+
+	metricsService, namespaceInfo := createMetricsServiceForNamespace(w, r, promSupplier, namespace)
+	if metricsService == nil {
+		// any returned value nil means error & response already written
+		return
+	}
+
+	params := models.IstioMetricsQuery{Namespace: namespace, Service: service}
+	err := extractIstioMetricsQueryParams(r, &params, namespaceInfo)
+	if err != nil {
+		RespondWithError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	metrics := metricsService.GetMetrics(params)
+	RespondWithJSON(w, http.StatusOK, metrics)
+}
+
 // AggregateMetrics is the API handler to fetch metrics to be displayed, related to a single aggregate
 func AggregateMetrics(w http.ResponseWriter, r *http.Request) {
 	getAggregateMetrics(w, r, defaultPromClientSupplier)
@@ -28,13 +115,13 @@ func getAggregateMetrics(w http.ResponseWriter, r *http.Request, promSupplier pr
 	aggregate := vars["aggregate"]
 	aggregateValue := vars["aggregateValue"]
 
-	prom, namespaceInfo := initClientsForMetrics(w, r, promSupplier, namespace)
-	if prom == nil {
+	metricsService, namespaceInfo := createMetricsServiceForNamespace(w, r, promSupplier, namespace)
+	if metricsService == nil {
 		// any returned value nil means error & response already written
 		return
 	}
 
-	params := prometheus.IstioMetricsQuery{Namespace: namespace, Aggregate: aggregate, AggregateValue: aggregateValue}
+	params := models.IstioMetricsQuery{Namespace: namespace, Aggregate: aggregate, AggregateValue: aggregateValue}
 	err := extractIstioMetricsQueryParams(r, &params, namespaceInfo)
 	if err != nil {
 		RespondWithError(w, http.StatusBadRequest, err.Error())
@@ -49,11 +136,39 @@ func getAggregateMetrics(w http.ResponseWriter, r *http.Request, promSupplier pr
 		return
 	}
 
-	metrics := prom.GetMetrics(&params)
+	metrics := metricsService.GetMetrics(params)
+	RespondWithJSON(w, http.StatusOK, metrics)
+}
+
+// NamespaceMetrics is the API handler to fetch metrics to be displayed, related to all
+// services in the namespace
+func NamespaceMetrics(w http.ResponseWriter, r *http.Request) {
+	getNamespaceMetrics(w, r, defaultPromClientSupplier)
+}
+
+// getServiceMetrics (mock-friendly version)
+func getNamespaceMetrics(w http.ResponseWriter, r *http.Request, promSupplier promClientSupplier) {
+	vars := mux.Vars(r)
+	namespace := vars["namespace"]
+
+	metricsService, namespaceInfo := createMetricsServiceForNamespace(w, r, promSupplier, namespace)
+	if metricsService == nil {
+		// any returned value nil means error & response already written
+		return
+	}
+
+	params := models.IstioMetricsQuery{Namespace: namespace}
+	err := extractIstioMetricsQueryParams(r, &params, namespaceInfo)
+	if err != nil {
+		RespondWithError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	metrics := metricsService.GetMetrics(params)
 	RespondWithJSON(w, http.StatusOK, metrics)
 }
 
-func extractIstioMetricsQueryParams(r *http.Request, q *prometheus.IstioMetricsQuery, namespaceInfo *models.Namespace) error {
+func extractIstioMetricsQueryParams(r *http.Request, q *models.IstioMetricsQuery, namespaceInfo *models.Namespace) error {
 	q.FillDefaults()
 	queryParams := r.URL.Query()
 	if filters, ok := queryParams["filters[]"]; ok && len(filters) > 0 {
@@ -78,10 +193,10 @@ func extractIstioMetricsQueryParams(r *http.Request, q *prometheus.IstioMetricsQ
 		}
 		q.Reporter = reporter
 	}
-	return extractBaseMetricsQueryParams(queryParams, &q.BaseMetricsQuery, namespaceInfo)
+	return extractBaseMetricsQueryParams(queryParams, &q.RangeQuery, namespaceInfo)
 }
 
-func extractBaseMetricsQueryParams(queryParams url.Values, q *prometheus.BaseMetricsQuery, namespaceInfo *models.Namespace) error {
+func extractBaseMetricsQueryParams(queryParams url.Values, q *prometheus.RangeQuery, namespaceInfo *models.Namespace) error {
 	if ri := queryParams.Get("rateInterval"); ri != "" {
 		q.RateInterval = ri
 	}
@@ -167,3 +282,85 @@ func extractBaseMetricsQueryParams(queryParams url.Values, q *prometheus.BaseMet
 	q.Start = time.Unix((q.Start.Unix()/stepInSecs)*stepInSecs, 0)
 	return nil
 }
+
+// MetricsStats is the API handler to compute some stats based on metrics
+func MetricsStats(w http.ResponseWriter, r *http.Request) {
+	body, err := ioutil.ReadAll(r.Body)
+	if err != nil {
+		RespondWithError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+	var raw models.MetricsStatsQueries
+	err = json.Unmarshal(body, &raw)
+	if err != nil {
+		RespondWithError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+	metricsService, queries, warns := prepareStatsQueries(w, r, raw.Queries, defaultPromClientSupplier)
+	if len(queries) == 0 && warns != nil {
+		// All queries failed to be adjusted => return an error
+		handleErrorResponse(w, warns)
+		return
+	}
+	stats, err := metricsService.GetStats(queries)
+	if err != nil {
+		handleErrorResponse(w, err)
+		return
+	}
+	result := models.MetricsStatsResult{Stats: stats}
+	if warns != nil {
+		result.Warnings = warns.Strings()
+	}
+	RespondWithJSON(w, http.StatusOK, result)
+}
+
+func prepareStatsQueries(w http.ResponseWriter, r *http.Request, rawQ []models.MetricsStatsQuery, promSupplier promClientSupplier) (*business.MetricsService, []models.MetricsStatsQuery, *util.Errors) {
+	// Get unique namespaces list
+	var namespaces []string
+	for _, q := range rawQ {
+		found := false
+		for _, ns := range namespaces {
+			if ns == q.Target.Namespace {
+				found = true
+				break
+			}
+		}
+		if !found {
+			namespaces = append(namespaces, q.Target.Namespace)
+		}
+	}
+
+	// Create the metrics service, along with namespaces information for adjustements
+	metricsService, nsInfos := createMetricsServiceForNamespaces(w, r, promSupplier, namespaces)
+
+	// Keep only valid queries (fill errors if needed) and adjust queryTime / interval
+	var errors util.Errors
+	var validQueries []models.MetricsStatsQuery
+	for _, q := range rawQ {
+		if valErr := q.Validate(); valErr != nil {
+			errors.Merge(valErr)
+			continue
+		}
+		if nsInfoErr, ok := nsInfos[q.Target.Namespace]; !ok {
+			errors.Add(fmt.Errorf("Missing info for namespace '%s'", q.Target.Namespace))
+			continue
+		} else if nsInfoErr.err != nil {
+			errors.Add(fmt.Errorf("Namespace '%s': %v", q.Target.Namespace, nsInfoErr.err))
+			continue
+		} else {
+			namespaceInfo := nsInfoErr.info
+			interval, err := util.AdjustRateInterval(namespaceInfo.CreationTimestamp, q.QueryTime, q.RawInterval)
+			if err != nil {
+				errors.Add(err)
+				continue
+			}
+			q.Interval = interval
+			if q.Interval != q.RawInterval {
+				log.Debugf("Rate interval for namespace %s was adjusted to %s (original = %s, query time = %v, namespace created = %v)",
+					q.Target.Namespace, q.Interval, q.RawInterval, q.QueryTime, namespaceInfo.CreationTimestamp)
+			}
+			validQueries = append(validQueries, q)
+		}
+	}
+	return metricsService, validQueries, errors.OrNil()
+}
diff --git a/handlers/namespaces.go b/handlers/namespaces.go
index a4af2f2e7..8d00c16f4 100644
--- a/handlers/namespaces.go
+++ b/handlers/namespaces.go
@@ -8,7 +8,6 @@ import (
 
 	"github.com/kiali/kiali/log"
 	"github.com/kiali/kiali/models"
-	"github.com/kiali/kiali/prometheus"
 )
 
 func NamespaceList(w http.ResponseWriter, r *http.Request) {
@@ -30,34 +29,6 @@ func NamespaceList(w http.ResponseWriter, r *http.Request) {
 	RespondWithJSON(w, http.StatusOK, namespaces)
 }
 
-// NamespaceMetrics is the API handler to fetch metrics to be displayed, related to all
-// services in the namespace
-func NamespaceMetrics(w http.ResponseWriter, r *http.Request) {
-	getNamespaceMetrics(w, r, defaultPromClientSupplier)
-}
-
-// getServiceMetrics (mock-friendly version)
-func getNamespaceMetrics(w http.ResponseWriter, r *http.Request, promSupplier promClientSupplier) {
-	vars := mux.Vars(r)
-	namespace := vars["namespace"]
-
-	prom, namespaceInfo := initClientsForMetrics(w, r, promSupplier, namespace)
-	if prom == nil {
-		// any returned value nil means error & response already written
-		return
-	}
-
-	params := prometheus.IstioMetricsQuery{Namespace: namespace}
-	err := extractIstioMetricsQueryParams(r, &params, namespaceInfo)
-	if err != nil {
-		RespondWithError(w, http.StatusBadRequest, err.Error())
-		return
-	}
-
-	metrics := prom.GetMetrics(&params)
-	RespondWithJSON(w, http.StatusOK, metrics)
-}
-
 // 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(w http.ResponseWriter, r *http.Request) {
diff --git a/handlers/services.go b/handlers/services.go
index 8db133a86..b72fe2779 100644
--- a/handlers/services.go
+++ b/handlers/services.go
@@ -8,7 +8,6 @@ import (
 
 	"github.com/kiali/kiali/business"
 	"github.com/kiali/kiali/models"
-	"github.com/kiali/kiali/prometheus"
 	"github.com/kiali/kiali/util"
 )
 
@@ -34,34 +33,6 @@ func ServiceList(w http.ResponseWriter, r *http.Request) {
 	RespondWithJSON(w, http.StatusOK, serviceList)
 }
 
-// ServiceMetrics is the API handler to fetch metrics to be displayed, related to a single service
-func ServiceMetrics(w http.ResponseWriter, r *http.Request) {
-	getServiceMetrics(w, r, defaultPromClientSupplier)
-}
-
-// getServiceMetrics (mock-friendly version)
-func getServiceMetrics(w http.ResponseWriter, r *http.Request, promSupplier promClientSupplier) {
-	vars := mux.Vars(r)
-	namespace := vars["namespace"]
-	service := vars["service"]
-
-	prom, namespaceInfo := initClientsForMetrics(w, r, promSupplier, namespace)
-	if prom == nil {
-		// any returned value nil means error & response already written
-		return
-	}
-
-	params := prometheus.IstioMetricsQuery{Namespace: namespace, Service: service}
-	err := extractIstioMetricsQueryParams(r, &params, namespaceInfo)
-	if err != nil {
-		RespondWithError(w, http.StatusBadRequest, err.Error())
-		return
-	}
-
-	metrics := prom.GetMetrics(&params)
-	RespondWithJSON(w, http.StatusOK, metrics)
-}
-
 // ServiceDetails is the API handler to fetch full details of an specific service
 func ServiceDetails(w http.ResponseWriter, r *http.Request) {
 	// Get business layer
@@ -126,21 +97,21 @@ func ServiceDashboard(w http.ResponseWriter, r *http.Request) {
 	namespace := vars["namespace"]
 	service := vars["service"]
 
-	prom, namespaceInfo := initClientsForMetrics(w, r, defaultPromClientSupplier, namespace)
-	if prom == nil {
+	metricsService, namespaceInfo := createMetricsServiceForNamespace(w, r, defaultPromClientSupplier, namespace)
+	if metricsService == nil {
 		// any returned value nil means error & response already written
 		return
 	}
 
-	params := prometheus.IstioMetricsQuery{Namespace: namespace, Service: service}
+	params := models.IstioMetricsQuery{Namespace: namespace, Service: service}
 	err := extractIstioMetricsQueryParams(r, &params, namespaceInfo)
 	if err != nil {
 		RespondWithError(w, http.StatusBadRequest, err.Error())
 		return
 	}
 
-	svc := business.NewDashboardsService(prom)
-	dashboard, err := svc.GetIstioDashboard(params)
+	metrics := metricsService.GetMetrics(params)
+	dashboard, err := business.NewDashboardsService().BuildIstioDashboard(metrics, params.Direction)
 	if err != nil {
 		RespondWithError(w, http.StatusInternalServerError, err.Error())
 		return
diff --git a/handlers/utils.go b/handlers/utils.go
index 2bfc14d04..0c4afe3b5 100644
--- a/handlers/utils.go
+++ b/handlers/utils.go
@@ -19,22 +19,37 @@ func validateURL(serviceURL string) (*url.URL, error) {
 	return url.ParseRequestURI(serviceURL)
 }
 
-func checkNamespaceAccess(w http.ResponseWriter, r *http.Request, namespace string) *models.Namespace {
-	layer, err := getBusiness(r)
-	if err != nil {
-		RespondWithError(w, http.StatusInternalServerError, err.Error())
-		return nil
+func checkNamespaceAccess(nsServ business.NamespaceService, namespace string) (*models.Namespace, error) {
+	if nsInfo, err := nsServ.GetNamespace(namespace); err != nil {
+		return nil, err
+	} else {
+		return nsInfo, nil
 	}
+}
 
-	if nsInfo, err := layer.Namespace.GetNamespace(namespace); err != nil {
-		RespondWithError(w, http.StatusForbidden, "Cannot access namespace data: "+err.Error())
-		return nil
-	} else {
-		return nsInfo
+func createMetricsServiceForNamespace(w http.ResponseWriter, r *http.Request, promSupplier promClientSupplier, namespace string) (*business.MetricsService, *models.Namespace) {
+	metrics, infoMap := createMetricsServiceForNamespaces(w, r, promSupplier, []string{namespace})
+	if result, ok := infoMap[namespace]; ok {
+		if result.err != nil {
+			RespondWithError(w, http.StatusForbidden, "Cannot access namespace data: "+result.err.Error())
+			return nil, nil
+		}
+		return metrics, result.info
 	}
+	return nil, nil
 }
 
-func initClientsForMetrics(w http.ResponseWriter, r *http.Request, promSupplier promClientSupplier, namespace string) (*prometheus.Client, *models.Namespace) {
+type nsInfoError struct {
+	info *models.Namespace
+	err  error
+}
+
+func createMetricsServiceForNamespaces(w http.ResponseWriter, r *http.Request, promSupplier promClientSupplier, namespaces []string) (*business.MetricsService, map[string]nsInfoError) {
+	layer, err := getBusiness(r)
+	if err != nil {
+		RespondWithError(w, http.StatusInternalServerError, err.Error())
+		return nil, nil
+	}
 	prom, err := promSupplier()
 	if err != nil {
 		log.Error(err)
@@ -42,11 +57,13 @@ func initClientsForMetrics(w http.ResponseWriter, r *http.Request, promSupplier
 		return nil, nil
 	}
 
-	nsInfo := checkNamespaceAccess(w, r, namespace)
-	if nsInfo == nil {
-		return nil, nil
+	nsInfos := make(map[string]nsInfoError)
+	for _, ns := range namespaces {
+		info, err := checkNamespaceAccess(layer.Namespace, ns)
+		nsInfos[ns] = nsInfoError{info: info, err: err}
 	}
-	return prom, nsInfo
+	metrics := business.NewMetricsService(prom)
+	return metrics, nsInfos
 }
 
 // getToken retrieves the token from the request's context
diff --git a/handlers/workloads.go b/handlers/workloads.go
index 35ad687cb..a71bbc4d0 100644
--- a/handlers/workloads.go
+++ b/handlers/workloads.go
@@ -1,18 +1,13 @@
 package handlers
 
 import (
-	"fmt"
 	"io/ioutil"
 	"net/http"
-	"strconv"
-	"time"
 
 	"github.com/gorilla/mux"
-	core_v1 "k8s.io/api/core/v1"
-	meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 
-	businesspkg "github.com/kiali/kiali/business"
-	"github.com/kiali/kiali/prometheus"
+	"github.com/kiali/kiali/business"
+	"github.com/kiali/kiali/models"
 )
 
 // WorkloadList is the API handler to fetch all the workloads to be displayed, related to a single namespace
@@ -93,55 +88,27 @@ func WorkloadUpdate(w http.ResponseWriter, r *http.Request) {
 	RespondWithJSON(w, http.StatusOK, workloadDetails)
 }
 
-// WorkloadMetrics is the API handler to fetch metrics to be displayed, related to a single workload
-func WorkloadMetrics(w http.ResponseWriter, r *http.Request) {
-	getWorkloadMetrics(w, r, defaultPromClientSupplier)
-}
-
-// getWorkloadMetrics (mock-friendly version)
-func getWorkloadMetrics(w http.ResponseWriter, r *http.Request, promSupplier promClientSupplier) {
-	vars := mux.Vars(r)
-	namespace := vars["namespace"]
-	workload := vars["workload"]
-
-	prom, namespaceInfo := initClientsForMetrics(w, r, promSupplier, namespace)
-	if prom == nil {
-		// any returned value nil means error & response already written
-		return
-	}
-
-	params := prometheus.IstioMetricsQuery{Namespace: namespace, Workload: workload}
-	err := extractIstioMetricsQueryParams(r, &params, namespaceInfo)
-	if err != nil {
-		RespondWithError(w, http.StatusBadRequest, err.Error())
-		return
-	}
-
-	metrics := prom.GetMetrics(&params)
-	RespondWithJSON(w, http.StatusOK, metrics)
-}
-
 // WorkloadDashboard is the API handler to fetch Istio dashboard, related to a single workload
 func WorkloadDashboard(w http.ResponseWriter, r *http.Request) {
 	vars := mux.Vars(r)
 	namespace := vars["namespace"]
 	workload := vars["workload"]
 
-	prom, namespaceInfo := initClientsForMetrics(w, r, defaultPromClientSupplier, namespace)
-	if prom == nil {
+	metricsService, namespaceInfo := createMetricsServiceForNamespace(w, r, defaultPromClientSupplier, namespace)
+	if metricsService == nil {
 		// any returned value nil means error & response already written
 		return
 	}
 
-	params := prometheus.IstioMetricsQuery{Namespace: namespace, Workload: workload}
+	params := models.IstioMetricsQuery{Namespace: namespace, Workload: workload}
 	err := extractIstioMetricsQueryParams(r, &params, namespaceInfo)
 	if err != nil {
 		RespondWithError(w, http.StatusBadRequest, err.Error())
 		return
 	}
 
-	svc := businesspkg.NewDashboardsService(prom)
-	dashboard, err := svc.GetIstioDashboard(params)
+	metrics := metricsService.GetMetrics(params)
+	dashboard, err := business.NewDashboardsService().BuildIstioDashboard(metrics, params.Direction)
 	if err != nil {
 		RespondWithError(w, http.StatusInternalServerError, err.Error())
 		return
@@ -187,47 +154,19 @@ func PodLogs(w http.ResponseWriter, r *http.Request) {
 	pod := vars["pod"]
 
 	// Get log options
-	opts := businesspkg.LogOptions{PodLogOptions: core_v1.PodLogOptions{Timestamps: true}}
-	if container := queryParams.Get("container"); container != "" {
-		opts.Container = container
-	}
-	if duration := queryParams.Get("duration"); duration != "" {
-		duration, err := time.ParseDuration(duration)
-
-		if err != nil {
-			RespondWithError(w, http.StatusInternalServerError, fmt.Sprintf("Invalid duration [%s]: %v", duration, err))
-			return
-		}
+	opts, err := business.Workload.BuildLogOptionsCriteria(
+		queryParams.Get("container"),
+		queryParams.Get("duration"),
+		queryParams.Get("sinceTime"),
+		queryParams.Get("tailLines"))
 
-		opts.Duration = &duration
-	}
-	if sinceTime := queryParams.Get("sinceTime"); sinceTime != "" {
-		if numTime, err := strconv.ParseInt(sinceTime, 10, 64); err == nil {
-			opts.SinceTime = &meta_v1.Time{Time: time.Unix(numTime, 0)}
-		} else {
-			RespondWithError(w, http.StatusInternalServerError, fmt.Sprintf("Invalid sinceTime [%s]: %v", sinceTime, err))
-			return
-		}
-	}
-	if tailLines := queryParams.Get("tailLines"); tailLines != "" {
-		if numLines, err := strconv.ParseInt(tailLines, 10, 64); err == nil {
-			if numLines > 0 {
-				opts.TailLines = &numLines
-			}
-		} else {
-			RespondWithError(w, http.StatusInternalServerError, fmt.Sprintf("Invalid tailLines [%s]: %v", tailLines, err))
-			return
-		}
-	}
-
-	if duration := queryParams.Get("duration"); duration != "" {
-		if parsed, err := time.ParseDuration(duration); err != nil {
-			opts.Duration = &parsed
-		}
+	if err != nil {
+		handleErrorResponse(w, err)
+		return
 	}
 
 	// Fetch pod logs
-	podLogs, err := business.Workload.GetPodLogs(namespace, pod, &opts)
+	podLogs, err := business.Workload.GetPodLogs(namespace, pod, opts)
 	if err != nil {
 		handleErrorResponse(w, err)
 		return
diff --git a/kiali.go b/kiali.go
index 75a559ae0..607155e31 100644
--- a/kiali.go
+++ b/kiali.go
@@ -25,8 +25,6 @@ import (
 	"regexp"
 	"strings"
 
-	"github.com/golang/glog"
-
 	"github.com/kiali/kiali/config"
 	"github.com/kiali/kiali/log"
 	"github.com/kiali/kiali/prometheus/internalmetrics"
@@ -53,7 +51,8 @@ func init() {
 }
 
 func main() {
-	defer glog.Flush()
+
+	log.InitializeLogger()
 	util.Clock = util.RealClock{}
 
 	// process command line
@@ -68,7 +67,7 @@ func main() {
 	if *argConfigFile != "" {
 		c, err := config.LoadFromFile(*argConfigFile)
 		if err != nil {
-			glog.Fatal(err)
+			log.Fatal(err)
 		}
 		config.Set(c)
 	} else {
@@ -78,7 +77,7 @@ func main() {
 	log.Tracef("Kiali Configuration:\n%s", config.Get())
 
 	if err := validateConfig(); err != nil {
-		glog.Fatal(err)
+		log.Fatal(err)
 	}
 
 	consoleVersion := determineConsoleVersion()
diff --git a/log/log.go b/log/log.go
index 5be4db5f6..4ccaf77b2 100644
--- a/log/log.go
+++ b/log/log.go
@@ -1,72 +1,185 @@
 package log
 
 import (
-	"fmt"
+	"os"
+	"strconv"
+	"strings"
+	"time"
 
-	"github.com/golang/glog"
+	"github.com/rs/zerolog"
+	"github.com/rs/zerolog/log"
 )
 
+type Format string
+
 const (
-	debug glog.Level = glog.Level(4)
-	trace glog.Level = glog.Level(5)
+	FallbackLogFormat       = "text"
+	FallbackTimeFieldFormat = time.RFC3339
 )
 
+var supportedTimeFormats = [...]string{
+	time.ANSIC,
+	time.UnixDate,
+	time.RubyDate,
+	time.RFC822,
+	time.RFC822Z,
+	time.RFC850,
+	time.RFC1123,
+	time.RFC1123Z,
+	time.RFC3339,
+	time.RFC3339Nano,
+	time.Kitchen,
+	time.Stamp,
+	time.StampMilli,
+	time.StampMicro,
+	time.StampNano,
+}
+
+// Configures the global log level and log format.
+func InitializeLogger() zerolog.Logger {
+	zerolog.TimeFieldFormat = resolveTimeFormatFromEnv()
+
+	logSamplerRateAsString, isSamplerRateDefined := os.LookupEnv("LOG_SAMPLER_RATE")
+	if isSamplerRateDefined {
+		logSamplerRate, err := strconv.Atoi(logSamplerRateAsString)
+		if err != nil {
+			log.Warn().Msgf("Provided sampling rate %s cannot be parsed to int32. "+
+				"No sampling rate will be set. Error: %v", logSamplerRateAsString, err)
+		} else if logSamplerRate != 1 {
+			log.Debug().Msgf("Setting log sample rate to every %dth event", logSamplerRate)
+			log.Logger = log.Sample(&zerolog.BasicSampler{N: uint32(logSamplerRate)})
+		}
+	}
+
+	logFormat := resolveLogFormatFromEnv()
+	if logFormat != "json" {
+		log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: zerolog.TimeFieldFormat, NoColor: true})
+	}
+
+	logLevel := resolveLogLevelFromEnv()
+	zerolog.SetGlobalLevel(logLevel)
+
+	return log.Logger
+}
+
 func Info(args ...interface{}) {
-	glog.InfoDepth(1, args...)
+	log.Info().Msgf("%s", args...)
 }
 
 func Infof(format string, args ...interface{}) {
-	glog.InfoDepth(1, fmt.Sprintf(format, args...))
+	log.Info().Msgf(format, args...)
 }
 
 func Warning(args ...interface{}) {
-	glog.WarningDepth(1, args...)
+	log.Warn().Msgf("%s", args...)
 }
 
 func Warningf(format string, args ...interface{}) {
-	glog.WarningDepth(1, fmt.Sprintf(format, args...))
+	log.Warn().Msgf(format, args...)
 }
 
 func Error(args ...interface{}) {
-	glog.ErrorDepth(1, args...)
+	log.Error().Msgf("%s", args...)
 }
 
 func Errorf(format string, args ...interface{}) {
-	glog.ErrorDepth(1, fmt.Sprintf(format, args...))
+	log.Error().Msgf(format, args...)
 }
 
-// Debug will log a message at verbose level 4 and will ensure the caller's stack frame is used
 func Debug(args ...interface{}) {
-	if glog.V(debug) {
-		glog.InfoDepth(1, "DEBUG: "+fmt.Sprint(args...)) // 1 == depth in the stack of the caller
-	}
+	log.Debug().Msgf("%s", args...)
 }
 
-// Debugf will log a message at verbose level 4 and will ensure the caller's stack frame is used
 func Debugf(format string, args ...interface{}) {
-	if glog.V(debug) {
-		glog.InfoDepth(1, fmt.Sprintf("DEBUG: "+format, args...)) // 1 == depth in the stack of the caller
-	}
+	log.Debug().Msgf(format, args...)
 }
 
 func IsDebug() bool {
-	return bool(glog.V(debug))
+	return zerolog.GlobalLevel() == zerolog.DebugLevel
 }
 
-// Trace will log a message at verbose level 5 and will ensure the caller's stack frame is used
 func Trace(args ...interface{}) {
-	if glog.V(trace) {
-		glog.InfoDepth(1, "TRACE: "+fmt.Sprint(args...)) // 1 == depth in the stack of the caller
-	}
+	log.Trace().Msgf("%s", args...)
 }
 
-// Tracef will log a message at verbose level 5 and will ensure the caller's stack frame is used
 func Tracef(format string, args ...interface{}) {
-	if glog.V(trace) {
-		glog.InfoDepth(1, fmt.Sprintf("TRACE: "+format, args...)) // 1 == depth in the stack of the caller
-	}
+	log.Trace().Msgf(format, args...)
 }
 
 func IsTrace() bool {
-	return bool(glog.V(trace))
+	return zerolog.GlobalLevel() == zerolog.TraceLevel
+}
+
+func Fatal(args ...interface{}) {
+	log.Fatal().Msgf("%s", args...)
+}
+
+func Fatalf(format string, args ...interface{}) {
+	log.Fatal().Msgf(format, args...)
+}
+
+// Resolves the environment settings for the log level. Considers the verbose_mode from server version <=1.25.
+func resolveLogLevelFromEnv() zerolog.Level {
+	logLevel, isDefined := os.LookupEnv("LOG_LEVEL")
+
+	if !isDefined {
+		return zerolog.InfoLevel
+	}
+
+	switch logLevel {
+	case "0":
+		return zerolog.FatalLevel
+	case "1":
+		return zerolog.ErrorLevel
+	case "2":
+		return zerolog.WarnLevel
+	case "3":
+		return zerolog.InfoLevel
+	case "4":
+		return zerolog.DebugLevel
+	case "5":
+		return zerolog.TraceLevel
+	default:
+		logLevelFromString, err := zerolog.ParseLevel(strings.ToLower(logLevel))
+		if err != nil {
+			log.Warn().Msgf("Provided LOG_LEVEL %s is invalid. Fallback to info.", os.Getenv("LOG_LEVEL"))
+			return zerolog.InfoLevel
+		}
+		return logLevelFromString
+	}
+}
+
+// Resolves and validates the log format. FallbackLogFormat is used as a default.
+func resolveLogFormatFromEnv() string {
+	logFormatEnv, isDefined := os.LookupEnv("LOG_FORMAT")
+
+	if !isDefined {
+		return FallbackLogFormat
+	}
+
+	switch logFormatEnv {
+	case "text", "json":
+		return logFormatEnv
+	default:
+		Warningf("Provided LOG_FORMAT %s is invalid. Fallback to text.", logFormatEnv)
+		return FallbackLogFormat
+	}
+}
+
+// Resolves and validates the provided log time format. FallbackTimeFieldFormat is used as a fallback.
+func resolveTimeFormatFromEnv() string {
+	logTimeFieldFormat, isDefined := os.LookupEnv("LOG_TIME_FIELD_FORMAT")
+
+	if !isDefined {
+		return FallbackTimeFieldFormat
+	}
+
+	for _, supportedTimeFormat := range supportedTimeFormats {
+		if logTimeFieldFormat == supportedTimeFormat {
+			return logTimeFieldFormat
+		}
+	}
+
+	Warningf("Provided LOG_TIME_FIELD_FORMAT %s is not supported. Fallback to %s", logTimeFieldFormat, FallbackTimeFieldFormat)
+	return FallbackTimeFieldFormat
 }
diff --git a/make/Makefile.build.mk b/make/Makefile.build.mk
index ccabec57d..49be440e1 100644
--- a/make/Makefile.build.mk
+++ b/make/Makefile.build.mk
@@ -7,7 +7,7 @@ clean:
 	@echo Cleaning...
 	@rm -f kiali
 	@rm -rf ${GOPATH}/bin/kiali
-	@[ -d ${GOPATH}/pkg/* ] && chmod -R +rw ${GOPATH}/pkg/* || true
+	@[ -d ${GOPATH}/pkg ] && chmod -R +rw ${GOPATH}/pkg/* || true
 	@rm -rf ${GOPATH}/pkg/*
 	@rm -rf ${OUTDIR}/docker
 
@@ -78,7 +78,7 @@ test-e2e:
 ## run: Run kiali binary
 run:
 	@echo Running...
-	@${GOPATH}/bin/kiali -v 4 -config config.yaml
+	@LOG_LEVEL="debug" ${GOPATH}/bin/kiali -config config.yaml
 
 #
 # Swagger Documentation
diff --git a/make/Makefile.cluster.mk b/make/Makefile.cluster.mk
index b966ef79a..d846813e5 100644
--- a/make/Makefile.cluster.mk
+++ b/make/Makefile.cluster.mk
@@ -63,8 +63,11 @@ else
 	@exit 1
 endif
 
+.get-cluster-istio-version:
+	@$(eval CLUSTER_ISTIO_VERSION ?= $(shell ${OC} exec $$(${OC} get pods -n ${ISTIO_NAMESPACE} -l app=istiod -o name 2>/dev/null) -n ${ISTIO_NAMESPACE} -- curl -s http://localhost:15014/version 2>/dev/null | grep . || echo "N/A"))
+
 ## cluster-status: Outputs details of the client and server for the cluster
-cluster-status: .prepare-cluster
+cluster-status: .prepare-cluster .get-cluster-istio-version
 	@echo "================================================================="
 	@echo "CLUSTER DETAILS"
 	@echo "================================================================="
@@ -87,6 +90,8 @@ else ifeq ($(CLUSTER_TYPE),minikube)
 	@echo "Console URL: Run 'minikube dashboard' to access the UI console"
 	@echo "================================================================="
 endif
+	@echo "Istio Version (if installed): ${CLUSTER_ISTIO_VERSION}"
+	@echo "================================================================="
 	@echo "Kiali image as seen from inside the cluster:       ${CLUSTER_KIALI_INTERNAL_NAME}"
 	@echo "Kiali image that will be pushed to the cluster:    ${CLUSTER_KIALI_TAG}"
 	@echo "Operator image as seen from inside the cluster:    ${CLUSTER_OPERATOR_INTERNAL_NAME}"
diff --git a/make/Makefile.molecule.mk b/make/Makefile.molecule.mk
index 3267d64ec..934bdc1e8 100644
--- a/make/Makefile.molecule.mk
+++ b/make/Makefile.molecule.mk
@@ -68,12 +68,19 @@ MOLECULE_WAIT_RETRIES ?= 360
 endif
 MOLECULE_WAIT_RETRIES_ARG ?= --env MOLECULE_WAIT_RETRIES=${MOLECULE_WAIT_RETRIES}
 
-## molecule-build: Builds an image to run Molecule without requiring the host to have python/pip installed
-molecule-build: .ensure-operator-repo-exists
+.prepare-force-molecule-build:
 ifeq ($(DORP),docker)
-	docker build -t kiali-molecule:latest ${ROOTDIR}/operator/molecule/docker
+	@$(eval FORCE_MOLECULE_BUILD ?= $(shell docker inspect kiali-molecule:latest > /dev/null 2>&1 || echo "true"))
 else
-	podman build -t kiali-molecule:latest ${ROOTDIR}/operator/molecule/docker
+	@$(eval FORCE_MOLECULE_BUILD ?= $(shell podman inspect kiali-molecule:latest > /dev/null 2>&1 || echo "true"))
+endif
+
+## molecule-build: Builds an image to run Molecule without requiring the host to have python/pip installed. If it already exists, and you want to build it again, set env var FORCE_MOLECULE_BUILD to "true".
+molecule-build: .ensure-operator-repo-exists .prepare-force-molecule-build
+ifeq ($(DORP),docker)
+	@if [ "${FORCE_MOLECULE_BUILD}" == "true" ]; then docker build -t kiali-molecule:latest ${ROOTDIR}/operator/molecule/docker; else echo "Will not rebuild kiali-molecule image."; fi
+else
+	@if [ "${FORCE_MOLECULE_BUILD}" == "true" ]; then podman build -t kiali-molecule:latest ${ROOTDIR}/operator/molecule/docker; else echo "Will not rebuild kiali-molecule image."; fi
 endif
 
 ifndef MOLECULE_ADD_HOST_ARGS
@@ -87,7 +94,7 @@ else
 endif
 
 ## molecule-test: Runs Molecule tests using the Molecule docker image
-molecule-test: .ensure-operator-repo-exists .ensure-helm-charts-repo-exists .prepare-add-host-args
+molecule-test: .ensure-operator-repo-exists .ensure-helm-charts-repo-exists .prepare-add-host-args molecule-build
 ifeq ($(DORP),docker)
 	for msn in ${MOLECULE_SCENARIO}; do docker run --rm ${MOLECULE_DOCKER_TERM_ARGS} --env MOLECULE_HELM_CHARTS_REPO=/tmp/helm-charts-repo -v "${HELM_CHARTS_REPO}":/tmp/helm-charts-repo:ro -v "${ROOTDIR}/operator":/tmp/$(basename "${ROOTDIR}/operator"):ro -v "${MOLECULE_KUBECONFIG}":/root/.kube/config:ro -v /var/run/docker.sock:/var/run/docker.sock ${MOLECULE_MINIKUBE_VOL_ARG} ${MOLECULE_MINIKUBE_ENV_ARGS} -w /tmp/$(basename "${ROOTDIR}/operator") --network="host" ${MOLECULE_ADD_HOST_ARGS} --add-host="api.crc.testing:192.168.130.11" --add-host="kiali-istio-system.apps-crc.testing:192.168.130.11" --add-host="prometheus-istio-system.apps-crc.testing:192.168.130.11" --env DORP=${DORP} ${MOLECULE_IMAGE_ENV_ARGS} ${MOLECULE_DUMP_LOGS_ON_ERROR_ENV_VAR} ${MOLECULE_IMAGE_PULL_POLICY_ENV_ARGS} ${MOLECULE_WAIT_RETRIES_ARG} kiali-molecule:latest molecule ${MOLECULE_DEBUG_ARG} test ${MOLECULE_DESTROY_NEVER_ARG} --scenario-name $${msn}; if [ "$$?" != "0" ]; then echo "Molecule test failed: $${msn}"; exit 1; fi; done
 else
@@ -95,7 +102,7 @@ else
 endif
 
 ## molecule-test-all: Runs all Molecule tests using the Molecule docker image
-molecule-test-all: .ensure-operator-repo-exists .ensure-helm-charts-repo-exists .prepare-add-host-args
+molecule-test-all: .ensure-operator-repo-exists .ensure-helm-charts-repo-exists .prepare-add-host-args molecule-build
 ifeq ($(DORP),docker)
 	docker run --rm ${MOLECULE_DOCKER_TERM_ARGS} --env MOLECULE_HELM_CHARTS_REPO=/tmp/helm-charts-repo -v "${HELM_CHARTS_REPO}":/tmp/helm-charts-repo:ro -v "${ROOTDIR}/operator":/tmp/$(basename "${ROOTDIR}/operator"):ro -v "${MOLECULE_KUBECONFIG}":/root/.kube/config:ro -v /var/run/docker.sock:/var/run/docker.sock ${MOLECULE_MINIKUBE_VOL_ARG} ${MOLECULE_MINIKUBE_ENV_ARGS} -w /tmp/$(basename "${ROOTDIR}/operator") --network="host" ${MOLECULE_ADD_HOST_ARGS} --add-host="api.crc.testing:192.168.130.11" --add-host="kiali-istio-system.apps-crc.testing:192.168.130.11" --add-host="prometheus-istio-system.apps-crc.testing:192.168.130.11" --env DORP=${DORP} ${MOLECULE_IMAGE_ENV_ARGS} ${MOLECULE_DUMP_LOGS_ON_ERROR_ENV_VAR} ${MOLECULE_IMAGE_PULL_POLICY_ENV_ARGS} ${MOLECULE_WAIT_RETRIES_ARG} kiali-molecule:latest molecule ${MOLECULE_DEBUG_ARG} test ${MOLECULE_DESTROY_NEVER_ARG} --all
 else
diff --git a/make/Makefile.operator.mk b/make/Makefile.operator.mk
index 85a695780..3de61d2af 100644
--- a/make/Makefile.operator.mk
+++ b/make/Makefile.operator.mk
@@ -3,7 +3,7 @@
 #
 
 .ensure-operator-is-running: .ensure-oc-exists
-	@${OC} get pods -l app=kiali-operator -n kiali-operator 2>/dev/null | grep "^kiali-operator.*Running" > /dev/null ;\
+	@${OC} get pods -l app.kubernetes.io/name=kiali-operator -n kiali-operator 2>/dev/null | grep "^kiali-operator.*Running" > /dev/null ;\
 	RETVAL=$$?; \
 	if [ $$RETVAL -ne 0 ]; then \
 	  echo "The Operator is not running. Cannot continue."; exit 1; \
@@ -69,7 +69,6 @@ ISTIO_NAMESPACE="${ISTIO_NAMESPACE}" \
 NAMESPACE="${NAMESPACE}" \
 ROUTER_HOSTNAME="$(shell ${OC} get $(shell (${OC} get routes -n ${NAMESPACE} -o name 2>/dev/null || echo 'noroute') | head -n 1) -n ${NAMESPACE} -o jsonpath='{.status.ingress[0].routerCanonicalHostname}' 2>/dev/null)" \
 SERVICE_TYPE="${SERVICE_TYPE}" \
-VERBOSE_MODE="${VERBOSE_MODE}" \
 KIALI_CR_SPEC_VERSION="${KIALI_CR_SPEC_VERSION}" \
 envsubst | ${OC} apply -n "${OPERATOR_INSTALL_KIALI_CR_NAMESPACE}" -f -
 
@@ -82,17 +81,17 @@ kiali-delete: .ensure-oc-exists
 kiali-purge: .ensure-oc-exists
 	@echo Purge Kiali resources
 	${OC} patch kiali kiali -n "${OPERATOR_INSTALL_KIALI_CR_NAMESPACE}" -p '{"metadata":{"finalizers": []}}' --type=merge ; true
-	${OC} delete --ignore-not-found=true all,secrets,sa,configmaps,deployments,roles,rolebindings,ingresses --selector="app=kiali" -n "${NAMESPACE}"
-	${OC} delete --ignore-not-found=true clusterroles,clusterrolebindings --selector="app=kiali"
+	${OC} delete --ignore-not-found=true all,secrets,sa,configmaps,deployments,roles,rolebindings,ingresses --selector="app.kubernetes.io/name=kiali" -n "${NAMESPACE}"
+	${OC} delete --ignore-not-found=true clusterroles,clusterrolebindings --selector="app.kubernetes.io/name=kiali"
 ifeq ($(CLUSTER_TYPE),openshift)
-	${OC} delete --ignore-not-found=true routes --selector="app=kiali" -n "${NAMESPACE}" ; true
-	${OC} delete --ignore-not-found=true consolelinks.console.openshift.io,oauthclients.oauth.openshift.io --selector="app=kiali" ; true
+	${OC} delete --ignore-not-found=true routes --selector="app.kubernetes.io/name=kiali" -n "${NAMESPACE}" ; true
+	${OC} delete --ignore-not-found=true consolelinks.console.openshift.io,oauthclients.oauth.openshift.io --selector="app.kubernetes.io/name=kiali" ; true
 endif
 
 ## kiali-reload-image: Refreshing the Kiali pod by deleting it which forces a redeployment
 kiali-reload-image: .ensure-oc-exists
 	@echo Refreshing Kiali pod within namespace ${NAMESPACE}
-	${OC} delete pod --selector=app=kiali -n ${NAMESPACE}
+	${OC} delete pod --selector=app.kubernetes.io/name=kiali -n ${NAMESPACE}
 
 ## run-operator-playbook: Run the operator dev playbook to run the operator ansible script locally.
 run-operator-playbook: .ensure-operator-repo-exists .ensure-operator-helm-chart-exists
diff --git a/models/iter8.go b/models/iter8.go
index 833efc50c..d7dc60002 100644
--- a/models/iter8.go
+++ b/models/iter8.go
@@ -124,11 +124,11 @@ type Iter8Match struct {
 }
 
 type HTTPMatchRequest struct {
-	URI     HTTPMarchRule   `json:"uri,omitempty"`
-	Headers []HTTPMarchRule `json:"headers,omitempty"`
+	URI     HTTPMatchRule   `json:"uri,omitempty"`
+	Headers []HTTPMatchRule `json:"headers,omitempty"`
 }
 
-type HTTPMarchRule struct {
+type HTTPMatchRule struct {
 	Key         string `json:"key,omitempty"`
 	Match       string `json:"match,omitempty"`
 	StringMatch string `json:"stringMatch,omitempty"`
@@ -240,6 +240,15 @@ func (i *Iter8ExperimentDetail) Parse(iter8Object kubernetes.Iter8Experiment) {
 		Percentage:    spec.TrafficControl.Percentage,
 		OnTermination: spec.TrafficControl.OnTermination,
 	}
+	if spec.TrafficControl.Match.HTTP != nil {
+		ptr := make([]HTTPMatchRequest, len(spec.TrafficControl.Match.HTTP))
+		for i, m := range spec.TrafficControl.Match.HTTP {
+			nm := HTTPMatchRequest{}
+			nm.parse(m)
+			ptr[i] = nm
+		}
+		trafficControl.Match.HTTP = ptr
+	}
 
 	targetServiceNamespace := spec.Service.Namespace
 	if targetServiceNamespace == "" {
@@ -337,12 +346,40 @@ func (i *Iter8ExperimentItem) Parse(iter8Object kubernetes.Iter8Experiment) {
 		i.Kind = "Deployment"
 	}
 	i.ExperimentType = status.ExperimentType
-	// successCrideriaStatus := Iter8SuccessCrideriaStatus{}
-	// successCrideriaStatus = Iter8SuccessCrideriaStatus{
-	// 	status.Assestment.Winner.Name,
-	// 	status.Assestment.Winner.WinnerFound,
-	// 	status.Assestment.Winner.Winner,
-	// 	status.Assestment.Winner.Probability,
-	// }
 	i.Winner = status.Assestment.Winner
 }
+
+func (iter8URI *HTTPMatchRule) parse(uri *kubernetes.StringMatch) {
+
+	if uri.Exact != nil {
+		iter8URI.Match = "exact"
+		iter8URI.StringMatch = *uri.Exact
+	} else if uri.Prefix != nil {
+		iter8URI.Match = "prefix"
+		iter8URI.StringMatch = *uri.Prefix
+	} else if uri.Regex != nil {
+		iter8URI.Match = "regex"
+		iter8URI.StringMatch = *uri.Regex
+	}
+}
+
+func (hm *HTTPMatchRequest) parse(m *kubernetes.HTTPMatchRequest) {
+	if m.URI != nil {
+		header := HTTPMatchRule{}
+		header.parse(m.URI)
+		hm.URI = header
+	}
+	if len(m.Headers) > 0 {
+		hm.Headers = make([]HTTPMatchRule, len(m.Headers))
+		index := 0
+		for ii, h := range m.Headers {
+			if h != nil {
+				header := HTTPMatchRule{}
+				header.parse(h)
+				header.Key = ii
+				hm.Headers[index] = header
+				index++
+			}
+		}
+	}
+}
diff --git a/models/metrics.go b/models/metrics.go
new file mode 100644
index 000000000..f5bb6bfeb
--- /dev/null
+++ b/models/metrics.go
@@ -0,0 +1,133 @@
+package models
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/kiali/kiali/prometheus"
+	"github.com/kiali/kiali/util"
+)
+
+//////////////////////////////////////////////////////////////////////////////
+// INPUT / QUERY TYPES
+
+// IstioMetricsQuery holds query parameters for a typical metrics query
+type IstioMetricsQuery struct {
+	prometheus.RangeQuery
+	Filters         []string
+	Namespace       string
+	App             string
+	Workload        string
+	Service         string
+	Direction       string // outbound | inbound
+	RequestProtocol string // e.g. http | grpc
+	Reporter        string // source | destination, defaults to source if not provided
+	Aggregate       string
+	AggregateValue  string
+}
+
+// FillDefaults fills the struct with default parameters
+func (q *IstioMetricsQuery) FillDefaults() {
+	q.RangeQuery.FillDefaults()
+	q.Reporter = "source"
+	q.Direction = "outbound"
+}
+
+// CustomMetricsQuery holds query parameters for a custom metrics query
+type CustomMetricsQuery struct {
+	prometheus.RangeQuery
+	Namespace         string
+	App               string
+	Version           string
+	RawDataAggregator string
+}
+
+// FillDefaults fills the struct with default parameters
+func (q *CustomMetricsQuery) FillDefaults() {
+	q.RangeQuery.FillDefaults()
+	q.RawDataAggregator = "sum"
+}
+
+type MetricsStatsQueries struct {
+	Queries []MetricsStatsQuery
+}
+
+type Target struct {
+	Namespace string
+	Name      string
+	Kind      string // app | workload | service
+}
+
+type MetricsStatsQuery struct {
+	Target       Target
+	PeerTarget   *Target
+	RawQueryTime int64     `json:"queryTime"`
+	QueryTime    time.Time `json:"-"`
+	RawInterval  string    `json:"interval"`
+	Interval     string    `json:"-"`
+	Direction    string    // outbound | inbound
+	Avg          bool
+	Quantiles    []string
+}
+
+func (q *MetricsStatsQuery) Validate() *util.Errors {
+	var errs util.Errors
+	if q.Target.Name == "" {
+		errs.AddString("bad request: 'target.name' must be defined")
+	}
+	if q.Target.Kind != "app" && q.Target.Kind != "workload" && q.Target.Kind != "service" {
+		errs.AddString("bad request: 'target.kind' must be either 'app', 'workload' or 'service'")
+	}
+	if q.Target.Kind == "service" && q.Direction != "inbound" {
+		errs.AddString("bad request: only 'inbound' direction is allowed for kind 'service'")
+	}
+	if q.Direction != "inbound" && q.Direction != "outbound" {
+		errs.AddString("bad request: 'direction' must be either 'inbound' or 'outbound'")
+	}
+	if q.RawQueryTime == 0 {
+		errs.AddString("bad request: 'queryTime' must be defined")
+	}
+	if q.RawInterval == "" {
+		errs.AddString("bad request: 'interval' must be defined")
+	}
+	q.QueryTime = time.Unix(q.RawQueryTime, 0)
+	return errs.OrNil()
+}
+
+// GenKey !! HAS to mirror frontend's genStatsKey in SpanTable.tsx
+func (q *MetricsStatsQuery) GenKey() string {
+	peer := ""
+	if q.PeerTarget != nil {
+		peer = q.PeerTarget.GenKey()
+	}
+	return fmt.Sprintf("%s:%s:%s:%s", q.Target.GenKey(), peer, q.Direction, q.RawInterval)
+}
+func (t *Target) GenKey() string {
+	return fmt.Sprintf("%s:%s:%s", t.Namespace, t.Kind, t.Name)
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// OUTPUT / QUERY RESULTS
+
+// Metrics contains all simple metrics and histograms data for standard timeseries queries
+type Metrics struct {
+	Metrics    map[string]*prometheus.Metric   `json:"metrics"`
+	Histograms map[string]prometheus.Histogram `json:"histograms"`
+}
+
+// Stat holds arbitrary stat name & value
+type Stat struct {
+	Name  string  `json:"name"` // E.g. avg, p99, etc.
+	Value float64 `json:"value"`
+}
+
+// MetricsStats contains opinionated statistics on metrics on a single target. Currently limited to response times (avg/percentiles over interval)
+type MetricsStats struct {
+	ResponseTimes []Stat `json:"responseTimes"`
+}
+
+// MetricsStatsResult holds the MetricsStats per target, plus errors
+type MetricsStatsResult struct {
+	Stats    map[string]MetricsStats `json:"stats"` // Key is built from query params, see "GenKey" above. The same key needs to be generated client-side for matching.
+	Warnings []string                `json:"warnings"`
+}
diff --git a/models/pod.go b/models/pod.go
index 16aa420b4..c39097082 100644
--- a/models/pod.go
+++ b/models/pod.go
@@ -21,6 +21,8 @@ type Pod struct {
 	IstioContainers     []*ContainerInfo  `json:"istioContainers"`
 	IstioInitContainers []*ContainerInfo  `json:"istioInitContainers"`
 	Status              string            `json:"status"`
+	StatusMessage       string            `json:"statusMessage"`
+	StatusReason        string            `json:"statusReason"`
 	AppLabel            bool              `json:"appLabel"`
 	VersionLabel        bool              `json:"versionLabel"`
 	Annotations         map[string]string `json:"annotations"`
@@ -39,7 +41,7 @@ type ContainerInfo struct {
 	Image string `json:"image"`
 }
 
-// ParseDeployment extracts desired information from k8s []Pod info
+// Parse extracts desired information from k8s []Pod info
 func (pods *Pods) Parse(list []core_v1.Pod) {
 	if list == nil {
 		return
@@ -104,6 +106,8 @@ func (pod *Pod) Parse(p *core_v1.Pod) {
 		pod.Containers = append(pod.Containers, &container)
 	}
 	pod.Status = string(p.Status.Phase)
+	pod.StatusMessage = string(p.Status.Message)
+	pod.StatusReason = string(p.Status.Reason)
 	_, pod.AppLabel = p.Labels[conf.IstioLabels.AppLabelName]
 	_, pod.VersionLabel = p.Labels[conf.IstioLabels.VersionLabelName]
 }
diff --git a/prometheus/client.go b/prometheus/client.go
index acecb95eb..710f98d7d 100644
--- a/prometheus/client.go
+++ b/prometheus/client.go
@@ -19,14 +19,14 @@ import (
 
 // ClientInterface for mocks (only mocked function are necessary here)
 type ClientInterface interface {
-	FetchHistogramRange(metricName, labels, grouping string, q *BaseMetricsQuery) Histogram
-	FetchRange(metricName, labels, grouping, aggregator string, q *BaseMetricsQuery) *Metric
-	FetchRateRange(metricName, labels, grouping string, q *BaseMetricsQuery) *Metric
+	FetchHistogramRange(metricName, labels, grouping string, q *RangeQuery) Histogram
+	FetchHistogramValues(metricName, labels, grouping, rateInterval string, avg bool, quantiles []string, queryTime time.Time) (map[string]model.Vector, error)
+	FetchRange(metricName, labels, grouping, aggregator string, q *RangeQuery) *Metric
+	FetchRateRange(metricName string, labels []string, grouping string, q *RangeQuery) *Metric
 	GetAllRequestRates(namespace, ratesInterval string, queryTime time.Time) (model.Vector, error)
 	GetAppRequestRates(namespace, app, ratesInterval string, queryTime time.Time) (model.Vector, model.Vector, error)
 	GetConfiguration() (prom_v1.ConfigResult, error)
 	GetFlags() (prom_v1.FlagsResult, error)
-	GetMetrics(query *IstioMetricsQuery) Metrics
 	GetNamespaceServicesRequestRates(namespace, ratesInterval string, queryTime time.Time) (model.Vector, error)
 	GetServiceRequestRates(namespace, service, ratesInterval string, queryTime time.Time) (model.Vector, error)
 	GetWorkloadRequestRates(namespace, workload, ratesInterval string, queryTime time.Time) (model.Vector, model.Vector, error)
@@ -94,11 +94,6 @@ func (in *Client) Inject(api prom_v1.API) {
 	in.api = api
 }
 
-// GetMetrics returns the Metrics related to the provided query options.
-func (in *Client) GetMetrics(query *IstioMetricsQuery) Metrics {
-	return getMetrics(in.api, query)
-}
-
 // GetAllRequestRates queries Prometheus to fetch request counter rates, over a time interval, for requests
 // into, internal to, or out of the namespace. 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
@@ -210,7 +205,7 @@ func (in *Client) GetWorkloadRequestRates(namespace, workload, ratesInterval str
 }
 
 // FetchRange fetches a simple metric (gauge or counter) in given range
-func (in *Client) FetchRange(metricName, labels, grouping, aggregator string, q *BaseMetricsQuery) *Metric {
+func (in *Client) FetchRange(metricName, labels, grouping, aggregator string, q *RangeQuery) *Metric {
 	query := fmt.Sprintf("%s(%s%s)", aggregator, metricName, labels)
 	if grouping != "" {
 		query += fmt.Sprintf(" by (%s)", grouping)
@@ -220,15 +215,20 @@ func (in *Client) FetchRange(metricName, labels, grouping, aggregator string, q
 }
 
 // FetchRateRange fetches a counter's rate in given range
-func (in *Client) FetchRateRange(metricName, labels, grouping string, q *BaseMetricsQuery) *Metric {
-	return fetchRateRange(in.api, metricName, []string{labels}, grouping, q)
+func (in *Client) FetchRateRange(metricName string, labels []string, grouping string, q *RangeQuery) *Metric {
+	return fetchRateRange(in.api, metricName, labels, grouping, q)
 }
 
 // FetchHistogramRange fetches bucketed metric as histogram in given range
-func (in *Client) FetchHistogramRange(metricName, labels, grouping string, q *BaseMetricsQuery) Histogram {
+func (in *Client) FetchHistogramRange(metricName, labels, grouping string, q *RangeQuery) Histogram {
 	return fetchHistogramRange(in.api, metricName, labels, grouping, q)
 }
 
+// FetchHistogramValues fetches bucketed metric as histogram at a given specific time
+func (in *Client) FetchHistogramValues(metricName, labels, grouping, rateInterval string, avg bool, quantiles []string, queryTime time.Time) (map[string]model.Vector, error) {
+	return fetchHistogramValues(in.api, metricName, labels, grouping, rateInterval, avg, quantiles, queryTime)
+}
+
 // API returns the Prometheus V1 HTTP API for performing calls not supported natively by this client
 func (in *Client) API() prom_v1.API {
 	return in.api
diff --git a/prometheus/metrics.go b/prometheus/metrics.go
index 7dbf7269f..48035b43f 100644
--- a/prometheus/metrics.go
+++ b/prometheus/metrics.go
@@ -3,8 +3,6 @@ package prometheus
 import (
 	"context"
 	"fmt"
-	"strings"
-	"sync"
 	"time"
 
 	prom_v1 "github.com/prometheus/client_golang/api/prometheus/v1"
@@ -13,139 +11,7 @@ import (
 	"github.com/kiali/kiali/prometheus/internalmetrics"
 )
 
-const (
-	regexGrpcResponseStatusErr = "^[1-9]$|^1[0-6]$"
-	regexResponseCodeErr       = "^0$|^[4-5]\\\\d\\\\d$"
-)
-
-func getMetrics(api prom_v1.API, q *IstioMetricsQuery) Metrics {
-	labels, labelsError := buildLabelStrings(q)
-	grouping := strings.Join(q.ByLabels, ",")
-	metrics := fetchAllMetrics(api, q, labels, labelsError, grouping)
-	return metrics
-}
-
-func buildLabelStrings(q *IstioMetricsQuery) (string, []string) {
-	labels := []string{fmt.Sprintf(`reporter="%s"`, q.Reporter)}
-	ref := "destination"
-	if q.Direction == "outbound" {
-		ref = "source"
-	}
-
-	if q.Service != "" {
-		// inbound only
-		labels = append(labels, fmt.Sprintf(`destination_service_name="%s"`, q.Service))
-		if q.Namespace != "" {
-			labels = append(labels, fmt.Sprintf(`destination_service_namespace="%s"`, q.Namespace))
-		}
-	} else if q.Namespace != "" {
-		labels = append(labels, fmt.Sprintf(`%s_workload_namespace="%s"`, ref, q.Namespace))
-	}
-	if q.Workload != "" {
-		labels = append(labels, fmt.Sprintf(`%s_workload="%s"`, ref, q.Workload))
-	}
-	if q.App != "" {
-		labels = append(labels, fmt.Sprintf(`%s_canonical_service="%s"`, ref, q.App))
-	}
-	if q.RequestProtocol != "" {
-		labels = append(labels, fmt.Sprintf(`request_protocol="%s"`, q.RequestProtocol))
-	}
-	if q.Aggregate != "" {
-		labels = append(labels, fmt.Sprintf(`%s="%s"`, q.Aggregate, q.AggregateValue))
-	}
-
-	full := "{" + strings.Join(labels, ",") + "}"
-
-	errors := []string{}
-	protocol := strings.ToLower(q.RequestProtocol)
-
-	// both http and grpc requests can suffer from no response (response_code=0) or an http error
-	// (response_code=4xx,5xx), and so we always perform a query against response_code:
-	httpLabels := append(labels, fmt.Sprintf(`response_code=~"%s"`, regexResponseCodeErr))
-	errors = append(errors, "{"+strings.Join(httpLabels, ",")+"}")
-
-	// if necessary also look for grpc errors. note that the grpc test intentionally avoids
-	// `grpc_response_status!="0"`. We need to be backward compatible and handle the case where
-	// grpc_response_status does not exist, or if it is simply unset. In Prometheus, negative tests on a
-	// non-existent label match everything, but positive tests match nothing. So, we stay positive.
-	// furthermore, make sure we only count grpc errors with successful http status.
-	if protocol != "http" {
-		grpcLabels := append(labels, fmt.Sprintf(`grpc_response_status=~"%s",response_code!~"%s"`, regexGrpcResponseStatusErr, regexResponseCodeErr))
-		errors = append(errors, ("{" + strings.Join(grpcLabels, ",") + "}"))
-	}
-
-	return full, errors
-}
-
-func fetchAllMetrics(api prom_v1.API, q *IstioMetricsQuery, labels string, labelsError []string, grouping string) Metrics {
-	var wg sync.WaitGroup
-	fetchRate := func(p8sFamilyName string, metric **Metric, lbl []string) {
-		defer wg.Done()
-		m := fetchRateRange(api, p8sFamilyName, lbl, grouping, &q.BaseMetricsQuery)
-		*metric = m
-	}
-
-	fetchHisto := func(p8sFamilyName string, histo *Histogram) {
-		defer wg.Done()
-		h := fetchHistogramRange(api, p8sFamilyName, labels, grouping, &q.BaseMetricsQuery)
-		*histo = h
-	}
-
-	type resultHolder struct {
-		metric     *Metric
-		histo      Histogram
-		definition istioMetric
-	}
-	maxResults := len(istioMetrics)
-	if len(q.Filters) != 0 {
-		maxResults = len(q.Filters)
-	}
-	results := make([]*resultHolder, maxResults)
-
-	for _, istioMetric := range istioMetrics {
-		// if filters is empty, fetch all anyway
-		doFetch := len(q.Filters) == 0
-		if !doFetch {
-			for _, filter := range q.Filters {
-				if filter == istioMetric.kialiName {
-					doFetch = true
-					break
-				}
-			}
-		}
-		if doFetch {
-			wg.Add(1)
-			result := resultHolder{definition: istioMetric}
-			results = append(results, &result)
-			if istioMetric.isHisto {
-				go fetchHisto(istioMetric.istioName, &result.histo)
-			} else {
-				labelsToUse := istioMetric.labelsToUse(labels, labelsError)
-				go fetchRate(istioMetric.istioName, &result.metric, labelsToUse)
-			}
-		}
-	}
-	wg.Wait()
-
-	// Return results as two maps per reporter
-	metrics := make(map[string]*Metric)
-	histograms := make(map[string]Histogram)
-	for _, result := range results {
-		if result != nil {
-			if result.definition.isHisto {
-				histograms[result.definition.kialiName] = result.histo
-			} else {
-				metrics[result.definition.kialiName] = result.metric
-			}
-		}
-	}
-	return Metrics{
-		Metrics:    metrics,
-		Histograms: histograms,
-	}
-}
-
-func fetchRateRange(api prom_v1.API, metricName string, labels []string, grouping string, q *BaseMetricsQuery) *Metric {
+func fetchRateRange(api prom_v1.API, metricName string, labels []string, grouping string, q *RangeQuery) *Metric {
 	var query string
 	// Example: round(sum(rate(my_counter{foo=bar}[5m])) by (baz), 0.001)
 	for i, labelsInstance := range labels {
@@ -165,12 +31,35 @@ func fetchRateRange(api prom_v1.API, metricName string, labels []string, groupin
 	return fetchRange(api, query, q.Range)
 }
 
-func fetchHistogramRange(api prom_v1.API, metricName, labels, grouping string, q *BaseMetricsQuery) Histogram {
-	histogram := make(Histogram)
+func fetchHistogramRange(api prom_v1.API, metricName, labels, grouping string, q *RangeQuery) Histogram {
+	// Note: the p8s queries are not run in parallel here, but they are at the caller's place.
+	//	This is because we may not want to create too many threads in the lowest layer
+	queries := buildHistogramQueries(metricName, labels, grouping, q.RateInterval, q.Avg, q.Quantiles)
+	histogram := make(Histogram, len(queries))
+	for k, query := range queries {
+		histogram[k] = fetchRange(api, query, q.Range)
+	}
+	return histogram
+}
 
+func fetchHistogramValues(api prom_v1.API, metricName, labels, grouping, rateInterval string, avg bool, quantiles []string, queryTime time.Time) (map[string]model.Vector, error) {
 	// Note: the p8s queries are not run in parallel here, but they are at the caller's place.
 	//	This is because we may not want to create too many threads in the lowest layer
-	if q.Avg {
+	queries := buildHistogramQueries(metricName, labels, grouping, rateInterval, avg, quantiles)
+	histogram := make(map[string]model.Vector, len(queries))
+	for k, query := range queries {
+		result, err := api.Query(context.Background(), query, queryTime)
+		if err != nil {
+			return nil, err
+		}
+		histogram[k] = result.(model.Vector)
+	}
+	return histogram, nil
+}
+
+func buildHistogramQueries(metricName, labels, grouping, rateInterval string, avg bool, quantiles []string) map[string]string {
+	queries := make(map[string]string)
+	if avg {
 		groupingAvg := ""
 		if grouping != "" {
 			groupingAvg = fmt.Sprintf(" by (%s)", grouping)
@@ -178,24 +67,24 @@ func fetchHistogramRange(api prom_v1.API, metricName, labels, grouping string, q
 		// Average
 		// Example: sum(rate(my_histogram_sum{foo=bar}[5m])) by (baz) / sum(rate(my_histogram_count{foo=bar}[5m])) by (baz)
 		query := fmt.Sprintf("sum(rate(%s_sum%s[%s]))%s / sum(rate(%s_count%s[%s]))%s",
-			metricName, labels, q.RateInterval, groupingAvg, metricName, labels, q.RateInterval, groupingAvg)
+			metricName, labels, rateInterval, groupingAvg, metricName, labels, rateInterval, groupingAvg)
 		query = roundSignificant(query, 0.001)
-		histogram["avg"] = fetchRange(api, query, q.Range)
+		queries["avg"] = query
 	}
 
 	groupingQuantile := ""
 	if grouping != "" {
 		groupingQuantile = fmt.Sprintf(",%s", grouping)
 	}
-	for _, quantile := range q.Quantiles {
+	for _, quantile := range quantiles {
 		// Example: round(histogram_quantile(0.5, sum(rate(my_histogram_bucket{foo=bar}[5m])) by (le,baz)), 0.001)
 		query := fmt.Sprintf("histogram_quantile(%s, sum(rate(%s_bucket%s[%s])) by (le%s))",
-			quantile, metricName, labels, q.RateInterval, groupingQuantile)
+			quantile, metricName, labels, rateInterval, groupingQuantile)
 		query = roundSignificant(query, 0.001)
-		histogram[quantile] = fetchRange(api, query, q.Range)
+		queries[quantile] = query
 	}
 
-	return histogram
+	return queries
 }
 
 func fetchRange(api prom_v1.API, query string, bounds prom_v1.Range) *Metric {
diff --git a/prometheus/types.go b/prometheus/types.go
index b80c7f836..50f0978c0 100644
--- a/prometheus/types.go
+++ b/prometheus/types.go
@@ -7,8 +7,8 @@ import (
 	"github.com/prometheus/common/model"
 )
 
-// BaseMetricsQuery holds common parameters for all kinds of queries
-type BaseMetricsQuery struct {
+// RangeQuery holds common parameters for all kinds of range queries
+type RangeQuery struct {
 	prom_v1.Range
 	RateInterval string
 	RateFunc     string
@@ -18,7 +18,7 @@ type BaseMetricsQuery struct {
 }
 
 // FillDefaults fills the struct with default parameters
-func (q *BaseMetricsQuery) fillDefaults() {
+func (q *RangeQuery) FillDefaults() {
 	q.End = time.Now()
 	q.Start = q.End.Add(-30 * time.Minute)
 	q.Step = 15 * time.Second
@@ -27,49 +27,6 @@ func (q *BaseMetricsQuery) fillDefaults() {
 	q.Avg = true
 }
 
-// IstioMetricsQuery holds query parameters for a typical metrics query
-type IstioMetricsQuery struct {
-	BaseMetricsQuery
-	Filters         []string
-	Namespace       string
-	App             string
-	Workload        string
-	Service         string
-	Direction       string // outbound | inbound
-	RequestProtocol string // e.g. http | grpc
-	Reporter        string // source | destination, defaults to source if not provided
-	Aggregate       string
-	AggregateValue  string
-}
-
-// FillDefaults fills the struct with default parameters
-func (q *IstioMetricsQuery) FillDefaults() {
-	q.BaseMetricsQuery.fillDefaults()
-	q.Reporter = "source"
-	q.Direction = "outbound"
-}
-
-// CustomMetricsQuery holds query parameters for a custom metrics query
-type CustomMetricsQuery struct {
-	BaseMetricsQuery
-	Namespace         string
-	App               string
-	Version           string
-	RawDataAggregator string
-}
-
-// FillDefaults fills the struct with default parameters
-func (q *CustomMetricsQuery) FillDefaults() {
-	q.BaseMetricsQuery.fillDefaults()
-	q.RawDataAggregator = "sum"
-}
-
-// Metrics contains all simple metrics and histograms data
-type Metrics struct {
-	Metrics    map[string]*Metric   `json:"metrics"`
-	Histograms map[string]Histogram `json:"histograms"`
-}
-
 // Metric holds the Prometheus Matrix model, which contains one or more time series (depending on grouping)
 type Metric struct {
 	Matrix model.Matrix `json:"matrix"`
diff --git a/routing/routes.go b/routing/routes.go
index e56599705..fb9b73858 100644
--- a/routing/routes.go
+++ b/routing/routes.go
@@ -1433,6 +1433,28 @@ func NewRoutes() (r *Routes) {
 			handlers.Iter8ExperimentGetYaml,
 			true,
 		},
+
+		// swagger:route POST /stats/metrics stats metricsStats
+		// ---
+		// Produces metrics statistics
+		//
+		// 		Produces:
+		//		- application/json
+		//
+		//		Schemes: http, https
+		//
+		// responses:
+		//    400: badRequestError
+		//    503: serviceUnavailableError
+		//		500: internalError
+		//		200: metricsStatsResponse
+		{
+			Name:          "MetricsStats",
+			Method:        "POST",
+			Pattern:       "/api/stats/metrics",
+			HandlerFunc:   handlers.MetricsStats,
+			Authenticated: true,
+		},
 	}
 
 	return
diff --git a/swagger.json b/swagger.json
index 38c767fa5..d7a67ed2d 100644
--- a/swagger.json
+++ b/swagger.json
@@ -3612,6 +3612,45 @@
         }
       }
     },
+    "/stats/metrics": {
+      "post": {
+        "description": "Produces metrics statistics",
+        "produces": [
+          "application/json"
+        ],
+        "schemes": [
+          "http",
+          "https"
+        ],
+        "tags": [
+          "stats"
+        ],
+        "operationId": "metricsStats",
+        "parameters": [
+          {
+            "name": "Body",
+            "in": "body",
+            "schema": {
+              "$ref": "#/definitions/MetricsStatsQueries"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/metricsStatsResponse"
+          },
+          "400": {
+            "$ref": "#/responses/badRequestError"
+          },
+          "500": {
+            "$ref": "#/responses/internalError"
+          },
+          "503": {
+            "$ref": "#/responses/serviceUnavailableError"
+          }
+        }
+      }
+    },
     "/status": {
       "get": {
         "description": "Endpoint to get the status of Kiali",
@@ -4194,7 +4233,23 @@
       },
       "x-go-package": "github.com/kiali/kiali/models"
     },
-    "HTTPMarchRule": {
+    "HTTPMatchRequest": {
+      "type": "object",
+      "properties": {
+        "headers": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/HTTPMatchRule"
+          },
+          "x-go-name": "Headers"
+        },
+        "uri": {
+          "$ref": "#/definitions/HTTPMatchRule"
+        }
+      },
+      "x-go-package": "github.com/kiali/kiali/models"
+    },
+    "HTTPMatchRule": {
       "type": "object",
       "properties": {
         "key": {
@@ -4212,22 +4267,6 @@
       },
       "x-go-package": "github.com/kiali/kiali/models"
     },
-    "HTTPMatchRequest": {
-      "type": "object",
-      "properties": {
-        "headers": {
-          "type": "array",
-          "items": {
-            "$ref": "#/definitions/HTTPMarchRule"
-          },
-          "x-go-name": "Headers"
-        },
-        "uri": {
-          "$ref": "#/definitions/HTTPMarchRule"
-        }
-      },
-      "x-go-package": "github.com/kiali/kiali/models"
-    },
     "Initializer": {
       "type": "object",
       "title": "Initializer is information about an initializer that has not yet completed.",
@@ -5128,7 +5167,7 @@
       "x-go-package": "github.com/kiali/kiali/prometheus"
     },
     "Metrics": {
-      "description": "Metrics contains all simple metrics and histograms data",
+      "description": "Metrics contains all simple metrics and histograms data for standard timeseries queries",
       "type": "object",
       "properties": {
         "histograms": {
@@ -5149,7 +5188,66 @@
           "x-go-name": "Metrics"
         }
       },
-      "x-go-package": "github.com/kiali/kiali/prometheus"
+      "x-go-package": "github.com/kiali/kiali/models"
+    },
+    "MetricsStats": {
+      "description": "MetricsStats contains opinionated statistics on metrics on a single target. Currently limited to response times (avg/percentiles over interval)",
+      "type": "object",
+      "properties": {
+        "responseTimes": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/Stat"
+          },
+          "x-go-name": "ResponseTimes"
+        }
+      },
+      "x-go-package": "github.com/kiali/kiali/models"
+    },
+    "MetricsStatsQueries": {
+      "type": "object",
+      "properties": {
+        "Queries": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/MetricsStatsQuery"
+          }
+        }
+      },
+      "x-go-package": "github.com/kiali/kiali/models"
+    },
+    "MetricsStatsQuery": {
+      "type": "object",
+      "properties": {
+        "Avg": {
+          "type": "boolean"
+        },
+        "Direction": {
+          "type": "string"
+        },
+        "PeerTarget": {
+          "$ref": "#/definitions/Target"
+        },
+        "Quantiles": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
+        "Target": {
+          "$ref": "#/definitions/Target"
+        },
+        "interval": {
+          "type": "string",
+          "x-go-name": "RawInterval"
+        },
+        "queryTime": {
+          "type": "integer",
+          "format": "int64",
+          "x-go-name": "RawQueryTime"
+        }
+      },
+      "x-go-package": "github.com/kiali/kiali/models"
     },
     "MonitoringDashboard": {
       "description": "MonitoringDashboard is the model representing custom monitoring dashboard, transformed from MonitoringDashboard k8s resource",
@@ -5529,6 +5627,14 @@
           "type": "string",
           "x-go-name": "Status"
         },
+        "statusMessage": {
+          "type": "string",
+          "x-go-name": "StatusMessage"
+        },
+        "statusReason": {
+          "type": "string",
+          "x-go-name": "StatusReason"
+        },
         "versionLabel": {
           "type": "boolean",
           "x-go-name": "VersionLabel"
@@ -6195,6 +6301,22 @@
       "type": "string",
       "x-go-package": "github.com/jaegertracing/jaeger/model/json"
     },
+    "Stat": {
+      "description": "Stat holds arbitrary stat name \u0026 value",
+      "type": "object",
+      "properties": {
+        "name": {
+          "type": "string",
+          "x-go-name": "Name"
+        },
+        "value": {
+          "type": "number",
+          "format": "double",
+          "x-go-name": "Value"
+        }
+      },
+      "x-go-package": "github.com/kiali/kiali/models"
+    },
     "Status": {
       "type": "object",
       "title": "Status is a return value for calls that don't return other objects.",
@@ -6350,6 +6472,21 @@
       "type": "string",
       "x-go-package": "k8s.io/apimachinery/pkg/apis/meta/v1"
     },
+    "Target": {
+      "type": "object",
+      "properties": {
+        "Kind": {
+          "type": "string"
+        },
+        "Name": {
+          "type": "string"
+        },
+        "Namespace": {
+          "type": "string"
+        }
+      },
+      "x-go-package": "github.com/kiali/kiali/models"
+    },
     "Time": {
       "description": "+protobuf.options.marshal=false\n+protobuf.as=Timestamp\n+protobuf.options.(gogoproto.goproto_stringer)=false",
       "type": "object",
@@ -7388,6 +7525,12 @@
         "$ref": "#/definitions/Metrics"
       }
     },
+    "metricsStatsResponse": {
+      "description": "Response of the metrics stats query",
+      "schema": {
+        "$ref": "#/definitions/MetricsStats"
+      }
+    },
     "namespaceAppHealthResponse": {
       "description": "namespaceAppHealthResponse is a map of app name x health",
       "schema": {
diff --git a/util/errors.go b/util/errors.go
new file mode 100644
index 000000000..1c1b36719
--- /dev/null
+++ b/util/errors.go
@@ -0,0 +1,51 @@
+package util
+
+import (
+	"errors"
+	"fmt"
+	"strings"
+)
+
+// Errors is a struct implementing error interface, allowing to accumulate several errors as a flat list
+type Errors struct {
+	errors []error
+}
+
+func (in *Errors) Add(err error) {
+	in.errors = append(in.errors, err)
+}
+
+func (in *Errors) AddString(str string) {
+	in.errors = append(in.errors, errors.New(str))
+}
+
+func (in *Errors) Count() int {
+	return len(in.errors)
+}
+
+func (in *Errors) IsEmpty() bool {
+	return len(in.errors) == 0
+}
+
+func (in *Errors) Merge(others *Errors) {
+	in.errors = append(in.errors, others.errors...)
+}
+
+func (in *Errors) Strings() []string {
+	var str []string
+	for _, err := range in.errors {
+		str = append(str, err.Error())
+	}
+	return str
+}
+
+func (in *Errors) Error() string {
+	return fmt.Sprintf("%d errors:\n%s", in.Count(), strings.Join(in.Strings(), "\n"))
+}
+
+func (in *Errors) OrNil() *Errors {
+	if in.IsEmpty() {
+		return nil
+	}
+	return in
+}
diff --git a/util/httputil/http_util.go b/util/httputil/http_util.go
index 9209af43a..bf73955ae 100644
--- a/util/httputil/http_util.go
+++ b/util/httputil/http_util.go
@@ -126,7 +126,9 @@ func GuessKialiURL(r *http.Request) string {
 	// priority, because this is the port where the pod is listening, which may
 	// be mapped to another public port via the Service/Ingress. So, HTTP headers
 	// take priority.
-	if fwdPort, ok := r.Header["X-Forwarded-Port"]; ok && len(fwdPort) == 1 {
+	if len(cfg.Server.WebPort) > 0 {
+		port = cfg.Server.WebPort
+	} else if fwdPort, ok := r.Header["X-Forwarded-Port"]; ok && len(fwdPort) == 1 {
 		port = fwdPort[0]
 	} else if len(r.URL.Host) != 0 {
 		if len(r.URL.Port()) != 0 {



Test patch:
diff --git a/business/dashboards_test.go b/business/dashboards_test.go
index 794488055..4b9a1d7d2 100644
--- a/business/dashboards_test.go
+++ b/business/dashboards_test.go
@@ -7,28 +7,19 @@ import (
 	"github.com/stretchr/testify/assert"
 
 	"github.com/kiali/kiali/config"
+	"github.com/kiali/kiali/models"
 	"github.com/kiali/kiali/prometheus"
-	"github.com/kiali/kiali/prometheus/prometheustest"
 )
 
-func TestGetIstioDashboard(t *testing.T) {
+func TestBuildIstioDashboard(t *testing.T) {
 	assert := assert.New(t)
 
 	// Setup mocks
-	prom := new(prometheustest.PromClientMock)
 	conf := config.NewConfig()
 	config.Set(conf)
-	service := NewDashboardsService(prom)
+	service := NewDashboardsService()
 
-	query := prometheus.IstioMetricsQuery{
-		Namespace: "my-namespace",
-		App:       "my-app",
-	}
-	query.FillDefaults()
-	query.Direction = "inbound"
-	prom.On("GetMetrics", &query).Return(fakeMetrics())
-
-	dashboard, err := service.GetIstioDashboard(query)
+	dashboard, err := service.BuildIstioDashboard(fakeMetrics(), "inbound")
 
 	assert.Nil(err)
 	assert.Equal("Inbound Metrics", dashboard.Title)
@@ -65,8 +56,8 @@ func fakeHistogram(avg int) prometheus.Histogram {
 	}
 }
 
-func fakeMetrics() prometheus.Metrics {
-	return prometheus.Metrics{
+func fakeMetrics() models.Metrics {
+	return models.Metrics{
 		Metrics: map[string]*prometheus.Metric{
 			"request_count":       fakeCounter(10),
 			"request_error_count": fakeCounter(11),
diff --git a/business/metrics_labels_builder_test.go b/business/metrics_labels_builder_test.go
new file mode 100644
index 000000000..f42d1e7e7
--- /dev/null
+++ b/business/metrics_labels_builder_test.go
@@ -0,0 +1,54 @@
+package business
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestMetricsLabelsBuilderInboundHttp(t *testing.T) {
+	assert := assert.New(t)
+
+	lb := NewMetricsLabelsBuilder("inbound")
+	lb.App("test", "ns")
+	lb.Reporter("source")
+	lb.Protocol("http")
+	assert.Equal(`{destination_workload_namespace="ns",destination_canonical_service="test",reporter="source",request_protocol="http"}`, lb.Build())
+
+	errs := lb.BuildForErrors()
+	assert.Len(errs, 1)
+	assert.Equal(`{destination_workload_namespace="ns",destination_canonical_service="test",reporter="source",request_protocol="http",response_code=~"^0$|^[4-5]\\d\\d$"}`, errs[0])
+}
+
+func TestMetricsLabelsBuilderOutboundGrpc(t *testing.T) {
+	assert := assert.New(t)
+
+	lb := NewMetricsLabelsBuilder("outbound")
+	lb.Workload("test", "ns")
+	lb.Reporter("destination")
+	lb.Protocol("grpc")
+	assert.Equal(`{source_workload_namespace="ns",source_workload="test",reporter="destination",request_protocol="grpc"}`, lb.Build())
+
+	errs := lb.BuildForErrors()
+	assert.Len(errs, 2)
+	assert.Equal(`{source_workload_namespace="ns",source_workload="test",reporter="destination",request_protocol="grpc",response_code=~"^0$|^[4-5]\\d\\d$"}`, errs[0])
+	assert.Equal(`{source_workload_namespace="ns",source_workload="test",reporter="destination",request_protocol="grpc",grpc_response_status=~"^[1-9]$|^1[0-6]$",response_code!~"^0$|^[4-5]\\d\\d$"}`, errs[1])
+}
+
+func TestMetricsLabelsBuilderInboundPeerLabels(t *testing.T) {
+	assert := assert.New(t)
+
+	lb := NewMetricsLabelsBuilder("inbound")
+	lb.Service("test", "ns")
+	lb.PeerApp("peer", "ns2")
+	assert.Equal(`{destination_service_name="test",destination_service_namespace="ns",source_workload_namespace="ns2",source_canonical_service="peer"}`, lb.Build())
+}
+
+func TestMetricsLabelsBuilderOutboundPeerLabels(t *testing.T) {
+	assert := assert.New(t)
+
+	lb := NewMetricsLabelsBuilder("outbound")
+	lb.Workload("test", "ns")
+	lb.PeerService("peer", "ns2")
+	assert.Equal(`{source_workload_namespace="ns",source_workload="test",destination_service_name="peer",destination_service_namespace="ns2"}`, lb.Build())
+}
diff --git a/business/metrics_test.go b/business/metrics_test.go
new file mode 100644
index 000000000..1508f2567
--- /dev/null
+++ b/business/metrics_test.go
@@ -0,0 +1,414 @@
+package business
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/prometheus/common/model"
+	"github.com/stretchr/testify/assert"
+
+	"github.com/kiali/kiali/config"
+	"github.com/kiali/kiali/models"
+	"github.com/kiali/kiali/prometheus"
+	"github.com/kiali/kiali/prometheus/prometheustest"
+)
+
+func setupMocked() (*MetricsService, *prometheustest.PromAPIMock, error) {
+	config.Set(config.NewConfig())
+	api := new(prometheustest.PromAPIMock)
+	client, err := prometheus.NewClient()
+	if err != nil {
+		return nil, nil, err
+	}
+	client.Inject(api)
+	return NewMetricsService(client), api, nil
+}
+
+func TestGetServiceMetrics(t *testing.T) {
+	srv, api, err := setupMocked()
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	q := models.IstioMetricsQuery{
+		Namespace: "bookinfo",
+		Service:   "productpage",
+	}
+	q.FillDefaults()
+	q.Direction = "inbound"
+	q.RateInterval = "5m"
+	q.Quantiles = []string{"0.99"}
+
+	labels := `reporter="source",destination_service_name="productpage",destination_service_namespace="bookinfo"`
+	api.MockRangeRounded("sum(rate(istio_requests_total{"+labels+"}[5m]))", 2.5)
+	api.MockRangeErrRounded("sum(rate(istio_requests_total{"+labels+`,response_code=~"^0$|^[4-5]\\d\\d$"}[5m])) OR sum(rate(istio_requests_total{`+labels+`,grpc_response_status=~"^[1-9]$|^1[0-6]$",response_code!~"^0$|^[4-5]\\d\\d$"}[5m]))`, 4.5)
+	api.MockRangeRounded("sum(rate(istio_request_bytes_sum{"+labels+"}[5m]))", 1000)
+	api.MockRangeRounded("sum(rate(istio_response_bytes_sum{"+labels+"}[5m]))", 1001)
+	api.MockRangeRounded("sum(rate(istio_tcp_received_bytes_total{"+labels+"}[5m]))", 11)
+	api.MockRangeRounded("sum(rate(istio_tcp_sent_bytes_total{"+labels+"}[5m]))", 13)
+	api.MockHistoRange("istio_request_bytes", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.7)
+	api.MockHistoRange("istio_request_duration_seconds", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.8)
+	api.MockHistoRange("istio_request_duration_milliseconds", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.8)
+	api.MockHistoRange("istio_response_bytes", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.9)
+
+	// Test that range and rate interval are changed when needed (namespace bounds)
+	metrics := srv.GetMetrics(q)
+
+	assert.Equal(t, 6, len(metrics.Metrics), "Should have 6 simple metrics")
+	assert.Equal(t, 3, len(metrics.Histograms), "Should have 3 histograms")
+	rqCountIn := metrics.Metrics["request_count"]
+	assert.NotNil(t, rqCountIn)
+	rqErrorCountIn := metrics.Metrics["request_error_count"]
+	assert.NotNil(t, rqErrorCountIn)
+	rqThroughput := metrics.Metrics["request_throughput"]
+	assert.NotNil(t, rqThroughput)
+	rsThroughput := metrics.Metrics["response_throughput"]
+	assert.NotNil(t, rsThroughput)
+	rqSizeIn := metrics.Histograms["request_size"]
+	assert.NotNil(t, rqSizeIn)
+	rqDurationMillisIn := metrics.Histograms["request_duration_millis"]
+	assert.NotNil(t, rqDurationMillisIn)
+	rsSizeIn := metrics.Histograms["response_size"]
+	assert.NotNil(t, rsSizeIn)
+	tcpRecIn := metrics.Metrics["tcp_received"]
+	assert.NotNil(t, tcpRecIn)
+	tcpSentIn := metrics.Metrics["tcp_sent"]
+	assert.NotNil(t, tcpSentIn)
+
+	assert.Equal(t, 2.5, float64(rqCountIn.Matrix[0].Values[0].Value))
+	assert.Equal(t, 4.5, float64(rqErrorCountIn.Matrix[0].Values[0].Value))
+	assert.Equal(t, 1000.0, float64(rqThroughput.Matrix[0].Values[0].Value))
+	assert.Equal(t, 1001.0, float64(rsThroughput.Matrix[0].Values[0].Value))
+	assert.Equal(t, 0.7, float64(rqSizeIn["0.99"].Matrix[0].Values[0].Value))
+	assert.Equal(t, 0.8, float64(rqDurationMillisIn["0.99"].Matrix[0].Values[0].Value))
+	assert.Equal(t, 0.9, float64(rsSizeIn["0.99"].Matrix[0].Values[0].Value))
+	assert.Equal(t, 11.0, float64(tcpRecIn.Matrix[0].Values[0].Value))
+	assert.Equal(t, 13.0, float64(tcpSentIn.Matrix[0].Values[0].Value))
+}
+
+func TestGetAppMetrics(t *testing.T) {
+	srv, api, err := setupMocked()
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	labels := `reporter="source",source_workload_namespace="bookinfo",source_canonical_service="productpage"`
+	api.MockRangeRounded("sum(rate(istio_requests_total{"+labels+"}[5m]))", 1.5)
+	api.MockRangeErrRounded("sum(rate(istio_requests_total{"+labels+`,response_code=~"^0$|^[4-5]\\d\\d$"}[5m])) OR sum(rate(istio_requests_total{`+labels+`,grpc_response_status=~"^[1-9]$|^1[0-6]$",response_code!~"^0$|^[4-5]\\d\\d$"}[5m]))`, 3.5)
+	api.MockRangeRounded("sum(rate(istio_request_bytes_sum{"+labels+"}[5m]))", 1000)
+	api.MockRangeRounded("sum(rate(istio_response_bytes_sum{"+labels+"}[5m]))", 1001)
+	api.MockRangeRounded("sum(rate(istio_tcp_received_bytes_total{"+labels+"}[5m]))", 10)
+	api.MockRangeRounded("sum(rate(istio_tcp_sent_bytes_total{"+labels+"}[5m]))", 12)
+	api.MockHistoRange("istio_request_bytes", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.4)
+	api.MockHistoRange("istio_request_duration_seconds", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.5)
+	api.MockHistoRange("istio_request_duration_milliseconds", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.5)
+	api.MockHistoRange("istio_response_bytes", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.6)
+
+	q := models.IstioMetricsQuery{
+		Namespace: "bookinfo",
+		App:       "productpage",
+	}
+	q.FillDefaults()
+	q.RateInterval = "5m"
+	q.Quantiles = []string{"0.5", "0.95", "0.99"}
+	metrics := srv.GetMetrics(q)
+
+	assert.Equal(t, 6, len(metrics.Metrics), "Should have 6 simple metrics")
+	assert.Equal(t, 3, len(metrics.Histograms), "Should have 3 histograms")
+	rqCountIn := metrics.Metrics["request_count"]
+	assert.NotNil(t, rqCountIn)
+	rqErrorCountIn := metrics.Metrics["request_error_count"]
+	assert.NotNil(t, rqErrorCountIn)
+	rqThroughput := metrics.Metrics["request_throughput"]
+	assert.NotNil(t, rqThroughput)
+	rsThroughput := metrics.Metrics["response_throughput"]
+	assert.NotNil(t, rsThroughput)
+	rqSizeIn := metrics.Histograms["request_size"]
+	assert.NotNil(t, rqSizeIn)
+	rqDurationMillisIn := metrics.Histograms["request_duration_millis"]
+	assert.NotNil(t, rqDurationMillisIn)
+	rsSizeIn := metrics.Histograms["response_size"]
+	assert.NotNil(t, rsSizeIn)
+	tcpRecIn := metrics.Metrics["tcp_received"]
+	assert.NotNil(t, tcpRecIn)
+	tcpSentIn := metrics.Metrics["tcp_sent"]
+	assert.NotNil(t, tcpSentIn)
+
+	assert.Equal(t, 1.5, float64(rqCountIn.Matrix[0].Values[0].Value))
+	assert.Equal(t, 3.5, float64(rqErrorCountIn.Matrix[0].Values[0].Value))
+	assert.Equal(t, 1000.0, float64(rqThroughput.Matrix[0].Values[0].Value))
+	assert.Equal(t, 1001.0, float64(rsThroughput.Matrix[0].Values[0].Value))
+	assert.Equal(t, 0.35, float64(rqSizeIn["avg"].Matrix[0].Values[0].Value))
+	assert.Equal(t, 0.2, float64(rqSizeIn["0.5"].Matrix[0].Values[0].Value))
+	assert.Equal(t, 0.3, float64(rqSizeIn["0.95"].Matrix[0].Values[0].Value))
+	assert.Equal(t, 0.4, float64(rqSizeIn["0.99"].Matrix[0].Values[0].Value))
+	assert.Equal(t, 0.5, float64(rqDurationMillisIn["0.99"].Matrix[0].Values[0].Value))
+	assert.Equal(t, 0.6, float64(rsSizeIn["0.99"].Matrix[0].Values[0].Value))
+	assert.Equal(t, 10.0, float64(tcpRecIn.Matrix[0].Values[0].Value))
+	assert.Equal(t, 12.0, float64(tcpSentIn.Matrix[0].Values[0].Value))
+}
+
+func TestGetFilteredAppMetrics(t *testing.T) {
+	srv, api, err := setupMocked()
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	api.MockRangeRounded(`sum(rate(istio_requests_total{reporter="source",source_workload_namespace="bookinfo",source_canonical_service="productpage"}[5m]))`, 1.5)
+	api.MockHistoRange("istio_request_bytes", `{reporter="source",source_workload_namespace="bookinfo",source_canonical_service="productpage"}[5m]`, 0.35, 0.2, 0.3, 0.4)
+	q := models.IstioMetricsQuery{
+		Namespace: "bookinfo",
+		App:       "productpage",
+	}
+	q.FillDefaults()
+	q.RateInterval = "5m"
+	q.Filters = []string{"request_count", "request_size"}
+	metrics := srv.GetMetrics(q)
+
+	assert.Equal(t, 1, len(metrics.Metrics), "Should have 1 simple metric")
+	assert.Equal(t, 1, len(metrics.Histograms), "Should have 1 histogram")
+	rqCountOut := metrics.Metrics["request_count"]
+	assert.NotNil(t, rqCountOut)
+	rqSizeOut := metrics.Histograms["request_size"]
+	assert.NotNil(t, rqSizeOut)
+}
+
+func TestGetAppMetricsInstantRates(t *testing.T) {
+	srv, api, err := setupMocked()
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	api.MockRangeRounded(`sum(irate(istio_requests_total{reporter="source",source_workload_namespace="bookinfo",source_canonical_service="productpage"}[1m]))`, 1.5)
+	q := models.IstioMetricsQuery{
+		Namespace: "bookinfo",
+		App:       "productpage",
+	}
+	q.FillDefaults()
+	q.RateFunc = "irate"
+	q.Filters = []string{"request_count"}
+	metrics := srv.GetMetrics(q)
+
+	assert.Equal(t, 1, len(metrics.Metrics), "Should have 1 simple metric")
+	assert.Equal(t, 0, len(metrics.Histograms), "Should have no histogram")
+	rqCountOut := metrics.Metrics["request_count"]
+	assert.NotNil(t, rqCountOut)
+}
+
+func TestGetAppMetricsUnavailable(t *testing.T) {
+	srv, api, err := setupMocked()
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	// Mock everything to return empty data
+	api.MockEmptyRangeRounded(`sum(rate(istio_requests_total{reporter="source",source_workload_namespace="bookinfo",source_canonical_service="productpage"}[5m]))`)
+	api.MockEmptyHistoRange("istio_request_bytes", `{reporter="source",source_workload_namespace="bookinfo",source_canonical_service="productpage"}[5m]`)
+	q := models.IstioMetricsQuery{
+		Namespace: "bookinfo",
+		App:       "productpage",
+	}
+	q.FillDefaults()
+	q.RateInterval = "5m"
+	q.Quantiles = []string{"0.5", "0.95", "0.99"}
+	q.Filters = []string{"request_count", "request_size"}
+	metrics := srv.GetMetrics(q)
+
+	assert.Equal(t, 1, len(metrics.Metrics), "Should have 1 simple metric")
+	assert.Equal(t, 1, len(metrics.Histograms), "Should have 1 histogram")
+	rqCountIn := metrics.Metrics["request_count"]
+	assert.NotNil(t, rqCountIn)
+	rqSizeIn := metrics.Histograms["request_size"]
+	assert.NotNil(t, rqSizeIn)
+
+	// Simple metric & histogram are empty
+	assert.Empty(t, rqCountIn.Matrix[0].Values)
+	assert.Empty(t, rqSizeIn["avg"].Matrix[0].Values)
+	assert.Empty(t, rqSizeIn["0.5"].Matrix[0].Values)
+	assert.Empty(t, rqSizeIn["0.95"].Matrix[0].Values)
+	assert.Empty(t, rqSizeIn["0.99"].Matrix[0].Values)
+}
+
+func TestGetNamespaceMetrics(t *testing.T) {
+	srv, api, err := setupMocked()
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	labels := `reporter="source",source_workload_namespace="bookinfo"`
+	api.MockRangeRounded("sum(rate(istio_requests_total{"+labels+"}[5m]))", 1.5)
+	api.MockRangeErrRounded("sum(rate(istio_requests_total{"+labels+`,response_code=~"^0$|^[4-5]\\d\\d$"}[5m])) OR sum(rate(istio_requests_total{`+labels+`,grpc_response_status=~"^[1-9]$|^1[0-6]$",response_code!~"^0$|^[4-5]\\d\\d$"}[5m]))`, 3.5)
+	api.MockRangeRounded("sum(rate(istio_request_bytes_sum{"+labels+"}[5m]))", 1000)
+	api.MockRangeRounded("sum(rate(istio_response_bytes_sum{"+labels+"}[5m]))", 1001)
+	api.MockRangeRounded("sum(rate(istio_tcp_received_bytes_total{"+labels+"}[5m]))", 10)
+	api.MockRangeRounded("sum(rate(istio_tcp_sent_bytes_total{"+labels+"}[5m]))", 12)
+	api.MockHistoRange("istio_request_bytes", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.4)
+	api.MockHistoRange("istio_request_duration_seconds", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.5)
+	api.MockHistoRange("istio_request_duration_milliseconds", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.5)
+	api.MockHistoRange("istio_response_bytes", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.6)
+
+	q := models.IstioMetricsQuery{
+		Namespace: "bookinfo",
+	}
+	q.FillDefaults()
+	q.RateInterval = "5m"
+	q.Quantiles = []string{"0.5", "0.95", "0.99"}
+	metrics := srv.GetMetrics(q)
+
+	assert.Equal(t, 6, len(metrics.Metrics), "Should have 6 simple metrics")
+	assert.Equal(t, 3, len(metrics.Histograms), "Should have 3 histograms")
+	rqCountOut := metrics.Metrics["request_count"]
+	assert.NotNil(t, rqCountOut)
+	rqErrorCountOut := metrics.Metrics["request_error_count"]
+	assert.NotNil(t, rqErrorCountOut)
+	rqThroughput := metrics.Metrics["request_throughput"]
+	assert.NotNil(t, rqThroughput)
+	rsThroughput := metrics.Metrics["response_throughput"]
+	assert.NotNil(t, rsThroughput)
+	rqSizeOut := metrics.Histograms["request_size"]
+	assert.NotNil(t, rqSizeOut)
+	rqDurationMillisOut := metrics.Histograms["request_duration_millis"]
+	assert.NotNil(t, rqDurationMillisOut)
+	rsSizeOut := metrics.Histograms["response_size"]
+	assert.NotNil(t, rsSizeOut)
+	tcpRecOut := metrics.Metrics["tcp_received"]
+	assert.NotNil(t, tcpRecOut)
+	tcpSentOut := metrics.Metrics["tcp_sent"]
+	assert.NotNil(t, tcpSentOut)
+
+	assert.Equal(t, 1.5, float64(rqCountOut.Matrix[0].Values[0].Value))
+	assert.Equal(t, 3.5, float64(rqErrorCountOut.Matrix[0].Values[0].Value))
+	assert.Equal(t, 1000.0, float64(rqThroughput.Matrix[0].Values[0].Value))
+	assert.Equal(t, 1001.0, float64(rsThroughput.Matrix[0].Values[0].Value))
+	assert.Equal(t, 0.35, float64(rqSizeOut["avg"].Matrix[0].Values[0].Value))
+	assert.Equal(t, 0.2, float64(rqSizeOut["0.5"].Matrix[0].Values[0].Value))
+	assert.Equal(t, 0.3, float64(rqSizeOut["0.95"].Matrix[0].Values[0].Value))
+	assert.Equal(t, 0.4, float64(rqSizeOut["0.99"].Matrix[0].Values[0].Value))
+	assert.Equal(t, 0.5, float64(rqDurationMillisOut["0.99"].Matrix[0].Values[0].Value))
+	assert.Equal(t, 0.6, float64(rsSizeOut["0.99"].Matrix[0].Values[0].Value))
+	assert.Equal(t, 10.0, float64(tcpRecOut.Matrix[0].Values[0].Value))
+	assert.Equal(t, 12.0, float64(tcpSentOut.Matrix[0].Values[0].Value))
+}
+
+func TestCreateMetricsLabelsBuilder(t *testing.T) {
+	assert := assert.New(t)
+	q := models.IstioMetricsQuery{
+		Namespace: "bookinfo",
+		App:       "productpage",
+	}
+	q.FillDefaults()
+	q.Reporter = "source"
+	lb := createMetricsLabelsBuilder(&q)
+	assert.Equal(`{reporter="source",source_workload_namespace="bookinfo",source_canonical_service="productpage"}`, lb.Build())
+}
+
+func TestCreateStatsMetricsLabelsBuilder(t *testing.T) {
+	assert := assert.New(t)
+	q := models.MetricsStatsQuery{
+		Target: models.Target{
+			Namespace: "ns3",
+			Name:      "foo",
+			Kind:      "app",
+		},
+		Direction: "inbound",
+		Interval:  "3h",
+		Avg:       true,
+		Quantiles: []string{"0.90", "0.5"},
+		QueryTime: time.Now(),
+	}
+	lb := createStatsMetricsLabelsBuilder(&q)
+	assert.Equal(`{reporter="destination",destination_workload_namespace="ns3",destination_canonical_service="foo"}`, lb.Build())
+}
+
+func TestCreateStatsMetricsLabelsBuilderWithPeer(t *testing.T) {
+	assert := assert.New(t)
+	q := models.MetricsStatsQuery{
+		Target: models.Target{
+			Namespace: "ns3",
+			Name:      "foo",
+			Kind:      "app",
+		},
+		PeerTarget: &models.Target{
+			Namespace: "ns4",
+			Name:      "bar",
+			Kind:      "app",
+		},
+		Direction: "inbound",
+		Interval:  "3h",
+		Avg:       true,
+		Quantiles: []string{"0.90", "0.5"},
+		QueryTime: time.Now(),
+	}
+	lb := createStatsMetricsLabelsBuilder(&q)
+	assert.Equal(`{reporter="destination",destination_workload_namespace="ns3",destination_canonical_service="foo",source_workload_namespace="ns4",source_canonical_service="bar"}`, lb.Build())
+}
+
+func TestGetMetricsStats(t *testing.T) {
+	assert := assert.New(t)
+	srv, api, err := setupMocked()
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	queryTime := time.Now()
+	queries := []models.MetricsStatsQuery{{
+		Target: models.Target{
+			Namespace: "ns1",
+			Name:      "foo",
+			Kind:      "app",
+		},
+		Direction:    "outbound",
+		Interval:     "30m",
+		RawInterval:  "30m",
+		Avg:          true,
+		Quantiles:    []string{"0.95"},
+		RawQueryTime: queryTime.Unix(),
+	}, {
+		Target: models.Target{
+			Namespace: "ns2",
+			Name:      "bar",
+			Kind:      "service",
+		},
+		PeerTarget: &models.Target{
+			Namespace: "ns3",
+			Name:      "w1",
+			Kind:      "workload",
+		},
+		Direction:    "inbound",
+		Interval:     "3h",
+		RawInterval:  "3h",
+		Avg:          false,
+		Quantiles:    []string{"0.5", "0.95"},
+		RawQueryTime: queryTime.Unix(),
+	}}
+
+	// Setup mocks
+	v0 := model.Vector{createSample(0)}
+	q1Avg := model.Vector{createSample(5)}
+	q1P95 := model.Vector{createSample(8)}
+	q2P50 := model.Vector{createSample(6.3)}
+	q2P95 := model.Vector{createSample(9.3)}
+	q1Labels := `reporter="source",source_workload_namespace="ns1",source_canonical_service="foo"`
+	q2Labels := `reporter="destination",destination_service_name="bar",destination_service_namespace="ns2",source_workload_namespace="ns3",source_workload="w1"`
+	api.MockHistoValue("istio_request_duration_milliseconds", "{"+q1Labels+"}[30m]", q1Avg, v0, q1P95, v0)
+	api.MockHistoValue("istio_request_duration_milliseconds", "{"+q2Labels+"}[3h]", v0, q2P50, q2P95, v0)
+
+	stats, err := srv.GetStats(queries)
+
+	assert.Nil(err)
+	assert.Len(stats, 2)
+	fmt.Printf("%v\n", stats)
+	assert.Equal([]models.Stat{{Name: "0.95", Value: 8.0}, {Name: "avg", Value: 5.0}}, stats["ns1:app:foo::outbound:30m"].ResponseTimes)
+	assert.Equal([]models.Stat{{Name: "0.5", Value: 6.3}, {Name: "0.95", Value: 9.3}}, stats["ns2:service:bar:ns3:workload:w1:inbound:3h"].ResponseTimes)
+}
+
+func createSample(value float64) *model.Sample {
+	return &model.Sample{
+		Timestamp: model.Now(),
+		Value:     model.SampleValue(value),
+		Metric:    model.Metric{},
+	}
+}
diff --git a/handlers/metrics_test.go b/handlers/metrics_test.go
index 6f5c7fe28..74ce070c3 100644
--- a/handlers/metrics_test.go
+++ b/handlers/metrics_test.go
@@ -16,6 +16,8 @@ import (
 	prom_v1 "github.com/prometheus/client_golang/api/prometheus/v1"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
+	core_v1 "k8s.io/api/core/v1"
+	meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 
 	"github.com/kiali/kiali/business"
 	"github.com/kiali/kiali/config"
@@ -44,7 +46,7 @@ func TestExtractMetricsQueryParams(t *testing.T) {
 	q.Add("requestProtocol", "http")
 	req.URL.RawQuery = q.Encode()
 
-	mq := prometheus.IstioMetricsQuery{Namespace: "ns"}
+	mq := models.IstioMetricsQuery{Namespace: "ns"}
 	err = extractIstioMetricsQueryParams(req, &mq, buildNamespace("ns", time.Time{}))
 	if err != nil {
 		t.Fatal(err)
@@ -78,7 +80,7 @@ func TestExtractMetricsQueryParamsStepLimitCase(t *testing.T) {
 	q.Add("duration", "1000")        // Makes start = 2018-04-10T12:24:20
 	req.URL.RawQuery = q.Encode()
 
-	mq := prometheus.IstioMetricsQuery{Namespace: "ns"}
+	mq := models.IstioMetricsQuery{Namespace: "ns"}
 	err = extractIstioMetricsQueryParams(req, &mq, buildNamespace("ns", time.Time{}))
 	if err != nil {
 		t.Fatal(err)
@@ -103,7 +105,7 @@ func TestExtractMetricsQueryIntervalBoundary(t *testing.T) {
 	q.Add("rateInterval", "35m")
 	req.URL.RawQuery = q.Encode()
 
-	mq := prometheus.IstioMetricsQuery{Namespace: "ns"}
+	mq := models.IstioMetricsQuery{Namespace: "ns"}
 	err = extractIstioMetricsQueryParams(req, &mq, buildNamespace("ns", time.Date(2018, 4, 10, 12, 10, 0, 0, time.UTC)))
 	if err != nil {
 		t.Fatal(err)
@@ -125,7 +127,7 @@ func TestExtractMetricsQueryStartTimeBoundary(t *testing.T) {
 	q.Add("rateInterval", "1m")
 	req.URL.RawQuery = q.Encode()
 
-	mq := prometheus.IstioMetricsQuery{Namespace: "ns"}
+	mq := models.IstioMetricsQuery{Namespace: "ns"}
 	namespaceTimestamp := time.Date(2018, 4, 10, 12, 30, 0, 0, time.UTC)
 
 	err = extractIstioMetricsQueryParams(req, &mq, buildNamespace("ns", namespaceTimestamp))
@@ -336,3 +338,143 @@ func setupAggregateMetricsEndpoint(t *testing.T) (*httptest.Server, *prometheust
 
 	return ts, api, k8s
 }
+
+func TestPrepareStatsQueriesPartialError(t *testing.T) {
+	assert := assert.New(t)
+	prom, _, _ := utilSetupMocks(t)
+
+	req := httptest.NewRequest("GET", "/foo", nil)
+	req = req.WithContext(context.WithValue(req.Context(), "token", "test"))
+	w := httptest.NewRecorder()
+	queryTime := time.Date(2020, 10, 22, 0, 0, 0, 0, time.UTC).Unix()
+
+	rawQ := []models.MetricsStatsQuery{
+		{
+			Target: models.Target{
+				Namespace: "ns1",
+				Name:      "foo",
+				Kind:      "app",
+			},
+			Direction:    "inbound",
+			RawInterval:  "3h",
+			Avg:          true,
+			Quantiles:    []string{"0.90", "0.5"},
+			RawQueryTime: queryTime,
+		},
+		{
+			Target: models.Target{
+				Namespace: "ns1",
+				Name:      "foo",
+				Kind:      "app",
+			},
+			Direction:    "inbound",
+			RawInterval:  "30m",
+			Avg:          true,
+			Quantiles:    []string{"0.90", "0.5"},
+			RawQueryTime: queryTime,
+		},
+		{
+			Target: models.Target{
+				Namespace: "ns2",
+				Name:      "bar",
+				Kind:      "app",
+			},
+			Direction:    "outbound",
+			RawInterval:  "30m",
+			Avg:          true,
+			Quantiles:    []string{"0.90", "0.5"},
+			RawQueryTime: queryTime,
+		},
+		{
+			Target: models.Target{
+				Namespace: "nsNil",
+				Name:      "baz",
+				Kind:      "app",
+			},
+			Direction:    "inbound",
+			RawInterval:  "30m",
+			Avg:          true,
+			Quantiles:    []string{"0.90", "0.5"},
+			RawQueryTime: queryTime,
+		},
+	}
+
+	srv, queries, errs := prepareStatsQueries(w, req, rawQ, prom)
+
+	assert.NotNil(errs)
+	errsStr := errs.Strings()
+	assert.Len(errsStr, 1)
+	assert.Equal("Namespace 'nsNil': no privileges", errsStr[0])
+	assert.NotNil(srv)
+	assert.Len(queries, 3)
+	assert.Equal("ns1", queries[0].Target.Namespace)
+	assert.Equal("3h", queries[0].Interval)
+	assert.Equal("ns1", queries[1].Target.Namespace)
+	assert.Equal("30m", queries[1].Interval)
+	assert.Equal("ns2", queries[2].Target.Namespace)
+	assert.Equal("30m", queries[2].Interval)
+	assert.Equal(http.StatusOK, w.Code)
+}
+
+func TestPrepareStatsQueriesNoErrorIntervalAdjusted(t *testing.T) {
+	assert := assert.New(t)
+	prom, _, k8s := utilSetupMocks(t)
+	queryTime := time.Date(2020, 10, 22, 0, 0, 0, 0, time.UTC)
+	creation := meta_v1.NewTime(queryTime.Add(-1 * time.Hour))
+	k8s.On("GetNamespace", "ns3").Return(&core_v1.Namespace{ObjectMeta: meta_v1.ObjectMeta{Name: "ns3", CreationTimestamp: creation}}, nil)
+
+	req := httptest.NewRequest("GET", "/foo", nil)
+	req = req.WithContext(context.WithValue(req.Context(), "token", "test"))
+	w := httptest.NewRecorder()
+
+	rawQ := []models.MetricsStatsQuery{{
+		Target: models.Target{
+			Namespace: "ns3",
+			Name:      "foo",
+			Kind:      "app",
+		},
+		Direction:    "inbound",
+		RawInterval:  "3h",
+		Avg:          true,
+		Quantiles:    []string{"0.90", "0.5"},
+		RawQueryTime: queryTime.Unix(),
+	}}
+
+	srv, queries, errs := prepareStatsQueries(w, req, rawQ, prom)
+
+	assert.Nil(errs)
+	assert.NotNil(srv)
+	assert.Len(queries, 1)
+	assert.Equal("ns3", queries[0].Target.Namespace)
+	assert.Equal("3600s", queries[0].Interval) // 3h adjusted to 1h (3600s) due to namespace creation date
+	assert.Equal(http.StatusOK, w.Code)
+}
+
+func TestValidateBadRequest(t *testing.T) {
+	assert := assert.New(t)
+	prom, _, _ := utilSetupMocks(t)
+	queryTime := time.Date(2020, 10, 22, 0, 0, 0, 0, time.UTC)
+
+	req := httptest.NewRequest("GET", "/foo", nil)
+	req = req.WithContext(context.WithValue(req.Context(), "token", "test"))
+	w := httptest.NewRecorder()
+
+	rawQ := []models.MetricsStatsQuery{{
+		Target: models.Target{
+			Namespace: "ns1",
+			Name:      "foo",
+			Kind:      "x",
+		},
+		Direction:    "x",
+		RawInterval:  "30m",
+		Avg:          true,
+		Quantiles:    []string{"0.90", "0.5"},
+		RawQueryTime: queryTime.Unix(),
+	}}
+
+	_, _, errs := prepareStatsQueries(w, req, rawQ, prom)
+
+	assert.NotNil(errs)
+	assert.Contains(errs.Error(), "bad request")
+	assert.Len(errs.Strings(), 2)
+}
diff --git a/handlers/services_test.go b/handlers/services_test.go
index acc262399..44b4ff520 100644
--- a/handlers/services_test.go
+++ b/handlers/services_test.go
@@ -29,7 +29,13 @@ func TestServiceMetricsDefault(t *testing.T) {
 	ts, api, _ := setupServiceMetricsEndpoint(t)
 	defer ts.Close()
 
-	url := ts.URL + "/api/namespaces/ns/services/svc/metrics"
+	req, err := http.NewRequest("GET", ts.URL+"/api/namespaces/ns/services/svc/metrics", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	q := req.URL.Query()
+	q.Add("direction", "inbound")
+	req.URL.RawQuery = q.Encode()
 	now := time.Now()
 	delta := 15 * time.Second
 	var gaugeSentinel uint32
@@ -48,7 +54,8 @@ func TestServiceMetricsDefault(t *testing.T) {
 		assert.WithinDuration(t, now.Add(-30*time.Minute), r.Start, delta)
 	})
 
-	resp, err := http.Get(url)
+	httpclient := &http.Client{}
+	resp, err := httpclient.Do(req)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -69,6 +76,7 @@ func TestServiceMetricsWithParams(t *testing.T) {
 		t.Fatal(err)
 	}
 	q := req.URL.Query()
+	q.Add("direction", "inbound")
 	q.Add("rateInterval", "5h")
 	q.Add("rateFunc", "rate")
 	q.Add("step", "2")
diff --git a/handlers/utils_test.go b/handlers/utils_test.go
new file mode 100644
index 000000000..fe5065fd5
--- /dev/null
+++ b/handlers/utils_test.go
@@ -0,0 +1,95 @@
+package handlers
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	core_v1 "k8s.io/api/core/v1"
+	meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/kiali/kiali/business"
+	"github.com/kiali/kiali/config"
+	"github.com/kiali/kiali/kubernetes/kubetest"
+	"github.com/kiali/kiali/prometheus"
+	"github.com/kiali/kiali/prometheus/prometheustest"
+)
+
+// Setup mock
+func utilSetupMocks(t *testing.T) (promClientSupplier, *prometheustest.PromAPIMock, *kubetest.K8SClientMock) {
+	conf := config.NewConfig()
+	conf.KubernetesConfig.CacheEnabled = false
+	config.Set(conf)
+	k8s := new(kubetest.K8SClientMock)
+	k8s.On("IsOpenShift").Return(false)
+	k8s.On("GetNamespace", "ns1").Return(&core_v1.Namespace{ObjectMeta: meta_v1.ObjectMeta{Name: "ns1"}}, nil)
+	k8s.On("GetNamespace", "ns2").Return(&core_v1.Namespace{ObjectMeta: meta_v1.ObjectMeta{Name: "ns2"}}, nil)
+	k8s.On("GetNamespace", "nsNil").Return((*core_v1.Namespace)(nil), errors.New("no privileges"))
+
+	promAPI := new(prometheustest.PromAPIMock)
+	prom, err := prometheus.NewClient()
+	if err != nil {
+		t.Fatal(err)
+		return nil, nil, nil
+	}
+	prom.Inject(promAPI)
+
+	mockClientFactory := kubetest.NewK8SClientFactoryMock(k8s)
+	business.SetWithBackends(mockClientFactory, nil)
+	return func() (*prometheus.Client, error) { return prom, nil }, promAPI, k8s
+}
+
+func TestCreateMetricsServiceForNamespace(t *testing.T) {
+	assert := assert.New(t)
+	prom, _, _ := utilSetupMocks(t)
+
+	req := httptest.NewRequest("GET", "/foo", nil)
+	req = req.WithContext(context.WithValue(req.Context(), "token", "test"))
+
+	w := httptest.NewRecorder()
+	srv, info := createMetricsServiceForNamespace(w, req, prom, "ns1")
+
+	assert.NotNil(srv)
+	assert.NotNil(info)
+	assert.Equal("ns1", info.Name)
+	assert.Equal(http.StatusOK, w.Code)
+}
+
+func TestCreateMetricsServiceForNamespaceForbidden(t *testing.T) {
+	assert := assert.New(t)
+	prom, _, _ := utilSetupMocks(t)
+
+	req := httptest.NewRequest("GET", "/foo", nil)
+	req = req.WithContext(context.WithValue(req.Context(), "token", "test"))
+
+	w := httptest.NewRecorder()
+	srv, info := createMetricsServiceForNamespace(w, req, prom, "nsNil")
+
+	assert.Nil(srv)
+	assert.Nil(info)
+	assert.Equal(http.StatusForbidden, w.Code)
+}
+
+func TestCreateMetricsServiceForSeveralNamespaces(t *testing.T) {
+	assert := assert.New(t)
+	prom, _, _ := utilSetupMocks(t)
+
+	req := httptest.NewRequest("GET", "/foo", nil)
+	req = req.WithContext(context.WithValue(req.Context(), "token", "test"))
+
+	w := httptest.NewRecorder()
+	srv, info := createMetricsServiceForNamespaces(w, req, prom, []string{"ns1", "ns2", "nsNil"})
+
+	assert.NotNil(srv)
+	assert.Len(info, 3)
+	assert.Equal(http.StatusOK, w.Code)
+	assert.Equal("ns1", info["ns1"].info.Name)
+	assert.Nil(info["ns1"].err)
+	assert.Equal("ns2", info["ns2"].info.Name)
+	assert.Nil(info["ns2"].err)
+	assert.Nil(info["nsNil"].info)
+	assert.Equal("no privileges", info["nsNil"].err.Error())
+}
diff --git a/log/log_test.go b/log/log_test.go
new file mode 100644
index 000000000..739af49fe
--- /dev/null
+++ b/log/log_test.go
@@ -0,0 +1,262 @@
+package log
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"github.com/rs/zerolog/log"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/rs/zerolog"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestEnvVarLogLevel(t *testing.T) {
+	defer os.Unsetenv("LOG_LEVEL")
+
+	type levelsToTestStruct struct {
+		stringLevel string
+		level       zerolog.Level
+	}
+
+	levelsToTest := []levelsToTestStruct{
+		{
+			stringLevel: "FATAL",
+			level:       zerolog.FatalLevel,
+		},
+		{
+			stringLevel: "fatal",
+			level:       zerolog.FatalLevel,
+		},
+		{
+			stringLevel: "FaTaL",
+			level:       zerolog.FatalLevel,
+		},
+		{
+			stringLevel: "ERROR",
+			level:       zerolog.ErrorLevel,
+		},
+		{
+			stringLevel: "WARN",
+			level:       zerolog.WarnLevel,
+		},
+		{
+			stringLevel: "INFO",
+			level:       zerolog.InfoLevel,
+		},
+		{
+			stringLevel: "DEBUG",
+			level:       zerolog.DebugLevel,
+		},
+		{
+			stringLevel: "TRACE",
+			level:       zerolog.TraceLevel,
+		},
+		{
+			stringLevel: "0",
+			level:       zerolog.FatalLevel,
+		},
+		{
+			stringLevel: "1",
+			level:       zerolog.ErrorLevel,
+		},
+		{
+			stringLevel: "2",
+			level:       zerolog.WarnLevel,
+		},
+		{
+			stringLevel: "3",
+			level:       zerolog.InfoLevel,
+		},
+		{
+			stringLevel: "4",
+			level:       zerolog.DebugLevel,
+		},
+		{
+			stringLevel: "5",
+			level:       zerolog.TraceLevel,
+		},
+		{
+			stringLevel: "invalid",
+			level:       zerolog.InfoLevel,
+		},
+	}
+
+	for _, levelToTest := range levelsToTest {
+		os.Setenv("LOG_LEVEL", levelToTest.stringLevel)
+		assert.Equal(t, levelToTest.level, resolveLogLevelFromEnv(), fmt.Sprintf("LOG_LEVEL=%v,levelToTest=%+v", os.Getenv("LOG_LEVEL"), levelToTest))
+	}
+}
+
+func TestLogRegression(t *testing.T) {
+
+	originalEnv := os.Environ()
+	defer func() {
+		for _, kvp := range originalEnv {
+			kvpArray := strings.Split(kvp, "=")
+			os.Setenv(kvpArray[0], kvpArray[1])
+		}
+	}()
+
+	type loggedMessageAsJsonStruct struct {
+		Level   string
+		Time    string
+		Message string
+	}
+
+	type logOutputTestStruct struct {
+		envSettings        map[string]string
+		expectedLogMessage string
+	}
+
+	tests := []logOutputTestStruct{
+		{
+			envSettings: map[string]string{
+				"LOG_FORMAT": "text",
+			},
+			expectedLogMessage: "INF Kiali logging test 0: map[LOG_FORMAT:text]",
+		},
+		{
+			envSettings: map[string]string{
+				"LOG_FORMAT":            "text",
+				"LOG_LEVEL":             "debug",
+				"LOG_TIME_FIELD_FORMAT": time.RFC1123Z,
+			},
+			expectedLogMessage: "INF Kiali logging test 1: map[LOG_FORMAT:text LOG_LEVEL:debug LOG_TIME_FIELD_FORMAT:Mon, 02 Jan 2006 15:04:05 -0700]",
+		},
+		{
+			envSettings: map[string]string{
+				"LOG_FORMAT":            "json",
+				"LOG_LEVEL":             "debug",
+				"LOG_TIME_FIELD_FORMAT": time.RFC1123Z,
+			},
+			expectedLogMessage: "Kiali logging test 2: map[LOG_FORMAT:json LOG_LEVEL:debug LOG_TIME_FIELD_FORMAT:Mon, 02 Jan 2006 15:04:05 -0700]",
+		},
+	}
+
+	for index, test := range tests {
+
+		t.Logf("Cleaned env vars")
+		os.Clearenv()
+		for envName, envValue := range test.envSettings {
+			os.Setenv(envName, envValue)
+		}
+		t.Logf("Set ENVs: %v", os.Environ())
+
+		t.Run(fmt.Sprintf("Test %d", index), func(t *testing.T) {
+
+			buf := &bytes.Buffer{}
+			log.Logger = InitializeLogger().Output(buf)
+
+			if !isJsonLogFormat() {
+				t.Logf("Overwrite logger for test %d", index)
+				log.Logger = log.Logger.Output(zerolog.ConsoleWriter{Out: buf, TimeFormat: zerolog.TimeFieldFormat, NoColor: true})
+			}
+
+			Infof("Kiali logging %s %d: %v", "test", index, test.envSettings)
+
+			loggedMessage := buf.String()
+			t.Logf("Logged message: %s", loggedMessage)
+
+			var timestampAsString string
+			isRightLogFormat := false
+			hasLogLevelInformation := false
+
+			if isJSON(loggedMessage) {
+				var loggedMessageAsJson loggedMessageAsJsonStruct
+				_ = json.Unmarshal([]byte(loggedMessage), &loggedMessageAsJson)
+				timestampAsString = loggedMessageAsJson.Time
+				loggedMessage = loggedMessageAsJson.Message
+				isRightLogFormat = isJsonLogFormat()
+				parsedLevel, _ := zerolog.ParseLevel(loggedMessageAsJson.Level)
+				hasLogLevelInformation = parsedLevel == zerolog.InfoLevel
+			} else {
+				isRightLogFormat = !isJsonLogFormat()
+				timestampAsString = strings.Split(loggedMessage, " INF")[0]
+				hasLogLevelInformation = strings.Contains(loggedMessage, "INF")
+			}
+
+			timestamp, _ := time.Parse(zerolog.TimeFieldFormat, timestampAsString)
+			assert.True(t, strings.Contains(loggedMessage, test.expectedLogMessage))
+			assert.True(t, isRightLogFormat)
+			assert.True(t, hasLogLevelInformation)
+			assert.NotNil(t, timestamp)
+		})
+	}
+}
+
+func TestEnvVarLogSampler(t *testing.T) {
+
+	originalEnv := os.Environ()
+	defer func() {
+		for _, kvp := range originalEnv {
+			kvpArray := strings.Split(kvp, "=")
+			os.Setenv(kvpArray[0], kvpArray[1])
+		}
+	}()
+
+	os.Clearenv()
+	t.Logf("Cleaned env vars")
+	os.Setenv("LOG_SAMPLER_RATE", "10")
+	os.Setenv("LOG_FORMAT", "json")
+	os.Setenv("LOG_LEVEL", "debug")
+	t.Logf("Set ENVs: %v", os.Environ())
+
+	buf := &bytes.Buffer{}
+	log.Logger = InitializeLogger().Output(buf)
+
+	numberOfLogs := 0
+	for numberOfLogs < 10 {
+		Info("Kiali logging test")
+		numberOfLogs++
+	}
+
+	numberOfLogMessages := 0
+	numberOfLogMessages = bytes.Count(buf.Bytes(), []byte{'\n'})
+
+	t.Logf("Logged messages: %d - %s", numberOfLogMessages, buf.String())
+
+	assert.Equal(t, 1, numberOfLogMessages)
+}
+
+func TestSupportedTimeFormats(t *testing.T) {
+	defer os.Unsetenv("LOG_TIME_FIELD_FORMAT")
+
+	type formatsToTestStruct struct {
+		format     string
+		testResult string
+	}
+
+	formatsToTest := []formatsToTestStruct{
+		{
+			format:     time.RFC1123Z,
+			testResult: time.RFC1123Z,
+		},
+		{
+			format:     "some-imaginary-format",
+			testResult: time.RFC3339,
+		},
+		{
+			format:     "1990-07-06T15:07:05Z09:00",
+			testResult: time.RFC3339,
+		},
+	}
+
+	for _, formatToTest := range formatsToTest {
+		os.Setenv("LOG_TIME_FIELD_FORMAT", formatToTest.format)
+		assert.Equal(t, formatToTest.testResult, resolveTimeFormatFromEnv(), fmt.Sprintf("LOG_TIME_FIELD_FORMAT=%v,formatToTest=%+v", os.Getenv("LOG_TIME_FIELD_FORMAT"), formatToTest))
+	}
+
+}
+
+func isJSON(s string) bool {
+	var js map[string]interface{}
+	return json.Unmarshal([]byte(s), &js) == nil
+}
+
+func isJsonLogFormat() bool {
+	return os.Getenv("LOG_FORMAT") == "json"
+}
diff --git a/models/additional_details_test.go b/models/additional_details_test.go
index b86850243..85f1b203b 100644
--- a/models/additional_details_test.go
+++ b/models/additional_details_test.go
@@ -12,16 +12,16 @@ func TestAdditionalDetails(t *testing.T) {
 	assert := assert.New(t)
 	conf := config.NewConfig()
 	conf.AdditionalDisplayDetails = []config.AdditionalDisplayItem{
-		config.AdditionalDisplayItem{
+		{
 			Annotation: "my-annotation-1",
 			Title:      "Title 1",
 		},
-		config.AdditionalDisplayItem{
+		{
 			Annotation:     "my-annotation-2",
 			Title:          "Title 2",
 			IconAnnotation: "my-icon-annotation-2",
 		},
-		config.AdditionalDisplayItem{
+		{
 			Annotation:     "my-annotation-3",
 			Title:          "Title 3",
 			IconAnnotation: "my-icon-annotation-3",
@@ -60,16 +60,16 @@ func TestEmptyAdditionalDetails(t *testing.T) {
 	assert := assert.New(t)
 	conf := config.NewConfig()
 	conf.AdditionalDisplayDetails = []config.AdditionalDisplayItem{
-		config.AdditionalDisplayItem{
+		{
 			Annotation: "my-annotation-1",
 			Title:      "Title 1",
 		},
-		config.AdditionalDisplayItem{
+		{
 			Annotation:     "my-annotation-2",
 			Title:          "Title 2",
 			IconAnnotation: "my-icon-annotation-2",
 		},
-		config.AdditionalDisplayItem{
+		{
 			Annotation:     "my-annotation-3",
 			Title:          "Title 3",
 			IconAnnotation: "my-icon-annotation-3",
diff --git a/prometheus/prometheustest/client_test.go b/prometheus/prometheustest/client_test.go
index 7e7c87bc0..154f10072 100644
--- a/prometheus/prometheustest/client_test.go
+++ b/prometheus/prometheustest/client_test.go
@@ -1,7 +1,6 @@
 package prometheustest
 
 import (
-	"fmt"
 	"testing"
 	"time"
 
@@ -25,283 +24,6 @@ func setupMocked() (*prometheus.Client, *PromAPIMock, error) {
 	return client, api, nil
 }
 
-func round(q string) string {
-	return fmt.Sprintf("round(%s, 0.001000) > 0.001000 or %s", q, q)
-}
-
-func roundErrs(q string) string {
-	return fmt.Sprintf("round((%s), 0.001000) > 0.001000 or (%s)", q, q)
-}
-
-func TestGetServiceMetrics(t *testing.T) {
-	client, api, err := setupMocked()
-	if err != nil {
-		t.Error(err)
-		return
-	}
-
-	q := prometheus.IstioMetricsQuery{
-		Namespace: "bookinfo",
-		Service:   "productpage",
-	}
-	q.FillDefaults()
-	q.Direction = "inbound"
-	q.RateInterval = "5m"
-	q.Quantiles = []string{"0.99"}
-	expectedRange := prom_v1.Range{
-		Start: q.Start,
-		End:   q.End,
-		Step:  q.Step,
-	}
-
-	labels := `reporter="source",destination_service_name="productpage",destination_service_namespace="bookinfo"`
-	mockWithRange(api, expectedRange, round("sum(rate(istio_requests_total{"+labels+"}[5m]))"), 2.5)
-	mockWithRange(api, expectedRange, roundErrs("sum(rate(istio_requests_total{"+labels+`,response_code=~"^0$|^[4-5]\\d\\d$"}[5m])) OR sum(rate(istio_requests_total{`+labels+`,grpc_response_status=~"^[1-9]$|^1[0-6]$",response_code!~"^0$|^[4-5]\\d\\d$"}[5m]))`), 4.5)
-	mockWithRange(api, expectedRange, round("sum(rate(istio_request_bytes_sum{"+labels+"}[5m]))"), 1000)
-	mockWithRange(api, expectedRange, round("sum(rate(istio_response_bytes_sum{"+labels+"}[5m]))"), 1001)
-	mockWithRange(api, expectedRange, round("sum(rate(istio_tcp_received_bytes_total{"+labels+"}[5m]))"), 11)
-	mockWithRange(api, expectedRange, round("sum(rate(istio_tcp_sent_bytes_total{"+labels+"}[5m]))"), 13)
-	mockHistogram(api, "istio_request_bytes", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.7)
-	mockHistogram(api, "istio_request_duration_milliseconds", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.8)
-	mockHistogram(api, "istio_response_bytes", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.9)
-
-	// Test that range and rate interval are changed when needed (namespace bounds)
-	metrics := client.GetMetrics(&q)
-
-	assert.Equal(t, 6, len(metrics.Metrics), "Should have 6 simple metrics")
-	assert.Equal(t, 3, len(metrics.Histograms), "Should have 3 histograms")
-	rqCountIn := metrics.Metrics["request_count"]
-	assert.NotNil(t, rqCountIn)
-	rqErrorCountIn := metrics.Metrics["request_error_count"]
-	assert.NotNil(t, rqErrorCountIn)
-	rqThroughput := metrics.Metrics["request_throughput"]
-	assert.NotNil(t, rqThroughput)
-	rsThroughput := metrics.Metrics["response_throughput"]
-	assert.NotNil(t, rsThroughput)
-	rqSizeIn := metrics.Histograms["request_size"]
-	assert.NotNil(t, rqSizeIn)
-	rqDurationMillisIn := metrics.Histograms["request_duration_millis"]
-	assert.NotNil(t, rqDurationMillisIn)
-	rsSizeIn := metrics.Histograms["response_size"]
-	assert.NotNil(t, rsSizeIn)
-	tcpRecIn := metrics.Metrics["tcp_received"]
-	assert.NotNil(t, tcpRecIn)
-	tcpSentIn := metrics.Metrics["tcp_sent"]
-	assert.NotNil(t, tcpSentIn)
-
-	assert.Equal(t, 2.5, float64(rqCountIn.Matrix[0].Values[0].Value))
-	assert.Equal(t, 4.5, float64(rqErrorCountIn.Matrix[0].Values[0].Value))
-	assert.Equal(t, 1000.0, float64(rqThroughput.Matrix[0].Values[0].Value))
-	assert.Equal(t, 1001.0, float64(rsThroughput.Matrix[0].Values[0].Value))
-	assert.Equal(t, 0.7, float64(rqSizeIn["0.99"].Matrix[0].Values[0].Value))
-	assert.Equal(t, 0.8, float64(rqDurationMillisIn["0.99"].Matrix[0].Values[0].Value))
-	assert.Equal(t, 0.9, float64(rsSizeIn["0.99"].Matrix[0].Values[0].Value))
-	assert.Equal(t, 11.0, float64(tcpRecIn.Matrix[0].Values[0].Value))
-	assert.Equal(t, 13.0, float64(tcpSentIn.Matrix[0].Values[0].Value))
-}
-
-func TestGetAppMetrics(t *testing.T) {
-	client, api, err := setupMocked()
-	if err != nil {
-		t.Error(err)
-		return
-	}
-	labels := `reporter="source",source_workload_namespace="bookinfo",source_canonical_service="productpage"`
-	mockRange(api, round("sum(rate(istio_requests_total{"+labels+"}[5m]))"), 1.5)
-	mockRange(api, roundErrs("sum(rate(istio_requests_total{"+labels+`,response_code=~"^0$|^[4-5]\\d\\d$"}[5m])) OR sum(rate(istio_requests_total{`+labels+`,grpc_response_status=~"^[1-9]$|^1[0-6]$",response_code!~"^0$|^[4-5]\\d\\d$"}[5m]))`), 3.5)
-	mockRange(api, round("sum(rate(istio_request_bytes_sum{"+labels+"}[5m]))"), 1000)
-	mockRange(api, round("sum(rate(istio_response_bytes_sum{"+labels+"}[5m]))"), 1001)
-	mockRange(api, round("sum(rate(istio_tcp_received_bytes_total{"+labels+"}[5m]))"), 10)
-	mockRange(api, round("sum(rate(istio_tcp_sent_bytes_total{"+labels+"}[5m]))"), 12)
-	mockHistogram(api, "istio_request_bytes", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.4)
-	mockHistogram(api, "istio_request_duration_milliseconds", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.5)
-	mockHistogram(api, "istio_response_bytes", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.6)
-
-	q := prometheus.IstioMetricsQuery{
-		Namespace: "bookinfo",
-		App:       "productpage",
-	}
-	q.FillDefaults()
-	q.RateInterval = "5m"
-	q.Quantiles = []string{"0.5", "0.95", "0.99"}
-	metrics := client.GetMetrics(&q)
-
-	assert.Equal(t, 6, len(metrics.Metrics), "Should have 6 simple metrics")
-	assert.Equal(t, 3, len(metrics.Histograms), "Should have 3 histograms")
-	rqCountIn := metrics.Metrics["request_count"]
-	assert.NotNil(t, rqCountIn)
-	rqErrorCountIn := metrics.Metrics["request_error_count"]
-	assert.NotNil(t, rqErrorCountIn)
-	rqThroughput := metrics.Metrics["request_throughput"]
-	assert.NotNil(t, rqThroughput)
-	rsThroughput := metrics.Metrics["response_throughput"]
-	assert.NotNil(t, rsThroughput)
-	rqSizeIn := metrics.Histograms["request_size"]
-	assert.NotNil(t, rqSizeIn)
-	rqDurationMillisIn := metrics.Histograms["request_duration_millis"]
-	assert.NotNil(t, rqDurationMillisIn)
-	rsSizeIn := metrics.Histograms["response_size"]
-	assert.NotNil(t, rsSizeIn)
-	tcpRecIn := metrics.Metrics["tcp_received"]
-	assert.NotNil(t, tcpRecIn)
-	tcpSentIn := metrics.Metrics["tcp_sent"]
-	assert.NotNil(t, tcpSentIn)
-
-	assert.Equal(t, 1.5, float64(rqCountIn.Matrix[0].Values[0].Value))
-	assert.Equal(t, 3.5, float64(rqErrorCountIn.Matrix[0].Values[0].Value))
-	assert.Equal(t, 1000.0, float64(rqThroughput.Matrix[0].Values[0].Value))
-	assert.Equal(t, 1001.0, float64(rsThroughput.Matrix[0].Values[0].Value))
-	assert.Equal(t, 0.35, float64(rqSizeIn["avg"].Matrix[0].Values[0].Value))
-	assert.Equal(t, 0.2, float64(rqSizeIn["0.5"].Matrix[0].Values[0].Value))
-	assert.Equal(t, 0.3, float64(rqSizeIn["0.95"].Matrix[0].Values[0].Value))
-	assert.Equal(t, 0.4, float64(rqSizeIn["0.99"].Matrix[0].Values[0].Value))
-	assert.Equal(t, 0.5, float64(rqDurationMillisIn["0.99"].Matrix[0].Values[0].Value))
-	assert.Equal(t, 0.6, float64(rsSizeIn["0.99"].Matrix[0].Values[0].Value))
-	assert.Equal(t, 10.0, float64(tcpRecIn.Matrix[0].Values[0].Value))
-	assert.Equal(t, 12.0, float64(tcpSentIn.Matrix[0].Values[0].Value))
-}
-
-func TestGetFilteredAppMetrics(t *testing.T) {
-	client, api, err := setupMocked()
-	if err != nil {
-		t.Error(err)
-		return
-	}
-	mockRange(api, round(`sum(rate(istio_requests_total{reporter="source",source_workload_namespace="bookinfo",source_canonical_service="productpage"}[5m]))`), 1.5)
-	mockHistogram(api, "istio_request_bytes", `{reporter="source",source_workload_namespace="bookinfo",source_canonical_service="productpage"}[5m]`, 0.35, 0.2, 0.3, 0.4)
-	q := prometheus.IstioMetricsQuery{
-		Namespace: "bookinfo",
-		App:       "productpage",
-	}
-	q.FillDefaults()
-	q.RateInterval = "5m"
-	q.Filters = []string{"request_count", "request_size"}
-	metrics := client.GetMetrics(&q)
-
-	assert.Equal(t, 1, len(metrics.Metrics), "Should have 1 simple metric")
-	assert.Equal(t, 1, len(metrics.Histograms), "Should have 1 histogram")
-	rqCountOut := metrics.Metrics["request_count"]
-	assert.NotNil(t, rqCountOut)
-	rqSizeOut := metrics.Histograms["request_size"]
-	assert.NotNil(t, rqSizeOut)
-}
-
-func TestGetAppMetricsInstantRates(t *testing.T) {
-	client, api, err := setupMocked()
-	if err != nil {
-		t.Error(err)
-		return
-	}
-	mockRange(api, round(`sum(irate(istio_requests_total{reporter="source",source_workload_namespace="bookinfo",source_canonical_service="productpage"}[1m]))`), 1.5)
-	q := prometheus.IstioMetricsQuery{
-		Namespace: "bookinfo",
-		App:       "productpage",
-	}
-	q.FillDefaults()
-	q.RateFunc = "irate"
-	q.Filters = []string{"request_count"}
-	metrics := client.GetMetrics(&q)
-
-	assert.Equal(t, 1, len(metrics.Metrics), "Should have 1 simple metric")
-	assert.Equal(t, 0, len(metrics.Histograms), "Should have no histogram")
-	rqCountOut := metrics.Metrics["request_count"]
-	assert.NotNil(t, rqCountOut)
-}
-
-func TestGetAppMetricsUnavailable(t *testing.T) {
-	client, api, err := setupMocked()
-	if err != nil {
-		t.Error(err)
-		return
-	}
-	// Mock everything to return empty data
-	mockEmptyRange(api, round(`sum(rate(istio_requests_total{reporter="source",source_workload_namespace="bookinfo",source_canonical_service="productpage"}[5m]))`))
-	mockEmptyHistogram(api, "istio_request_bytes", `{reporter="source",source_workload_namespace="bookinfo",source_canonical_service="productpage"}[5m]`)
-	q := prometheus.IstioMetricsQuery{
-		Namespace: "bookinfo",
-		App:       "productpage",
-	}
-	q.FillDefaults()
-	q.RateInterval = "5m"
-	q.Quantiles = []string{"0.5", "0.95", "0.99"}
-	q.Filters = []string{"request_count", "request_size"}
-	metrics := client.GetMetrics(&q)
-
-	assert.Equal(t, 1, len(metrics.Metrics), "Should have 1 simple metric")
-	assert.Equal(t, 1, len(metrics.Histograms), "Should have 1 histogram")
-	rqCountIn := metrics.Metrics["request_count"]
-	assert.NotNil(t, rqCountIn)
-	rqSizeIn := metrics.Histograms["request_size"]
-	assert.NotNil(t, rqSizeIn)
-
-	// Simple metric & histogram are empty
-	assert.Empty(t, rqCountIn.Matrix[0].Values)
-	assert.Empty(t, rqSizeIn["avg"].Matrix[0].Values)
-	assert.Empty(t, rqSizeIn["0.5"].Matrix[0].Values)
-	assert.Empty(t, rqSizeIn["0.95"].Matrix[0].Values)
-	assert.Empty(t, rqSizeIn["0.99"].Matrix[0].Values)
-}
-
-func TestGetNamespaceMetrics(t *testing.T) {
-	client, api, err := setupMocked()
-	if err != nil {
-		t.Error(err)
-		return
-	}
-	labels := `reporter="source",source_workload_namespace="bookinfo"`
-	mockRange(api, round("sum(rate(istio_requests_total{"+labels+"}[5m]))"), 1.5)
-	mockRange(api, roundErrs("sum(rate(istio_requests_total{"+labels+`,response_code=~"^0$|^[4-5]\\d\\d$"}[5m])) OR sum(rate(istio_requests_total{`+labels+`,grpc_response_status=~"^[1-9]$|^1[0-6]$",response_code!~"^0$|^[4-5]\\d\\d$"}[5m]))`), 3.5)
-	mockRange(api, round("sum(rate(istio_request_bytes_sum{"+labels+"}[5m]))"), 1000)
-	mockRange(api, round("sum(rate(istio_response_bytes_sum{"+labels+"}[5m]))"), 1001)
-	mockRange(api, round("sum(rate(istio_tcp_received_bytes_total{"+labels+"}[5m]))"), 10)
-	mockRange(api, round("sum(rate(istio_tcp_sent_bytes_total{"+labels+"}[5m]))"), 12)
-	mockHistogram(api, "istio_request_bytes", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.4)
-	mockHistogram(api, "istio_request_duration_milliseconds", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.5)
-	mockHistogram(api, "istio_response_bytes", "{"+labels+"}[5m]", 0.35, 0.2, 0.3, 0.6)
-
-	q := prometheus.IstioMetricsQuery{
-		Namespace: "bookinfo",
-	}
-	q.FillDefaults()
-	q.RateInterval = "5m"
-	q.Quantiles = []string{"0.5", "0.95", "0.99"}
-	metrics := client.GetMetrics(&q)
-
-	assert.Equal(t, 6, len(metrics.Metrics), "Should have 6 simple metrics")
-	assert.Equal(t, 3, len(metrics.Histograms), "Should have 3 histograms")
-	rqCountOut := metrics.Metrics["request_count"]
-	assert.NotNil(t, rqCountOut)
-	rqErrorCountOut := metrics.Metrics["request_error_count"]
-	assert.NotNil(t, rqErrorCountOut)
-	rqThroughput := metrics.Metrics["request_throughput"]
-	assert.NotNil(t, rqThroughput)
-	rsThroughput := metrics.Metrics["response_throughput"]
-	assert.NotNil(t, rsThroughput)
-	rqSizeOut := metrics.Histograms["request_size"]
-	assert.NotNil(t, rqSizeOut)
-	rqDurationMillisOut := metrics.Histograms["request_duration_millis"]
-	assert.NotNil(t, rqDurationMillisOut)
-	rsSizeOut := metrics.Histograms["response_size"]
-	assert.NotNil(t, rsSizeOut)
-	tcpRecOut := metrics.Metrics["tcp_received"]
-	assert.NotNil(t, tcpRecOut)
-	tcpSentOut := metrics.Metrics["tcp_sent"]
-	assert.NotNil(t, tcpSentOut)
-
-	assert.Equal(t, 1.5, float64(rqCountOut.Matrix[0].Values[0].Value))
-	assert.Equal(t, 3.5, float64(rqErrorCountOut.Matrix[0].Values[0].Value))
-	assert.Equal(t, 1000.0, float64(rqThroughput.Matrix[0].Values[0].Value))
-	assert.Equal(t, 1001.0, float64(rsThroughput.Matrix[0].Values[0].Value))
-	assert.Equal(t, 0.35, float64(rqSizeOut["avg"].Matrix[0].Values[0].Value))
-	assert.Equal(t, 0.2, float64(rqSizeOut["0.5"].Matrix[0].Values[0].Value))
-	assert.Equal(t, 0.3, float64(rqSizeOut["0.95"].Matrix[0].Values[0].Value))
-	assert.Equal(t, 0.4, float64(rqSizeOut["0.99"].Matrix[0].Values[0].Value))
-	assert.Equal(t, 0.5, float64(rqDurationMillisOut["0.99"].Matrix[0].Values[0].Value))
-	assert.Equal(t, 0.6, float64(rsSizeOut["0.99"].Matrix[0].Values[0].Value))
-	assert.Equal(t, 10.0, float64(tcpRecOut.Matrix[0].Values[0].Value))
-	assert.Equal(t, 12.0, float64(tcpSentOut.Matrix[0].Values[0].Value))
-}
-
 func TestGetAllRequestRates(t *testing.T) {
 	client, api, err := setupMocked()
 	if err != nil {
@@ -318,7 +40,7 @@ func TestGetAllRequestRates(t *testing.T) {
 			Metric:    model.Metric{"foo": "bar"},
 		},
 	}
-	mockQueryWithTime(api, `rate(istio_requests_total{destination_service_namespace="ns",source_workload_namespace!="ns"}[5m]) > 0`, queryTime, &vectorQ1)
+	api.OnQueryTime(`rate(istio_requests_total{destination_service_namespace="ns",source_workload_namespace!="ns"}[5m]) > 0`, &queryTime, vectorQ1)
 
 	vectorQ2 := model.Vector{
 		&model.Sample{
@@ -326,7 +48,7 @@ func TestGetAllRequestRates(t *testing.T) {
 			Value:     model.SampleValue(2),
 			Metric:    model.Metric{"foo": "bar"}},
 	}
-	mockQueryWithTime(api, `rate(istio_requests_total{source_workload_namespace="ns"}[5m]) > 0`, queryTime, &vectorQ2)
+	api.OnQueryTime(`rate(istio_requests_total{source_workload_namespace="ns"}[5m]) > 0`, &queryTime, vectorQ2)
 
 	rates, _ := client.GetAllRequestRates("ns", "5m", queryTime)
 	assert.Equal(t, 2, rates.Len())
@@ -350,7 +72,7 @@ func TestGetAllRequestRatesIstioSystem(t *testing.T) {
 			Metric:    model.Metric{"foo": "bar"},
 		},
 	}
-	mockQueryWithTime(api, `rate(istio_requests_total{destination_service_namespace="istio-system",source_workload_namespace!="istio-system"}[5m]) > 0`, queryTime, &vectorQ1)
+	api.OnQueryTime(`rate(istio_requests_total{destination_service_namespace="istio-system",source_workload_namespace!="istio-system"}[5m]) > 0`, &queryTime, vectorQ1)
 
 	vectorQ2 := model.Vector{
 		&model.Sample{
@@ -358,7 +80,7 @@ func TestGetAllRequestRatesIstioSystem(t *testing.T) {
 			Value:     model.SampleValue(2),
 			Metric:    model.Metric{"foo": "bar"}},
 	}
-	mockQueryWithTime(api, `rate(istio_requests_total{source_workload_namespace="istio-system"}[5m]) > 0`, queryTime, &vectorQ2)
+	api.OnQueryTime(`rate(istio_requests_total{source_workload_namespace="istio-system"}[5m]) > 0`, &queryTime, vectorQ2)
 
 	rates, _ := client.GetAllRequestRates("istio-system", "5m", queryTime)
 	assert.Equal(t, 2, rates.Len())
@@ -382,7 +104,7 @@ func TestGetNamespaceServicesRequestRates(t *testing.T) {
 			Metric:    model.Metric{"foo": "bar"},
 		},
 	}
-	mockQueryWithTime(api, `rate(istio_requests_total{destination_service_namespace="ns"}[5m]) > 0`, queryTime, &vectorQ1)
+	api.OnQueryTime(`rate(istio_requests_total{destination_service_namespace="ns"}[5m]) > 0`, &queryTime, vectorQ1)
 
 	rates, _ := client.GetNamespaceServicesRequestRates("ns", "5m", queryTime)
 	assert.Equal(t, 1, rates.Len())
@@ -415,86 +137,6 @@ func TestFlags(t *testing.T) {
 	assert.Equal(t, flags["storage.tsdb.retention"], "6h")
 }
 
-func mockQueryWithTime(api *PromAPIMock, query string, queryTime time.Time, ret *model.Vector) {
-	api.On(
-		"Query",
-		mock.AnythingOfType("*context.emptyCtx"),
-		query,
-		queryTime).
-		Return(*ret, nil)
-}
-
-func mockQueryRange(api *PromAPIMock, query string, ret *model.Matrix) {
-	api.On(
-		"QueryRange",
-		mock.AnythingOfType("*context.emptyCtx"),
-		query,
-		mock.AnythingOfType(`v1.Range`)).
-		Return(*ret, nil)
-}
-
-func mockRange(api *PromAPIMock, query string, ret model.SampleValue) {
-	metric := model.Metric{
-		"reporter": "destination",
-		"__name__": "whatever",
-		"instance": "whatever",
-		"job":      "whatever"}
-	matrix := model.Matrix{
-		&model.SampleStream{
-			Metric: metric,
-			Values: []model.SamplePair{{Timestamp: 0, Value: ret}}}}
-	mockQueryRange(api, query, &matrix)
-}
-
-func mockWithRange(api *PromAPIMock, qRange prom_v1.Range, query string, ret model.SampleValue) {
-	metric := model.Metric{
-		"reporter": "destination",
-		"__name__": "whatever",
-		"instance": "whatever",
-		"job":      "whatever"}
-	matrix := model.Matrix{
-		&model.SampleStream{
-			Metric: metric,
-			Values: []model.SamplePair{{Timestamp: 0, Value: ret}}}}
-	api.On(
-		"QueryRange",
-		mock.AnythingOfType("*context.emptyCtx"),
-		query,
-		qRange).
-		Return(matrix, nil)
-}
-
-func mockEmptyRange(api *PromAPIMock, query string) {
-	metric := model.Metric{
-		"reporter": "destination",
-		"__name__": "whatever",
-		"instance": "whatever",
-		"job":      "whatever"}
-	matrix := model.Matrix{
-		&model.SampleStream{
-			Metric: metric,
-			Values: []model.SamplePair{}}}
-	mockQueryRange(api, query, &matrix)
-}
-
-func mockHistogram(api *PromAPIMock, baseName string, suffix string, retAvg model.SampleValue, retMed model.SampleValue, ret95 model.SampleValue, ret99 model.SampleValue) {
-	histMetric := "sum(rate(" + baseName + "_bucket" + suffix + ")) by (le))"
-	mockRange(api, round("histogram_quantile(0.5, "+histMetric), retMed)
-	mockRange(api, round("histogram_quantile(0.95, "+histMetric), ret95)
-	mockRange(api, round("histogram_quantile(0.99, "+histMetric), ret99)
-	mockRange(api, round("histogram_quantile(0.999, "+histMetric), ret99)
-	mockRange(api, round("sum(rate("+baseName+"_sum"+suffix+")) / sum(rate("+baseName+"_count"+suffix+"))"), retAvg)
-}
-
-func mockEmptyHistogram(api *PromAPIMock, baseName string, suffix string) {
-	histMetric := "sum(rate(" + baseName + "_bucket" + suffix + ")) by (le))"
-	mockEmptyRange(api, round("histogram_quantile(0.5, "+histMetric))
-	mockEmptyRange(api, round("histogram_quantile(0.95, "+histMetric))
-	mockEmptyRange(api, round("histogram_quantile(0.99, "+histMetric))
-	mockEmptyRange(api, round("histogram_quantile(0.999, "+histMetric))
-	mockEmptyRange(api, round("sum(rate("+baseName+"_sum"+suffix+")) / sum(rate("+baseName+"_count"+suffix+"))"))
-}
-
 func mockConfig(api *PromAPIMock, ret prom_v1.ConfigResult) {
 	api.On("Config", mock.AnythingOfType("*context.emptyCtx")).Return(ret, nil)
 }
diff --git a/prometheus/prometheustest/mock.go b/prometheus/prometheustest/mock.go
index b603cc09a..a11eba248 100644
--- a/prometheus/prometheustest/mock.go
+++ b/prometheus/prometheustest/mock.go
@@ -2,6 +2,7 @@ package prometheustest
 
 import (
 	"context"
+	"fmt"
 	"time"
 
 	"github.com/prometheus/client_golang/api"
@@ -87,6 +88,96 @@ func (o *PromAPIMock) TargetsMetadata(ctx context.Context, matchTarget, metric,
 	return args.Get(0).([]prom_v1.MetricMetadata), nil
 }
 
+func (o *PromAPIMock) OnQueryTime(query string, t *time.Time, ret model.Vector) {
+	if t == nil {
+		o.On("Query", mock.AnythingOfType("*context.emptyCtx"), query, mock.AnythingOfType("time.Time")).Return(ret, nil)
+	} else {
+		o.On("Query", mock.AnythingOfType("*context.emptyCtx"), query, *t).Return(ret, nil)
+	}
+}
+
+func (o *PromAPIMock) MockTimeRounded(query string, ret model.Vector) {
+	o.OnQueryTime(round(query), nil, ret)
+}
+
+func (o *PromAPIMock) OnQueryRange(query string, r *prom_v1.Range, ret model.Matrix) {
+	if r == nil {
+		o.On("QueryRange", mock.AnythingOfType("*context.emptyCtx"), query, mock.AnythingOfType("v1.Range")).Return(ret, nil)
+	} else {
+		o.On("QueryRange", mock.AnythingOfType("*context.emptyCtx"), query, *r).Return(ret, nil)
+	}
+}
+
+func singleValueMatrix(ret model.SampleValue) model.Matrix {
+	return model.Matrix{
+		&model.SampleStream{
+			Metric: model.Metric{
+				"reporter": "destination",
+				"__name__": "whatever",
+				"instance": "whatever",
+				"job":      "whatever"},
+			Values: []model.SamplePair{{Timestamp: 0, Value: ret}}}}
+}
+
+func emptyMatrix() model.Matrix {
+	return model.Matrix{
+		&model.SampleStream{
+			Metric: model.Metric{
+				"reporter": "destination",
+				"__name__": "whatever",
+				"instance": "whatever",
+				"job":      "whatever"},
+			Values: []model.SamplePair{}}}
+}
+
+func (o *PromAPIMock) MockRangeErrRounded(query string, ret model.SampleValue) {
+	o.OnQueryRange(roundErrs(query), nil, singleValueMatrix(ret))
+}
+
+func (o *PromAPIMock) MockRangeRounded(query string, ret model.SampleValue) {
+	o.OnQueryRange(round(query), nil, singleValueMatrix(ret))
+}
+
+func (o *PromAPIMock) MockEmptyRangeRounded(query string) {
+	o.OnQueryRange(round(query), nil, emptyMatrix())
+}
+
+func (o *PromAPIMock) MockHistoValue(baseName, suffix string, retAvg model.Vector, retMed model.Vector, ret95 model.Vector, ret99 model.Vector) {
+	histMetric := "sum(rate(" + baseName + "_bucket" + suffix + ")) by (le))"
+	o.MockTimeRounded("histogram_quantile(0.5, "+histMetric, retMed)
+	o.MockTimeRounded("histogram_quantile(0.95, "+histMetric, ret95)
+	o.MockTimeRounded("histogram_quantile(0.99, "+histMetric, ret99)
+	o.MockTimeRounded("histogram_quantile(0.999, "+histMetric, ret99)
+	o.MockTimeRounded("sum(rate("+baseName+"_sum"+suffix+")) / sum(rate("+baseName+"_count"+suffix+"))", retAvg)
+}
+
+func (o *PromAPIMock) MockHistoValueGroupedBy(baseName, labels, groups string, retAvg model.Vector, retMed model.Vector, ret95 model.Vector, ret99 model.Vector) {
+	histMetric := "sum(rate(" + baseName + "_bucket" + labels + ")) by (le," + groups + "))"
+	o.MockTimeRounded("histogram_quantile(0.5, "+histMetric, retMed)
+	o.MockTimeRounded("histogram_quantile(0.95, "+histMetric, ret95)
+	o.MockTimeRounded("histogram_quantile(0.99, "+histMetric, ret99)
+	o.MockTimeRounded("histogram_quantile(0.999, "+histMetric, ret99)
+	o.MockTimeRounded("sum(rate("+baseName+"_sum"+labels+")) by ("+groups+") / sum(rate("+baseName+"_count"+labels+")) by ("+groups+")", retAvg)
+}
+
+func (o *PromAPIMock) MockHistoRange(baseName string, suffix string, retAvg model.SampleValue, retMed model.SampleValue, ret95 model.SampleValue, ret99 model.SampleValue) {
+	histMetric := "sum(rate(" + baseName + "_bucket" + suffix + ")) by (le))"
+	o.MockRangeRounded("histogram_quantile(0.5, "+histMetric, retMed)
+	o.MockRangeRounded("histogram_quantile(0.95, "+histMetric, ret95)
+	o.MockRangeRounded("histogram_quantile(0.99, "+histMetric, ret99)
+	o.MockRangeRounded("histogram_quantile(0.999, "+histMetric, ret99)
+	o.MockRangeRounded("sum(rate("+baseName+"_sum"+suffix+")) / sum(rate("+baseName+"_count"+suffix+"))", retAvg)
+}
+
+func (o *PromAPIMock) MockEmptyHistoRange(baseName string, suffix string) {
+	histMetric := "sum(rate(" + baseName + "_bucket" + suffix + ")) by (le))"
+	o.MockEmptyRangeRounded("histogram_quantile(0.5, " + histMetric)
+	o.MockEmptyRangeRounded("histogram_quantile(0.95, " + histMetric)
+	o.MockEmptyRangeRounded("histogram_quantile(0.99, " + histMetric)
+	o.MockEmptyRangeRounded("histogram_quantile(0.999, " + histMetric)
+	o.MockEmptyRangeRounded("sum(rate(" + baseName + "_sum" + suffix + ")) / sum(rate(" + baseName + "_count" + suffix + "))")
+}
+
 // AlwaysReturnEmpty mocks all possible queries to return empty result
 func (o *PromAPIMock) AlwaysReturnEmpty() {
 	metric := model.Metric{
@@ -190,22 +281,30 @@ func (o *PromClientMock) GetWorkloadRequestRates(namespace, workload, ratesInter
 	return args.Get(0).(model.Vector), args.Get(1).(model.Vector), args.Error(2)
 }
 
-func (o *PromClientMock) FetchRange(metricName, labels, grouping, aggregator string, q *prometheus.BaseMetricsQuery) *prometheus.Metric {
+func (o *PromClientMock) FetchRange(metricName, labels, grouping, aggregator string, q *prometheus.RangeQuery) *prometheus.Metric {
 	args := o.Called(metricName, labels, grouping, aggregator, q)
 	return args.Get(0).(*prometheus.Metric)
 }
 
-func (o *PromClientMock) FetchRateRange(metricName, labels, grouping string, q *prometheus.BaseMetricsQuery) *prometheus.Metric {
+func (o *PromClientMock) FetchRateRange(metricName string, labels []string, grouping string, q *prometheus.RangeQuery) *prometheus.Metric {
 	args := o.Called(metricName, labels, grouping, q)
 	return args.Get(0).(*prometheus.Metric)
 }
 
-func (o *PromClientMock) FetchHistogramRange(metricName, labels, grouping string, q *prometheus.BaseMetricsQuery) prometheus.Histogram {
+func (o *PromClientMock) FetchHistogramRange(metricName, labels, grouping string, q *prometheus.RangeQuery) prometheus.Histogram {
 	args := o.Called(metricName, labels, grouping, q)
 	return args.Get(0).(prometheus.Histogram)
 }
 
-func (o *PromClientMock) GetMetrics(query *prometheus.IstioMetricsQuery) prometheus.Metrics {
-	args := o.Called(query)
-	return args.Get(0).(prometheus.Metrics)
+func (o *PromClientMock) FetchHistogramValues(metricName, labels, grouping, rateInterval string, avg bool, quantiles []string, queryTime time.Time) (map[string]model.Vector, error) {
+	args := o.Called(metricName, labels, grouping, rateInterval, avg, quantiles, queryTime)
+	return args.Get(0).(map[string]model.Vector), args.Error((1))
+}
+
+func round(q string) string {
+	return fmt.Sprintf("round(%s, 0.001000) > 0.001000 or %s", q, q)
+}
+
+func roundErrs(q string) string {
+	return fmt.Sprintf("round((%s), 0.001000) > 0.001000 or (%s)", q, q)
 }
diff --git a/tests/e2e/tests/test_api_methods.py b/tests/e2e/tests/test_api_methods.py
index 6398d406b..8870c6014 100644
--- a/tests/e2e/tests/test_api_methods.py
+++ b/tests/e2e/tests/test_api_methods.py
@@ -19,7 +19,7 @@ def before_all_tests(kiali_client):
                           'istioConfigDetailsSubtype', 'serviceDashboard', 'workloadDashboard', 'appDashboard',
                           'authenticationInfo', 'openshiftCheckToken', 'customDashboard', 'podDetails', 'podLogs',
                           'namespaceTls', 'getThreeScaleInfo', 'getThreeScaleHandlers', 'getThreeScaleService',
-                          'meshTls', 'namespaceValidations', 'appSpans', 'appTraces']
+                          'meshTls', 'namespaceValidations', 'appSpans', 'appTraces', 'serviceTraces', 'workloadSpans', 'workloadTraces', 'serviceSpans']
 
     for key in swagger.operation:
         swagger_method_list.append(key)
@@ -286,13 +286,30 @@ def test_namespace_spans_list(kiali_client):
     if 'v1.0' in get_kiali_version(kiali_client).get('Kiali core version'):
         pytest.skip()
 
-    evaluate_response(kiali_client, method_name='appSpans', path={'namespace': 'bookinfo'})
+    evaluate_response(kiali_client, method_name='appSpans', path={'namespace': 'bookinfo', 'app': 'details'})
 
 def test_namespace_traces_list(kiali_client):
     if 'v1.0' in get_kiali_version(kiali_client).get('Kiali core version'):
         pytest.skip()
 
-    evaluate_response(kiali_client, method_name='appTraces', path={'namespace': 'bookinfo'})
+    evaluate_response(kiali_client, method_name='appTraces', path={'namespace': 'bookinfo', 'app': 'details'})
+                                                                   
+def test_service_traces_list(kiali_client):
+    
+    evaluate_response(kiali_client, method_name='serviceTraces', path={'namespace': 'bookinfo', 'service':'details'})
+
+def test_service_spans_list(kiali_client):
+    
+    evaluate_response(kiali_client, method_name='serviceSpans', path={'namespace': 'bookinfo', 'service':'details'})
+
+def test_workload_traces_list(kiali_client):
+    
+    evaluate_response(kiali_client, method_name='workloadTraces', path={'namespace': 'bookinfo', 'workload':'details-v1'})
+
+def test_workload_spans_list(kiali_client):
+
+    evaluate_response(kiali_client, method_name='workloadSpans', path={'namespace': 'bookinfo', 'workload':'details-v1'})
+
 
 def test_negative_400(kiali_client):
 
@@ -322,7 +339,7 @@ def test_negative_403(kiali_client):
     INVALID_PARAMS_WORKLOADHEALTH = {'namespace': 'invalid', 'workload': 'details-v1'}
 
     evaluate_response(kiali_client, method_name='appDetails', path=INVALID_PARAMS_APPDETAILS, status_code_expected=403)
-    evaluate_response(kiali_client, method_name='istioConfigDetails', path=INVALID_PARAMS_ISTIOCONFIGDETAILS, status_code_expected=403)
+    #evaluate_response(kiali_client, method_name='istioConfigDetails', path=INVALID_PARAMS_ISTIOCONFIGDETAILS, status_code_expected=403)
     evaluate_response(kiali_client, method_name='workloadDetails', path=INVALID_PARAMS_WORKLOADDETAILS, status_code_expected=403)
     evaluate_response(kiali_client, method_name='serviceHealth', path=INVALID_PARAMS_SERVICEHEALTH, status_code_expected=403)
     evaluate_response(kiali_client, method_name='appHealth', path=INVALID_PARAMS_APPHEALTH, status_code_expected=403)
diff --git a/tests/e2e/tests/test_kiali_traces.py b/tests/e2e/tests/test_kiali_traces.py
new file mode 100644
index 000000000..349f33d87
--- /dev/null
+++ b/tests/e2e/tests/test_kiali_traces.py
@@ -0,0 +1,83 @@
+import pytest
+import tests.conftest as conftest
+
+bookinfo_namespace = conftest.get_bookinfo_namespace()
+
+def test_service_traces_endpoint(kiali_client):
+    response = kiali_client.request(method_name='serviceTraces', path={'namespace': bookinfo_namespace,  'service':'details'})
+    if response.status_code == 404 or response.status_code == 500:
+        pytest.skip()  
+    elif response.status_code == 200:
+        traceID = response.json().get('data')[0].get('traceID')
+        assert traceID != None
+        spans_list = response.json().get('data')[0].get('spans')
+        assert spans_list != None
+        for traceID in spans_list:
+            assert traceID != None and traceID != ''
+    else:
+        assert False
+
+def test_workload_traces_endpoint(kiali_client):
+    response = kiali_client.request(method_name='workloadTraces', path={'namespace': bookinfo_namespace,  'workload':'details-v1'})
+    if response.status_code == 404 or response.status_code == 500:
+        pytest.skip()  
+    elif response.status_code == 200:
+        traceID = response.json().get('data')[0].get('traceID')
+        assert traceID != None
+        spans_list = response.json().get('data')[0].get('spans')
+        assert spans_list != None
+        for traceID in spans_list:
+            assert traceID != None and traceID != ''
+    else:
+        assert False
+        
+def test_app_traces_endpoint(kiali_client):
+    response = kiali_client.request(method_name='appTraces', path={'namespace': bookinfo_namespace,  'app':'details'})
+    if response.status_code == 404 or response.status_code == 500:
+        pytest.skip()  
+    elif response.status_code == 200:
+        traceID = response.json().get('data')[0].get('traceID')
+        assert traceID != None
+        spans_list = response.json().get('data')[0].get('spans')
+        assert spans_list != None
+        for traceID in spans_list:
+            assert traceID != None and traceID != ''
+    else:
+        assert False    
+
+def test_workload_spans_endpoint(kiali_client):
+    response = kiali_client.request(method_name='workloadSpans',  path={'namespace': bookinfo_namespace, 'workload':'details-v1'})
+    if response.status_code == 500:
+        pytest.skip()  
+    elif response.status_code == 200:
+        traceID = response.json()[0]['traceID']
+        assert traceID != None 
+        references_traceID = response.json()[0]['references'][0]['traceID']
+        assert references_traceID != None and references_traceID != ''
+    else:
+        assert False
+
+
+def test_service_spans_endpoint(kiali_client):
+    response = kiali_client.request(method_name='serviceSpans',  path={'namespace': bookinfo_namespace, 'service':'details'})
+    if response.status_code == 500:
+        pytest.skip()  
+    elif response.status_code == 200:
+        traceID = response.json()[0]['traceID']
+        assert traceID != None 
+        references_traceID = response.json()[0]['references'][0]['traceID']
+        assert references_traceID != None and references_traceID != ''
+    else:
+        assert False
+
+def test_app_spans_endpoint(kiali_client):
+    response = kiali_client.request(method_name='appSpans',  path={'namespace': bookinfo_namespace, 'app':'details'})
+    if response.status_code == 500:
+        pytest.skip()  
+    elif response.status_code == 200:
+        traceID = response.json()[0]['traceID']
+        assert traceID != None 
+        references_traceID = response.json()[0]['references'][0]['traceID']
+        assert references_traceID != None and references_traceID != ''
+    else:
+        assert False
diff --git a/util/httputil/http_util_test.go b/util/httputil/http_util_test.go
index 9552cb84a..ea8aa8cbb 100644
--- a/util/httputil/http_util_test.go
+++ b/util/httputil/http_util_test.go
@@ -56,6 +56,21 @@ func TestGuessKialiURLReadsForwardedPort(t *testing.T) {
 	assert.Equal(t, "https://kiali:123456/custom/kiali", guessedUrl)
 }
 
+func TestGuessKialiURLWebFQDNPort(t *testing.T) {
+	// See reference: https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/x-forwarded-headers.html#x-forwarded-port
+
+	conf := config.NewConfig()
+	conf.Server.WebRoot = "/custom/kiali"
+	conf.Server.WebPort = "1234"
+	conf.Server.Port = 700
+	config.Set(conf)
+
+	request, _ := http.NewRequest("GET", "https://kiali:2800/custom/kiali/path/", nil)
+	guessedUrl := GuessKialiURL(request)
+
+	assert.Equal(t, "https://kiali:1234/custom/kiali", guessedUrl)
+}
+
 func TestGuessKialiURLReadsHostPortFromRequestUrlAttr(t *testing.T) {
 	request := setupAndCreateRequest()
 	request.URL.Host = "myHost:8000"
@@ -108,8 +123,9 @@ func TestGuessKialiURLPrioritizesConfig(t *testing.T) {
 	request := setupAndCreateRequest()
 
 	conf := config.NewConfig()
-	conf.Server.WebRoot = "/foo/bar"
 	conf.Server.WebFQDN = "subdomain.domain.dev"
+	conf.Server.WebPort = "4321"
+	conf.Server.WebRoot = "/foo/bar"
 	conf.Server.WebSchema = "http"
 	conf.Server.Port = 700
 	config.Set(conf)
@@ -118,5 +134,5 @@ func TestGuessKialiURLPrioritizesConfig(t *testing.T) {
 	request.Header.Add("X-Forwarded-Proto", "https")
 	guessedUrl := GuessKialiURL(request)
 
-	assert.Equal(t, "http://subdomain.domain.dev:443/foo/bar", guessedUrl)
+	assert.Equal(t, "http://subdomain.domain.dev:4321/foo/bar", guessedUrl)
 }