Repository: linkerd/linkerd2
Pull request #12965 (2024-08-20 15:33:16)

Dual-stack support for ExternalWorkloads
This changes the `workloadIPs.maxItems` field in the ExternalWorkload CRD from `1` to `2`, to accommodate for an IPv4 and IPv6 pair. This is a BC change, so there's no need to bump the CRD version.

The control plane already supports this, so this change is mainly about expansions to the unit tests to also account for the double stack case.


Patch:
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index 7d2131da2..61c8ea0d3 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -9,7 +9,7 @@ jobs:
     runs-on: ubuntu-22.04
     steps:
       - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
-      - uses: tj-actions/changed-files@6b2903bdce6310cfbddd87c418f253cf29b2dec9
+      - uses: tj-actions/changed-files@40853de9f8ce2d6cfdc73c1b96f14e22ba44aec4
         id: changed
         with:
           files: |
diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
index 301a44931..04c498a48 100644
--- a/.github/workflows/integration.yml
+++ b/.github/workflows/integration.yml
@@ -28,7 +28,7 @@ jobs:
       - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
       - id: tag
         run: echo "tag=$(CI_FORCE_CLEAN=1 bin/root-tag)" >> "$GITHUB_OUTPUT"
-      - uses: tj-actions/changed-files@6b2903bdce6310cfbddd87c418f253cf29b2dec9
+      - uses: tj-actions/changed-files@40853de9f8ce2d6cfdc73c1b96f14e22ba44aec4
         id: core
         with:
           files: |
@@ -81,7 +81,7 @@ jobs:
           docker cp "$id:/out/linkerd-linux-amd64" /home/runner/archives/linkerd
           v=$(/home/runner/archives/linkerd version --short --client)
           [[ "$v" == '${{ needs.meta.outputs.tag }}' ]] || exit 1
-      - uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029
+      - uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a
         with:
           name: image-archives-cli
           path: /home/runner/archives
@@ -118,7 +118,7 @@ jobs:
         run: |
           mkdir -p /home/runner/archives
           docker save '${{ steps.build.outputs.image }}' >'/home/runner/archives/${{ matrix.component }}.tar'
-      - uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029
+      - uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a
         with:
           name: image-archives-${{ matrix.component }}
           path: /home/runner/archives
@@ -240,7 +240,7 @@ jobs:
         run: |
           mkdir -p /home/runner/archives
           docker save '${{ steps.build.outputs.image }}' >'/home/runner/archives/${{ matrix.component }}.tar'
-      - uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029
+      - uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a
         with:
           name: image-archives-${{ matrix.component }}
           path: /home/runner/archives
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a3166f1f0..f27876890 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -83,7 +83,7 @@ jobs:
       # https://github.com/actions/upload-artifact/issues/8
       - name: Upload artifact
         if: matrix.component == 'cli-bin'
-        uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029
+        uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a
         with:
           name: image-archives-cli
           path: /home/runner/archives
@@ -169,7 +169,7 @@ jobs:
           args: pack bin/win/linkerd.nuspec
       - name: Chocolatey - upload package
         if: startsWith(github.ref, 'refs/tags/stable')
-        uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029
+        uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a
         with:
           name: choco
           path: ./linkerd.*.nupkg
@@ -264,7 +264,7 @@ jobs:
       - name: Checkout code
         uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
       - name: Log into GCP
-        uses: "google-github-actions/auth@71fee32a0bb7e97b4d33d548e7d957010649d8fa"
+        uses: "google-github-actions/auth@62cf5bd3e4211a0a0b51f2c6d6a37129d828611d"
         with:
           credentials_json: ${{ secrets.LINKERD_SITE_TOKEN }}
       - name: Edge Helm chart creation and upload
diff --git a/.proxy-version b/.proxy-version
index 2fda8898e..7eacb3575 100644
--- a/.proxy-version
+++ b/.proxy-version
@@ -1,1 +1,1 @@
-v2.246.0
+v2.250.0
diff --git a/Cargo.lock b/Cargo.lock
index 0342916cc..937868f01 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -33,7 +33,7 @@ dependencies = [
  "getrandom",
  "once_cell",
  "version_check",
- "zerocopy 0.7.35",
+ "zerocopy",
 ]
 
 [[package]]
@@ -238,15 +238,18 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
 
 [[package]]
 name = "bytes"
-version = "1.6.1"
+version = "1.7.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a12916984aab3fa6e39d655a33e09c0071eb36d6ab3aea5c2d78551f1df6d952"
+checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50"
 
 [[package]]
 name = "cc"
-version = "1.1.7"
+version = "1.1.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26a5c3fd7bfa1ce3897a3a3501d362b2d87b7f2583ebcb4a949ec25911025cbc"
+checksum = "57b6a275aa2903740dc87da01c62040406b8812552e97129a63ea8850a17c6e6"
+dependencies = [
+ "shlex",
+]
 
 [[package]]
 name = "cfg-if"
