Repository: linkerd/linkerd2
Pull request #12989 (2024-08-26 03:57:21)

build(deps): bump github.com/prometheus/client_golang from 1.19.1 to 1.20.2
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.19.1 to 1.20.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/prometheus/client_golang/releases">github.com/prometheus/client_golang's releases</a>.</em></p>
<blockquote>
<h2>v1.20.2</h2>
<ul>
<li>[BUGFIX] promhttp: Unset Content-Encoding header when data is uncompressed. <a href="https://redirect.github.com/prometheus/client_golang/issues/1596">#1596</a></li>
</ul>
<h2>v1.20.1</h2>
<p>This release contains the critical fix for the <a href="https://redirect.github.com/prometheus/client_golang/issues/1584">issue</a>. Thanks to <a href="https://github.com/geberl"><code>@​geberl</code></a>, <a href="https://github.com/CubicrootXYZ"><code>@​CubicrootXYZ</code></a>, <a href="https://github.com/zetaab"><code>@​zetaab</code></a> and <a href="https://github.com/timofurrer"><code>@​timofurrer</code></a> for helping us with the investigation!</p>
<ul>
<li>[BUGFIX] process-collector: Fixed unregistered descriptor error when using process collector with PedanticRegistry on Linux machines. <a href="https://redirect.github.com/prometheus/client_golang/issues/1587">#1587</a></li>
</ul>
<h2>v1.20.0</h2>
<p>Thanks everyone for contributions!</p>
<p>:warning: In this release we remove one (broken anyway, given Go runtime changes) metric and add three new (representing GOGC, GOMEMLIMIT and GOMAXPROCS flags) to the default <code>collectors.NewGoCollector()</code> collector. Given its popular usage, expect your binary to expose two additional metric.</p>
<h2>Changes</h2>
<ul>
<li>[CHANGE] :warning: go-collector: Remove <code>go_memstat_lookups_total</code> metric which was always 0; Go runtime stopped sharing pointer lookup statistics. <a href="https://redirect.github.com/prometheus/client_golang/issues/1577">#1577</a></li>
<li>[FEATURE] :warning: go-collector: Add 3 default metrics: <code>go_gc_gogc_percent</code>, <code>go_gc_gomemlimit_bytes</code> and <code>go_sched_gomaxprocs_threads</code> as those are recommended by the Go team. <a href="https://redirect.github.com/prometheus/client_golang/issues/1559">#1559</a></li>
<li>[FEATURE] go-collector: Add more information to all metrics' HELP e.g. the exact <code>runtime/metrics</code> sourcing each metric (if relevant). <a href="https://redirect.github.com/prometheus/client_golang/issues/1568">#1568</a> <a href="https://redirect.github.com/prometheus/client_golang/issues/1578">#1578</a></li>
<li>[FEATURE] testutil: Add CollectAndFormat method. <a href="https://redirect.github.com/prometheus/client_golang/issues/1503">#1503</a></li>
<li>[FEATURE] histograms: Add support for exemplars in native histograms. <a href="https://redirect.github.com/prometheus/client_golang/issues/1471">#1471</a></li>
<li>[FEATURE] promhttp: Add experimental support for <code>zstd</code> on scrape, controlled by the request <code>Accept-Encoding</code> header. <a href="https://redirect.github.com/prometheus/client_golang/issues/1496">#1496</a></li>
<li>[FEATURE] api/v1: Add <code>WithLimit</code> parameter to all API methods that supports it. <a href="https://redirect.github.com/prometheus/client_golang/issues/1544">#1544</a></li>
<li>[FEATURE] prometheus: Add support for created timestamps in constant histograms and constant summaries. <a href="https://redirect.github.com/prometheus/client_golang/issues/1537">#1537</a></li>
<li>[FEATURE] process-collectors: Add network usage metrics: <code>process_network_receive_bytes_total</code> and <code>process_network_transmit_bytes_total</code>. <a href="https://redirect.github.com/prometheus/client_golang/issues/1555">#1555</a></li>
<li>[FEATURE] promlint: Add duplicated metric lint rule. <a href="https://redirect.github.com/prometheus/client_golang/issues/1472">#1472</a></li>
<li>[BUGFIX] promlint: Relax metric type in name linter rule. <a href="https://redirect.github.com/prometheus/client_golang/issues/1455">#1455</a></li>
<li>[BUGFIX] promhttp: Make sure server
instrumentation wrapping supports new and future extra responseWriter methods. <a href="https://redirect.github.com/prometheus/client_golang/issues/1480">#1480</a></li>
<li>[BUGFIX] testutil: Functions using compareMetricFamilies are now failing if filtered metricNames are not in the input. <a href="https://redirect.github.com/prometheus/client_golang/issues/1424">#1424</a></li>
</ul>
<!-- raw HTML omitted -->
<ul>
<li>feat(prometheus/testutil/promlint/validations): refine lintMetricType… by <a href="https://github.com/foehammer127"><code>@​foehammer127</code></a> in <a href="https://redirect.github.com/prometheus/client_golang/pull/1455">prometheus/client_golang#1455</a></li>
<li>Bump github.com/prometheus/client_golang from 1.18.0 to 1.19.0 in /examples/middleware by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/prometheus/client_golang/pull/1457">prometheus/client_golang#1457</a></li>
<li>Bump github.com/prometheus/client_model from 0.5.0 to 0.6.0 by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/prometheus/client_golang/pull/1458">prometheus/client_golang#1458</a></li>
<li>Bump golang.org/x/sys from 0.16.0 to 0.17.0 by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/prometheus/client_golang/pull/1459">prometheus/client_golang#1459</a></li>
<li>Bump github.com/prometheus/client_golang from 1.18.0 to 1.19.0 in /tutorial/whatsup by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/prometheus/client_golang/pull/1461">prometheus/client_golang#1461</a></li>
<li>Merge Release 1.19 back to main by <a href="https://github.com/ArthurSens"><code>@​ArthurSens</code></a> in <a href="https://redirect.github.com/prometheus/client_golang/pull/1462">prometheus/client_golang#1462</a></li>
<li>Bump the github-actions group with 2 updates by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/prometheus/client_golang/pull/1456">prometheus/client_golang#1456</a></li>
<li>Bump google.golang.org/protobuf from 1.32.0 to 1.33.0 by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/prometheus/client_golang/pull/1466">prometheus/client_golang#1466</a></li>
<li>Bump google.golang.org/protobuf from 1.32.0 to 1.33.0 in /examples/middleware by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/prometheus/client_golang/pull/1467">prometheus/client_golang#1467</a></li>
<li>Bump google.golang.org/protobuf from 1.32.0 to 1.33.0 in /tutorial/whatsup by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/prometheus/client_golang/pull/1469">prometheus/client_golang#1469</a></li>
<li>Add LintDuplicateMetric to promlint by <a href="https://github.com/bboreham"><code>@​bboreham</code></a> in <a href="https://redirect.github.com/prometheus/client_golang/pull/1472">prometheus/client_golang#1472</a></li>
<li>Auto-update Go Collector Metrics for new Go versions by <a href="https://github.com/SachinSahu431"><code>@​SachinSahu431</code></a> in <a href="https://redirect.github.com/prometheus/client_golang/pull/1476">prometheus/client_golang#1476</a></li>
<li>Implement Unwrap() for responseWriterDelegator by <a href="https://github.com/igor-drozdov"><code>@​igor-drozdov</code></a> in <a href="https://redirect.github.com/prometheus/client_golang/pull/1480">prometheus/client_golang#1480</a></li>
<li>Bump golang.org/x/sys from 0.17.0 to 0.18.0 by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/prometheus/client_golang/pull/1485">prometheus/client_golang#1485</a></li>
<li>Bump github.com/prometheus/procfs from 0.12.0 to 0.13.0 by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/prometheus/client_golang/pull/1486">prometheus/client_golang#1486</a></li>
<li>ci: Remove hardcoded supported Go versions from go.yml by <a href="https://github.com/SachinSahu431"><code>@​SachinSahu431</code></a> in <a href="https://redirect.github.com/prometheus/client_golang/pull/1489">prometheus/client_golang#1489</a></li>
<li>feat: metrics generation workflow by <a href="https://github.com/SachinSahu431"><code>@​SachinSahu431</code></a> in <a href="https://redirect.github.com/prometheus/client_golang/pull/1481">prometheus/client_golang#1481</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a href="https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md">github.com/prometheus/client_golang's changelog</a>.</em></p>
<blockquote>
<h2>1.20.2 / 2024-08-23</h2>
<ul>
<li>[BUGFIX] promhttp: Unset Content-Encoding header when data is uncompressed. <a href="https://redirect.github.com/prometheus/client_golang/issues/1596">#1596</a></li>
</ul>
<h2>1.20.1 / 2024-08-20</h2>
<ul>
<li>[BUGFIX] process-collector: Fixed unregistered descriptor error when using process collector with <code>PedanticRegistry</code> on linux machines. <a href="https://redirect.github.com/prometheus/client_golang/issues/1587">#1587</a></li>
</ul>
<h2>1.20.0 / 2024-08-14</h2>
<ul>
<li>[CHANGE] :warning: go-collector: Remove <code>go_memstat_lookups_total</code> metric which was always 0; Go runtime stopped sharing pointer lookup statistics. <a href="https://redirect.github.com/prometheus/client_golang/issues/1577">#1577</a></li>
<li>[FEATURE] :warning: go-collector: Add 3 default metrics: <code>go_gc_gogc_percent</code>, <code>go_gc_gomemlimit_bytes</code> and <code>go_sched_gomaxprocs_threads</code> as those are recommended by the Go team. <a href="https://redirect.github.com/prometheus/client_golang/issues/1559">#1559</a></li>
<li>[FEATURE] go-collector: Add more information to all metrics' HELP e.g. the exact <code>runtime/metrics</code> sourcing each metric (if relevant). <a href="https://redirect.github.com/prometheus/client_golang/issues/1568">#1568</a> <a href="https://redirect.github.com/prometheus/client_golang/issues/1578">#1578</a></li>
<li>[FEATURE] testutil: Add CollectAndFormat method. <a href="https://redirect.github.com/prometheus/client_golang/issues/1503">#1503</a></li>
<li>[FEATURE] histograms: Add support for exemplars in native histograms. <a href="https://redirect.github.com/prometheus/client_golang/issues/1471">#1471</a></li>
<li>[FEATURE] promhttp: Add experimental support for <code>zstd</code> on scrape, controlled by the request <code>Accept-Encoding</code> header. <a href="https://redirect.github.com/prometheus/client_golang/issues/1496">#1496</a></li>
<li>[FEATURE] api/v1: Add <code>WithLimit</code> parameter to all API methods that supports it. <a href="https://redirect.github.com/prometheus/client_golang/issues/1544">#1544</a></li>
<li>[FEATURE] prometheus: Add support for created timestamps in constant histograms and constant summaries. <a href="https://redirect.github.com/prometheus/client_golang/issues/1537">#1537</a></li>
<li>[FEATURE] process-collector: Add network usage metrics: <code>process_network_receive_bytes_total</code> and <code>process_network_transmit_bytes_total</code>. <a href="https://redirect.github.com/prometheus/client_golang/issues/1555">#1555</a></li>
<li>[FEATURE] promlint: Add duplicated metric lint rule. <a href="https://redirect.github.com/prometheus/client_golang/issues/1472">#1472</a></li>
<li>[BUGFIX] promlint: Relax metric type in name linter rule. <a href="https://redirect.github.com/prometheus/client_golang/issues/1455">#1455</a></li>
<li>[BUGFIX] promhttp: Make sure server instrumentation wrapping supports new and future extra responseWriter methods. <a href="https://redirect.github.com/prometheus/client_golang/issues/1480">#1480</a></li>
<li>[BUGFIX] testutil: Functions using compareMetricFamilies are now failing if filtered metricNames are not in the input. <a href="https://redirect.github.com/prometheus/client_golang/issues/1424">#1424</a></li>
</ul>
<h2>1.19.0 / 2024-02-27</h2>
<p>The module <code>prometheus/common v0.48.0</code> introduced an incompatibility when used together with client_golang (See <a href="https://redirect.github.com/prometheus/client_golang/pull/1448">prometheus/client_golang#1448</a> for more details). If your project uses client_golang and you want to use <code>prometheus/common v0.48.0</code> or higher, please update client_golang to v1.19.0.</p>
<ul>
<li>[CHANGE] Minimum required go version is now 1.20 (we also test client_golang against new 1.22 version). <a href="https://redirect.github.com/prometheus/client_golang/issues/1445">#1445</a> <a href="https://redirect.github.com/prometheus/client_golang/issues/1449">#1449</a></li>
<li>[FEATURE] collectors: Add version collector. <a href="https://redirect.github.com/prometheus/client_golang/issues/1422">#1422</a> <a href="https://redirect.github.com/prometheus/client_golang/issues/1427">#1427</a></li>
</ul>
<h2>1.18.0 / 2023-12-22</h2>
<ul>
<li>[FEATURE] promlint: Allow creation of custom metric validations. <a href="https://redirect.github.com/prometheus/client_golang/issues/1311">#1311</a></li>
<li>[FEATURE] Go programs using client_golang can be built in wasip1 OS. <a href="https://redirect.github.com/prometheus/client_golang/issues/1350">#1350</a></li>
<li>[BUGFIX] histograms: Add timer to reset ASAP after bucket limiting has happened. <a href="https://redirect.github.com/prometheus/client_golang/issues/1367">#1367</a></li>
<li>[BUGFIX] testutil: Fix comparison of metrics with empty Help strings. <a href="https://redirect.github.com/prometheus/client_golang/issues/1378">#1378</a></li>
<li>[ENHANCEMENT] Improved performance of <code>MetricVec.WithLabelValues(...)</code>. <a href="https://redirect.github.com/prometheus/client_golang/issues/1360">#1360</a></li>
</ul>
<h2>1.17.0 / 2023-09-27</h2>
<ul>
<li>[CHANGE] Minimum required go version is now 1.19 (we also test client_golang against new 1.21 version). <a href="https://redirect.github.com/prometheus/client_golang/issues/1325">#1325</a></li>
<li>[FEATURE] Add support for Created Timestamps in Counters, Summaries and Historams. <a href="https://redirect.github.com/prometheus/client_golang/issues/1313">#1313</a></li>
<li>[ENHANCEMENT] Enable detection of a native histogram without observations. <a href="https://redirect.github.com/prometheus/client_golang/issues/1314">#1314</a></li>
</ul>
<h2>1.16.0 / 2023-06-15</h2>
<ul>
<li>[BUGFIX] api: Switch to POST for LabelNames, Series, and QueryExemplars. <a href="https://redirect.github.com/prometheus/client_golang/issues/1252">#1252</a></li>
<li>[BUGFIX] api: Fix undefined execution order in return statements. <a href="https://redirect.github.com/prometheus/client_golang/issues/1260">#1260</a></li>
<li>[BUGFIX] native histograms: Fix bug in bucket key calculation. <a href="https://redirect.github.com/prometheus/client_golang/issues/1279">#1279</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/prometheus/client_golang/commit/67121dc55a7c1ac353141e360a2b184c5e3d5529"><code>67121dc</code></a> Merge pull request <a href="https://redirect.github.com/prometheus/client_golang/issues/1596">#1596</a> from mrueg/fix-uncompressed-content-header</li>
<li><a href="https://github.com/prometheus/client_golang/commit/187acd4ca1a05e3207990e427cae7057a1e58961"><code>187acd4</code></a> Cut 1.20.2</li>
<li><a href="https://github.com/prometheus/client_golang/commit/f7f8f3a1e2c09da4b88b78b7e04a9252187cf71f"><code>f7f8f3a</code></a> fix: Unset Content-Encoding header when uncompressed</li>
<li><a href="https://github.com/prometheus/client_golang/commit/2254d6c3087d3660f73785ae123c7cc705f2a9c2"><code>2254d6c</code></a> Merge pull request <a href="https://redirect.github.com/prometheus/client_golang/issues/1587">#1587</a> from prometheus/fix-processcollector</li>
<li><a href="https://github.com/prometheus/client_golang/commit/4a15d0584653a36547195042512218cad99888c6"><code>4a15d05</code></a> Cut 1.20.1</li>
<li><a href="https://github.com/prometheus/client_golang/commit/f2dd7b35fd3f28b6a59b64953aab727b5c0c0404"><code>f2dd7b3</code></a> Use pedantic registry in other places too, to double check.</li>
<li><a href="https://github.com/prometheus/client_golang/commit/261fe84cd4f670b8e51bf74f6f6bbb96aa1fce20"><code>261fe84</code></a> bugfix: Pass network metrics to processCollector's Describe() function</li>
<li><a href="https://github.com/prometheus/client_golang/commit/5bf3341b6684a18bfdc019c9b2c154ab6a42c0a1"><code>5bf3341</code></a> Use NewPedanticRegistry in Process' Collector tests</li>
<li><a href="https://github.com/prometheus/client_golang/commit/73b811c54a628c7a7fe43005fb81351f64da9289"><code>73b811c</code></a> Cut 1.20.0 release. (<a href="https://redirect.github.com/prometheus/client_golang/issues/1580">#1580</a>)</li>
<li><a href="https://github.com/prometheus/client_golang/commit/7ce508988aa3bfc91d56afa9f82b7167b36c4590"><code>7ce5089</code></a> gocollector: Attach original runtime/metrics metric name to help. (<a href="https://redirect.github.com/prometheus/client_golang/issues/1578">#1578</a>)</li>
<li>Additional commits viewable in <a href="https://github.com/prometheus/client_golang/compare/v1.19.1...v1.20.2">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/prometheus/client_golang&package-manager=go_modules&previous-version=1.19.1&new-version=1.20.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>


Patch:
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index 61c8ea0d3..bbab4e615 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@40853de9f8ce2d6cfdc73c1b96f14e22ba44aec4
+      - uses: tj-actions/changed-files@e9772d140489982e0e3704fea5ee93d536f1e275
         id: changed
         with:
           files: |
diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
index 04c498a48..a5a84dbba 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@40853de9f8ce2d6cfdc73c1b96f14e22ba44aec4
+      - uses: tj-actions/changed-files@e9772d140489982e0e3704fea5ee93d536f1e275
         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@834a144ee995460fba8ed112a2fc961b36a5ec5a
+      - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874
         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@834a144ee995460fba8ed112a2fc961b36a5ec5a
+      - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874
         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@834a144ee995460fba8ed112a2fc961b36a5ec5a
+      - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874
         with:
           name: image-archives-${{ matrix.component }}
           path: /home/runner/archives
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f27876890..5167dba1b 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@834a144ee995460fba8ed112a2fc961b36a5ec5a
+        uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874
         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@834a144ee995460fba8ed112a2fc961b36a5ec5a
+        uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874
         with:
           name: choco
           path: ./linkerd.*.nupkg
diff --git a/.proxy-version b/.proxy-version
index 913f4f1de..7eacb3575 100644
--- a/.proxy-version
+++ b/.proxy-version
@@ -1,1 +1,1 @@
-v2.249.0
+v2.250.0
diff --git a/Cargo.lock b/Cargo.lock
index ffb39f750..7f30c35f7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -244,9 +244,12 @@ 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"
@@ -317,9 +320,9 @@ 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"
@@ -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",
 ]
@@ -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.125"
+version = "1.0.127"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83c8e735a073ccf5be70aa8066aa984eaf2fa000db6c8d0100ae605b366d31ed"
+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.40.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "daa4fb1bc778bd6f04cbfc4bb2d06a7396a8f299dc33ea1900cedaa316f467b1"
+checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998"
 dependencies = [
  "backtrace",
  "bytes",
diff --git a/README.md b/README.md
index e4aa3e693..e6f287aa6 100644
--- a/README.md
+++ b/README.md
@@ -52,20 +52,6 @@ contributing changes.
   list][linkerd-dev], and [announcements mailing list][linkerd-announce].
 * Follow [@Linkerd][twitter] on Twitter.
 * Join the [Linkerd Slack][slack].
-* Join us in the regular online community meetings!
-
-## Community meetings
-
-We host regular online meetings for contributors, adopters, maintainers, and
-anyone else interested to connect in a synchronous fashion. These meetings
-usually take place the last Thursday of the month at 9am Pacific / 4pm UTC.
-
-* [Zoom link](https://zoom.us/my/cncflinkerd)
-* [Google calendar](https://calendar.google.com/calendar/embed?src=buoyant.io_j28ik70vrl3418f4oldkdici7o%40group.calendar.google.com)
-* [Minutes from previous meetings](https://docs.google.com/document/d/1OvXYL5Q53klQFZPokQJas72YqkWXplkPQUguFbRW7Wo/edit)
-* [Recordings from previous meetings](https://www.youtube.com/playlist?list=PLI9FkLPXDscBHP91Ud3lyJScI4ZCjRG6F)
-
-We're a friendly group, so please feel free to join us!
 
 ## Steering Committee meetings
 
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 a48e0cd3e..94589cb20 100644
--- a/go.mod
+++ b/go.mod
@@ -26,7 +26,7 @@ require (
 	github.com/nsf/termbox-go v1.1.1
 	github.com/patrickmn/go-cache v2.1.0+incompatible
 	github.com/pkg/browser v0.0.0-20170505125900-c90ca0c84f15
-	github.com/prometheus/client_golang v1.19.1
+	github.com/prometheus/client_golang v1.20.2
 	github.com/prometheus/client_model v0.6.1
 	github.com/prometheus/common v0.55.0
 	github.com/sergi/go-diff v1.3.1
@@ -103,7 +103,7 @@ require (
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/josharian/intern v1.0.0 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
-	github.com/klauspost/compress v1.17.0 // indirect
+	github.com/klauspost/compress v1.17.9 // indirect
 	github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
 	github.com/mailru/easyjson v0.7.7 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
diff --git a/go.sum b/go.sum
index e8fe9fac8..a3bb9e30e 100644
--- a/go.sum
+++ b/go.sum
@@ -280,8 +280,8 @@ github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4d
 github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
 github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
-github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
+github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -291,6 +291,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
 github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
 github.com/linkerd/linkerd2-proxy-api v0.14.0 h1:gSMKSFzA43yyut6DOHFbGWggNgqIuFz90Y3+Ox17v/8=
@@ -365,8 +367,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH
 github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
 github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
 github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g=
-github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
-github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
+github.com/prometheus/client_golang v1.20.2 h1:5ctymQzZlyOON1666svgwn3s6IKWgfbjsejTMiXIyjg=
+github.com/prometheus/client_golang v1.20.2/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
 github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
 github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
diff --git a/policy-controller/k8s/status/Cargo.toml b/policy-controller/k8s/status/Cargo.toml
index 264b93a32..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.125"
+serde_json = "1.0.127"
 thiserror = "1"
 tokio = { version = "1", features = ["macros"] }
 tracing = "0.1.40"
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/package.json b/web/app/package.json
index c37181e0f..ecf83123e 100644
--- a/web/app/package.json
+++ b/web/app/package.json
@@ -4,7 +4,7 @@
   "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.6.0",
@@ -16,7 +16,7 @@
     "@material-ui/icons": "4.11.3",
     "@material-ui/lab": "^4.0.0-alpha.61",
     "classnames": "2.5.1",
-    "core-js": "^3.37.1",
+    "core-js": "^3.38.1",
     "css-mediaquery": "^0.1.2",
     "d3-drag": "3.0.0",
     "d3-force": "3.0.0",
@@ -44,9 +44,9 @@
     "@babel/core": "^7.25.2",
     "@babel/eslint-parser": "^7.25.1",
     "@babel/plugin-proposal-class-properties": "^7.17.12",
-    "@babel/preset-env": "^7.25.3",
+    "@babel/preset-env": "^7.25.4",
     "@babel/preset-react": "^7.24.7",
-    "@babel/runtime": "^7.25.0",
+    "@babel/runtime": "^7.25.6",
     "@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 9f7785c42..a072d2174 100644
--- a/web/app/yarn.lock
+++ b/web/app/yarn.lock
@@ -23,10 +23,10 @@
     "@babel/highlight" "^7.24.7"
     picocolors "^1.0.0"
 
-"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.25.2":
-  version "7.25.2"
-  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.25.2.tgz#e41928bd33475305c586f6acbbb7e3ade7a6f7f5"
-  integrity sha512-bYcppcpKBvX4znYaPEeFau03bp89ShqNMLs+rmdptMw+heSZh9+z84d2YG+K7cYLbWwzdjtDoW/uqZmPjulClQ==
+"@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.25.2":
   version "7.25.2"
@@ -58,10 +58,10 @@
     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"
 
@@ -136,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"
@@ -162,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"
@@ -173,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"
@@ -618,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"
@@ -651,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"
@@ -668,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":
@@ -897,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"
@@ -1029,20 +1062,20 @@
     "@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.3":
-  version "7.25.3"
-  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.25.3.tgz#0bf4769d84ac51d1073ab4a86f00f30a3a83c67c"
-  integrity sha512-QsYW7UeAaXvLPX9tdVliMJE7MD7M6MLYVTovRTIwhoYQVFHR1rM4wO8wqAezYi3/BpSD+NzVCZ69R6smWiIi8g==
+"@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.2"
+    "@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"
@@ -1071,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"
@@ -1105,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"
@@ -1118,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"
@@ -1152,10 +1185,10 @@
   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.6", "@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.6"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.6.tgz#9afc3289f7184d8d7f98b099884c26317b9264d2"
+  integrity sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==
   dependencies:
     regenerator-runtime "^0.14.0"
 
@@ -1168,7 +1201,7 @@
     "@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", "@babel/traverse@^7.25.0", "@babel/traverse@^7.25.1", "@babel/traverse@^7.25.2", "@babel/traverse@^7.25.3":
+"@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==
@@ -1958,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==
@@ -2833,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"
@@ -2980,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:
@@ -3078,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"
@@ -3433,17 +3458,17 @@ 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"
-  resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.37.1.tgz#d21751ddb756518ac5a00e4d66499df981a62db9"
-  integrity sha512-Xn6qmxrQZyB0FFY8E3bgRXei3lWDJHhvI+u0q9TKIYM49G8pAr0FgnnrFRAmsbptZL1yxRADVXn+x5AGsbBfyw==
+core-js@^3.38.1:
+  version "3.38.1"
+  resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.38.1.tgz#aa375b79a286a670388a1a363363d53677c0383e"
+  integrity sha512-OP35aUorbU3Zvlx7pjsFdu1rGNnD4pgw/CWoYzRY3t2EzoVT7shKHY1dlAy3f41cGIO7ZDPQimhGFTlEYkG/Hw==
 
 core-util-is@~1.0.0:
   version "1.0.3"
@@ -3982,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"
@@ -4012,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"
@@ -4527,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"
@@ -7154,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"
@@ -9556,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"
@@ -9570,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"


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/pkg/prometheus/test_helper.go b/pkg/prometheus/test_helper.go
index ed5d41e06..227171d01 100644
--- a/pkg/prometheus/test_helper.go
+++ b/pkg/prometheus/test_helper.go
@@ -70,12 +70,12 @@ func (m *MockProm) Flags(ctx context.Context) (promv1.FlagsResult, error) {
 }
 
 // LabelValues performs a query for the values of the given label, time range and matchers.
-func (m *MockProm) LabelValues(ctx context.Context, label string, matches []string, startTime time.Time, endTime time.Time) (model.LabelValues, promv1.Warnings, error) {
+func (m *MockProm) LabelValues(ctx context.Context, label string, matches []string, startTime time.Time, endTime time.Time, opts ...promv1.Option) (model.LabelValues, promv1.Warnings, error) {
 	return nil, nil, nil
 }
 
 // Series finds series by label matchers.
-func (m *MockProm) Series(ctx context.Context, matches []string, startTime time.Time, endTime time.Time) ([]model.LabelSet, promv1.Warnings, error) {
+func (m *MockProm) Series(ctx context.Context, matches []string, startTime time.Time, endTime time.Time, opts ...promv1.Option) ([]model.LabelSet, promv1.Warnings, error) {
 	return nil, nil, nil
 }
 
@@ -93,7 +93,7 @@ func (m *MockProm) Targets(ctx context.Context) (promv1.TargetsResult, error) {
 }
 
 // LabelNames returns the unique label names present in the block in sorted order by given time range and matchers.
-func (m *MockProm) LabelNames(ctx context.Context, matches []string, startTime time.Time, endTime time.Time) ([]string, promv1.Warnings, error) {
+func (m *MockProm) LabelNames(ctx context.Context, matches []string, startTime time.Time, endTime time.Time, opts ...promv1.Option) ([]string, promv1.Warnings, error) {
 	return []string{}, nil, nil
 }
 
@@ -128,7 +128,7 @@ func (m *MockProm) QueryExemplars(ctx context.Context, query string, startTime t
 }
 
 // TSDB returns the cardinality statistics.
-func (m *MockProm) TSDB(ctx context.Context) (promv1.TSDBResult, error) {
+func (m *MockProm) TSDB(ctx context.Context, opts ...promv1.Option) (promv1.TSDBResult, error) {
 	return promv1.TSDBResult{}, nil
 }
 
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,