@@ -269,9 +272,9 @@ dependencies = [
 
 [[package]]
 name = "clap"
-version = "4.5.11"
+version = "4.5.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "35723e6a11662c2afb578bcf0b88bf6ea8e21282a953428f240574fcc3a2b5b3"
+checksum = "ed6719fffa43d0d87e5fd8caeab59be1554fb028cd30edc88fc4369b17971019"
 dependencies = [
  "clap_builder",
  "clap_derive",
@@ -279,9 +282,9 @@ dependencies = [
 
 [[package]]
 name = "clap_builder"
-version = "4.5.11"
+version = "4.5.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "49eb96cbfa7cfa35017b7cd548c75b14c3118c98b423041d70562665e07fb0fa"
+checksum = "216aec2b177652e3846684cbfe25c9964d18ec45234f0f5da5157b207ed1aab6"
 dependencies = [
  "anstyle",
  "clap_lex",
@@ -289,9 +292,9 @@ dependencies = [
 
 [[package]]
 name = "clap_derive"
-version = "4.5.11"
+version = "4.5.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d029b67f89d30bbb547c89fd5161293c0aec155fc691d7924b64550662db93e"
+checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0"
 dependencies = [
  "heck",
  "proc-macro2",
@@ -317,15 +320,15 @@ dependencies = [
 
 [[package]]
 name = "core-foundation-sys"
-version = "0.8.6"
+version = "0.8.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
 
 [[package]]
 name = "cpufeatures"
-version = "0.2.12"
+version = "0.2.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504"
+checksum = "51e852e6dc9a5bed1fae92dd2375037bf2b768725bf3be87811edee3249d09ad"
 dependencies = [
  "libc",
 ]
@@ -1144,9 +1147,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
 
 [[package]]
 name = "libc"
-version = "0.2.155"
+version = "0.2.158"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
+checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439"
 
 [[package]]
 name = "linked-hash-map"
@@ -1399,9 +1402,9 @@ dependencies = [
 
 [[package]]
 name = "mio"
-version = "1.0.1"
+version = "1.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4569e456d394deccd22ce1c1913e6ea0e54519f577285001215d33557431afe4"
+checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec"
 dependencies = [
  "hermit-abi",
  "libc",
@@ -1430,9 +1433,9 @@ dependencies = [
 
 [[package]]
 name = "object"
-version = "0.36.2"
+version = "0.36.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f203fa8daa7bb185f760ae12bd8e097f63d17041dcdcaf675ac54cdf863170e"
+checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a"
 dependencies = [
  "memchr",
 ]
@@ -1636,11 +1639,11 @@ checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
 
 [[package]]
 name = "ppv-lite86"
-version = "0.2.18"
+version = "0.2.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dee4364d9f3b902ef14fab8a1ddffb783a1cb6b4bba3bfc1fa3922732c7de97f"
+checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
 dependencies = [
- "zerocopy 0.6.6",
+ "zerocopy",
 ]
 
 [[package]]
@@ -1732,9 +1735,9 @@ dependencies = [
 
 [[package]]
 name = "quote"
-version = "1.0.36"
+version = "1.0.37"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
+checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
 dependencies = [
  "proc-macro2",
 ]
@@ -1780,9 +1783,9 @@ dependencies = [
 
 [[package]]
 name = "regex"
-version = "1.10.5"
+version = "1.10.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f"
+checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619"
 dependencies = [
  "aho-corasick",
  "memchr",
@@ -1995,9 +1998,9 @@ dependencies = [
 
 [[package]]
 name = "serde"
-version = "1.0.204"
+version = "1.0.209"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12"
+checksum = "99fce0ffe7310761ca6bf9faf5115afbc19688edd00171d81b1bb1b116c63e09"
 dependencies = [
  "serde_derive",
 ]
@@ -2014,9 +2017,9 @@ dependencies = [
 
 [[package]]
 name = "serde_derive"
-version = "1.0.204"
+version = "1.0.209"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222"
+checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -2036,9 +2039,9 @@ dependencies = [
 
 [[package]]
 name = "serde_json"
-version = "1.0.121"
+version = "1.0.127"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ab380d7d9f22ef3f21ad3e6c1ebe8e4fc7a2000ccba2e4d71fc96f15b2cb609"
+checksum = "8043c06d9f82bd7271361ed64f415fe5e12a77fdb52e573e7f06a516dea329ad"
 dependencies = [
  "itoa",
  "memchr",
@@ -2090,6 +2093,12 @@ dependencies = [
  "lazy_static",
 ]
 
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
 [[package]]
 name = "signal-hook-registry"
 version = "1.4.2"
@@ -2211,9 +2220,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
 
 [[package]]
 name = "tokio"
-version = "1.39.2"
+version = "1.39.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "daa4fb1bc778bd6f04cbfc4bb2d06a7396a8f299dc33ea1900cedaa316f467b1"
+checksum = "9babc99b9923bfa4804bd74722ff02c0381021eafa4db9949217e3be8e84fff5"
 dependencies = [
  "backtrace",
  "bytes",
@@ -2403,15 +2412,15 @@ dependencies = [
 
 [[package]]
 name = "tower-layer"
-version = "0.3.2"
+version = "0.3.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
 
 [[package]]
 name = "tower-service"
-version = "0.3.2"
+version = "0.3.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
 
 [[package]]
 name = "tracing"
@@ -2766,34 +2775,14 @@ version = "0.52.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0"
 
-[[package]]
-name = "zerocopy"
-version = "0.6.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "854e949ac82d619ee9a14c66a1b674ac730422372ccb759ce0c39cabcf2bf8e6"
-dependencies = [
- "byteorder",
- "zerocopy-derive 0.6.6",
-]
-
 [[package]]
 name = "zerocopy"
 version = "0.7.35"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
 dependencies = [
- "zerocopy-derive 0.7.35",
-]
-
-[[package]]
-name = "zerocopy-derive"
-version = "0.6.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "125139de3f6b9d625c39e2efdd73d41bdac468ccd556556440e322be0e1bbd91"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.66",
+ "byteorder",
+ "zerocopy-derive",
 ]
 
 [[package]]
diff --git a/charts/linkerd-crds/templates/workload/external-workload.yaml b/charts/linkerd-crds/templates/workload/external-workload.yaml
index 715e779aa..2e6e43ae6 100644
--- a/charts/linkerd-crds/templates/workload/external-workload.yaml
+++ b/charts/linkerd-crds/templates/workload/external-workload.yaml
@@ -229,16 +229,16 @@ spec:
               workloadIPs:
                 type: array
                 description: workloadIPs contains a list of IP addresses that
-                  can be used to send traffic to the workload.
+                  can be used to send traffic to the workload. This field may
+                  hold a maximum of two entries. If one entry, it can be an
+                  IPv4 or IPv6 address; if two entries it should contain one
+                  IPv4 address and one IPv6 address.
                 items:
                   type: object
                   properties:
                     ip:
                       type: string
-                # TODO: relax this in the future when ipv6 is supported
-                # an external workload (like a pod) should only
-                # support 2 interfaces
-                maxItems: 1
+                maxItems: 2
             type: object
             required:
             - meshTLS
diff --git a/cli/table/table.go b/cli/table/table.go
index ba585f721..9d264b691 100644
--- a/cli/table/table.go
+++ b/cli/table/table.go
@@ -5,6 +5,7 @@ import (
 	"io"
 	"sort"
 	"strings"
+	"unicode/utf8"
 )
 
 type (
@@ -48,7 +49,7 @@ func NewColumn(header string) Column {
 	return Column{
 		Header:   header,
 		Flexible: true,
-		Width:    len(header),
+		Width:    utf8.RuneCountInString(header),
 	}
 }
 
@@ -74,8 +75,8 @@ func (t *Table) columnWidths() []int {
 		width := col.Width
 		if col.Flexible {
 			for _, row := range t.Data {
-				if len(row[c]) > width {
-					width = len(row[c])
+				if utf8.RuneCountInString(row[c]) > width {
+					width = utf8.RuneCountInString(row[c])
 				}
 			}
 		}
@@ -106,14 +107,24 @@ func (t *Table) renderRow(w io.Writer, row Row, columnWidths []int) {
 			continue
 		}
 		value := row[c]
-		if len(value) > columnWidths[c] {
+		if utf8.RuneCountInString(value) > columnWidths[c] {
 			value = value[:columnWidths[c]]
 		}
-		padding := strings.Repeat(" ", columnWidths[c]-len(value))
-		if col.LeftAlign {
-			fmt.Fprintf(w, "%s%s%s", value, padding, t.ColumnSpacing)
+		padding := strings.Repeat(" ", columnWidths[c]-utf8.RuneCountInString(value))
+		spacing := t.ColumnSpacing
+		if strings.HasSuffix(value, "─") && c < len(t.Columns)-1 && strings.HasPrefix(row[c+1], "─") {
+			spacing = "──"
+		}
+		if strings.HasPrefix(value, "─") {
+			padding = strings.Repeat("─", columnWidths[c]-utf8.RuneCountInString(value))
+			fmt.Fprintf(w, "%s%s%s", padding, value, spacing)
+		} else if strings.HasSuffix(value, "─") {
+			padding = strings.Repeat("─", columnWidths[c]-utf8.RuneCountInString(value))
+			fmt.Fprintf(w, "%s%s%s", value, padding, spacing)
+		} else if col.LeftAlign {
+			fmt.Fprintf(w, "%s%s%s", value, padding, spacing)
 		} else {
-			fmt.Fprintf(w, "%s%s%s", padding, value, t.ColumnSpacing)
+			fmt.Fprintf(w, "%s%s%s", padding, value, spacing)
 		}
 	}
 	fmt.Fprint(w, "\n")
diff --git a/controller/api/destination/external-workload/endpoints_reconciler.go b/controller/api/destination/external-workload/endpoints_reconciler.go
index cef6b6e96..fc65d5da3 100644
--- a/controller/api/destination/external-workload/endpoints_reconciler.go
+++ b/controller/api/destination/external-workload/endpoints_reconciler.go
@@ -78,10 +78,9 @@ func (r *endpointsReconciler) reconcile(svc *corev1.Service, ews []*ewv1beta1.Ex
 	// Get the list of supported address types for the service
 	supportedAddrTypes := getSupportedAddressTypes(svc)
 	for _, slice := range existingSlices {
-		// If a slice has an address type that the slice does not support, then
+		// If a slice has an address type that the service does not support, then
 		// it should be deleted
-		_, supported := supportedAddrTypes[slice.AddressType]
-		if !supported {
+		if _, supported := supportedAddrTypes[slice.AddressType]; !supported {
 			toDelete = append(toDelete, slice)
 			continue
 		}
diff --git a/go.mod b/go.mod
index 17bb0b142..a48e0cd3e 100644
--- a/go.mod
+++ b/go.mod
@@ -35,8 +35,8 @@ require (
 	github.com/spf13/cobra v1.8.1
 	github.com/spf13/pflag v1.0.5
 	go.opencensus.io v0.24.0
-	golang.org/x/net v0.27.0
-	golang.org/x/tools v0.23.0
+	golang.org/x/net v0.28.0
+	golang.org/x/tools v0.24.0
 	google.golang.org/grpc v1.65.0
 	google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1
 	google.golang.org/protobuf v1.34.2
@@ -44,13 +44,13 @@ require (
 	helm.sh/helm/v3 v3.15.3
 	k8s.io/api v0.30.3
 	k8s.io/apiextensions-apiserver v0.30.3
-	k8s.io/apimachinery v0.30.3
+	k8s.io/apimachinery v0.31.0
 	k8s.io/client-go v0.30.3
 	k8s.io/code-generator v0.30.3
 	k8s.io/endpointslice v0.30.3
 	k8s.io/klog/v2 v2.130.1
 	k8s.io/kube-aggregator v0.30.3
-	k8s.io/utils v0.0.0-20230726121419-3b25d923346b
+	k8s.io/utils v0.0.0-20240711033017-18e509b52bc8
 	sigs.k8s.io/gateway-api v0.8.1
 	sigs.k8s.io/yaml v1.4.0
 )
@@ -74,14 +74,15 @@ require (
 	github.com/distribution/reference v0.5.0 // indirect
 	github.com/docker/cli v25.0.1+incompatible // indirect
 	github.com/docker/distribution v2.8.3+incompatible // indirect
-	github.com/docker/docker v26.1.4+incompatible // indirect
+	github.com/docker/docker v26.1.5+incompatible // indirect
 	github.com/docker/docker-credential-helpers v0.7.0 // indirect
 	github.com/docker/go-connections v0.5.0 // indirect
 	github.com/docker/go-metrics v0.0.1 // indirect
 	github.com/emicklei/go-restful/v3 v3.11.2 // indirect
 	github.com/felixge/httpsnoop v1.0.3 // indirect
+	github.com/fxamacker/cbor/v2 v2.7.0 // indirect
 	github.com/go-errors/errors v1.4.2 // indirect
-	github.com/go-logr/logr v1.4.1 // indirect
+	github.com/go-logr/logr v1.4.2 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/go-openapi/jsonpointer v0.21.0 // indirect
 	github.com/go-openapi/jsonreference v0.21.0 // indirect
@@ -109,7 +110,7 @@ require (
 	github.com/mitchellh/copystructure v1.2.0 // indirect
 	github.com/mitchellh/reflectwalk v1.0.2 // indirect
 	github.com/moby/locker v1.0.1 // indirect
-	github.com/moby/spdystream v0.2.0 // indirect
+	github.com/moby/spdystream v0.4.0 // indirect
 	github.com/moby/term v0.5.0 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
@@ -120,7 +121,6 @@ require (
 	github.com/opencontainers/image-spec v1.1.0-rc6 // indirect
 	github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
 	github.com/pkg/errors v0.9.1 // indirect
-	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
 	github.com/prometheus/procfs v0.15.1 // indirect
 	github.com/rivo/uniseg v0.4.4 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
@@ -128,6 +128,7 @@ require (
 	github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect
 	github.com/spf13/cast v1.5.1 // indirect
 	github.com/stretchr/objx v0.5.1 // indirect
+	github.com/x448/float16 v0.8.4 // indirect
 	github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
 	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
 	github.com/xeipuuv/gojsonschema v1.2.0 // indirect
@@ -137,13 +138,13 @@ require (
 	go.opentelemetry.io/otel/metric v1.19.0 // indirect
 	go.opentelemetry.io/otel/trace v1.19.0 // indirect
 	go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect
-	golang.org/x/crypto v0.25.0 // indirect
-	golang.org/x/mod v0.19.0 // indirect
+	golang.org/x/crypto v0.26.0 // indirect
+	golang.org/x/mod v0.20.0 // indirect
 	golang.org/x/oauth2 v0.21.0 // indirect
-	golang.org/x/sync v0.7.0 // indirect
-	golang.org/x/sys v0.22.0 // indirect
-	golang.org/x/term v0.22.0 // indirect
-	golang.org/x/text v0.16.0 // indirect
+	golang.org/x/sync v0.8.0 // indirect
+	golang.org/x/sys v0.23.0 // indirect
+	golang.org/x/term v0.23.0 // indirect
+	golang.org/x/text v0.17.0 // indirect
 	golang.org/x/time v0.3.0 // indirect
 	google.golang.org/api v0.143.0 // indirect
 	google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect
diff --git a/go.sum b/go.sum
index 0d3f01b09..e8fe9fac8 100644
--- a/go.sum
+++ b/go.sum
@@ -105,8 +105,8 @@ github.com/docker/cli v25.0.1+incompatible h1:mFpqnrS6Hsm3v1k7Wa/BO23oz0k121MTbT
 github.com/docker/cli v25.0.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
 github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
-github.com/docker/docker v26.1.4+incompatible h1:vuTpXDuoga+Z38m1OZHzl7NKisKWaWlhjQk7IDPSLsU=
-github.com/docker/docker v26.1.4+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v26.1.5+incompatible h1:NEAxTwEjxV6VbBMBoGG3zPqbiJosIApZjxlbrG9q3/g=
+github.com/docker/docker v26.1.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker-credential-helpers v0.7.0 h1:xtCHsjxogADNZcdv1pKUHXryefjlVRqWqIhk/uXJp0A=
 github.com/docker/docker-credential-helpers v0.7.0/go.mod h1:rETQfLdHNT3foU5kuNkFR1R1V12OJRRO5lzt2D1b5X0=
 github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
@@ -137,6 +137,8 @@ github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0X
 github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
 github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
 github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
+github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
 github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
@@ -147,8 +149,8 @@ github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2
 github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
 github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
-github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
@@ -160,8 +162,8 @@ github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5
 github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
 github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
-github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
-github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
+github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
+github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
 github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
 github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
@@ -224,8 +226,8 @@ github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OI
 github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
 github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
 github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec=
-github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM=
+github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
@@ -239,7 +241,6 @@ github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH
 github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
 github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
 github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
-github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
 github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/grantae/certinfo v0.0.0-20170412194111-59d56a35515b h1:NGgE5ELokSf2tZ/bydyDUKrvd/jP8lrAoPNeBuMOTOk=
@@ -315,8 +316,8 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx
 github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
 github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg=
 github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
-github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8=
-github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c=
+github.com/moby/spdystream v0.4.0 h1:Vy79D6mHeJJjiPdFEL2yku1kl0chZpJfZcPpb16BRl8=
+github.com/moby/spdystream v0.4.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
 github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
 github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
 github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
@@ -339,10 +340,10 @@ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J
 github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
 github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY=
 github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo=
-github.com/onsi/ginkgo/v2 v2.15.0 h1:79HwNRBAZHOEwrczrgSOPy+eFTTlIGELKy5as+ClttY=
-github.com/onsi/ginkgo/v2 v2.15.0/go.mod h1:HlxMHtYF57y6Dpf+mc5529KKmSq9h2FpCF+/ZkwUxKM=
-github.com/onsi/gomega v1.31.0 h1:54UJxxj6cPInHS3a35wm6BK/F9nHYueZ1NVujHDrnXE=
-github.com/onsi/gomega v1.31.0/go.mod h1:DW9aCi7U6Yi40wNVAvT6kzFnEVEI5n3DloYBiKiT6zk=
+github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA=
+github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To=
+github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
+github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
 github.com/opencontainers/image-spec v1.1.0-rc6 h1:XDqvyKsJEbRtATzkgItUqBA7QHk58yxX1Ov9HERHNqU=
@@ -385,8 +386,8 @@ github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
 github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
-github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
-github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
@@ -425,6 +426,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
 github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
 github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
 github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
 github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
@@ -468,8 +471,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
-golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
-golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
+golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
+golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -501,8 +504,8 @@ golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzB
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
-golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
+golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -529,8 +532,8 @@ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwY
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
-golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
-golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
+golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
+golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -547,8 +550,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
-golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
+golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -582,14 +585,14 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
-golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
+golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
-golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
-golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
+golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
+golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -597,8 +600,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
-golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
+golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -637,8 +640,8 @@ golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWc
 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
-golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
+golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
+golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -746,8 +749,8 @@ k8s.io/api v0.30.3 h1:ImHwK9DCsPA9uoU3rVh4QHAHHK5dTSv1nxJUapx8hoQ=
 k8s.io/api v0.30.3/go.mod h1:GPc8jlzoe5JG3pb0KJCSLX5oAFIW3/qNJITlDj8BH04=
 k8s.io/apiextensions-apiserver v0.30.3 h1:oChu5li2vsZHx2IvnGP3ah8Nj3KyqG3kRSaKmijhB9U=
 k8s.io/apiextensions-apiserver v0.30.3/go.mod h1:uhXxYDkMAvl6CJw4lrDN4CPbONkF3+XL9cacCT44kV4=
-k8s.io/apimachinery v0.30.3 h1:q1laaWCmrszyQuSQCfNB8cFgCuDAoPszKY4ucAjDwHc=
-k8s.io/apimachinery v0.30.3/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc=
+k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc=
+k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
 k8s.io/cli-runtime v0.30.0 h1:0vn6/XhOvn1RJ2KJOC6IRR2CGqrpT6QQF4+8pYpWQ48=
 k8s.io/cli-runtime v0.30.0/go.mod h1:vATpDMATVTMA79sZ0YUCzlMelf6rUjoBzlp+RnoM+cg=
 k8s.io/client-go v0.30.3 h1:bHrJu3xQZNXIi8/MoxYtZBBWQQXwy16zqJwloXXfD3k=
@@ -766,8 +769,8 @@ k8s.io/kube-aggregator v0.30.3 h1:hy5zfQ7p6BuJgc/XtGp3GBh2MPfOj6b1n3raKKMHOQE=
 k8s.io/kube-aggregator v0.30.3/go.mod h1:2SP0IckvQoOwwZN8lmtWUnTZTgIpwOWvidWtxyqLwuk=
 k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=
 k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98=
-k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI=
-k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
+k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A=
+k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
 oras.land/oras-go v1.2.5 h1:XpYuAwAb0DfQsunIyMfeET92emK8km3W4yEzZvUbsTo=
 oras.land/oras-go v1.2.5/go.mod h1:PuAwRShRZCsZb7g8Ar3jKKQR/2A/qN+pkYxIOd/FAoo=
 rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
diff --git a/policy-controller/k8s/status/Cargo.toml b/policy-controller/k8s/status/Cargo.toml
index 00f4f9b76..fc102cc7d 100644
--- a/policy-controller/k8s/status/Cargo.toml
+++ b/policy-controller/k8s/status/Cargo.toml
@@ -19,7 +19,7 @@ linkerd-policy-controller-k8s-api = { path = "../api" }
 parking_lot = "0.12"
 prometheus-client = { version = "0.22.3", default-features = false }
 serde = "1"
-serde_json = "1.0.121"
+serde_json = "1.0.127"
 thiserror = "1"
 tokio = { version = "1", features = ["macros"] }
 tracing = "0.1.40"
diff --git a/viz/charts/linkerd-viz/templates/namespace-metadata.yaml b/viz/charts/linkerd-viz/templates/namespace-metadata.yaml
index 606f0df2e..ae7967725 100644
--- a/viz/charts/linkerd-viz/templates/namespace-metadata.yaml
+++ b/viz/charts/linkerd-viz/templates/namespace-metadata.yaml
@@ -49,7 +49,7 @@ spec:
           runAsNonRoot: true
           readOnlyRootFilesystem: true
           runAsUser: {{.Values.defaultUID}}
-          runAsUser: {{.Values.defaultGID}}
+          runAsGroup: {{.Values.defaultGID}}
           seccompProfile:
             type: RuntimeDefault
         args:
diff --git a/viz/cmd/root.go b/viz/cmd/root.go
index 1f52026bd..5213f5edd 100644
--- a/viz/cmd/root.go
+++ b/viz/cmd/root.go
@@ -89,6 +89,8 @@ func NewCmdViz() *cobra.Command {
 	vizCmd.AddCommand(newCmdProfile())
 	vizCmd.AddCommand(NewCmdRoutes())
 	vizCmd.AddCommand(NewCmdStat())
+	vizCmd.AddCommand(NewCmdStatInbound())
+	vizCmd.AddCommand(NewCmdStatOutbound())
 	vizCmd.AddCommand(NewCmdTap())
 	vizCmd.AddCommand(NewCmdTop())
 	vizCmd.AddCommand(newCmdUninstall())
diff --git a/viz/cmd/stat-inbound.go b/viz/cmd/stat-inbound.go
new file mode 100644
index 000000000..62d6a55d8
--- /dev/null
+++ b/viz/cmd/stat-inbound.go
@@ -0,0 +1,410 @@
+package cmd
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"math"
+	"os"
+	"time"
+
+	"github.com/linkerd/linkerd2/cli/table"
+	pkgcmd "github.com/linkerd/linkerd2/pkg/cmd"
+	"github.com/linkerd/linkerd2/pkg/healthcheck"
+	"github.com/linkerd/linkerd2/pkg/k8s"
+	pb "github.com/linkerd/linkerd2/viz/metrics-api/gen/viz"
+	"github.com/linkerd/linkerd2/viz/pkg/api"
+	hc "github.com/linkerd/linkerd2/viz/pkg/healthcheck"
+	"github.com/linkerd/linkerd2/viz/pkg/prometheus"
+	pkgUtil "github.com/linkerd/linkerd2/viz/pkg/util"
+	promv1 "github.com/prometheus/client_golang/api/prometheus/v1"
+	"github.com/prometheus/common/model"
+	log "github.com/sirupsen/logrus"
+	"github.com/spf13/cobra"
+)
+
+type statInboundOptions struct {
+	statOptionsBase
+	prometheusURL string
+}
+
+type inboundRowKey struct {
+	name      string
+	server    string
+	port      string
+	route     string
+	routeType string
+}
+
+type inboundRow struct {
+	successes  uint64
+	failures   uint64
+	latencyP50 float64
+	latencyP95 float64
+	latencyP99 float64
+}
+
+type inboundJsonRow struct {
+	Name        string   `json:"name"`
+	Server      string   `json:"server"`
+	Port        string   `json:"port"`
+	Route       string   `json:"route"`
+	RouteType   string   `json:"routeType"`
+	SuccessRate float64  `json:"successesRate"`
+	RPS         float64  `json:"rps"`
+	LatencyP50  *float64 `json:"latencyMsP50"`
+	LatencyP95  *float64 `json:"latencyMsP95"`
+	LatencyP99  *float64 `json:"latencyMsP99"`
+}
+
+func newStatInboundOptions() *statInboundOptions {
+	return &statInboundOptions{
+		statOptionsBase: *newStatOptionsBase(),
+	}
+}
+
+// NewCmdStatInbound creates a new cobra command `stat-inbound` for inbound stats functionality
+func NewCmdStatInbound() *cobra.Command {
+	options := newStatInboundOptions()
+
+	cmd := &cobra.Command{
+		Use:   "stat-inbound [flags] (RESOURCE)",
+		Short: "Display inbound traffic stats about a resource",
+		Long: `Display inbound traffic stats about a resource.
+
+  The RESOURCE argument specifies the target resource to display stats from:
+  TYPE/NAME
+
+  Examples:
+  * cronjob/my-cronjob
+  * deploy/my-deploy
+  * ds/my-daemonset
+  * job/my-job
+  * ns/my-ns
+  * rc/my-replication-controller
+  * rs/my-replicaset
+  * sts/my-statefulset
+
+  Valid resource types include:
+  * cronjobs
+  * daemonsets
+  * deployments
+  * namespaces
+  * jobs
+  * pods
+  * replicasets
+  * replicationcontrollers
+  * statefulsets`,
+		Args: cobra.MinimumNArgs(1),
+		ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+
+			k8sAPI, err := k8s.NewAPI(kubeconfigPath, kubeContext, impersonate, impersonateGroup, 0)
+			if err != nil {
+				return nil, cobra.ShellCompDirectiveError
+			}
+
+			if options.namespace == "" {
+				options.namespace = pkgcmd.GetDefaultNamespace(kubeconfigPath, kubeContext)
+			}
+
+			cc := k8s.NewCommandCompletion(k8sAPI, options.namespace)
+
+			results, err := cc.Complete(args, toComplete)
+			if err != nil {
+				return nil, cobra.ShellCompDirectiveError
+			}
+
+			return results, cobra.ShellCompDirectiveDefault
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if options.namespace == "" {
+				options.namespace = pkgcmd.GetDefaultNamespace(kubeconfigPath, kubeContext)
+			}
+
+			promApi, err := api.NewPrometheusClient(cmd.Context(), hc.VizOptions{
+				Options: &healthcheck.Options{
+					ControlPlaneNamespace: controlPlaneNamespace,
+					KubeConfig:            kubeconfigPath,
+					Impersonate:           impersonate,
+					ImpersonateGroup:      impersonateGroup,
+					KubeContext:           kubeContext,
+					APIAddr:               apiAddr,
+				},
+				VizNamespaceOverride: vizNamespace,
+			}, options.prometheusURL)
+			if err != nil {
+				return err
+			}
+
+			resource, err := pkgUtil.BuildResource(options.namespace, args[0])
+			if err != nil {
+				return err
+			}
+
+			// Issue Prometheus queries.
+
+			responseChan := queryRate(
+				cmd.Context(),
+				promApi,
+				"response_total",
+				options.timeWindow,
+				model.LabelSet{"direction": "inbound"},
+				model.LabelNames{"srv_name", "srv_kind", "route_name", "route_kind", "classification", "target_port"},
+				resource,
+			)
+
+			quantiles := queryQuantiles(
+				cmd.Context(),
+				promApi,
+				"response_latency_ms_bucket",
+				options.timeWindow,
+				model.LabelSet{"direction": "inbound"},
+				model.LabelNames{"srv_name", "srv_kind", "route_name", "route_kind", "target_port"},
+				resource,
+			)
+
+			// Collect Prometheus results.
+
+			results := make(map[inboundRowKey]inboundRow)
+
+			for sample := range responseChan {
+				labels := sample.Metric
+				key := inboundKeyForSample(sample, resource)
+				row := results[key]
+				if labels["classification"] == "success" {
+					row.successes += uint64(sample.Value)
+				} else {
+					row.failures += uint64(sample.Value)
+				}
+				results[key] = row
+			}
+
+			for quantile, resultsChan := range quantiles {
+				for sample := range resultsChan {
+					key := inboundKeyForSample(sample, resource)
+					row := results[key]
+					row.populateLatency(quantile, sample)
+					results[key] = row
+				}
+			}
+
+			// Render output.
+			timeWindow, err := time.ParseDuration(options.timeWindow)
+			if err != nil {
+				return err
+			}
+			if options.outputFormat == "json" {
+				return renderStatInboundJson(results, timeWindow)
+			} else if options.outputFormat == "table" || options.outputFormat == "" {
+				return renderStatInboundTable(results, timeWindow)
+			} else {
+				return fmt.Errorf("Invalid output format: %s", options.outputFormat)
+			}
+		},
+	}
+
+	cmd.PersistentFlags().StringVarP(&options.outputFormat, "output", "o", options.outputFormat, "Output format; one of: \"table\" or \"json\"")
+	cmd.PersistentFlags().StringVarP(&options.namespace, "namespace", "n", options.namespace, "Namespace")
+	cmd.PersistentFlags().StringVarP(&options.timeWindow, "time-window", "t", options.timeWindow, "Stat window (for example: \"10s\", \"1m\", \"10m\", \"1h\")")
+	cmd.PersistentFlags().StringVar(&options.prometheusURL, "prometheusURL", options.prometheusURL, "Address of Prometheus instance to query")
+
+	return cmd
+}
+
+func queryRate(
+	ctx context.Context,
+	promAPI promv1.API,
+	metric string,
+	timeWindow string,
+	labels model.LabelSet,
+	groupBy model.LabelNames,
+	resource *pb.Resource,
+) <-chan *model.Sample {
+	results := make(chan *model.Sample)
+	go func() {
+		defer close(results)
+		query := fmt.Sprintf("sum(increase(%s%s[%s])) by (%s)",
+			metric,
+			labels.Merge(prometheus.QueryLabels(resource)),
+			timeWindow,
+			append(groupBy, prometheus.GroupByLabelNames(resource)...),
+		)
+		log.Debug(query)
+		val, warn, err := promAPI.Query(ctx, query, time.Time{})
+		if warn != nil {
+			log.Warnf("%v", warn)
+		}
+		if err != nil {
+			fmt.Fprintln(os.Stderr, "failed to query Prometheus: ", err.Error())
+			os.Exit(1)
+		}
+		for _, sample := range val.(model.Vector) {
+			results <- sample
+		}
+	}()
+	return results
+}
+
+func queryQuantiles(
+	ctx context.Context,
+	promAPI promv1.API,
+	metric string,
+	timeWindow string,
+	labels model.LabelSet,
+	groupBy model.LabelNames,
+	resource *pb.Resource,
+) map[string]chan *model.Sample {
+	results := map[string]chan *model.Sample{
+		"0.5":  make(chan *model.Sample),
+		"0.95": make(chan *model.Sample),
+		"0.99": make(chan *model.Sample),
+	}
+	for quantile, resultsChan := range results {
+		go func(quantile string) {
+			defer close(resultsChan)
+			query := fmt.Sprintf("histogram_quantile(%s, sum(irate(%s%s[%s])) by (le, %s))",
+				quantile,
+				metric,
+				labels.Merge(prometheus.QueryLabels(resource)),
+				timeWindow,
+				append(groupBy, prometheus.GroupByLabelNames(resource)...),
+			)
+			log.Debug(query)
+			val, warn, err := promAPI.Query(ctx, query, time.Time{})
+			if warn != nil {
+				log.Warnf("%v", warn)
+			}
+			if err != nil {
+				fmt.Fprintln(os.Stderr, "failed to query Prometheus: ", err.Error())
+				os.Exit(1)
+			}
+			for _, sample := range val.(model.Vector) {
+				resultsChan <- sample
+			}
+		}(quantile)
+	}
+	return results
+}
+
+func inboundKeyForSample(sample *model.Sample, resource *pb.Resource) inboundRowKey {
+	labels := sample.Metric
+	server := (string)(labels["srv_name"])
+	route := (string)(labels["route_name"])
+	routeType := (string)(labels["route_kind"])
+	if labels["srv_kind"] == "default" {
+		server = "[default]"
+	}
+	if labels["route_name"] == "default" {
+		route = "[default]"
+	}
+	if labels["route_kind"] == "default" {
+		routeType = ""
+	}
+
+	return inboundRowKey{
+		name:      (string)(labels[prometheus.ResourceType(resource)]),
+		server:    server,
+		route:     route,
+		port:      (string)(labels["target_port"]),
+		routeType: routeType,
+	}
+}
+
+func formatLatencyMs(value float64) string {
+	latency := "-"
+	if !math.IsNaN(value) {
+		latency = fmt.Sprintf("%.0fms", value)
+	}
+	return latency
+}
+
+func (r *inboundRow) populateLatency(quantile string, sample *model.Sample) {
+	switch quantile {
+	case "0.5":
+		r.latencyP50 = float64(sample.Value)
+	case "0.95":
+		r.latencyP95 = float64(sample.Value)
+	case "0.99":
+		r.latencyP99 = float64(sample.Value)
+	}
+}
+
+func renderStatInboundTable(results map[inboundRowKey]inboundRow, windowLength time.Duration) error {
+	rows := make([][]string, 0)
+
+	for key, row := range results {
+		if row.failures+row.successes == 0 {
+			continue
+		}
+		rows = append(rows, []string{
+			key.name,
+			fmt.Sprintf("%s:%s", key.server, key.port),
+			key.route,
+			key.routeType,
+			fmt.Sprintf("%.2f%%", (float32)(row.successes)/(float32)(row.successes+row.failures)*100.0),
+			fmt.Sprintf("%.2f", (float32)(row.successes+row.failures)/float32(windowLength.Seconds())),
+			formatLatencyMs(row.latencyP50),
+			formatLatencyMs(row.latencyP95),
+			formatLatencyMs(row.latencyP99),
+		})
+	}
+
+	columns := []table.Column{
+		table.NewColumn("NAME").WithLeftAlign(),
+		table.NewColumn("SERVER").WithLeftAlign(),
+		table.NewColumn("ROUTE").WithLeftAlign(),
+		table.NewColumn("TYPE").WithLeftAlign(),
+		table.NewColumn("SUCCESS"),
+		table.NewColumn("RPS"),
+		table.NewColumn("LATENCY_P50"),
+		table.NewColumn("LATENCY_P95"),
+		table.NewColumn("LATENCY_P99"),
+	}
+
+	table := table.NewTable(columns, rows)
+	table.Sort = []int{0, 1, 3} // Name, Server, Route
+	table.Render(os.Stdout)
+
+	return nil
+}
+
+func renderStatInboundJson(results map[inboundRowKey]inboundRow, windowLength time.Duration) error {
+	rows := make([]inboundJsonRow, 0)
+	for key, result := range results {
+		result := result // To avoid golangci-lint complaining about memory aliasing.
+		if result.failures+result.successes == 0 {
+			continue
+		}
+
+		row := inboundJsonRow{
+			Name:        key.name,
+			Server:      key.server,
+			Port:        key.port,
+			Route:       key.route,
+			RouteType:   key.routeType,
+			SuccessRate: float64(result.successes) / float64(result.successes+result.failures),
+			RPS:         float64(result.successes+result.failures) / windowLength.Seconds(),
+			LatencyP50:  &result.latencyP50,
+			LatencyP95:  &result.latencyP95,
+			LatencyP99:  &result.latencyP99,
+		}
+
+		if math.IsNaN(result.latencyP50) {
+			row.LatencyP50 = nil
+		}
+		if math.IsNaN(result.latencyP95) {
+			row.LatencyP95 = nil
+		}
+		if math.IsNaN(result.latencyP99) {
+			row.LatencyP99 = nil
+		}
+
+		rows = append(rows, row)
+	}
+	out, err := json.Marshal(rows)
+	if err != nil {
+		return err
+	}
+	fmt.Println(string(out))
+	return nil
+}
diff --git a/viz/cmd/stat-outbound.go b/viz/cmd/stat-outbound.go
new file mode 100644
index 000000000..185efd1ed
--- /dev/null
+++ b/viz/cmd/stat-outbound.go
@@ -0,0 +1,585 @@
+package cmd
+
+import (
+	"encoding/json"
+	"fmt"
+	"math"
+	"os"
+	"strings"
+	"time"
+
+	"github.com/linkerd/linkerd2/cli/table"
+	pkgcmd "github.com/linkerd/linkerd2/pkg/cmd"
+	"github.com/linkerd/linkerd2/pkg/healthcheck"
+	"github.com/linkerd/linkerd2/pkg/k8s"
+	pb "github.com/linkerd/linkerd2/viz/metrics-api/gen/viz"
+	"github.com/linkerd/linkerd2/viz/pkg/api"
+	hc "github.com/linkerd/linkerd2/viz/pkg/healthcheck"
+	"github.com/linkerd/linkerd2/viz/pkg/prometheus"
+	pkgUtil "github.com/linkerd/linkerd2/viz/pkg/util"
+	"github.com/prometheus/common/model"
+	"github.com/spf13/cobra"
+)
+
+type outboundRowKey struct {
+	name      string
+	service   string
+	port      string
+	route     string
+	routeType string
+}
+
+type outboundBackendRow struct {
+	successes  uint64
+	failures   uint64
+	latencyP50 float64
+	latencyP95 float64
+	latencyP99 float64
+	timeouts   uint64
+}
+
+type backendKey struct {
+	name string
+	port string
+}
+
+type outboundRouteRow struct {
+	outboundBackendRow
+	retries  uint64
+	backends map[backendKey]outboundBackendRow
+}
+
+type outboundJsonRow struct {
+	Name        string   `json:"name"`
+	Service     string   `json:"service"`
+	Port        string   `json:"port"`
+	Route       string   `json:"route"`
+	RouteType   string   `json:"routeType"`
+	Backend     string   `json:"backend,omitempty"`
+	BackendPort string   `json:"backendPort,omitempty"`
+	SuccessRate float64  `json:"successRate"`
+	RPS         float64  `json:"rps"`
+	LatencyP50  *float64 `json:"latencyMsP50"`
+	LatencyP95  *float64 `json:"latencyMsP95"`
+	LatencyP99  *float64 `json:"latencyMsP99"`
+	TimeoutRate float64  `json:"timeoutRate"`
+	RetryRate   *float64 `json:"retryRate,omitempty"`
+}
+
+// NewCmdStatOutbound creates a new cobra command `stat-outbound` for outbound stat functionality
+func NewCmdStatOutbound() *cobra.Command {
+	options := newStatInboundOptions()
+
+	cmd := &cobra.Command{
+		Use:   "stat-outbound [flags] (RESOURCE)",
+		Short: "Display outbound traffic stats about a resource",
+		Long: `Display outbound traffic stats about a resource.
+
+  The RESOURCE argument specifies the target resource to aggregate stats over:
+  TYPE/NAME
+
+  Examples:
+  * cronjob/my-cronjob
+  * deploy/my-deploy
+  * ds/my-daemonset
+  * job/my-job
+  * ns/my-ns
+  * rc/my-replication-controller
+  * rs/my-replicaset
+  * sts/my-statefulset
+
+  Valid resource types include:
+  * cronjobs
+  * daemonsets
+  * deployments
+  * namespaces
+  * jobs
+  * pods
+  * replicasets
+  * replicationcontrollers
+  * statefulsets`,
+		Args: cobra.MinimumNArgs(1),
+		ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+
+			k8sAPI, err := k8s.NewAPI(kubeconfigPath, kubeContext, impersonate, impersonateGroup, 0)
+			if err != nil {
+				return nil, cobra.ShellCompDirectiveError
+			}
+
+			if options.namespace == "" {
+				options.namespace = pkgcmd.GetDefaultNamespace(kubeconfigPath, kubeContext)
+			}
+
+			cc := k8s.NewCommandCompletion(k8sAPI, options.namespace)
+
+			results, err := cc.Complete(args, toComplete)
+			if err != nil {
+				return nil, cobra.ShellCompDirectiveError
+			}
+
+			return results, cobra.ShellCompDirectiveDefault
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if options.namespace == "" {
+				options.namespace = pkgcmd.GetDefaultNamespace(kubeconfigPath, kubeContext)
+			}
+
+			promApi, err := api.NewPrometheusClient(cmd.Context(), hc.VizOptions{
+				Options: &healthcheck.Options{
+					ControlPlaneNamespace: controlPlaneNamespace,
+					KubeConfig:            kubeconfigPath,
+					Impersonate:           impersonate,
+					ImpersonateGroup:      impersonateGroup,
+					KubeContext:           kubeContext,
+					APIAddr:               apiAddr,
+				},
+				VizNamespaceOverride: vizNamespace,
+			}, options.prometheusURL)
+			if err != nil {
+				return err
+			}
+			resource, err := pkgUtil.BuildResource(options.namespace, args[0])
+			if err != nil {
+				return err
+			}
+
+			// Issue Prometheus queries for HTTP.
+
+			httpBackendChan := queryRate(
+				cmd.Context(),
+				promApi,
+				"outbound_http_route_backend_response_statuses_total",
+				options.timeWindow,
+				model.LabelSet{},
+				model.LabelNames{"parent_name", "parent_port", "backend_name", "backend_port", "route_name", "route_kind", "http_status", "error"},
+				resource,
+			)
+
+			httpBackendQuantiles := queryQuantiles(
+				cmd.Context(),
+				promApi,
+				"outbound_http_route_backend_response_duration_seconds_bucket",
+				options.timeWindow,
+				model.LabelSet{},
+				model.LabelNames{"parent_name", "parent_port", "backend_name", "backend_port", "route_name", "route_kind"},
+				resource,
+			)
+
+			httpRouteChan := queryRate(
+				cmd.Context(),
+				promApi,
+				"outbound_http_route_request_statuses_total",
+				options.timeWindow,
+				model.LabelSet{},
+				model.LabelNames{"parent_name", "parent_port", "route_name", "route_kind", "http_status", "error"},
+				resource,
+			)
+
+			httpRetiesChan := queryRate(
+				cmd.Context(),
+				promApi,
+				"outbound_http_route_retry_requests_total",
+				options.timeWindow,
+				model.LabelSet{},
+				model.LabelNames{"parent_name", "parent_port", "route_name", "route_kind"},
+				resource,
+			)
+
+			httpRouteQuantiles := queryQuantiles(
+				cmd.Context(),
+				promApi,
+				"outbound_http_route_request_duration_seconds_bucket",
+				options.timeWindow,
+				model.LabelSet{},
+				model.LabelNames{"parent_name", "parent_port", "route_name", "route_kind"},
+				resource,
+			)
+
+			// Issue Prometheus queries for gRPC.
+
+			grpcBackendChan := queryRate(
+				cmd.Context(),
+				promApi,
+				"outbound_grpc_route_backend_response_statuses_total",
+				options.timeWindow,
+				model.LabelSet{},
+				model.LabelNames{"parent_name", "parent_port", "backend_name", "backend_port", "route_name", "route_kind", "grpc_status", "error"},
+				resource,
+			)
+
+			grpcBackendQuantiles := queryQuantiles(
+				cmd.Context(),
+				promApi,
+				"outbound_grpc_route_backend_response_duration_seconds_bucket",
+				options.timeWindow,
+				model.LabelSet{},
+				model.LabelNames{"parent_name", "parent_port", "backend_name", "backend_port", "route_name", "route_kind"},
+				resource,
+			)
+
+			grpcRouteChan := queryRate(
+				cmd.Context(),
+				promApi,
+				"outbound_grpc_route_request_statuses_total",
+				options.timeWindow,
+				model.LabelSet{},
+				model.LabelNames{"parent_name", "parent_port", "route_name", "route_kind", "grpc_status", "error"},
+				resource,
+			)
+
+			grpcRetiesChan := queryRate(
+				cmd.Context(),
+				promApi,
+				"outbound_grpc_route_retry_requests_total",
+				options.timeWindow,
+				model.LabelSet{},
+				model.LabelNames{"parent_name", "parent_port", "route_name", "route_kind"},
+				resource,
+			)
+
+			grpcRouteQuantiles := queryQuantiles(
+				cmd.Context(),
+				promApi,
+				"outbound_grpc_route_request_duration_seconds_bucket",
+				options.timeWindow,
+				model.LabelSet{},
+				model.LabelNames{"parent_name", "parent_port", "route_name", "route_kind"},
+				resource,
+			)
+
+			// Collect Prometheus results for HTTP.
+
+			results := make(map[outboundRowKey]outboundRouteRow)
+
+			for sample := range httpBackendChan {
+				key := outboundKeyForSample(sample, resource)
+				row := results[key]
+				row.populateBackendHTTPCounts(sample)
+				results[key] = row
+			}
+
+			for quantile, resultsChan := range httpBackendQuantiles {
+				for sample := range resultsChan {
+					key := outboundKeyForSample(sample, resource)
+					row := results[key]
+					row.populateBackendLatency(quantile, sample)
+					results[key] = row
+				}
+			}
+
+			for sample := range httpRouteChan {
+				key := outboundKeyForSample(sample, resource)
+				row := results[key]
+				row.populateHTTPCounts(sample)
+				results[key] = row
+			}
+			for sample := range httpRetiesChan {
+				key := outboundKeyForSample(sample, resource)
+				row := results[key]
+				row.retries += uint64(sample.Value)
+				results[key] = row
+			}
+			for quantile, resultsChan := range httpRouteQuantiles {
+				for sample := range resultsChan {
+					key := outboundKeyForSample(sample, resource)
+					row := results[key]
+					row.populateLatency(quantile, sample)
+					results[key] = row
+				}
+			}
+
+			// Collect Prometheus results for gRPC.
+
+			for sample := range grpcBackendChan {
+				key := outboundKeyForSample(sample, resource)
+				row := results[key]
+				row.populateBackendGRPCCounts(sample)
+				results[key] = row
+			}
+
+			for quantile, resultsChan := range grpcBackendQuantiles {
+				for sample := range resultsChan {
+					key := outboundKeyForSample(sample, resource)
+					row := results[key]
+					row.populateBackendLatency(quantile, sample)
+					results[key] = row
+				}
+			}
+
+			for sample := range grpcRouteChan {
+				key := outboundKeyForSample(sample, resource)
+				row := results[key]
+				row.populateGRPCCounts(sample)
+				results[key] = row
+			}
+			for sample := range grpcRetiesChan {
+				key := outboundKeyForSample(sample, resource)
+				row := results[key]
+				row.retries += uint64(sample.Value)
+				results[key] = row
+			}
+			for quantile, resultsChan := range grpcRouteQuantiles {
+				for sample := range resultsChan {
+					key := outboundKeyForSample(sample, resource)
+					row := results[key]
+					row.populateLatency(quantile, sample)
+					results[key] = row
+				}
+			}
+
+			// Render output.
+
+			windowLength, err := time.ParseDuration(options.timeWindow)
+			if err != nil {
+				return err
+			}
+
+			if options.outputFormat == "json" {
+				return renderStatOutboundJSON(results, windowLength)
+			} else if options.outputFormat == "table" || options.outputFormat == "" {
+				renderStatOutboundTable(results, windowLength)
+				return nil
+			} else {
+				return fmt.Errorf("Invalid output format: %s", options.outputFormat)
+			}
+		},
+	}
+
+	cmd.PersistentFlags().StringVarP(&options.outputFormat, "output", "o", options.outputFormat, "Output format; one of: \"table\" or \"json\"")
+	cmd.PersistentFlags().StringVarP(&options.namespace, "namespace", "n", options.namespace, "Namespace")
+	cmd.PersistentFlags().StringVarP(&options.timeWindow, "time-window", "t", options.timeWindow, "Stat window (for example: \"10s\", \"1m\", \"10m\", \"1h\")")
+	cmd.PersistentFlags().StringVar(&options.prometheusURL, "prometheusURL", options.prometheusURL, "Address of Prometheus instance to query")
+
+	return cmd
+}
+
+func outboundKeyForSample(sample *model.Sample, resource *pb.Resource) outboundRowKey {
+	labels := sample.Metric
+	route := (string)(labels["route_name"])
+	routeType := (string)(labels["route_kind"])
+	if labels["route_name"] == "default" {
+		route = "[default]"
+	}
+	if labels["route_kind"] == "default" {
+		routeType = ""
+		route = "[default]"
+	}
+
+	return outboundRowKey{
+		name:      (string)(labels[prometheus.ResourceType(resource)]),
+		service:   (string)(labels["parent_name"]),
+		port:      (string)(labels["parent_port"]),
+		route:     route,
+		routeType: routeType,
+	}
+}
+
+func (r *outboundBackendRow) populateLatency(quantile string, sample *model.Sample) {
+	switch quantile {
+	case "0.5":
+		r.latencyP50 = float64(sample.Value * 1000)
+	case "0.95":
+		r.latencyP95 = float64(sample.Value * 1000)
+	case "0.99":
+		r.latencyP99 = float64(sample.Value * 1000)
+	}
+}
+
+func (r *outboundRouteRow) populateBackendLatency(quantile string, sample *model.Sample) {
+	key := backendKey{
+		name: (string)(sample.Metric["backend_name"]),
+		port: (string)(sample.Metric["backend_port"]),
+	}
+	backend := r.backends[key]
+	backend.populateLatency(quantile, sample)
+	if r.backends == nil {
+		r.backends = make(map[backendKey]outboundBackendRow)
+	}
+	r.backends[key] = backend
+}
+
+func (r *outboundBackendRow) populateHTTPCounts(sample *model.Sample) {
+	labels := sample.Metric
+	if strings.HasPrefix((string)(labels["http_status"]), "5") || labels["error"] != "" {
+		r.failures += uint64(sample.Value)
+	} else {
+		r.successes += uint64(sample.Value)
+	}
+	if labels["error"] == "RESPONSE_HEADERS_TIMEOUT" || labels["error"] == "REQUEST_TIMEOUT" {
+		r.timeouts += uint64(sample.Value)
+	}
+}
+
+func (r *outboundRouteRow) populateBackendHTTPCounts(sample *model.Sample) {
+	key := backendKey{
+		name: (string)(sample.Metric["backend_name"]),
+		port: (string)(sample.Metric["backend_port"]),
+	}
+	backend := r.backends[key]
+	backend.populateHTTPCounts(sample)
+	if r.backends == nil {
+		r.backends = make(map[backendKey]outboundBackendRow)
+	}
+	r.backends[key] = backend
+}
+
+func (r *outboundBackendRow) populateGRPCCounts(sample *model.Sample) {
+	labels := sample.Metric
+	if labels["grpc_status"] != "OK" || labels["error"] != "" {
+		r.failures += uint64(sample.Value)
+	} else {
+		r.successes += uint64(sample.Value)
+	}
+	if labels["error"] == "RESPONSE_HEADERS_TIMEOUT" || labels["error"] == "REQUEST_TIMEOUT" {
+		r.timeouts += uint64(sample.Value)
+	}
+}
+
+func (r *outboundRouteRow) populateBackendGRPCCounts(sample *model.Sample) {
+	key := backendKey{
+		name: (string)(sample.Metric["backend_name"]),
+		port: (string)(sample.Metric["backend_port"]),
+	}
+	backend := r.backends[key]
+	backend.populateGRPCCounts(sample)
+	if r.backends == nil {
+		r.backends = make(map[backendKey]outboundBackendRow)
+	}
+	r.backends[key] = backend
+}
+
+func renderStatOutboundTable(results map[outboundRowKey]outboundRouteRow, windowLength time.Duration) {
+	rows := make([][]string, 0)
+
+	for key, row := range results {
+		if row.failures+row.successes == 0 {
+			continue
+		}
+		rows = append(rows, []string{
+			key.name,
+			fmt.Sprintf("%s:%s", key.service, key.port),
+			key.route,
+			key.routeType,
+			"", // backend
+			fmt.Sprintf("%.2f%%", (float32)(row.successes)/(float32)(row.successes+row.failures)*100.0),
+			fmt.Sprintf("%.2f", (float32)(row.successes+row.failures)/float32(windowLength.Seconds())),
+			formatLatencyMs(row.latencyP50),
+			formatLatencyMs(row.latencyP95),
+			formatLatencyMs(row.latencyP99),
+			fmt.Sprintf("%.2f%%", (float32)(row.timeouts)/(float32)(row.successes+row.failures)*100.0),
+			fmt.Sprintf("%.2f%%", (float32)(row.retries)/(float32)(row.successes+row.failures+row.retries)*100.0),
+		})
+		for backend, backendRow := range row.backends {
+			rows = append(rows, []string{
+				"",
+				"",
+				"├─",
+				"─►",
+				fmt.Sprintf("%s:%s", backend.name, backend.port),
+				fmt.Sprintf("%.2f%%", (float32)(backendRow.successes)/(float32)(backendRow.successes+backendRow.failures)*100.0),
+				fmt.Sprintf("%.2f", (float32)(backendRow.successes+backendRow.failures)/float32(windowLength.Seconds())),
+				formatLatencyMs(backendRow.latencyP50),
+				formatLatencyMs(backendRow.latencyP95),
+				formatLatencyMs(backendRow.latencyP99),
+				fmt.Sprintf("%.2f%%", (float32)(backendRow.timeouts)/(float32)(backendRow.successes+backendRow.failures)*100.0),
+				"", // retries
+			})
+		}
+		lastBackendLine := rows[len(rows)-1][2]
+		rows[len(rows)-1][2] = strings.Replace(lastBackendLine, "├", "└", 1)
+	}
+
+	columns := []table.Column{
+		table.NewColumn("NAME").WithLeftAlign(),
+		table.NewColumn("SERVICE").WithLeftAlign(),
+		table.NewColumn("ROUTE").WithLeftAlign(),
+		table.NewColumn("TYPE").WithLeftAlign(),
+		table.NewColumn("BACKEND").WithLeftAlign(),
+		table.NewColumn("SUCCESS"),
+		table.NewColumn("RPS"),
+		table.NewColumn("LATENCY_P50"),
+		table.NewColumn("LATENCY_P95"),
+		table.NewColumn("LATENCY_P99"),
+		table.NewColumn("TIMEOUTS"),
+		table.NewColumn("RETRIES"),
+	}
+
+	table := table.NewTable(columns, rows)
+	table.Render(os.Stdout)
+}
+
+func renderStatOutboundJSON(results map[outboundRowKey]outboundRouteRow, windowLength time.Duration) error {
+	rows := make([]outboundJsonRow, 0)
+
+	for key, result := range results {
+		result := result // To avoid golangci-lint complaining about memory aliasing.
+		if result.failures+result.successes == 0 {
+			continue
+		}
+		retryRate := (float64)(result.retries) / (float64)(result.successes+result.failures+result.retries)
+		row := outboundJsonRow{
+			Name:        key.name,
+			Service:     key.service,
+			Port:        key.port,
+			Route:       key.route,
+			RouteType:   key.routeType,
+			SuccessRate: (float64)(result.successes) / (float64)(result.successes+result.failures),
+			RPS:         (float64)(result.successes+result.failures) / windowLength.Seconds(),
+			LatencyP50:  &result.latencyP50,
+			LatencyP95:  &result.latencyP95,
+			LatencyP99:  &result.latencyP99,
+			TimeoutRate: (float64)(result.timeouts) / (float64)(result.successes+result.failures),
+			RetryRate:   &retryRate,
+		}
+		if math.IsNaN(result.latencyP50) {
+			row.LatencyP50 = nil
+		}
+		if math.IsNaN(result.latencyP95) {
+			row.LatencyP95 = nil
+		}
+		if math.IsNaN(result.latencyP99) {
+			row.LatencyP99 = nil
+		}
+
+		rows = append(rows, row)
+
+		for backend, result := range result.backends {
+			result := result // To avoid golangci-lint complaining about memory aliasing.
+			if result.failures+result.successes == 0 {
+				continue
+			}
+			row := outboundJsonRow{
+				Name:        key.name,
+				Service:     key.service,
+				Port:        key.port,
+				Route:       key.route,
+				RouteType:   key.routeType,
+				Backend:     backend.name,
+				BackendPort: backend.port,
+				SuccessRate: (float64)(result.successes) / (float64)(result.successes+result.failures),
+				RPS:         (float64)(result.successes+result.failures) / windowLength.Seconds(),
+				LatencyP50:  &result.latencyP50,
+				LatencyP95:  &result.latencyP95,
+				LatencyP99:  &result.latencyP99,
+				TimeoutRate: (float64)(result.timeouts) / (float64)(result.successes+result.failures),
+			}
+			if math.IsNaN(result.latencyP50) {
+				row.LatencyP50 = nil
+			}
+			if math.IsNaN(result.latencyP95) {
+				row.LatencyP95 = nil
+			}
+			if math.IsNaN(result.latencyP99) {
+				row.LatencyP99 = nil
+			}
+			rows = append(rows, row)
+		}
+	}
+
+	out, err := json.Marshal(rows)
+	if err != nil {
+		return err
+	}
+	fmt.Println(string(out))
+	return nil
+}
diff --git a/viz/metrics-api/authz.go b/viz/metrics-api/authz.go
index b0a318b5f..515a82f0c 100644
--- a/viz/metrics-api/authz.go
+++ b/viz/metrics-api/authz.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 
 	pb "github.com/linkerd/linkerd2/viz/metrics-api/gen/viz"
+	"github.com/linkerd/linkerd2/viz/pkg/prometheus"
 	"github.com/prometheus/common/model"
 	log "github.com/sirupsen/logrus"
 )
@@ -22,13 +23,13 @@ func (s *grpcServer) Authz(ctx context.Context, req *pb.AuthzRequest) (*pb.Authz
 		}, nil
 	}
 
-	labels := promQueryLabels(req.GetResource())
+	labels := prometheus.QueryLabels(req.GetResource())
 	reqLabels := labels.Merge(model.LabelSet{
 		"direction": model.LabelValue("inbound"),
 	})
 
 	groupBy := model.LabelNames{
-		routeKindLabel, routeNameLabel, authorizationKindLabel, authorizationNameLabel, serverKindLabel, serverNameLabel,
+		prometheus.RouteKindLabel, prometheus.RouteNameLabel, prometheus.AuthorizationKindLabel, prometheus.AuthorizationNameLabel, prometheus.ServerKindLabel, prometheus.ServerNameLabel,
 	}
 	promQueries := make(map[promType]string)
 	promQueries[promRequests] = fmt.Sprintf(reqQuery, reqLabels, req.TimeWindow, groupBy.String())
@@ -60,12 +61,12 @@ func (s *grpcServer) Authz(ctx context.Context, req *pb.AuthzRequest) (*pb.Authz
 	for _, result := range results {
 		for _, sample := range result.vec {
 			key := rowKey{
-				routeName:  string(sample.Metric[routeNameLabel]),
-				routeKind:  string(sample.Metric[routeKindLabel]),
-				serverName: string(sample.Metric[serverNameLabel]),
-				serverKind: string(sample.Metric[serverKindLabel]),
-				authzName:  string(sample.Metric[authorizationNameLabel]),
-				authzKind:  string(sample.Metric[authorizationKindLabel]),
+				routeName:  string(sample.Metric[prometheus.RouteNameLabel]),
+				routeKind:  string(sample.Metric[prometheus.RouteKindLabel]),
+				serverName: string(sample.Metric[prometheus.ServerNameLabel]),
+				serverKind: string(sample.Metric[prometheus.ServerKindLabel]),
+				authzName:  string(sample.Metric[prometheus.AuthorizationNameLabel]),
+				authzKind:  string(sample.Metric[prometheus.AuthorizationKindLabel]),
 			}
 			// Get the row if it exists or initialize an empty one
 			row := rows[key]
@@ -75,17 +76,17 @@ func (s *grpcServer) Authz(ctx context.Context, req *pb.AuthzRequest) (*pb.Authz
 					Stats:    &pb.BasicStats{},
 					SrvStats: &pb.ServerStats{
 						Srv: &pb.Resource{
-							Namespace: string(sample.Metric[namespaceLabel]),
+							Namespace: string(sample.Metric[prometheus.NamespaceLabel]),
 							Type:      key.serverKind,
 							Name:      key.serverName,
 						},
 						Route: &pb.Resource{
-							Namespace: string(sample.Metric[namespaceLabel]),
+							Namespace: string(sample.Metric[prometheus.NamespaceLabel]),
 							Type:      key.routeKind,
 							Name:      key.routeName,
 						},
 						Authz: &pb.Resource{
-							Namespace: string(sample.Metric[namespaceLabel]),
+							Namespace: string(sample.Metric[prometheus.NamespaceLabel]),
 							Type:      key.authzKind,
 							Name:      key.authzName,
 						},
diff --git a/viz/metrics-api/edges.go b/viz/metrics-api/edges.go
index 7faa1fbb9..fc0067f16 100644
--- a/viz/metrics-api/edges.go
+++ b/viz/metrics-api/edges.go
@@ -7,6 +7,7 @@ import (
 
 	"github.com/linkerd/linkerd2/pkg/k8s"
 	pb "github.com/linkerd/linkerd2/viz/metrics-api/gen/viz"
+	"github.com/linkerd/linkerd2/viz/pkg/prometheus"
 	"github.com/prometheus/common/model"
 	log "github.com/sirupsen/logrus"
 	v1 "k8s.io/api/core/v1"
@@ -38,7 +39,7 @@ func (s *grpcServer) Edges(ctx context.Context, req *pb.EdgesRequest) (*pb.Edges
 		return edgesError(req, "Edges request missing Selector Resource"), nil
 	}
 
-	resourceType := promResourceType(req.GetSelector().GetResource())
+	resourceType := prometheus.ResourceType(req.GetSelector().GetResource())
 	dstResourceType := "dst_" + resourceType
 	labelsOutbound := promDirectionLabels("outbound")
 	labelsOutboundStr := generateLabelStringWithExclusion(labelsOutbound, string(resourceType), string(dstResourceType))
diff --git a/viz/metrics-api/gateways.go b/viz/metrics-api/gateways.go
index 30377088d..6ee0a3b97 100644
--- a/viz/metrics-api/gateways.go
+++ b/viz/metrics-api/gateways.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/linkerd/linkerd2/pkg/k8s"
 	pb "github.com/linkerd/linkerd2/viz/metrics-api/gen/viz"
+	"github.com/linkerd/linkerd2/viz/pkg/prometheus"
 	"github.com/prometheus/common/model"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -42,14 +43,14 @@ func buildGatewaysRequestLabels(req *pb.GatewaysRequest) (labels model.LabelSet,
 	labels = model.LabelSet{}
 
 	if req.GatewayNamespace != "" {
-		labels[gatewayNamespaceLabel] = model.LabelValue(req.GatewayNamespace)
+		labels[prometheus.GatewayNamespaceLabel] = model.LabelValue(req.GatewayNamespace)
 	}
 
 	if req.RemoteClusterName != "" {
-		labels[remoteClusterNameLabel] = model.LabelValue(req.RemoteClusterName)
+		labels[prometheus.RemoteClusterNameLabel] = model.LabelValue(req.RemoteClusterName)
 	}
 
-	groupBy := model.LabelNames{gatewayNamespaceLabel, remoteClusterNameLabel, gatewayNameLabel}
+	groupBy := model.LabelNames{prometheus.GatewayNamespaceLabel, prometheus.RemoteClusterNameLabel, prometheus.GatewayNameLabel}
 
 	return labels, groupBy
 }
@@ -80,7 +81,7 @@ func processPrometheusResult(results []promResult, numSvcMap map[string]uint64)
 	for _, result := range results {
 		for _, sample := range result.vec {
 
-			clusterName := string(sample.Metric[remoteClusterNameLabel])
+			clusterName := string(sample.Metric[prometheus.RemoteClusterNameLabel])
 			numPairedSvc := numSvcMap[clusterName]
 
 			addRow := func() {
diff --git a/viz/metrics-api/policy.go b/viz/metrics-api/policy.go
index 077f2ab77..0c18fe9c5 100644
--- a/viz/metrics-api/policy.go
+++ b/viz/metrics-api/policy.go
@@ -7,6 +7,7 @@ import (
 
 	"github.com/linkerd/linkerd2/pkg/k8s"
 	pb "github.com/linkerd/linkerd2/viz/metrics-api/gen/viz"
+	"github.com/linkerd/linkerd2/viz/pkg/prometheus"
 	"github.com/prometheus/common/model"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -168,49 +169,49 @@ func (s *grpcServer) getPolicyMetrics(
 func buildServerRequestLabels(req *pb.StatSummaryRequest) (labels model.LabelSet, labelNames model.LabelNames) {
 	if req.GetSelector().GetResource().GetNamespace() != "" {
 		labels = labels.Merge(model.LabelSet{
-			namespaceLabel: model.LabelValue(req.GetSelector().GetResource().GetNamespace()),
+			prometheus.NamespaceLabel: model.LabelValue(req.GetSelector().GetResource().GetNamespace()),
 		})
 	}
 	var groupBy model.LabelNames
 	if req.GetSelector().GetResource().GetType() == k8s.Server {
 		// note that metricToKey assumes the label ordering (..., namespace, name)
-		groupBy = model.LabelNames{serverKindLabel, namespaceLabel, serverNameLabel}
+		groupBy = model.LabelNames{prometheus.ServerKindLabel, prometheus.NamespaceLabel, prometheus.ServerNameLabel}
 		labels = labels.Merge(model.LabelSet{
-			serverKindLabel: model.LabelValue("server"),
+			prometheus.ServerKindLabel: model.LabelValue("server"),
 		})
 		if req.GetSelector().GetResource().GetName() != "" {
 			labels = labels.Merge(model.LabelSet{
-				serverNameLabel: model.LabelValue(req.GetSelector().GetResource().GetName()),
+				prometheus.ServerNameLabel: model.LabelValue(req.GetSelector().GetResource().GetName()),
 			})
 		}
 	} else if req.GetSelector().GetResource().GetType() == k8s.ServerAuthorization {
 		// note that metricToKey assumes the label ordering (..., namespace, name)
-		groupBy = model.LabelNames{namespaceLabel, authorizationNameLabel}
+		groupBy = model.LabelNames{prometheus.NamespaceLabel, prometheus.AuthorizationNameLabel}
 		labels = labels.Merge(model.LabelSet{
-			authorizationKindLabel: model.LabelValue("serverauthorization"),
+			prometheus.AuthorizationKindLabel: model.LabelValue("serverauthorization"),
 		})
 		if req.GetSelector().GetResource().GetName() != "" {
 			labels = labels.Merge(model.LabelSet{
-				authorizationNameLabel: model.LabelValue(req.GetSelector().GetResource().GetName()),
+				prometheus.AuthorizationNameLabel: model.LabelValue(req.GetSelector().GetResource().GetName()),
 			})
 		}
 	} else if req.GetSelector().GetResource().GetType() == k8s.AuthorizationPolicy {
 		// note that metricToKey assumes the label ordering (..., namespace, name)
-		groupBy = model.LabelNames{namespaceLabel, authorizationNameLabel}
+		groupBy = model.LabelNames{prometheus.NamespaceLabel, prometheus.AuthorizationNameLabel}
 		labels = labels.Merge(model.LabelSet{
-			authorizationKindLabel: model.LabelValue("authorizationpolicy"),
+			prometheus.AuthorizationKindLabel: model.LabelValue("authorizationpolicy"),
 		})
 		if req.GetSelector().GetResource().GetName() != "" {
 			labels = labels.Merge(model.LabelSet{
-				authorizationNameLabel: model.LabelValue(req.GetSelector().GetResource().GetName()),
+				prometheus.AuthorizationNameLabel: model.LabelValue(req.GetSelector().GetResource().GetName()),
 			})
 		}
 	} else if req.GetSelector().GetResource().GetType() == k8s.HTTPRoute {
 		// note that metricToKey assumes the label ordering (..., namespace, name)
-		groupBy = model.LabelNames{serverNameLabel, serverKindLabel, routeNameLabel, routeKindLabel, namespaceLabel, routeNameLabel}
+		groupBy = model.LabelNames{prometheus.ServerNameLabel, prometheus.ServerKindLabel, prometheus.RouteNameLabel, prometheus.RouteKindLabel, prometheus.NamespaceLabel, prometheus.RouteNameLabel}
 		if req.GetSelector().GetResource().GetName() != "" {
 			labels = labels.Merge(model.LabelSet{
-				routeNameLabel: model.LabelValue(req.GetSelector().GetResource().GetName()),
+				prometheus.RouteNameLabel: model.LabelValue(req.GetSelector().GetResource().GetName()),
 			})
 		}
 	}
@@ -220,7 +221,7 @@ func buildServerRequestLabels(req *pb.StatSummaryRequest) (labels model.LabelSet
 		// if --to flag is passed, Calculate traffic sent to the policy resource
 		// with additional filtering narrowing down to the workload
 		// it is sent to.
-		labels = labels.Merge(promQueryLabels(out.ToResource))
+		labels = labels.Merge(prometheus.QueryLabels(out.ToResource))
 
 	// No FromResource case as policy metrics are all inbound
 	default:
diff --git a/viz/metrics-api/prometheus.go b/viz/metrics-api/prometheus.go
index 0ee9929bf..a36678624 100644
--- a/viz/metrics-api/prometheus.go
+++ b/viz/metrics-api/prometheus.go
@@ -9,8 +9,6 @@ import (
 	"strings"
 	"time"
 
-	"github.com/linkerd/linkerd2/pkg/k8s"
-	pb "github.com/linkerd/linkerd2/viz/metrics-api/gen/viz"
 	"github.com/prometheus/common/model"
 	log "github.com/sirupsen/logrus"
 	"go.opencensus.io/trace"
@@ -35,19 +33,6 @@ const (
 	promLatencyP50      = promType("0.5")
 	promLatencyP95      = promType("0.95")
 	promLatencyP99      = promType("0.99")
-
-	namespaceLabel         = model.LabelName("namespace")
-	dstNamespaceLabel      = model.LabelName("dst_namespace")
-	gatewayNameLabel       = model.LabelName("gateway_name")
-	gatewayNamespaceLabel  = model.LabelName("gateway_namespace")
-	remoteClusterNameLabel = model.LabelName("target_cluster_name")
-	authorityLabel         = model.LabelName("authority")
-	serverKindLabel        = model.LabelName("srv_kind")
-	serverNameLabel        = model.LabelName("srv_name")
-	authorizationKindLabel = model.LabelName("authz_kind")
-	authorizationNameLabel = model.LabelName("authz_name")
-	routeKindLabel         = model.LabelName("route_kind")
-	routeNameLabel         = model.LabelName("route_name")
 )
 
 var (
@@ -91,74 +76,6 @@ func (s *grpcServer) queryProm(ctx context.Context, query string) (model.Vector,
 	return res.(model.Vector), nil
 }
 
-// add filtering by resource type
-// note that metricToKey assumes the label ordering (namespace, name)
-func promGroupByLabelNames(resource *pb.Resource) model.LabelNames {
-	names := model.LabelNames{namespaceLabel}
-
-	if resource.Type != k8s.Namespace {
-		names = append(names, promResourceType(resource))
-	}
-	return names
-}
-
-// add filtering by resource type
-// note that metricToKey assumes the label ordering (namespace, name)
-func promDstGroupByLabelNames(resource *pb.Resource) model.LabelNames {
-	names := model.LabelNames{dstNamespaceLabel}
-
-	if isNonK8sResourceQuery(resource.GetType()) {
-		names = append(names, promResourceType(resource))
-	} else if resource.Type != k8s.Namespace {
-		names = append(names, "dst_"+promResourceType(resource))
-	}
-	return names
-}
-
-// query a named resource
-func promQueryLabels(resource *pb.Resource) model.LabelSet {
-	set := model.LabelSet{}
-	if resource != nil {
-		if resource.Name != "" {
-			if resource.GetType() == k8s.Server {
-				set[serverKindLabel] = model.LabelValue("server")
-				set[serverNameLabel] = model.LabelValue(resource.GetName())
-			} else if resource.GetType() == k8s.ServerAuthorization {
-				set[authorizationKindLabel] = model.LabelValue("serverauthorization")
-				set[authorizationNameLabel] = model.LabelValue(resource.GetName())
-			} else if resource.GetType() == k8s.AuthorizationPolicy {
-				set[authorizationKindLabel] = model.LabelValue("authorizationpolicy")
-				set[authorizationNameLabel] = model.LabelValue(resource.GetName())
-			} else if resource.GetType() == k8s.HTTPRoute {
-				set[routeNameLabel] = model.LabelValue(resource.GetName())
-			} else if resource.GetType() != k8s.Service {
-				set[promResourceType(resource)] = model.LabelValue(resource.Name)
-			}
-		}
-		if shouldAddNamespaceLabel(resource) {
-			set[namespaceLabel] = model.LabelValue(resource.Namespace)
-		}
-	}
-	return set
-}
-
-// query a named resource
-func promDstQueryLabels(resource *pb.Resource) model.LabelSet {
-	set := model.LabelSet{}
-	if resource.Name != "" {
-		if isNonK8sResourceQuery(resource.GetType()) {
-			set[promResourceType(resource)] = model.LabelValue(resource.Name)
-		} else {
-			set["dst_"+promResourceType(resource)] = model.LabelValue(resource.Name)
-			if shouldAddNamespaceLabel(resource) {
-				set[dstNamespaceLabel] = model.LabelValue(resource.Namespace)
-			}
-		}
-	}
-
-	return set
-}
-
 // insert a not-nil check into a LabelSet to verify that data for a specified
 // label name exists. due to the `!=` this must be inserted as a string. the
 // structure of this code is taken from the Prometheus labelset.go library.
@@ -198,11 +115,6 @@ func generateQuantileQueries(quantileQuery, labels, timeWindow, groupBy string)
 	}
 }
 
-// determine if we should add "namespace=<namespace>" to a named query
-func shouldAddNamespaceLabel(resource *pb.Resource) bool {
-	return resource.Type != k8s.Namespace && resource.Namespace != ""
-}
-
 // query for inbound or outbound requests
 func promDirectionLabels(direction string) model.LabelSet {
 	return model.LabelSet{
@@ -216,11 +128,6 @@ func promPeerLabel(peer string) model.LabelSet {
 	}
 }
 
-func promResourceType(resource *pb.Resource) model.LabelName {
-	l5dLabel := k8s.KindToL5DLabel(resource.Type)
-	return model.LabelName(l5dLabel)
-}
-
 func (s *grpcServer) getPrometheusMetrics(ctx context.Context, requestQueries map[promType]string, latencyQueries map[promType]string) ([]promResult, error) {
 	resultChan := make(chan promResult)
 
diff --git a/viz/metrics-api/stat_summary.go b/viz/metrics-api/stat_summary.go
index fd5827735..f434afa92 100644
--- a/viz/metrics-api/stat_summary.go
+++ b/viz/metrics-api/stat_summary.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/linkerd/linkerd2/pkg/k8s"
 	pb "github.com/linkerd/linkerd2/viz/metrics-api/gen/viz"
+	"github.com/linkerd/linkerd2/viz/pkg/prometheus"
 	vizutil "github.com/linkerd/linkerd2/viz/pkg/util"
 	"github.com/prometheus/common/model"
 	log "github.com/sirupsen/logrus"
@@ -124,7 +125,7 @@ func (s *grpcServer) StatSummary(ctx context.Context, req *pb.StatSummaryRequest
 		statReq.Selector.Resource.Type = resource
 
 		go func() {
-			if isNonK8sResourceQuery(statReq.GetSelector().GetResource().GetType()) {
+			if prometheus.IsNonK8sResourceQuery(statReq.GetSelector().GetResource().GetType()) {
 				resultChan <- s.nonK8sResourceQuery(ctx, statReq)
 			} else if statReq.GetSelector().GetResource().GetType() == k8s.Service {
 				resultChan <- s.serviceResourceQuery(ctx, statReq)
@@ -397,10 +398,6 @@ func (s *grpcServer) nonK8sResourceQuery(ctx context.Context, req *pb.StatSummar
 	return resourceResult{res: &rsp, err: nil}
 }
 
-func isNonK8sResourceQuery(resourceType string) bool {
-	return resourceType == k8s.Authority
-}
-
 // get the list of objects for which we want to return results
 func getResultKeys(
 	req *pb.StatSummaryRequest,
@@ -430,23 +427,23 @@ func buildRequestLabels(req *pb.StatSummaryRequest) (labels model.LabelSet, labe
 
 	switch out := req.Outbound.(type) {
 	case *pb.StatSummaryRequest_ToResource:
-		labelNames = promGroupByLabelNames(req.Selector.Resource)
+		labelNames = prometheus.GroupByLabelNames(req.Selector.Resource)
 
-		labels = labels.Merge(promDstQueryLabels(out.ToResource))
-		labels = labels.Merge(promQueryLabels(req.Selector.Resource))
+		labels = labels.Merge(prometheus.DstQueryLabels(out.ToResource))
+		labels = labels.Merge(prometheus.QueryLabels(req.Selector.Resource))
 		labels = labels.Merge(promDirectionLabels("outbound"))
 
 	case *pb.StatSummaryRequest_FromResource:
-		labelNames = promDstGroupByLabelNames(req.Selector.Resource)
+		labelNames = prometheus.DstGroupByLabelNames(req.Selector.Resource)
 
-		labels = labels.Merge(promQueryLabels(out.FromResource))
-		labels = labels.Merge(promDstQueryLabels(req.Selector.Resource))
+		labels = labels.Merge(prometheus.QueryLabels(out.FromResource))
+		labels = labels.Merge(prometheus.DstQueryLabels(req.Selector.Resource))
 		labels = labels.Merge(promDirectionLabels("outbound"))
 
 	default:
-		labelNames = promGroupByLabelNames(req.Selector.Resource)
+		labelNames = prometheus.GroupByLabelNames(req.Selector.Resource)
 
-		labels = labels.Merge(promQueryLabels(req.Selector.Resource))
+		labels = labels.Merge(prometheus.QueryLabels(req.Selector.Resource))
 		labels = labels.Merge(promDirectionLabels("inbound"))
 	}
 
@@ -466,11 +463,11 @@ func buildServiceRequestLabels(req *pb.StatSummaryRequest) (labels model.LabelSe
 		// if --to flag is passed, Calculate traffic sent to the service
 		// with additional filtering narrowing down to the workload
 		// it is sent to.
-		labels = labels.Merge(promDstQueryLabels(out.ToResource))
+		labels = labels.Merge(prometheus.DstQueryLabels(out.ToResource))
 
 	case *pb.StatSummaryRequest_FromResource:
 		// if --from flag is passed, FromResource is never a service here
-		labels = labels.Merge(promQueryLabels(out.FromResource))
+		labels = labels.Merge(prometheus.QueryLabels(out.FromResource))
 
 	default:
 		// no extra labels needed
@@ -532,7 +529,7 @@ func (s *grpcServer) getServiceMetrics(ctx context.Context, req *pb.StatSummaryR
 	}
 	authority := fmt.Sprintf("%s.%s.svc.%s", service, namespace, s.clusterDomain)
 
-	reqLabels := generateLabelStringWithRegex(labels, string(authorityLabel), authority)
+	reqLabels := generateLabelStringWithRegex(labels, string(prometheus.AuthorityLabel), authority)
 
 	promQueries := map[promType]string{
 		promRequests: fmt.Sprintf(reqQuery, reqLabels, timeWindow, groupBy.String()),
@@ -541,7 +538,7 @@ func (s *grpcServer) getServiceMetrics(ctx context.Context, req *pb.StatSummaryR
 	if req.TcpStats {
 		// Service stats always need to have `peer=dst`, cuz there is no `src` with `authority` label
 		tcpLabels := labels.Merge(promPeerLabel("dst"))
-		tcpLabelString := generateLabelStringWithRegex(tcpLabels, string(authorityLabel), authority)
+		tcpLabelString := generateLabelStringWithRegex(tcpLabels, string(prometheus.AuthorityLabel), authority)
 		promQueries[promTCPConnections] = fmt.Sprintf(tcpConnectionsQuery, tcpLabelString, groupBy.String())
 		promQueries[promTCPReadBytes] = fmt.Sprintf(tcpReadBytesQuery, tcpLabelString, timeWindow, groupBy.String())
 		promQueries[promTCPWriteBytes] = fmt.Sprintf(tcpWriteBytesQuery, tcpLabelString, timeWindow, groupBy.String())
@@ -610,16 +607,16 @@ func processPrometheusMetrics(req *pb.StatSummaryRequest, results []promResult,
 
 			if authzStats[resource] == nil {
 				srv := pb.Resource{
-					Type: string(sample.Metric[serverKindLabel]),
-					Name: string(sample.Metric[serverNameLabel]),
+					Type: string(sample.Metric[prometheus.ServerKindLabel]),
+					Name: string(sample.Metric[prometheus.ServerNameLabel]),
 				}
 				route := pb.Resource{
-					Type: string(sample.Metric[routeKindLabel]),
-					Name: string(sample.Metric[routeNameLabel]),
+					Type: string(sample.Metric[prometheus.RouteKindLabel]),
+					Name: string(sample.Metric[prometheus.RouteNameLabel]),
 				}
 				authz := pb.Resource{
-					Type: string(sample.Metric[authorizationKindLabel]),
-					Name: string(sample.Metric[authorizationNameLabel]),
+					Type: string(sample.Metric[prometheus.AuthorizationKindLabel]),
+					Name: string(sample.Metric[prometheus.AuthorizationNameLabel]),
 				}
 				authzStats[resource] = &pb.ServerStats{
 					Srv:   &srv,
diff --git a/viz/metrics-api/top_routes.go b/viz/metrics-api/top_routes.go
index 13f9c828c..d08753e06 100644
--- a/viz/metrics-api/top_routes.go
+++ b/viz/metrics-api/top_routes.go
@@ -11,6 +11,7 @@ import (
 	api "github.com/linkerd/linkerd2/controller/k8s"
 	"github.com/linkerd/linkerd2/pkg/k8s"
 	pb "github.com/linkerd/linkerd2/viz/metrics-api/gen/viz"
+	"github.com/linkerd/linkerd2/viz/pkg/prometheus"
 	"github.com/prometheus/common/model"
 	log "github.com/sirupsen/logrus"
 	"k8s.io/apimachinery/pkg/labels"
@@ -295,13 +296,13 @@ func (s *grpcServer) buildRouteLabels(req *pb.TopRoutesRequest, dsts []string, r
 	switch req.Outbound.(type) {
 
 	case *pb.TopRoutesRequest_ToResource:
-		labels = labels.Merge(promQueryLabels(resource))
+		labels = labels.Merge(prometheus.QueryLabels(resource))
 		labels = labels.Merge(promDirectionLabels("outbound"))
 		return renderLabels(labels, dsts)
 
 	default:
 		labels = labels.Merge(promDirectionLabels("inbound"))
-		labels = labels.Merge(promQueryLabels(resource))
+		labels = labels.Merge(prometheus.QueryLabels(resource))
 		return renderLabels(labels, dsts)
 	}
 }
diff --git a/viz/pkg/api/api.go b/viz/pkg/api/api.go
index 69592ea65..58fda2a82 100644
--- a/viz/pkg/api/api.go
+++ b/viz/pkg/api/api.go
@@ -1,13 +1,17 @@
 package api
 
 import (
+	"context"
 	"fmt"
 	"os"
 	"time"
 
 	"github.com/linkerd/linkerd2/pkg/healthcheck"
+	"github.com/linkerd/linkerd2/pkg/k8s"
 	pb "github.com/linkerd/linkerd2/viz/metrics-api/gen/viz"
 	vizHealthCheck "github.com/linkerd/linkerd2/viz/pkg/healthcheck"
+	promApi "github.com/prometheus/client_golang/api"
+	promv1 "github.com/prometheus/client_golang/api/prometheus/v1"
 )
 
 // CheckClientOrExit builds a new Viz API client and executes default status
@@ -61,3 +65,45 @@ func exitOnError(result *healthcheck.CheckResult) {
 		os.Exit(1)
 	}
 }
+
+func NewPrometheusClient(ctx context.Context, hcOptions vizHealthCheck.VizOptions, addr string) (promv1.API, error) {
+	if addr == "" {
+		checks := []healthcheck.CategoryID{
+			healthcheck.KubernetesAPIChecks,
+		}
+		hc := vizHealthCheck.NewHealthChecker(checks, &hcOptions)
+		hc.AppendCategories(hc.VizCategory(false))
+		hc.RunChecks(exitOnError)
+
+		if hc.ExternalPrometheusURL() == "" {
+			portforward, err := k8s.NewPortForward(
+				ctx,
+				hc.KubeAPIClient(),
+				hc.VizNamespace(),
+				"prometheus",
+				"localhost",
+				0,
+				9090,
+				false,
+			)
+			if err != nil {
+				return nil, err
+			}
+
+			addr = fmt.Sprintf("http://%s", portforward.AddressAndPort())
+
+			if err = portforward.Init(); err != nil {
+				return nil, err
+			}
+		} else {
+			addr = hc.ExternalPrometheusURL()
+		}
+	}
+
+	promClient, err := promApi.NewClient(promApi.Config{Address: addr})
+	if err != nil {
+		return nil, err
+	}
+
+	return promv1.NewAPI(promClient), nil
+}
diff --git a/viz/pkg/healthcheck/healthcheck.go b/viz/pkg/healthcheck/healthcheck.go
index 6a15a6a27..f94f799fa 100644
--- a/viz/pkg/healthcheck/healthcheck.go
+++ b/viz/pkg/healthcheck/healthcheck.go
@@ -71,6 +71,17 @@ func (hc *HealthChecker) VizAPIClient() pb.ApiClient {
 	return hc.vizAPIClient
 }
 
+// ExternalPrometheusURL returns URL of the external prometheus if one is
+// configured. Otherwise, it returns an empty string.
+func (hc *HealthChecker) ExternalPrometheusURL() string {
+	return hc.externalPrometheusURL
+}
+
+// VizNamespace returns the namespace where the viz extension is installed.
+func (hc *HealthChecker) VizNamespace() string {
+	return hc.vizNamespace
+}
+
 // RunChecks implements the healthcheck.Runner interface
 func (hc *HealthChecker) RunChecks(observer healthcheck.CheckObserver) (bool, bool) {
 	return hc.HealthChecker.RunChecks(observer)
diff --git a/viz/pkg/prometheus/prometheus.go b/viz/pkg/prometheus/prometheus.go
new file mode 100644
index 000000000..6cbe13135
--- /dev/null
+++ b/viz/pkg/prometheus/prometheus.go
@@ -0,0 +1,104 @@
+package prometheus
+
+import (
+	"github.com/linkerd/linkerd2/pkg/k8s"
+	pb "github.com/linkerd/linkerd2/viz/metrics-api/gen/viz"
+	"github.com/prometheus/common/model"
+)
+
+const (
+	NamespaceLabel         = model.LabelName("namespace")
+	DstNamespaceLabel      = model.LabelName("dst_namespace")
+	GatewayNameLabel       = model.LabelName("gateway_name")
+	GatewayNamespaceLabel  = model.LabelName("gateway_namespace")
+	RemoteClusterNameLabel = model.LabelName("target_cluster_name")
+	AuthorityLabel         = model.LabelName("authority")
+	ServerKindLabel        = model.LabelName("srv_kind")
+	ServerNameLabel        = model.LabelName("srv_name")
+	AuthorizationKindLabel = model.LabelName("authz_kind")
+	AuthorizationNameLabel = model.LabelName("authz_name")
+	RouteKindLabel         = model.LabelName("route_kind")
+	RouteNameLabel         = model.LabelName("route_name")
+)
+
+// add filtering by resource type
+// note that metricToKey assumes the label ordering (namespace, name)
+func GroupByLabelNames(resource *pb.Resource) model.LabelNames {
+	names := model.LabelNames{NamespaceLabel}
+
+	if resource.Type != k8s.Namespace {
+		names = append(names, ResourceType(resource))
+	}
+	return names
+}
+
+// query a named resource
+func QueryLabels(resource *pb.Resource) model.LabelSet {
+	set := model.LabelSet{}
+	if resource != nil {
+		if resource.Name != "" {
+			if resource.GetType() == k8s.Server {
+				set[ServerKindLabel] = model.LabelValue("server")
+				set[ServerNameLabel] = model.LabelValue(resource.GetName())
+			} else if resource.GetType() == k8s.ServerAuthorization {
+				set[AuthorizationKindLabel] = model.LabelValue("serverauthorization")
+				set[AuthorizationNameLabel] = model.LabelValue(resource.GetName())
+			} else if resource.GetType() == k8s.AuthorizationPolicy {
+				set[AuthorizationKindLabel] = model.LabelValue("authorizationpolicy")
+				set[AuthorizationNameLabel] = model.LabelValue(resource.GetName())
+			} else if resource.GetType() == k8s.HTTPRoute {
+				set[RouteNameLabel] = model.LabelValue(resource.GetName())
+			} else if resource.GetType() != k8s.Service {
+				set[ResourceType(resource)] = model.LabelValue(resource.Name)
+			}
+		}
+		if shouldAddNamespaceLabel(resource) {
+			set[NamespaceLabel] = model.LabelValue(resource.Namespace)
+		}
+	}
+	return set
+}
+
+// add filtering by resource type
+// note that metricToKey assumes the label ordering (namespace, name)
+func DstGroupByLabelNames(resource *pb.Resource) model.LabelNames {
+	names := model.LabelNames{DstNamespaceLabel}
+
+	if IsNonK8sResourceQuery(resource.GetType()) {
+		names = append(names, ResourceType(resource))
+	} else if resource.Type != k8s.Namespace {
+		names = append(names, "dst_"+ResourceType(resource))
+	}
+	return names
+}
+
+// query a named resource
+func DstQueryLabels(resource *pb.Resource) model.LabelSet {
+	set := model.LabelSet{}
+	if resource.Name != "" {
+		if IsNonK8sResourceQuery(resource.GetType()) {
+			set[ResourceType(resource)] = model.LabelValue(resource.Name)
+		} else {
+			set["dst_"+ResourceType(resource)] = model.LabelValue(resource.Name)
+			if shouldAddNamespaceLabel(resource) {
+				set[DstNamespaceLabel] = model.LabelValue(resource.Namespace)
+			}
+		}
+	}
+
+	return set
+}
+
+func ResourceType(resource *pb.Resource) model.LabelName {
+	l5dLabel := k8s.KindToL5DLabel(resource.Type)
+	return model.LabelName(l5dLabel)
+}
+
+// determine if we should add "namespace=<namespace>" to a named query
+func shouldAddNamespaceLabel(resource *pb.Resource) bool {
+	return resource.Type != k8s.Namespace && resource.Namespace != ""
+}
+
+func IsNonK8sResourceQuery(resourceType string) bool {
+	return resourceType == k8s.Authority
+}
diff --git a/web/app/css/styles.css b/web/app/css/styles.css
index f83795c21..4fc9591ff 100644
--- a/web/app/css/styles.css
+++ b/web/app/css/styles.css
@@ -1,9 +1,6 @@
-/* material-ui uses Roboto */
-@import url("https://fonts.googleapis.com/css?family=Montserrat:400,700|Roboto:100,300,400|Roboto+Mono");
-
 :root {
   --linkblue: #2196f3;
-  --font-stack: 'Roboto', 'Lato', helvetica, arial, sans-serif;
+  --font-stack: 'Roboto', helvetica, arial, sans-serif;
 }
 
 body {
diff --git a/web/app/package.json b/web/app/package.json
index 40fbc480d..156bbb852 100644
--- a/web/app/package.json
+++ b/web/app/package.json
@@ -4,10 +4,10 @@
   "main": "index.js",
   "license": "MIT",
   "dependencies": {
-    "@babel/eslint-plugin": "7.24.7",
+    "@babel/eslint-plugin": "7.25.1",
     "@fortawesome/fontawesome-svg-core": "6.6.0",
     "@fortawesome/free-regular-svg-icons": "6.6.0",
-    "@fortawesome/free-solid-svg-icons": "6.5.2",
+    "@fortawesome/free-solid-svg-icons": "6.6.0",
     "@fortawesome/react-fontawesome": "0.2.2",
     "@lingui/core": "^3.17.1",
     "@lingui/macro": "3.17.2",
@@ -29,7 +29,7 @@
     "make-plural": "^7.4.0",
     "path": "0.12.7",
     "prop-types": "15.8.1",
-    "query-string": "^9.0.0",
+    "query-string": "^9.1.0",
     "react": "16.14.0",
     "react-dom": "16.14.0",
     "react-linkify": "1.0.0-alpha",
@@ -41,12 +41,12 @@
     "whatwg-fetch": "3.6.20"
   },
   "devDependencies": {
-    "@babel/core": "^7.24.9",
-    "@babel/eslint-parser": "^7.25.0",
+    "@babel/core": "^7.25.2",
+    "@babel/eslint-parser": "^7.25.1",
     "@babel/plugin-proposal-class-properties": "^7.17.12",
-    "@babel/preset-env": "^7.25.0",
+    "@babel/preset-env": "^7.25.4",
     "@babel/preset-react": "^7.24.7",
-    "@babel/runtime": "^7.25.0",
+    "@babel/runtime": "^7.25.4",
     "@lingui/cli": "3.17.2",
     "babel-core": "^7.0.0-bridge.0",
     "babel-jest": "^29.7.0",
@@ -63,7 +63,7 @@
     "eslint-config-airbnb": "^19.0.4",
     "eslint-plugin-import": "^2.29.1",
     "eslint-plugin-jsx-a11y": "^6.9.0",
-    "eslint-plugin-promise": "^7.0.0",
+    "eslint-plugin-promise": "^7.1.0",
     "eslint-plugin-react": "^7.35.0",
     "eslint-plugin-react-hooks": "^4.6.2",
     "eslint-webpack-plugin": "^4.2.0",
@@ -78,7 +78,7 @@
     "sinon-stub-promise": "4.0.0",
     "style-loader": "^4.0.0",
     "url-loader": "^4.1.1",
-    "webpack": "^5.93.0",
+    "webpack": "^5.94.0",
     "webpack-bundle-analyzer": "4.10.2",
     "webpack-cli": "5.1.4",
     "webpack-dev-server": "5.0.4"
diff --git a/web/app/yarn.lock b/web/app/yarn.lock
index 7301d6efc..3aecaaac6 100644
--- a/web/app/yarn.lock
+++ b/web/app/yarn.lock
@@ -23,64 +23,54 @@
     "@babel/highlight" "^7.24.7"
     picocolors "^1.0.0"
 
-"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.24.8", "@babel/compat-data@^7.25.0":
-  version "7.25.0"
-  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.25.0.tgz#6b226a5da3a686db3c30519750e071dce292ad95"
-  integrity sha512-P4fwKI2mjEb3ZU5cnMJzvRsRKGBUcs8jvxIoRmr6ufAY9Xk2Bz7JubRTTivkw55c7WQJfTECeqYVa+HZ0FzREg==
+"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.25.2", "@babel/compat-data@^7.25.4":
+  version "7.25.4"
+  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.25.4.tgz#7d2a80ce229890edcf4cc259d4d696cb4dae2fcb"
+  integrity sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==
 
-"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.24.9":
-  version "7.24.9"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.24.9.tgz#dc07c9d307162c97fa9484ea997ade65841c7c82"
-  integrity sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==
+"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.25.2":
+  version "7.25.2"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.25.2.tgz#ed8eec275118d7613e77a352894cd12ded8eba77"
+  integrity sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==
   dependencies:
     "@ampproject/remapping" "^2.2.0"
     "@babel/code-frame" "^7.24.7"
-    "@babel/generator" "^7.24.9"
-    "@babel/helper-compilation-targets" "^7.24.8"
-    "@babel/helper-module-transforms" "^7.24.9"
-    "@babel/helpers" "^7.24.8"
-    "@babel/parser" "^7.24.8"
-    "@babel/template" "^7.24.7"
-    "@babel/traverse" "^7.24.8"
-    "@babel/types" "^7.24.9"
+    "@babel/generator" "^7.25.0"
+    "@babel/helper-compilation-targets" "^7.25.2"
+    "@babel/helper-module-transforms" "^7.25.2"
+    "@babel/helpers" "^7.25.0"
+    "@babel/parser" "^7.25.0"
+    "@babel/template" "^7.25.0"
+    "@babel/traverse" "^7.25.2"
+    "@babel/types" "^7.25.2"
     convert-source-map "^2.0.0"
     debug "^4.1.0"
     gensync "^1.0.0-beta.2"
     json5 "^2.2.3"
     semver "^6.3.1"
 
-"@babel/eslint-parser@^7.25.0":
-  version "7.25.0"
-  resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.25.0.tgz#08ee4d119a86d73365580568626de0b59145af9e"
-  integrity sha512-mlcTKuQAjczDRwWLIxv+Q925jaMUO8Jl5dxmWJSSGVYfZ4rKMp8daQvVC3rM1G2v8V+/fO0yIVTSLS+2zcB8rg==
+"@babel/eslint-parser@^7.25.1":
+  version "7.25.1"
+  resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.25.1.tgz#469cee4bd18a88ff3edbdfbd227bd20e82aa9b82"
+  integrity sha512-Y956ghgTT4j7rKesabkh5WeqgSFZVFwaPR0IWFm7KFHFmmJ4afbG49SmfW4S+GyRPx0Dy5jxEWA5t0rpxfElWg==
   dependencies:
     "@nicolo-ribaudo/eslint-scope-5-internals" "5.1.1-v1"
     eslint-visitor-keys "^2.1.0"
     semver "^6.3.1"
 
-"@babel/eslint-plugin@7.24.7":
-  version "7.24.7"
-  resolved "https://registry.yarnpkg.com/@babel/eslint-plugin/-/eslint-plugin-7.24.7.tgz#ebdab31638cdcc720f0c788813066e9b5c0b3e29"
-  integrity sha512-lODNPJnM+OfcxxBvdmI2YmUeC0fBK3k9yET5O+1Eukr8d5VpO19c6ARtNheE2t2i/8XNYTzp3HeGEAAGZH3nnQ==
+"@babel/eslint-plugin@7.25.1":
+  version "7.25.1"
+  resolved "https://registry.yarnpkg.com/@babel/eslint-plugin/-/eslint-plugin-7.25.1.tgz#fc7fff590ab566c9d643fdecf346b69064157ef9"
+  integrity sha512-jF04YOsrCbEeQk4s+FwsuRddwBiAHooMDG9/nrV83HiYQwEuQppbXTeXyydxCoH5oEWmVBI51wHuZrcIXMkPfw==
   dependencies:
     eslint-rule-composer "^0.3.0"
 
-"@babel/generator@^7.20.14", "@babel/generator@^7.24.8", "@babel/generator@^7.24.9", "@babel/generator@^7.7.2":
-  version "7.24.9"
-  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.9.tgz#5c2575a1070e661bbbc9df82a853989c9a656f12"
-  integrity sha512-G8v3jRg+z8IwY1jHFxvCNhOPYPterE4XljNgdGTYfSTtzzwjIswIzIaSPSLs3R7yFuqnqNeay5rjICfqVr+/6A==
-  dependencies:
-    "@babel/types" "^7.24.9"
-    "@jridgewell/gen-mapping" "^0.3.5"
-    "@jridgewell/trace-mapping" "^0.3.25"
-    jsesc "^2.5.1"
-
-"@babel/generator@^7.25.0":
-  version "7.25.0"
-  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.25.0.tgz#f858ddfa984350bc3d3b7f125073c9af6988f18e"
-  integrity sha512-3LEEcj3PVW8pW2R1SR1M89g/qrYk/m/mB/tLqn7dn4sbBUQyTqnlod+II2U4dqiGtUmkcnAmkMDralTFZttRiw==
+"@babel/generator@^7.20.14", "@babel/generator@^7.25.0", "@babel/generator@^7.25.4", "@babel/generator@^7.7.2":
+  version "7.25.4"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.25.4.tgz#1dc63c1c9caae9e6dc24e264eac254eb25005669"
+  integrity sha512-NFtZmZsyzDPJnk9Zg3BbTfKKc9UlHYzD0E//p2Z3B9nCwwtJW9T0gVbCz8+fBngnn4zf1Dr3IK8PHQQHq0lDQw==
   dependencies:
-    "@babel/types" "^7.25.0"
+    "@babel/types" "^7.25.4"
     "@jridgewell/gen-mapping" "^0.3.5"
     "@jridgewell/trace-mapping" "^0.3.25"
     jsesc "^2.5.1"
@@ -107,12 +97,12 @@
     "@babel/traverse" "^7.24.7"
     "@babel/types" "^7.24.7"
 
-"@babel/helper-compilation-targets@^7.22.6", "@babel/helper-compilation-targets@^7.24.7", "@babel/helper-compilation-targets@^7.24.8":
-  version "7.24.8"
-  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.24.8.tgz#b607c3161cd9d1744977d4f97139572fe778c271"
-  integrity sha512-oU+UoqCHdp+nWVDkpldqIQL/i/bvAv53tRqLG/s+cOXxe66zOYLU7ar/Xs3LdmBihrUMEUhwu6dMZwbNOYDwvw==
+"@babel/helper-compilation-targets@^7.22.6", "@babel/helper-compilation-targets@^7.24.7", "@babel/helper-compilation-targets@^7.24.8", "@babel/helper-compilation-targets@^7.25.2":
+  version "7.25.2"
+  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz#e1d9410a90974a3a5a66e84ff55ef62e3c02d06c"
+  integrity sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==
   dependencies:
-    "@babel/compat-data" "^7.24.8"
+    "@babel/compat-data" "^7.25.2"
     "@babel/helper-validator-option" "^7.24.8"
     browserslist "^4.23.1"
     lru-cache "^5.1.1"
@@ -146,6 +136,19 @@
     "@babel/helper-split-export-declaration" "^7.24.7"
     semver "^6.3.1"
 
+"@babel/helper-create-class-features-plugin@^7.25.4":
+  version "7.25.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.4.tgz#57eaf1af38be4224a9d9dd01ddde05b741f50e14"
+  integrity sha512-ro/bFs3/84MDgDmMwbcHgDa8/E6J3QKNTk4xJJnVeFtGE+tL0K26E3pNxhYz2b67fJpt7Aphw5XcploKXuCvCQ==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.24.7"
+    "@babel/helper-member-expression-to-functions" "^7.24.8"
+    "@babel/helper-optimise-call-expression" "^7.24.7"
+    "@babel/helper-replace-supers" "^7.25.0"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.24.7"
+    "@babel/traverse" "^7.25.4"
+    semver "^6.3.1"
+
 "@babel/helper-create-regexp-features-plugin@^7.18.6":
   version "7.18.6"
   resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.18.6.tgz#3e35f4e04acbbf25f1b3534a657610a000543d3c"
@@ -172,6 +175,15 @@
     regexpu-core "^5.3.1"
     semver "^6.3.1"
 
+"@babel/helper-create-regexp-features-plugin@^7.25.2":
+  version "7.25.2"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.2.tgz#24c75974ed74183797ffd5f134169316cd1808d9"
+  integrity sha512-+wqVGP+DFmqwFD3EH6TMTfUNeqDehV3E/dl+Sd54eaXqm17tEUNbEIn4sVivVowbvUpOtIGxdo3GoXyDH9N/9g==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.24.7"
+    regexpu-core "^5.3.1"
+    semver "^6.3.1"
+
 "@babel/helper-define-polyfill-provider@^0.6.1":
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.1.tgz#fadc63f0c2ff3c8d02ed905dcea747c5b0fb74fd"
@@ -183,6 +195,17 @@
     lodash.debounce "^4.0.8"
     resolve "^1.14.2"
 
+"@babel/helper-define-polyfill-provider@^0.6.2":
+  version "0.6.2"
+  resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.2.tgz#18594f789c3594acb24cfdb4a7f7b7d2e8bd912d"
+  integrity sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==
+  dependencies:
+    "@babel/helper-compilation-targets" "^7.22.6"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    debug "^4.1.1"
+    lodash.debounce "^4.0.8"
+    resolve "^1.14.2"
+
 "@babel/helper-environment-visitor@^7.18.6":
   version "7.18.6"
   resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.6.tgz#b7eee2b5b9d70602e59d1a6cad7dd24de7ca6cd7"
@@ -211,13 +234,6 @@
     "@babel/template" "^7.24.7"
     "@babel/types" "^7.24.7"
 
-"@babel/helper-hoist-variables@^7.24.7":
-  version "7.24.7"
-  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz#b4ede1cde2fd89436397f30dc9376ee06b0f25ee"
-  integrity sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==
-  dependencies:
-    "@babel/types" "^7.24.7"
-
 "@babel/helper-member-expression-to-functions@^7.18.6":
   version "7.18.6"
   resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.6.tgz#44802d7d602c285e1692db0bad9396d007be2afc"
@@ -256,26 +272,15 @@
     "@babel/traverse" "^7.24.7"
     "@babel/types" "^7.24.7"
 
-"@babel/helper-module-transforms@^7.24.7", "@babel/helper-module-transforms@^7.24.8", "@babel/helper-module-transforms@^7.24.9":
-  version "7.24.9"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.24.9.tgz#e13d26306b89eea569180868e652e7f514de9d29"
-  integrity sha512-oYbh+rtFKj/HwBQkFlUzvcybzklmVdVV3UU+mN7n2t/q3yGHbuVdNxyFvSBO1tfvjyArpHNcWMAzsSPdyI46hw==
+"@babel/helper-module-transforms@^7.24.7", "@babel/helper-module-transforms@^7.24.8", "@babel/helper-module-transforms@^7.25.0", "@babel/helper-module-transforms@^7.25.2":
+  version "7.25.2"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz#ee713c29768100f2776edf04d4eb23b8d27a66e6"
+  integrity sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==
   dependencies:
-    "@babel/helper-environment-visitor" "^7.24.7"
     "@babel/helper-module-imports" "^7.24.7"
     "@babel/helper-simple-access" "^7.24.7"
-    "@babel/helper-split-export-declaration" "^7.24.7"
     "@babel/helper-validator-identifier" "^7.24.7"
-
-"@babel/helper-module-transforms@^7.25.0":
-  version "7.25.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.25.0.tgz#3ffc23c473a2769a7e40d3274495bd559fdd2ecc"
-  integrity sha512-bIkOa2ZJYn7FHnepzr5iX9Kmz8FjIz4UKzJ9zhX3dnYuVW0xul9RuR3skBfoLu+FPTQw90EHW9rJsSZhyLQ3fQ==
-  dependencies:
-    "@babel/helper-module-imports" "^7.24.7"
-    "@babel/helper-simple-access" "^7.24.7"
-    "@babel/helper-validator-identifier" "^7.24.7"
-    "@babel/traverse" "^7.25.0"
+    "@babel/traverse" "^7.25.2"
 
 "@babel/helper-optimise-call-expression@^7.18.6":
   version "7.18.6"
@@ -407,13 +412,13 @@
     "@babel/traverse" "^7.25.0"
     "@babel/types" "^7.25.0"
 
-"@babel/helpers@^7.24.8":
-  version "7.24.8"
-  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.24.8.tgz#2820d64d5d6686cca8789dd15b074cd862795873"
-  integrity sha512-gV2265Nkcz7weJJfvDoAEVzC1e2OTDpkGbEsebse8koXUJUXPsCMi7sRo/+SPMuMZ9MtUPnGwITTnQnU5YjyaQ==
+"@babel/helpers@^7.25.0":
+  version "7.25.0"
+  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.25.0.tgz#e69beb7841cb93a6505531ede34f34e6a073650a"
+  integrity sha512-MjgLZ42aCm0oGjJj8CtSM3DB8NOOf8h2l7DCTePJs29u+v7yO/RBX9nShlKMgFnRks/Q4tBAe7Hxnov9VkGwLw==
   dependencies:
-    "@babel/template" "^7.24.7"
-    "@babel/types" "^7.24.8"
+    "@babel/template" "^7.25.0"
+    "@babel/types" "^7.25.0"
 
 "@babel/highlight@^7.24.7":
   version "7.24.7"
@@ -425,23 +430,20 @@
     js-tokens "^4.0.0"
     picocolors "^1.0.0"
 
-"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.15", "@babel/parser@^7.24.7", "@babel/parser@^7.24.8":
-  version "7.24.8"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.8.tgz#58a4dbbcad7eb1d48930524a3fd93d93e9084c6f"
-  integrity sha512-WzfbgXOkGzZiXXCqk43kKwZjzwx4oulxZi3nq2TYL9mOjQv6kYwul9mz6ID36njuL7Xkp6nJEfok848Zj10j/w==
-
-"@babel/parser@^7.25.0":
-  version "7.25.0"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.25.0.tgz#9fdc9237504d797b6e7b8f66e78ea7f570d256ad"
-  integrity sha512-CzdIU9jdP0dg7HdyB+bHvDJGagUv+qtzZt5rYCWwW6tITNqV9odjp6Qu41gkG0ca5UfdDUWrKkiAnHHdGRnOrA==
+"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.15", "@babel/parser@^7.25.0", "@babel/parser@^7.25.4":
+  version "7.25.4"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.25.4.tgz#af4f2df7d02440286b7de57b1c21acfb2a6f257a"
+  integrity sha512-nq+eWrOgdtu3jG5Os4TQP3x3cLA8hR8TvJNjD8vnPa20WGycimcparWnLK4jJhElTK6SDyuJo1weMKO/5LpmLA==
+  dependencies:
+    "@babel/types" "^7.25.4"
 
-"@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.25.0":
-  version "7.25.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.0.tgz#328275f22d809b962978d998c6eba22a233ac8aa"
-  integrity sha512-dG0aApncVQwAUJa8tP1VHTnmU67BeIQvKafd3raEx315H54FfkZSz3B/TT+33ZQAjatGJA79gZqTtqL5QZUKXw==
+"@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.25.3":
+  version "7.25.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.3.tgz#dca427b45a6c0f5c095a1c639dfe2476a3daba7f"
+  integrity sha512-wUrcsxZg6rqBXG05HG1FPYgsP6EvwF4WpBbxIpWIIYnH8wG0gzx3yZY3dtEHas4sTAOGkbTsc9EGPxwff8lRoA==
   dependencies:
     "@babel/helper-plugin-utils" "^7.24.8"
-    "@babel/traverse" "^7.25.0"
+    "@babel/traverse" "^7.25.3"
 
 "@babel/plugin-bugfix-safari-class-field-initializer-scope@^7.25.0":
   version "7.25.0"
@@ -649,15 +651,15 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.24.7"
 
-"@babel/plugin-transform-async-generator-functions@^7.25.0":
-  version "7.25.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.0.tgz#b785cf35d73437f6276b1e30439a57a50747bddf"
-  integrity sha512-uaIi2FdqzjpAMvVqvB51S42oC2JEVgh0LDsGfZVDysWE8LrJtQC2jvKmOqEYThKyB7bDEb7BP1GYWDm7tABA0Q==
+"@babel/plugin-transform-async-generator-functions@^7.25.4":
+  version "7.25.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.4.tgz#2afd4e639e2d055776c9f091b6c0c180ed8cf083"
+  integrity sha512-jz8cV2XDDTqjKPwVPJBIjORVEmSGYhdRa8e5k5+vN+uwcjSrSxUaebBRa4ko1jqNF2uxyg8G6XYk30Jv285xzg==
   dependencies:
     "@babel/helper-plugin-utils" "^7.24.8"
     "@babel/helper-remap-async-to-generator" "^7.25.0"
     "@babel/plugin-syntax-async-generators" "^7.8.4"
-    "@babel/traverse" "^7.25.0"
+    "@babel/traverse" "^7.25.4"
 
 "@babel/plugin-transform-async-to-generator@^7.24.7":
   version "7.24.7"
@@ -682,13 +684,13 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.24.8"
 
-"@babel/plugin-transform-class-properties@^7.24.7":
-  version "7.24.7"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.24.7.tgz#256879467b57b0b68c7ddfc5b76584f398cd6834"
-  integrity sha512-vKbfawVYayKcSeSR5YYzzyXvsDFWU2mD8U5TFeXtbCPLFUqe7GyCgvO6XDHzje862ODrOwy6WCPmKeWHbCFJ4w==
+"@babel/plugin-transform-class-properties@^7.25.4":
+  version "7.25.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.4.tgz#bae7dbfcdcc2e8667355cd1fb5eda298f05189fd"
+  integrity sha512-nZeZHyCWPfjkdU5pA/uHiTaDAFUEqkpzf1YoQT2NeSynCGYq9rxfyI3XpQbfx/a0hSnFH6TGlEXvae5Vi7GD8g==
   dependencies:
-    "@babel/helper-create-class-features-plugin" "^7.24.7"
-    "@babel/helper-plugin-utils" "^7.24.7"
+    "@babel/helper-create-class-features-plugin" "^7.25.4"
+    "@babel/helper-plugin-utils" "^7.24.8"
 
 "@babel/plugin-transform-class-static-block@^7.24.7":
   version "7.24.7"
@@ -699,16 +701,16 @@
     "@babel/helper-plugin-utils" "^7.24.7"
     "@babel/plugin-syntax-class-static-block" "^7.14.5"
 
-"@babel/plugin-transform-classes@^7.25.0":
-  version "7.25.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.0.tgz#63122366527d88e0ef61b612554fe3f8c793991e"
-  integrity sha512-xyi6qjr/fYU304fiRwFbekzkqVJZ6A7hOjWZd+89FVcBqPV3S9Wuozz82xdpLspckeaafntbzglaW4pqpzvtSw==
+"@babel/plugin-transform-classes@^7.25.4":
+  version "7.25.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.4.tgz#d29dbb6a72d79f359952ad0b66d88518d65ef89a"
+  integrity sha512-oexUfaQle2pF/b6E0dwsxQtAol9TLSO88kQvym6HHBWFliV2lGdrPieX+WgMRLSJDVzdYywk7jXbLPuO2KLTLg==
   dependencies:
     "@babel/helper-annotate-as-pure" "^7.24.7"
-    "@babel/helper-compilation-targets" "^7.24.8"
+    "@babel/helper-compilation-targets" "^7.25.2"
     "@babel/helper-plugin-utils" "^7.24.8"
     "@babel/helper-replace-supers" "^7.25.0"
-    "@babel/traverse" "^7.25.0"
+    "@babel/traverse" "^7.25.4"
     globals "^11.1.0"
 
 "@babel/plugin-transform-computed-properties@^7.24.7":
@@ -781,7 +783,7 @@
     "@babel/helper-plugin-utils" "^7.24.7"
     "@babel/helper-skip-transparent-expression-wrappers" "^7.24.7"
 
-"@babel/plugin-transform-function-name@^7.25.0":
+"@babel/plugin-transform-function-name@^7.25.1":
   version "7.25.1"
   resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.1.tgz#b85e773097526c1a4fc4ba27322748643f26fc37"
   integrity sha512-TVVJVdW9RKMNgJJlLtHsKDTydjZAbwIsn6ySBPQaEAUU5+gVvlJt/9nRmqVbsV/IBanRjzWoaAQKLoamWVOUuA==
@@ -798,12 +800,12 @@
     "@babel/helper-plugin-utils" "^7.24.7"
     "@babel/plugin-syntax-json-strings" "^7.8.3"
 
-"@babel/plugin-transform-literals@^7.24.7":
-  version "7.24.7"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.24.7.tgz#36b505c1e655151a9d7607799a9988fc5467d06c"
-  integrity sha512-vcwCbb4HDH+hWi8Pqenwnjy+UiklO4Kt1vfspcQYFhJdpthSnW8XvWGyDZWKNVrVbVViI/S7K9PDJZiUmP2fYQ==
+"@babel/plugin-transform-literals@^7.25.2":
+  version "7.25.2"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.2.tgz#deb1ad14fc5490b9a65ed830e025bca849d8b5f3"
+  integrity sha512-HQI+HcTbm9ur3Z2DkO+jgESMAMcYLuN/A7NRw9juzxAezN9AvqvUTnpKP/9kkYANz6u7dFlAyOu44ejuGySlfw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.24.7"
+    "@babel/helper-plugin-utils" "^7.24.8"
 
 "@babel/plugin-transform-logical-assignment-operators@^7.24.7":
   version "7.24.7"
@@ -928,13 +930,13 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.24.7"
 
-"@babel/plugin-transform-private-methods@^7.24.7":
-  version "7.24.7"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.24.7.tgz#e6318746b2ae70a59d023d5cc1344a2ba7a75f5e"
-  integrity sha512-COTCOkG2hn4JKGEKBADkA8WNb35TGkkRbI5iT845dB+NyqgO8Hn+ajPbSnIQznneJTa3d30scb6iz/DhH8GsJQ==
+"@babel/plugin-transform-private-methods@^7.25.4":
+  version "7.25.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.4.tgz#9bbefbe3649f470d681997e0b64a4b254d877242"
+  integrity sha512-ao8BG7E2b/URaUQGqN3Tlsg+M3KlHY6rJ1O1gXAEUnZoyNQnvKyH87Kfg+FoxSeyWUB8ISZZsC91C44ZuBFytw==
   dependencies:
-    "@babel/helper-create-class-features-plugin" "^7.24.7"
-    "@babel/helper-plugin-utils" "^7.24.7"
+    "@babel/helper-create-class-features-plugin" "^7.25.4"
+    "@babel/helper-plugin-utils" "^7.24.8"
 
 "@babel/plugin-transform-private-property-in-object@^7.24.7":
   version "7.24.7"
@@ -1060,24 +1062,24 @@
     "@babel/helper-create-regexp-features-plugin" "^7.24.7"
     "@babel/helper-plugin-utils" "^7.24.7"
 
-"@babel/plugin-transform-unicode-sets-regex@^7.24.7":
-  version "7.24.7"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.24.7.tgz#d40705d67523803a576e29c63cef6e516b858ed9"
-  integrity sha512-2G8aAvF4wy1w/AGZkemprdGMRg5o6zPNhbHVImRz3lss55TYCBd6xStN19rt8XJHq20sqV0JbyWjOWwQRwV/wg==
+"@babel/plugin-transform-unicode-sets-regex@^7.25.4":
+  version "7.25.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.4.tgz#be664c2a0697ffacd3423595d5edef6049e8946c"
+  integrity sha512-qesBxiWkgN1Q+31xUE9RcMk79eOXXDCv6tfyGMRSs4RGlioSg2WVyQAm07k726cSE56pa+Kb0y9epX2qaXzTvA==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.24.7"
-    "@babel/helper-plugin-utils" "^7.24.7"
+    "@babel/helper-create-regexp-features-plugin" "^7.25.2"
+    "@babel/helper-plugin-utils" "^7.24.8"
 
-"@babel/preset-env@^7.25.0":
-  version "7.25.0"
-  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.25.0.tgz#3fe92e470311e91478129efda101816c680f0479"
-  integrity sha512-vYAA8PrCOeZfG4D87hmw1KJ1BPubghXP1e2MacRFwECGNKL76dkA38JEwYllbvQCpf/kLxsTtir0b8MtxKoVCw==
+"@babel/preset-env@^7.25.4":
+  version "7.25.4"
+  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.25.4.tgz#be23043d43a34a2721cd0f676c7ba6f1481f6af6"
+  integrity sha512-W9Gyo+KmcxjGahtt3t9fb14vFRWvPpu5pT6GBlovAK6BTBcxgjfVMSQCfJl4oi35ODrxP6xx2Wr8LNST57Mraw==
   dependencies:
-    "@babel/compat-data" "^7.25.0"
-    "@babel/helper-compilation-targets" "^7.24.8"
+    "@babel/compat-data" "^7.25.4"
+    "@babel/helper-compilation-targets" "^7.25.2"
     "@babel/helper-plugin-utils" "^7.24.8"
     "@babel/helper-validator-option" "^7.24.8"
-    "@babel/plugin-bugfix-firefox-class-in-computed-class-key" "^7.25.0"
+    "@babel/plugin-bugfix-firefox-class-in-computed-class-key" "^7.25.3"
     "@babel/plugin-bugfix-safari-class-field-initializer-scope" "^7.25.0"
     "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.25.0"
     "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.24.7"
@@ -1102,13 +1104,13 @@
     "@babel/plugin-syntax-top-level-await" "^7.14.5"
     "@babel/plugin-syntax-unicode-sets-regex" "^7.18.6"
     "@babel/plugin-transform-arrow-functions" "^7.24.7"
-    "@babel/plugin-transform-async-generator-functions" "^7.25.0"
+    "@babel/plugin-transform-async-generator-functions" "^7.25.4"
     "@babel/plugin-transform-async-to-generator" "^7.24.7"
     "@babel/plugin-transform-block-scoped-functions" "^7.24.7"
     "@babel/plugin-transform-block-scoping" "^7.25.0"
-    "@babel/plugin-transform-class-properties" "^7.24.7"
+    "@babel/plugin-transform-class-properties" "^7.25.4"
     "@babel/plugin-transform-class-static-block" "^7.24.7"
-    "@babel/plugin-transform-classes" "^7.25.0"
+    "@babel/plugin-transform-classes" "^7.25.4"
     "@babel/plugin-transform-computed-properties" "^7.24.7"
     "@babel/plugin-transform-destructuring" "^7.24.8"
     "@babel/plugin-transform-dotall-regex" "^7.24.7"
@@ -1118,9 +1120,9 @@
     "@babel/plugin-transform-exponentiation-operator" "^7.24.7"
     "@babel/plugin-transform-export-namespace-from" "^7.24.7"
     "@babel/plugin-transform-for-of" "^7.24.7"
-    "@babel/plugin-transform-function-name" "^7.25.0"
+    "@babel/plugin-transform-function-name" "^7.25.1"
     "@babel/plugin-transform-json-strings" "^7.24.7"
-    "@babel/plugin-transform-literals" "^7.24.7"
+    "@babel/plugin-transform-literals" "^7.25.2"
     "@babel/plugin-transform-logical-assignment-operators" "^7.24.7"
     "@babel/plugin-transform-member-expression-literals" "^7.24.7"
     "@babel/plugin-transform-modules-amd" "^7.24.7"
@@ -1136,7 +1138,7 @@
     "@babel/plugin-transform-optional-catch-binding" "^7.24.7"
     "@babel/plugin-transform-optional-chaining" "^7.24.8"
     "@babel/plugin-transform-parameters" "^7.24.7"
-    "@babel/plugin-transform-private-methods" "^7.24.7"
+    "@babel/plugin-transform-private-methods" "^7.25.4"
     "@babel/plugin-transform-private-property-in-object" "^7.24.7"
     "@babel/plugin-transform-property-literals" "^7.24.7"
     "@babel/plugin-transform-regenerator" "^7.24.7"
@@ -1149,10 +1151,10 @@
     "@babel/plugin-transform-unicode-escapes" "^7.24.7"
     "@babel/plugin-transform-unicode-property-regex" "^7.24.7"
     "@babel/plugin-transform-unicode-regex" "^7.24.7"
-    "@babel/plugin-transform-unicode-sets-regex" "^7.24.7"
+    "@babel/plugin-transform-unicode-sets-regex" "^7.25.4"
     "@babel/preset-modules" "0.1.6-no-external-plugins"
     babel-plugin-polyfill-corejs2 "^0.4.10"
-    babel-plugin-polyfill-corejs3 "^0.10.4"
+    babel-plugin-polyfill-corejs3 "^0.10.6"
     babel-plugin-polyfill-regenerator "^0.6.1"
     core-js-compat "^3.37.1"
     semver "^6.3.1"
@@ -1183,23 +1185,14 @@
   resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310"
   integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==
 
-"@babel/runtime@^7.1.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.20.13", "@babel/runtime@^7.21.0", "@babel/runtime@^7.25.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7":
-  version "7.25.0"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.0.tgz#3af9a91c1b739c569d5d80cc917280919c544ecb"
-  integrity sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==
+"@babel/runtime@^7.1.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.20.13", "@babel/runtime@^7.21.0", "@babel/runtime@^7.25.4", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7":
+  version "7.25.4"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.4.tgz#6ef37d678428306e7d75f054d5b1bdb8cf8aa8ee"
+  integrity sha512-DSgLeL/FNcpXuzav5wfYvHCGvynXkJbn3Zvc3823AEe9nPwW9IK4UoCSS5yGymmQzN0pCPvivtgS6/8U2kkm1w==
   dependencies:
     regenerator-runtime "^0.14.0"
 
-"@babel/template@^7.18.6", "@babel/template@^7.24.7", "@babel/template@^7.3.3":
-  version "7.24.7"
-  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.7.tgz#02efcee317d0609d2c07117cb70ef8fb17ab7315"
-  integrity sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==
-  dependencies:
-    "@babel/code-frame" "^7.24.7"
-    "@babel/parser" "^7.24.7"
-    "@babel/types" "^7.24.7"
-
-"@babel/template@^7.25.0":
+"@babel/template@^7.18.6", "@babel/template@^7.24.7", "@babel/template@^7.25.0", "@babel/template@^7.3.3":
   version "7.25.0"
   resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.25.0.tgz#e733dc3134b4fede528c15bc95e89cb98c52592a"
   integrity sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==
@@ -1208,48 +1201,23 @@
     "@babel/parser" "^7.25.0"
     "@babel/types" "^7.25.0"
 
-"@babel/traverse@^7.18.6", "@babel/traverse@^7.24.7", "@babel/traverse@^7.24.8":
-  version "7.24.8"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.8.tgz#6c14ed5232b7549df3371d820fbd9abfcd7dfab7"
-  integrity sha512-t0P1xxAPzEDcEPmjprAQq19NWum4K0EQPjMwZQZbHt+GiZqvjCHjj755Weq1YRPVzBI+3zSfvScfpnuIecVFJQ==
+"@babel/traverse@^7.18.6", "@babel/traverse@^7.24.7", "@babel/traverse@^7.24.8", "@babel/traverse@^7.25.0", "@babel/traverse@^7.25.1", "@babel/traverse@^7.25.2", "@babel/traverse@^7.25.3", "@babel/traverse@^7.25.4":
+  version "7.25.4"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.4.tgz#648678046990f2957407e3086e97044f13c3e18e"
+  integrity sha512-VJ4XsrD+nOvlXyLzmLzUs/0qjFS4sK30te5yEFlvbbUNEgKaVb2BHZUpAL+ttLPQAHNrsI3zZisbfha5Cvr8vg==
   dependencies:
     "@babel/code-frame" "^7.24.7"
-    "@babel/generator" "^7.24.8"
-    "@babel/helper-environment-visitor" "^7.24.7"
-    "@babel/helper-function-name" "^7.24.7"
-    "@babel/helper-hoist-variables" "^7.24.7"
-    "@babel/helper-split-export-declaration" "^7.24.7"
-    "@babel/parser" "^7.24.8"
-    "@babel/types" "^7.24.8"
-    debug "^4.3.1"
-    globals "^11.1.0"
-
-"@babel/traverse@^7.25.0", "@babel/traverse@^7.25.1":
-  version "7.25.1"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.1.tgz#64dbc31effc5f3fa3cf10d19df0e6310214743f5"
-  integrity sha512-LrHHoWq08ZpmmFqBAzN+hUdWwy5zt7FGa/hVwMcOqW6OVtwqaoD5utfuGYU87JYxdZgLUvktAsn37j/sYR9siA==
-  dependencies:
-    "@babel/code-frame" "^7.24.7"
-    "@babel/generator" "^7.25.0"
-    "@babel/parser" "^7.25.0"
+    "@babel/generator" "^7.25.4"
+    "@babel/parser" "^7.25.4"
     "@babel/template" "^7.25.0"
-    "@babel/types" "^7.25.0"
+    "@babel/types" "^7.25.4"
     debug "^4.3.1"
     globals "^11.1.0"
 
-"@babel/types@^7.0.0", "@babel/types@^7.18.6", "@babel/types@^7.20.7", "@babel/types@^7.22.5", "@babel/types@^7.24.7", "@babel/types@^7.24.8", "@babel/types@^7.24.9", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4":
-  version "7.24.9"
-  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.9.tgz#228ce953d7b0d16646e755acf204f4cf3d08cc73"
-  integrity sha512-xm8XrMKz0IlUdocVbYJe0Z9xEgidU7msskG8BbhnTPK/HZ2z/7FP7ykqPgrUH+C+r414mNfNWam1f2vqOjqjYQ==
-  dependencies:
-    "@babel/helper-string-parser" "^7.24.8"
-    "@babel/helper-validator-identifier" "^7.24.7"
-    to-fast-properties "^2.0.0"
-
-"@babel/types@^7.25.0":
-  version "7.25.0"
-  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.25.0.tgz#e6e3656c581f28da8452ed4f69e38008ec0ba41b"
-  integrity sha512-LcnxQSsd9aXOIgmmSpvZ/1yo46ra2ESYyqLcryaBZOghxy5qqOBjvCWP5JfkI8yl9rlxRgdLTTMCQQRcN2hdCg==
+"@babel/types@^7.0.0", "@babel/types@^7.18.6", "@babel/types@^7.20.7", "@babel/types@^7.22.5", "@babel/types@^7.24.7", "@babel/types@^7.24.8", "@babel/types@^7.25.0", "@babel/types@^7.25.2", "@babel/types@^7.25.4", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4":
+  version "7.25.4"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.25.4.tgz#6bcb46c72fdf1012a209d016c07f769e10adcb5f"
+  integrity sha512-zQ1ijeeCXVEh+aNL0RlmkPkG8HUiDcU2pzQQFjtbntgAczRASFzj4H+6+bV+dy1ntKR14I/DypeuRG1uma98iQ==
   dependencies:
     "@babel/helper-string-parser" "^7.24.8"
     "@babel/helper-validator-identifier" "^7.24.7"
@@ -1302,11 +1270,6 @@
   resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.0.tgz#a5417ae8427873f1dd08b70b3574b453e67b5f7f"
   integrity sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==
 
-"@fortawesome/fontawesome-common-types@6.5.2":
-  version "6.5.2"
-  resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.2.tgz#eaf2f5699f73cef198454ebc0c414e3688898179"
-  integrity sha512-gBxPg3aVO6J0kpfHNILc+NMhXnqHumFxOmjYCFfOiLZfwhnnfhtsdA2hfJlDnj+8PjAs6kKQPenOTKj3Rf7zHw==
-
 "@fortawesome/fontawesome-common-types@6.6.0":
   version "6.6.0"
   resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz#31ab07ca6a06358c5de4d295d4711b675006163f"
@@ -1326,12 +1289,12 @@
   dependencies:
     "@fortawesome/fontawesome-common-types" "6.6.0"
 
-"@fortawesome/free-solid-svg-icons@6.5.2":
-  version "6.5.2"
-  resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.2.tgz#9b40b077b27400a5e9fcbf2d15b986c7be69e9ca"
-  integrity sha512-QWFZYXFE7O1Gr1dTIp+D6UcFUF0qElOnZptpi7PBUMylJh+vFmIedVe1Ir6RM1t2tEQLLSV1k7bR4o92M+uqlw==
+"@fortawesome/free-solid-svg-icons@6.6.0":
+  version "6.6.0"
+  resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz#061751ca43be4c4d814f0adbda8f006164ec9f3b"
+  integrity sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==
   dependencies:
-    "@fortawesome/fontawesome-common-types" "6.5.2"
+    "@fortawesome/fontawesome-common-types" "6.6.0"
 
 "@fortawesome/react-fontawesome@0.2.2":
   version "0.2.2"
@@ -2028,15 +1991,7 @@
   dependencies:
     "@types/node" "*"
 
-"@types/eslint-scope@^3.7.3":
-  version "3.7.3"
-  resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.3.tgz#125b88504b61e3c8bc6f870882003253005c3224"
-  integrity sha512-PB3ldyrcnAicT35TWPs5IcwKD8S333HMaa2VVv4+wdvebJkjWuW/xESoB8IwRcog8HYVYamb1g/R31Qv5Bx03g==
-  dependencies:
-    "@types/eslint" "*"
-    "@types/estree" "*"
-
-"@types/eslint@*", "@types/eslint@^8.56.10":
+"@types/eslint@^8.56.10":
   version "8.56.10"
   resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.56.10.tgz#eb2370a73bf04a901eeba8f22595c7ee0f7eb58d"
   integrity sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==
@@ -2903,13 +2858,13 @@ babel-plugin-polyfill-corejs2@^0.4.10:
     "@babel/helper-define-polyfill-provider" "^0.6.1"
     semver "^6.3.1"
 
-babel-plugin-polyfill-corejs3@^0.10.4:
-  version "0.10.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.4.tgz#789ac82405ad664c20476d0233b485281deb9c77"
-  integrity sha512-25J6I8NGfa5YkCDogHRID3fVCadIR8/pGl1/spvCkzb6lVn6SR3ojpx9nOn9iEBcUsjY24AmdKm5khcfKdylcg==
+babel-plugin-polyfill-corejs3@^0.10.6:
+  version "0.10.6"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz#2deda57caef50f59c525aeb4964d3b2f867710c7"
+  integrity sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==
   dependencies:
-    "@babel/helper-define-polyfill-provider" "^0.6.1"
-    core-js-compat "^3.36.1"
+    "@babel/helper-define-polyfill-provider" "^0.6.2"
+    core-js-compat "^3.38.0"
 
 babel-plugin-polyfill-regenerator@^0.6.1:
   version "0.6.1"
@@ -3050,14 +3005,14 @@ browser-process-hrtime@^1.0.0:
   resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626"
   integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==
 
-browserslist@^4.21.10, browserslist@^4.23.0, browserslist@^4.23.1:
-  version "4.23.2"
-  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.2.tgz#244fe803641f1c19c28c48c4b6ec9736eb3d32ed"
-  integrity sha512-qkqSyistMYdxAcw+CzbZwlBy8AGmS/eEWs+sEV5TnLRGDOL+C5M2EnH6tlZyg0YoAxGJAFKh61En9BR941GnHA==
+browserslist@^4.21.10, browserslist@^4.23.1, browserslist@^4.23.3:
+  version "4.23.3"
+  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.3.tgz#debb029d3c93ebc97ffbc8d9cbb03403e227c800"
+  integrity sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==
   dependencies:
-    caniuse-lite "^1.0.30001640"
-    electron-to-chromium "^1.4.820"
-    node-releases "^2.0.14"
+    caniuse-lite "^1.0.30001646"
+    electron-to-chromium "^1.5.4"
+    node-releases "^2.0.18"
     update-browserslist-db "^1.1.0"
 
 bser@2.1.1:
@@ -3148,10 +3103,10 @@ camelcase@^6.0.0, camelcase@^6.2.0:
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809"
   integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==
 
-caniuse-lite@^1.0.30001640:
-  version "1.0.30001642"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001642.tgz#6aa6610eb24067c246d30c57f055a9d0a7f8d05f"
-  integrity sha512-3XQ0DoRgLijXJErLSl+bLnJ+Et4KqV1PY6JJBGAFlsNsz31zeAIncyeZfLCabHK/jtSh+671RM9YMldxjUPZtA==
+caniuse-lite@^1.0.30001646:
+  version "1.0.30001653"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001653.tgz#b8af452f8f33b1c77f122780a4aecebea0caca56"
+  integrity sha512-XGWQVB8wFQ2+9NZwZ10GxTYC5hk0Fa+q8cSkr0tgvMhYhMHP/QC+WTgrePMDBWiWc/pV+1ik82Al20XOK25Gcw==
 
 chai@4.5.0:
   version "4.5.0"
@@ -3503,12 +3458,12 @@ cookie@0.6.0:
   resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
   integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
 
-core-js-compat@^3.36.1, core-js-compat@^3.37.1:
-  version "3.37.1"
-  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.37.1.tgz#c844310c7852f4bdf49b8d339730b97e17ff09ee"
-  integrity sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==
+core-js-compat@^3.37.1, core-js-compat@^3.38.0:
+  version "3.38.1"
+  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.38.1.tgz#2bc7a298746ca5a7bcb9c164bcb120f2ebc09a09"
+  integrity sha512-JRH6gfXxGmrzF3tZ57lFx97YARxCXPaMzPo6jELZhv88pBH5VXpQ+y0znKGlFnzuaihqhLbefxSJxWJMPtfDzw==
   dependencies:
-    browserslist "^4.23.0"
+    browserslist "^4.23.3"
 
 core-js@^3.37.1:
   version "3.37.1"
@@ -4052,10 +4007,10 @@ ee-first@1.1.1:
   resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
   integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
 
-electron-to-chromium@^1.4.820:
-  version "1.4.827"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.827.tgz#76068ed1c71dd3963e1befc8ae815004b2da6a02"
-  integrity sha512-VY+J0e4SFcNfQy19MEoMdaIcZLmDCprqvBtkii1WTCTQHpRvf5N8+3kTYCgL/PcntvwQvmMJWTuDPsq+IlhWKQ==
+electron-to-chromium@^1.5.4:
+  version "1.5.13"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.13.tgz#1abf0410c5344b2b829b7247e031f02810d442e6"
+  integrity sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q==
 
 emittery@^0.13.1:
   version "0.13.1"
@@ -4082,10 +4037,10 @@ encodeurl@~1.0.2:
   resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
   integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
 
-enhanced-resolve@^5.17.0:
-  version "5.17.0"
-  resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.17.0.tgz#d037603789dd9555b89aaec7eb78845c49089bc5"
-  integrity sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==
+enhanced-resolve@^5.17.1:
+  version "5.17.1"
+  resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz#67bfbbcc2f81d511be77d686a90267ef7f898a15"
+  integrity sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==
   dependencies:
     graceful-fs "^4.2.4"
     tapable "^2.2.0"
@@ -4597,10 +4552,10 @@ eslint-plugin-jsx-a11y@^6.9.0:
     safe-regex-test "^1.0.3"
     string.prototype.includes "^2.0.0"
 
-eslint-plugin-promise@^7.0.0:
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-7.0.0.tgz#2d54cf6e92213cb2915be62abf338b64c089431d"
-  integrity sha512-wb1ECT+b90ndBdAujhIdAU8oQ3Vt5gKqP/t78KOmg0ifynrvc2jGR9f6ndbOVNFpKf6jLUBlBBDF3H3Wk0JICg==
+eslint-plugin-promise@^7.1.0:
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-7.1.0.tgz#06b3ad6d36b3c3ef3ec201c8a8d97049cf5dbb20"
+  integrity sha512-8trNmPxdAy3W620WKDpaS65NlM5yAumod6XeC4LOb+jxlkG4IVcp68c6dXY2ev+uT4U1PtG57YDV6EGAXN0GbQ==
 
 eslint-plugin-react-hooks@^4.6.2:
   version "4.6.2"
@@ -7224,10 +7179,10 @@ node-int64@^0.4.0:
   resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
   integrity sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=
 
-node-releases@^2.0.14:
-  version "2.0.14"
-  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b"
-  integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==
+node-releases@^2.0.18:
+  version "2.0.18"
+  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.18.tgz#f010e8d35e2fe8d6b2944f03f70213ecedc4ca3f"
+  integrity sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==
 
 normalize-path@^3.0.0, normalize-path@~3.0.0:
   version "3.0.0"
@@ -7895,10 +7850,10 @@ query-string@^7.0.0:
     split-on-first "^1.0.0"
     strict-uri-encode "^2.0.0"
 
-query-string@^9.0.0:
-  version "9.0.0"
-  resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.0.0.tgz#1fe177cd95545600f0deab93f5fb02fd4e3e7273"
-  integrity sha512-4EWwcRGsO2H+yzq6ddHcVqkCQ2EFUSfDMEjF8ryp8ReymyZhIuaFRGLomeOQLkrzacMHoyky2HW0Qe30UbzkKw==
+query-string@^9.1.0:
+  version "9.1.0"
+  resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.1.0.tgz#5f12a4653a4ba56021e113b5cf58e56581823e7a"
+  integrity sha512-t6dqMECpCkqfyv2FfwVS1xcB6lgXW/0XZSaKdsCNGYkqMO76AFiJEg4vINzoDKcZa6MS7JX+OHIjwh06K5vczw==
   dependencies:
     decode-uri-component "^0.4.1"
     filter-obj "^5.1.0"
@@ -9626,12 +9581,11 @@ webpack-sources@^3.2.3:
   resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
   integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
 
-webpack@^5.93.0:
-  version "5.93.0"
-  resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.93.0.tgz#2e89ec7035579bdfba9760d26c63ac5c3462a5e5"
-  integrity sha512-Y0m5oEY1LRuwly578VqluorkXbvXKh7U3rLoQCEO04M97ScRr44afGVkI0FQFsXzysk5OgFAxjZAb9rsGQVihA==
+webpack@^5.94.0:
+  version "5.94.0"
+  resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.94.0.tgz#77a6089c716e7ab90c1c67574a28da518a20970f"
+  integrity sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==
   dependencies:
-    "@types/eslint-scope" "^3.7.3"
     "@types/estree" "^1.0.5"
     "@webassemblyjs/ast" "^1.12.1"
     "@webassemblyjs/wasm-edit" "^1.12.1"
@@ -9640,7 +9594,7 @@ webpack@^5.93.0:
     acorn-import-attributes "^1.9.5"
     browserslist "^4.21.10"
     chrome-trace-event "^1.0.2"
-    enhanced-resolve "^5.17.0"
+    enhanced-resolve "^5.17.1"
     es-module-lexer "^1.2.1"
     eslint-scope "5.1.1"
     events "^3.2.0"
diff --git a/web/templates/includes/base.tmpl.html b/web/templates/includes/base.tmpl.html
index 7e80b42bb..f9b67f6ed 100644
--- a/web/templates/includes/base.tmpl.html
+++ b/web/templates/includes/base.tmpl.html
@@ -8,7 +8,8 @@
     <meta name="description" content="Linkerd">
     <meta name="keywords" content="Linkerd">
     <link rel="icon" type="image/png" href="{{.Contents.PathPrefix}}dist/img/favicon.png">
-    <link href="https://fonts.googleapis.com/css?family=Lato:300,400,700,900" rel="stylesheet">
+    <!-- material-ui uses Roboto -->
+    <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400|Roboto+Mono" rel="stylesheet">
     {{ template "script-tags" . }}
   </head>
   <body>


Test patch:
diff --git a/cli/cmd/testdata/install_crds.golden b/cli/cmd/testdata/install_crds.golden
index 44cc2101b..09ab07587 100644
--- a/cli/cmd/testdata/install_crds.golden
+++ b/cli/cmd/testdata/install_crds.golden
@@ -12041,16 +12041,16 @@ spec:
               workloadIPs:
                 type: array
                 description: workloadIPs contains a list of IP addresses that
-                  can be used to send traffic to the workload.
+                  can be used to send traffic to the workload. This field may
+                  hold a maximum of two entries. If one entry, it can be an
+                  IPv4 or IPv6 address; if two entries it should contain one
+                  IPv4 address and one IPv6 address.
                 items:
                   type: object
                   properties:
                     ip:
                       type: string
-                # TODO: relax this in the future when ipv6 is supported
-                # an external workload (like a pod) should only
-                # support 2 interfaces
-                maxItems: 1
+                maxItems: 2
             type: object
             required:
             - meshTLS
diff --git a/cli/cmd/testdata/install_helm_crds_output.golden b/cli/cmd/testdata/install_helm_crds_output.golden
index 9d4a7df19..0d8a6056e 100644
--- a/cli/cmd/testdata/install_helm_crds_output.golden
+++ b/cli/cmd/testdata/install_helm_crds_output.golden
@@ -12061,16 +12061,16 @@ spec:
               workloadIPs:
                 type: array
                 description: workloadIPs contains a list of IP addresses that
-                  can be used to send traffic to the workload.
+                  can be used to send traffic to the workload. This field may
+                  hold a maximum of two entries. If one entry, it can be an
+                  IPv4 or IPv6 address; if two entries it should contain one
+                  IPv4 address and one IPv6 address.
                 items:
                   type: object
                   properties:
                     ip:
                       type: string
-                # TODO: relax this in the future when ipv6 is supported
-                # an external workload (like a pod) should only
-                # support 2 interfaces
-                maxItems: 1
+                maxItems: 2
             type: object
             required:
             - meshTLS
diff --git a/cli/cmd/testdata/install_helm_crds_output_ha.golden b/cli/cmd/testdata/install_helm_crds_output_ha.golden
index 9d4a7df19..0d8a6056e 100644
--- a/cli/cmd/testdata/install_helm_crds_output_ha.golden
+++ b/cli/cmd/testdata/install_helm_crds_output_ha.golden
@@ -12061,16 +12061,16 @@ spec:
               workloadIPs:
                 type: array
                 description: workloadIPs contains a list of IP addresses that
-                  can be used to send traffic to the workload.
+                  can be used to send traffic to the workload. This field may
+                  hold a maximum of two entries. If one entry, it can be an
+                  IPv4 or IPv6 address; if two entries it should contain one
+                  IPv4 address and one IPv6 address.
                 items:
                   type: object
                   properties:
                     ip:
                       type: string
-                # TODO: relax this in the future when ipv6 is supported
-                # an external workload (like a pod) should only
-                # support 2 interfaces
-                maxItems: 1
+                maxItems: 2
             type: object
             required:
             - meshTLS
diff --git a/controller/api/destination/external-workload/endpoints_reconciler_test.go b/controller/api/destination/external-workload/endpoints_reconciler_test.go
index 3e22ff140..b0273bd81 100644
--- a/controller/api/destination/external-workload/endpoints_reconciler_test.go
+++ b/controller/api/destination/external-workload/endpoints_reconciler_test.go
@@ -25,6 +25,11 @@ import (
 	"sigs.k8s.io/yaml"
 )
 
+type IP struct {
+	addressType discoveryv1.AddressType
+	ip          string
+}
+
 var (
 	httpUnnamedPort = corev1.ServicePort{
 		Port: 8080,
@@ -49,8 +54,8 @@ var (
 // === Test create / update / delete ===
 
 // Test that when a service has no endpointslices written to the API Server, reconciling
-// with a workload will create a new endpointslice.
-func TestReconcilerCreatesNewEndpointSlice(t *testing.T) {
+// with a workload will create new endpointslices (one per IP family)
+func TestReconcilerCreatesNewEndpointSlices(t *testing.T) {
 	// We do not need to receive anything through the informers so
 	// create a client with no cached resources
 	k8sAPI, err := k8s.NewFakeAPI([]string{}...)
@@ -58,31 +63,78 @@ func TestReconcilerCreatesNewEndpointSlice(t *testing.T) {
 		t.Fatalf("unexpected error when creating Kubernetes clientset: %v", err)
 	}
 
-	svc := makeIPv4Service(map[string]string{"app": "test"}, []corev1.ServicePort{httpUnnamedPort}, "")
-	ew := makeExternalWorkload("1", "wlkd-1", map[string]string{"app": ""}, map[int32]string{8080: ""}, []string{"192.0.2.0"})
-	ew.ObjectMeta.UID = types.UID(fmt.Sprintf("%s-%s", ew.Namespace, ew.Name))
+	for _, tc := range []struct {
+		app      string
+		families []corev1.IPFamily
+		IPs      []IP
+	}{
+		{
+			"testIPv4",
+			[]corev1.IPFamily{corev1.IPv4Protocol},
+			[]IP{
+				{discoveryv1.AddressTypeIPv4, "192.0.2.0"},
+			},
+		},
+		{
+			"testIPv6",
+			[]corev1.IPFamily{corev1.IPv6Protocol},
+			[]IP{
+				{discoveryv1.AddressTypeIPv6, "2001:db8::8a2e:370:7334"},
+			},
+		},
+		{
+			"testDualStack",
+			[]corev1.IPFamily{corev1.IPv4Protocol, corev1.IPv6Protocol},
+			[]IP{
+				{discoveryv1.AddressTypeIPv4, "192.0.2.0"},
+				{discoveryv1.AddressTypeIPv6, "2001:db8::8a2e:370:7334"},
+			},
+		},
+	} {
+		t.Run(tc.app, func(t *testing.T) {
+			svc := makeService(tc.app, tc.families, map[string]string{"app": tc.app}, []corev1.ServicePort{httpUnnamedPort}, "")
 
-	r := newEndpointsReconciler(k8sAPI, testControllerName, defaultTestEndpointsQuota)
-	err = r.reconcile(svc, []*ewv1beta1.ExternalWorkload{ew}, nil)
-	if err != nil {
-		t.Fatalf("unexpected error when reconciling endpoints: %v", err)
-	}
+			IPs := []string{}
+			for _, ip := range tc.IPs {
+				IPs = append(IPs, ip.ip)
+			}
+			ew := makeExternalWorkload("1", "wlkd-"+tc.app, map[string]string{"app": ""}, map[int32]string{8080: ""}, IPs)
 
-	expectedEndpoint := makeEndpoint([]string{"192.0.2.0"}, true, ew)
-	es := fetchEndpointSlices(t, k8sAPI, svc)
-	if len(es) != 1 {
-		t.Fatalf("expected %d endpointslices after reconciliation, got %d instead", 1, len(es))
-	}
+			r := newEndpointsReconciler(k8sAPI, testControllerName, defaultTestEndpointsQuota)
+			err = r.reconcile(svc, []*ewv1beta1.ExternalWorkload{ew}, nil)
+			if err != nil {
+				t.Fatalf("unexpected error when reconciling endpoints: %v", err)
+			}
 
-	if len(es[0].Endpoints) != 1 {
-		t.Fatalf("expected %d endpointslices after reconciliation, got %d instead", 1, len(es[0].Endpoints))
-	}
+			endpointSlices := fetchEndpointSlices(t, k8sAPI, svc)
+			if len(endpointSlices) != len(tc.families) {
+				t.Fatalf("expected %d endpointslices after reconciliation, got %d instead", len(tc.families), len(endpointSlices))
+			}
 
-	if es[0].AddressType != discoveryv1.AddressTypeIPv4 {
-		t.Fatalf("expected endpointslice to have AF %s, got %s instead", discoveryv1.AddressTypeIPv4, es[0].AddressType)
+			for _, ip := range tc.IPs {
+				expectedEndpoint := makeEndpoint([]string{ip.ip}, true, ew)
+
+				var matchingES *discoveryv1.EndpointSlice
+				for _, es := range endpointSlices {
+					es := es
+					if es.AddressType == ip.addressType {
+						matchingES = &es
+						break
+					}
+				}
+				if matchingES == nil {
+					t.Fatalf("expected to find endpointslice for IP family %s, but none was found", ip.addressType)
+				}
+
+				if len(matchingES.Endpoints) != 1 {
+					t.Fatalf("expected %d endpointslices endpoints after reconciliation, got %d instead", 1, len(matchingES.Endpoints))
+				}
+
+				ep := matchingES.Endpoints[0]
+				diffEndpoints(t, ep, expectedEndpoint)
+			}
+		})
 	}
-	ep := es[0].Endpoints[0]
-	diffEndpoints(t, ep, expectedEndpoint)
 }
 
 // Test that when a service has no endpointslices written to the API Server, reconciling
@@ -96,7 +148,7 @@ func TestReconcilerCreatesNewEndpointSliceHeadless(t *testing.T) {
 		t.Fatalf("unexpected error when creating Kubernetes clientset: %v", err)
 	}
 
-	svc := makeIPv4Service(map[string]string{"app": "test"}, []corev1.ServicePort{httpUnnamedPort}, "")
+	svc := makeService("test-svc", []corev1.IPFamily{corev1.IPv4Protocol}, map[string]string{"app": "test"}, []corev1.ServicePort{httpUnnamedPort}, "")
 	svc.Spec.ClusterIP = corev1.ClusterIPNone
 	ew := makeExternalWorkload("1", "wlkd-1", map[string]string{"app": ""}, map[int32]string{8080: ""}, []string{"192.0.2.0"})
 	ew.Namespace = "default"
@@ -142,39 +194,72 @@ func TestReconcilerCreatesNewEndpointSliceHeadless(t *testing.T) {
 // reconciling with the two workloads updates the endpointslice
 func TestReconcilerUpdatesEndpointSlice(t *testing.T) {
 	// Create a service
-	svc := makeIPv4Service(map[string]string{"app": "test"}, []corev1.ServicePort{httpUnnamedPort}, "")
+	svc := makeService("test-svc", []corev1.IPFamily{corev1.IPv4Protocol, corev1.IPv6Protocol}, map[string]string{"app": "test"}, []corev1.ServicePort{httpUnnamedPort}, "")
 
 	// Create our existing workload
-	ewCreated := makeExternalWorkload("1", "wlkd-1", map[string]string{"app": "test"}, map[int32]string{8080: ""}, []string{"192.0.2.1"})
-	ewCreated.ObjectMeta.UID = types.UID(fmt.Sprintf("%s-%s", ewCreated.Namespace, ewCreated.Name))
+	ewCreated := makeExternalWorkload("1", "wlkd-1", map[string]string{"app": "test"}, map[int32]string{8080: ""}, []string{"192.0.2.1", "2001:db8::8a2e:370:7333"})
 
-	// Create an endpointslice
+	// Create endpointslices for IPv4 and IPv6
 	port := int32(8080)
 	ports := []discoveryv1.EndpointPort{{
 		Port: &port,
 	}}
-	es := makeEndpointSlice(svc, ports)
-	endpoints := []discoveryv1.Endpoint{}
-	endpoints = append(endpoints, externalWorkloadToEndpoint(discoveryv1.AddressTypeIPv4, ewCreated, svc))
-	es.Endpoints = endpoints
-	es.Generation = 1
-
-	// Create our "new" workload
-	ewUpdated := makeExternalWorkload("1", "wlkd-2", map[string]string{"app": "test"}, map[int32]string{8080: ""}, []string{"192.0.2.0"})
-	ewUpdated.ObjectMeta.UID = types.UID(fmt.Sprintf("%s-%s", ewUpdated.Namespace, ewUpdated.Name))
+	esIPv4, esIPv6 := makeDualEndpointSlices(svc, ports)
+	endpointsIPv4 := []discoveryv1.Endpoint{externalWorkloadToEndpoint(discoveryv1.AddressTypeIPv4, ewCreated, svc)}
+	esIPv4.Endpoints = endpointsIPv4
+	esIPv4.Generation = 1
+	endpointsIPv6 := []discoveryv1.Endpoint{externalWorkloadToEndpoint(discoveryv1.AddressTypeIPv6, ewCreated, svc)}
+	esIPv6.Endpoints = endpointsIPv6
+	esIPv6.Generation = 1
+
+	// Create our "new" workloads
+	ewUpdatedIPv4 := makeExternalWorkload("1", "wlkd-2", map[string]string{"app": "test"}, map[int32]string{8080: ""}, []string{"192.0.2.0"})
+	ewUpdatedIPv6 := makeExternalWorkload("1", "wlkd-3", map[string]string{"app": "test"}, map[int32]string{8080: ""}, []string{"2001:db8::8a2e:370:7334"})
 
 	// Convert endpointslice to string and register with fake client
-	k8sAPI, err := k8s.NewFakeAPI(endpointSliceAsYaml(t, es))
+	k8sAPI, err := k8s.NewFakeAPI(endpointSliceAsYaml(t, esIPv4), endpointSliceAsYaml(t, esIPv6))
 	if err != nil {
 		t.Fatalf("unexpected error when creating Kubernetes clientset: %v", err)
 	}
 
 	r := newEndpointsReconciler(k8sAPI, testControllerName, defaultTestEndpointsQuota)
-	err = r.reconcile(svc, []*ewv1beta1.ExternalWorkload{ewCreated, ewUpdated}, []*discoveryv1.EndpointSlice{es})
+	err = r.reconcile(svc, []*ewv1beta1.ExternalWorkload{ewCreated, ewUpdatedIPv4, ewUpdatedIPv6}, []*discoveryv1.EndpointSlice{esIPv4, esIPv6})
 	if err != nil {
 		t.Fatalf("unexpected error when reconciling endpoints: %v", err)
 	}
 
+	for _, ep := range getEndpoints(t, k8sAPI, svc, esIPv4) {
+		if ep.TargetRef.Name == ewUpdatedIPv4.Name {
+			expectedEndpoint := makeEndpoint([]string{"192.0.2.0"}, true, ewUpdatedIPv4)
+			diffEndpoints(t, ep, expectedEndpoint)
+		} else if ep.TargetRef.Name == ewCreated.Name {
+			expectedEndpoint := makeEndpoint([]string{"192.0.2.1"}, true, ewCreated)
+			diffEndpoints(t, ep, expectedEndpoint)
+		} else {
+			t.Errorf("found unexpected targetRef name %s", ep.TargetRef.Name)
+		}
+	}
+
+	for _, ep := range getEndpoints(t, k8sAPI, svc, esIPv6) {
+		if ep.TargetRef.Name == ewUpdatedIPv6.Name {
+			expectedEndpoint := makeEndpoint([]string{"2001:db8::8a2e:370:7334"}, true, ewUpdatedIPv6)
+			diffEndpoints(t, ep, expectedEndpoint)
+		} else if ep.TargetRef.Name == ewCreated.Name {
+			expectedEndpoint := makeEndpoint([]string{"2001:db8::8a2e:370:7333"}, true, ewCreated)
+			diffEndpoints(t, ep, expectedEndpoint)
+		} else {
+			t.Errorf("found unexpected targetRef name %s", ep.TargetRef.Name)
+		}
+	}
+}
+
+func getEndpoints(
+	t *testing.T,
+	k8sAPI *k8s.API,
+	svc *corev1.Service,
+	es *discoveryv1.EndpointSlice,
+) []discoveryv1.Endpoint {
+	t.Helper()
 	slice, err := k8sAPI.Client.DiscoveryV1().EndpointSlices(svc.Namespace).Get(context.Background(), es.Name, metav1.GetOptions{})
 	if err != nil {
 		t.Fatalf("unexpected error when retrieving endpointslice: %v", err)
@@ -183,39 +268,28 @@ func TestReconcilerUpdatesEndpointSlice(t *testing.T) {
 		t.Fatalf("expected %d endpointslices after reconciliation, got %d instead", 2, len(slice.Endpoints))
 	}
 
-	if slice.AddressType != discoveryv1.AddressTypeIPv4 {
-		t.Fatalf("expected endpointslice to have AF %s, got %s instead", discoveryv1.AddressTypeIPv4, slice.AddressType)
+	if slice.AddressType != es.AddressType {
+		t.Fatalf("expected endpointslice to have AF %s, got %s instead", es.AddressType, slice.AddressType)
 	}
 
-	for _, ep := range slice.Endpoints {
-		if ep.TargetRef.Name == ewUpdated.Name {
-			expectedEndpoint := makeEndpoint([]string{"192.0.2.0"}, true, ewUpdated)
-			diffEndpoints(t, ep, expectedEndpoint)
-		} else if ep.TargetRef.Name == ewCreated.Name {
-			expectedEndpoint := makeEndpoint([]string{"192.0.2.1"}, true, ewCreated)
-			diffEndpoints(t, ep, expectedEndpoint)
-		} else {
-			t.Errorf("found unexpected targetRef name %s", ep.TargetRef.Name)
-		}
-	}
+	return slice.Endpoints
 }
 
 // When an endpoint has changed, we should see the endpointslice change its
 // endpoint
 func TestReconcilerUpdatesEndpointSliceInPlace(t *testing.T) {
 	// Create a service
-	svc := makeIPv4Service(map[string]string{"app": "test"}, []corev1.ServicePort{httpUnnamedPort}, "")
+	svc := makeService("test-svc", []corev1.IPFamily{corev1.IPv4Protocol}, map[string]string{"app": "test"}, []corev1.ServicePort{httpUnnamedPort}, "")
 
 	// Create our existing workload
 	ewCreated := makeExternalWorkload("1", "wlkd-1", map[string]string{"app": "test"}, map[int32]string{8080: ""}, []string{"192.0.2.1"})
-	ewCreated.ObjectMeta.UID = types.UID(fmt.Sprintf("%s-%s", ewCreated.Namespace, ewCreated.Name))
 
 	// Create an endpointslice
 	port := int32(8080)
 	ports := []discoveryv1.EndpointPort{{
 		Port: &port,
 	}}
-	es := makeEndpointSlice(svc, ports)
+	es := makeEndpointSlice(svc, discoveryv1.AddressTypeIPv4, ports)
 	endpoints := []discoveryv1.Endpoint{}
 	endpoints = append(endpoints, externalWorkloadToEndpoint(discoveryv1.AddressTypeIPv4, ewCreated, svc))
 	es.Endpoints = endpoints
@@ -267,7 +341,7 @@ func TestReconcilerUpdatesEndpointSliceInPlace(t *testing.T) {
 
 // A named port on a service can target a different port on a workload
 func TestReconcileEndpointSlicesNamedPorts(t *testing.T) {
-	svc := makeIPv4Service(map[string]string{"app": "test"}, []corev1.ServicePort{httpNamedPort}, "192.0.2.1")
+	svc := makeService("test-svc", []corev1.IPFamily{corev1.IPv4Protocol}, map[string]string{"app": "test"}, []corev1.ServicePort{httpNamedPort}, "192.0.2.1")
 	ews := []*ewv1beta1.ExternalWorkload{}
 	// Generate a large number of external workloads
 	// randomise ports so that a named port maps to different target values
@@ -320,7 +394,7 @@ func TestReconcileEndpointSlicesNamedPorts(t *testing.T) {
 // a simple use case with 250 workloads matching a service and no existing slices
 // reconcile should create 3 slices, completely filling 2 of them
 func TestReconcileManyWorkloads(t *testing.T) {
-	svc := makeIPv4Service(map[string]string{"app": "test"}, []corev1.ServicePort{httpUnnamedPort}, "10.0.2.1")
+	svc := makeService("test-svc", []corev1.IPFamily{corev1.IPv4Protocol}, map[string]string{"app": "test"}, []corev1.ServicePort{httpUnnamedPort}, "10.0.2.1")
 	// start with 250 workloads
 	ews := []*ewv1beta1.ExternalWorkload{}
 	for i := 0; i < 250; i++ {
@@ -348,7 +422,7 @@ func TestReconcileManyWorkloads(t *testing.T) {
 //
 // We will drop 27 in the first slice closest to full
 func TestReconcileEndpointSlicesSomePreexisting(t *testing.T) {
-	svc := makeIPv4Service(map[string]string{"app": "test"}, []corev1.ServicePort{httpUnnamedPort}, "10.0.2.1")
+	svc := makeService("test-svc", []corev1.IPFamily{corev1.IPv4Protocol}, map[string]string{"app": "test"}, []corev1.ServicePort{httpUnnamedPort}, "10.0.2.1")
 	// start with 250 workloads
 	ews := []*ewv1beta1.ExternalWorkload{}
 	for i := 0; i < 250; i++ {
@@ -366,7 +440,7 @@ func TestReconcileEndpointSlicesSomePreexisting(t *testing.T) {
 		Port: &port,
 	}}
 
-	es1 := makeEndpointSlice(svc, esPorts)
+	es1 := makeEndpointSlice(svc, discoveryv1.AddressTypeIPv4, esPorts)
 	// Take a quarter of workloads in the first slice
 	for i := 1; i < len(ews)-4; i += 4 {
 		addrs := []string{ews[i].Spec.WorkloadIPs[0].Ip}
@@ -374,7 +448,7 @@ func TestReconcileEndpointSlicesSomePreexisting(t *testing.T) {
 		es1.Endpoints = append(es1.Endpoints, makeEndpoint(addrs, isReady, ews[i]))
 	}
 
-	es2 := makeEndpointSlice(svc, esPorts)
+	es2 := makeEndpointSlice(svc, discoveryv1.AddressTypeIPv4, esPorts)
 	// Take a quarter of workloads in the second slice
 	for i := 3; i < len(ews)-4; i += 4 {
 		addrs := []string{ews[i].Spec.WorkloadIPs[0].Ip}
@@ -406,7 +480,7 @@ func TestReconcileEndpointSlicesSomePreexisting(t *testing.T) {
 // Ensure reconciler updates everything in-place when a service requires a
 // change. That means we expect to only see updates, no creates.
 func TestReconcileEndpointSlicesUpdatingSvc(t *testing.T) {
-	svc := makeIPv4Service(map[string]string{"app": "test"}, []corev1.ServicePort{httpUnnamedPort}, "10.0.2.1")
+	svc := makeService("test-svc", []corev1.IPFamily{corev1.IPv4Protocol}, map[string]string{"app": "test"}, []corev1.ServicePort{httpUnnamedPort}, "10.0.2.1")
 	// start with 250 workloads
 	ews := []*ewv1beta1.ExternalWorkload{}
 	for i := 0; i < 250; i++ {
@@ -445,7 +519,7 @@ func TestReconcileEndpointSlicesUpdatingSvc(t *testing.T) {
 // This test will ensure that we update slices with the appropriate labels when
 // a service has changed.
 func TestReconcileEndpointSlicesLabelsUpdatingSvc(t *testing.T) {
-	svc := makeIPv4Service(map[string]string{"app": "test"}, []corev1.ServicePort{httpUnnamedPort}, "10.0.2.1")
+	svc := makeService("test-svc", []corev1.IPFamily{corev1.IPv4Protocol}, map[string]string{"app": "test"}, []corev1.ServicePort{httpUnnamedPort}, "10.0.2.1")
 	// start with 250 workloads
 	ews := []*ewv1beta1.ExternalWorkload{}
 	for i := 0; i < 250; i++ {
@@ -485,7 +559,7 @@ func TestReconcileEndpointSlicesLabelsUpdatingSvc(t *testing.T) {
 // In some cases, such as service labels updates, all slices for that service will require a change
 // However, this should not happen for reserved labels
 func TestReconcileEndpointSlicesReservedLabelsSvc(t *testing.T) {
-	svc := makeIPv4Service(map[string]string{"app": "test"}, []corev1.ServicePort{httpUnnamedPort}, "10.0.2.1")
+	svc := makeService("test-svc", []corev1.IPFamily{corev1.IPv4Protocol}, map[string]string{"app": "test"}, []corev1.ServicePort{httpUnnamedPort}, "10.0.2.1")
 	// start with 250 workloads
 	ews := []*ewv1beta1.ExternalWorkload{}
 	for i := 0; i < 250; i++ {
@@ -533,7 +607,7 @@ func TestReconcileEndpointSlicesReservedLabelsSvc(t *testing.T) {
 }
 
 func TestEndpointSlicesAreRecycled(t *testing.T) {
-	svc := makeIPv4Service(map[string]string{"app": "test"}, []corev1.ServicePort{httpUnnamedPort}, "10.0.2.1")
+	svc := makeService("test-svc", []corev1.IPFamily{corev1.IPv4Protocol}, map[string]string{"app": "test"}, []corev1.ServicePort{httpUnnamedPort}, "10.0.2.1")
 	// start with 250 workloads
 	ews := []*ewv1beta1.ExternalWorkload{}
 	for i := 0; i < 300; i++ {
@@ -556,7 +630,7 @@ func TestEndpointSlicesAreRecycled(t *testing.T) {
 	for i, ew := range ews {
 		sliceNum := i / 30
 		if i%30 == 0 {
-			existingSlices = append(existingSlices, makeEndpointSlice(svc, esPorts))
+			existingSlices = append(existingSlices, makeEndpointSlice(svc, discoveryv1.AddressTypeIPv4, esPorts))
 		}
 
 		addrs := []string{ews[i].Spec.WorkloadIPs[0].Ip}
@@ -602,7 +676,13 @@ func newClientset(t *testing.T, k8sConfigs []string) (*k8s.API, func() []k8stest
 	return k8sAPI, actions
 }
 
-func makeEndpointSlice(svc *corev1.Service, ports []discoveryv1.EndpointPort) *discoveryv1.EndpointSlice {
+func makeDualEndpointSlices(svc *corev1.Service, ports []discoveryv1.EndpointPort) (*discoveryv1.EndpointSlice, *discoveryv1.EndpointSlice) {
+	esIPv4 := makeEndpointSlice(svc, discoveryv1.AddressTypeIPv4, ports)
+	esIPv6 := makeEndpointSlice(svc, discoveryv1.AddressTypeIPv6, ports)
+	return esIPv4, esIPv6
+}
+
+func makeEndpointSlice(svc *corev1.Service, addrType discoveryv1.AddressType, ports []discoveryv1.EndpointPort) *discoveryv1.EndpointSlice {
 	// We need an ownerRef to point to our service
 	ownerRef := metav1.NewControllerRef(svc, schema.GroupVersionKind{Version: "v1", Kind: "Service"})
 	slice := &discoveryv1.EndpointSlice{
@@ -612,7 +692,7 @@ func makeEndpointSlice(svc *corev1.Service, ports []discoveryv1.EndpointPort) *d
 			Labels:          map[string]string{},
 			OwnerReferences: []metav1.OwnerReference{*ownerRef},
 		},
-		AddressType: discoveryv1.AddressTypeIPv4,
+		AddressType: addrType,
 		Endpoints:   []discoveryv1.Endpoint{},
 		Ports:       ports,
 	}
@@ -706,18 +786,23 @@ func endpointSliceAsYaml(t *testing.T, es *discoveryv1.EndpointSlice) string {
 
 }
 
-func makeIPv4Service(selector map[string]string, ports []corev1.ServicePort, clusterIP string) *corev1.Service {
+func makeService(
+	name string,
+	ipFamilies []corev1.IPFamily,
+	selector map[string]string,
+	ports []corev1.ServicePort,
+	clusterIP string) *corev1.Service {
 	return &corev1.Service{
 		ObjectMeta: metav1.ObjectMeta{
-			Name:      "test-svc",
+			Name:      name,
 			Namespace: "default",
-			UID:       "default-test-svc",
+			UID:       types.UID(name),
 		},
 		Spec: corev1.ServiceSpec{
 			Ports:      ports,
 			Selector:   selector,
 			ClusterIP:  clusterIP,
-			IPFamilies: []corev1.IPFamily{corev1.IPv4Protocol},
+			IPFamilies: ipFamilies,
 		},
 		Status: corev1.ServiceStatus{},
 	}
diff --git a/policy-test/tests/e2e_audit.rs b/policy-test/tests/e2e_audit.rs
index 0fd25433e..da4d5f406 100644
--- a/policy-test/tests/e2e_audit.rs
+++ b/policy-test/tests/e2e_audit.rs
@@ -1,6 +1,9 @@
 use kube::{Client, ResourceExt};
 use linkerd_policy_controller_k8s_api as k8s;
-use linkerd_policy_test::{create, create_ready_pod, curl, web, with_temp_ns, LinkerdInject};
+use linkerd_policy_test::{
+    await_condition, create, create_ready_pod, curl, endpoints_ready, web, with_temp_ns,
+    LinkerdInject,
+};
 
 #[tokio::test(flavor = "current_thread")]
 async fn server_audit() {
@@ -14,6 +17,8 @@ async fn server_audit() {
             create_ready_pod(&client, web::pod(&ns))
         );
 
+        await_condition(&client, &ns, "web", endpoints_ready).await;
+
         // All requests should fail
         let curl = curl::Runner::init(&client, &ns).await;
         let (injected, uninjected) = tokio::join!(
@@ -65,6 +70,8 @@ async fn ns_audit() {
             create_ready_pod(&client, web::pod(&ns))
         );
 
+        await_condition(&client, &ns, "web", endpoints_ready).await;
+
         // Unmeshed requests should fail
         let curl = curl::Runner::init(&client, &ns).await;
         let (injected, uninjected) = tokio::join!(
diff --git a/policy-test/tests/e2e_authorization_policy.rs b/policy-test/tests/e2e_authorization_policy.rs
index cf7294021..73adde4a0 100644
--- a/policy-test/tests/e2e_authorization_policy.rs
+++ b/policy-test/tests/e2e_authorization_policy.rs
@@ -36,6 +36,8 @@ async fn meshtls() {
             create_ready_pod(&client, web::pod(&ns))
         );
 
+        await_condition(&client, &ns, "web", endpoints_ready).await;
+
         let curl = curl::Runner::init(&client, &ns).await;
         let (injected, uninjected) = tokio::join!(
             curl.run("curl-injected", "http://web", LinkerdInject::Enabled),
@@ -89,6 +91,8 @@ async fn targets_route() {
             create_ready_pod(&client, web::pod(&ns))
         );
 
+        await_condition(&client, &ns, "web", endpoints_ready).await;
+
         let curl = curl::Runner::init(&client, &ns).await;
 
         let (allowed, no_route, unauth, no_authz) = tokio::join!(
@@ -195,6 +199,8 @@ async fn targets_namespace() {
             create_ready_pod(&client, web::pod(&ns))
         );
 
+        await_condition(&client, &ns, "web", endpoints_ready).await;
+
         let curl = curl::Runner::init(&client, &ns).await;
         let (injected, uninjected) = tokio::join!(
             curl.run("curl-injected", "http://web", LinkerdInject::Enabled),
@@ -240,6 +246,8 @@ async fn meshtls_namespace() {
             create_ready_pod(&client, web::pod(&ns))
         );
 
+        await_condition(&client, &ns, "web", endpoints_ready).await;
+
         let curl = curl::Runner::init(&client, &ns).await;
         let (injected, uninjected) = tokio::join!(
             curl.run("curl-injected", "http://web", LinkerdInject::Enabled),
@@ -541,6 +549,8 @@ async fn empty_authentications() {
             create_ready_pod(&client, web::pod(&ns))
         );
 
+        await_condition(&client, &ns, "web", endpoints_ready).await;
+
         // All requests should work.
         let curl = curl::Runner::init(&client, &ns).await;
         let (injected, uninjected) = tokio::join!(
diff --git a/policy-test/tests/e2e_http_routing.rs b/policy-test/tests/e2e_http_routing.rs
index c7c2cc44c..ca75431a2 100644
--- a/policy-test/tests/e2e_http_routing.rs
+++ b/policy-test/tests/e2e_http_routing.rs
@@ -1,5 +1,8 @@
 use linkerd_policy_controller_k8s_api as k8s;
-use linkerd_policy_test::{create, create_ready_pod, curl, web, with_temp_ns, LinkerdInject};
+use linkerd_policy_test::{
+    await_condition, create, create_ready_pod, curl, endpoints_ready, web, with_temp_ns,
+    LinkerdInject,
+};
 
 #[tokio::test(flavor = "current_thread")]
 async fn path_based_routing() {
@@ -40,6 +43,8 @@ async fn path_based_routing() {
             create_ready_pod(&client, web::pod(&ns))
         );
 
+        await_condition(&client, &ns, "web", endpoints_ready).await;
+
         let curl = curl::Runner::init(&client, &ns).await;
         let (valid, invalid, notfound) = tokio::join!(
             curl.run("curl-valid", "http://web/valid", LinkerdInject::Enabled),
diff --git a/policy-test/tests/e2e_server_authorization.rs b/policy-test/tests/e2e_server_authorization.rs
index 67b923bf1..157c5405a 100644
--- a/policy-test/tests/e2e_server_authorization.rs
+++ b/policy-test/tests/e2e_server_authorization.rs
@@ -34,6 +34,8 @@ async fn meshtls() {
             create_ready_pod(&client, web::pod(&ns))
         );
 
+        await_condition(&client, &ns, "web", endpoints_ready).await;
+
         let curl = curl::Runner::init(&client, &ns).await;
         let (injected, uninjected) = tokio::join!(
             curl.run("curl-injected", "http://web", LinkerdInject::Enabled),
diff --git a/test/integration/deep/dualstack/testdata/ipfamilies-server-client.yml b/test/integration/deep/dualstack/testdata/ipfamilies-server-client.yml
index 30b16582b..43b8a2b63 100644
--- a/test/integration/deep/dualstack/testdata/ipfamilies-server-client.yml
+++ b/test/integration/deep/dualstack/testdata/ipfamilies-server-client.yml
@@ -25,6 +25,10 @@ spec:
         args:
         - -c
         - 'go run /go/src/app/main.go'
+        readinessProbe:
+          httpGet:
+            path: /
+            port: http
         volumeMounts:
         - name: go-app
           mountPath: /go/src/app
diff --git a/viz/metrics-api/edges_test.go b/viz/metrics-api/edges_test.go
index c870b040d..cc6029fac 100644
--- a/viz/metrics-api/edges_test.go
+++ b/viz/metrics-api/edges_test.go
@@ -8,6 +8,7 @@ import (
 
 	pkgK8s "github.com/linkerd/linkerd2/pkg/k8s"
 	pb "github.com/linkerd/linkerd2/viz/metrics-api/gen/viz"
+	"github.com/linkerd/linkerd2/viz/pkg/prometheus"
 	"github.com/prometheus/common/model"
 	"google.golang.org/protobuf/proto"
 )
@@ -29,12 +30,12 @@ func genOutboundPromSample(resourceNamespace, resourceName, resourceNameDst, res
 
 	return &model.Sample{
 		Metric: model.Metric{
-			resourceLabel:     model.LabelValue(resourceName),
-			namespaceLabel:    model.LabelValue(resourceNamespace),
-			dstNamespaceLabel: model.LabelValue(resourceNamespaceDst),
-			dstResourceLabel:  model.LabelValue(resourceNameDst),
-			serverIDLabel:     model.LabelValue(serverID),
-			podLabel:          model.LabelValue(resourceName + "-0"),
+			resourceLabel:                model.LabelValue(resourceName),
+			prometheus.NamespaceLabel:    model.LabelValue(resourceNamespace),
+			prometheus.DstNamespaceLabel: model.LabelValue(resourceNamespaceDst),
+			dstResourceLabel:             model.LabelValue(resourceNameDst),
+			serverIDLabel:                model.LabelValue(serverID),
+			podLabel:                     model.LabelValue(resourceName + "-0"),
 		},
 		Value:     123,
 		Timestamp: 456,