Repository: linkerd/linkerd2
Pull request #6610 (2021-08-05 17:03:21)

Bump @fortawesome/free-regular-svg-icons from 5.15.3 to 5.15.4 in /web/app
Bumps [@fortawesome/free-regular-svg-icons](https://github.com/FortAwesome/Font-Awesome) from 5.15.3 to 5.15.4.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/FortAwesome/Font-Awesome/releases"><code>@​fortawesome/free-regular-svg-icons</code>'s releases</a>.</em></p>
<blockquote>
<h2>Release 5.15.4</h2>
<h3>Changed</h3>
<ul>
<li>Removed the tripadvisor brand icon by request of Tripadvisor</li>
<li>Update bug, bullseye, drone, icons <a href="https://github-redirect.dependabot.com/FortAwesome/Font-Awesome/issues/17800">FortAwesome/Font-Awesome#17800</a> <a href="https://github-redirect.dependabot.com/FortAwesome/Font-Awesome/issues/17106">FortAwesome/Font-Awesome#17106</a> <a href="https://github-redirect.dependabot.com/FortAwesome/Font-Awesome/issues/17730">FortAwesome/Font-Awesome#17730</a></li>
<li>Update bootstrap, discord, figma, and font-awesome-* brand icons <a href="https://github-redirect.dependabot.com/FortAwesome/Font-Awesome/issues/17436">FortAwesome/Font-Awesome#17436</a></li>
</ul>
<h3>Fixed</h3>
<ul>
<li>Made font-family matching case-insensitive for SVG + JavaScript version of Font Awesome <a href="https://github-redirect.dependabot.com/FortAwesome/Font-Awesome/issues/17860">FortAwesome/Font-Awesome#17860</a></li>
<li>Corrected missing version hospital-user <a href="https://github-redirect.dependabot.com/FortAwesome/Font-Awesome/issues/17435">FortAwesome/Font-Awesome#17435</a></li>
<li>Removed useless .tgz file in <code>@​fortawesome/fontawesome-pro</code> and all <code>@​fortawesome/pro</code>-*-svg-icons</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a href="https://github.com/FortAwesome/Font-Awesome/blob/master/CHANGELOG.md"><code>@​fortawesome/free-regular-svg-icons</code>'s changelog</a>.</em></p>
<blockquote>
<h2><a href="https://github.com/FortAwesome/Font-Awesome/releases/tag/5.15.4">5.15.4</a> - 2021-08-04</h2>
<h3>Changed</h3>
<ul>
<li>Removed the tripadvisor brand icon by request of Tripadvisor</li>
<li>Update bug, bullseye, drone, icons <a href="https://github-redirect.dependabot.com/FortAwesome/Font-Awesome/issues/17800">FortAwesome/Font-Awesome#17800</a> <a href="https://github-redirect.dependabot.com/FortAwesome/Font-Awesome/issues/17106">FortAwesome/Font-Awesome#17106</a> <a href="https://github-redirect.dependabot.com/FortAwesome/Font-Awesome/issues/17730">FortAwesome/Font-Awesome#17730</a></li>
<li>Update bootstrap, discord, figma, and font-awesome-* brand icons <a href="https://github-redirect.dependabot.com/FortAwesome/Font-Awesome/issues/17436">FortAwesome/Font-Awesome#17436</a></li>
</ul>
<h3>Fixed</h3>
<ul>
<li>Made font-family matching case-insensitive for SVG + JavaScript version of Font Awesome <a href="https://github-redirect.dependabot.com/FortAwesome/Font-Awesome/issues/17860">FortAwesome/Font-Awesome#17860</a></li>
<li>Corrected missing version hospital-user <a href="https://github-redirect.dependabot.com/FortAwesome/Font-Awesome/issues/17435">FortAwesome/Font-Awesome#17435</a></li>
<li>Removed useless .tgz file in <code>@​fortawesome/fontawesome-pro</code> and all <code>@​fortawesome/pro</code>-*-svg-icons</li>
</ul>
<hr />
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/FortAwesome/Font-Awesome/commit/7d3d774145ac38663f6d1effc6def0334b68ab7e"><code>7d3d774</code></a> Release 5.15.4 (<a href="https://github-redirect.dependabot.com/FortAwesome/Font-Awesome/issues/18141">#18141</a>)</li>
<li>See full diff in <a href="https://github.com/FortAwesome/Font-Awesome/compare/5.15.3...5.15.4">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@fortawesome/free-regular-svg-icons&package-manager=npm_and_yarn&previous-version=5.15.3&new-version=5.15.4)](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 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/lock.yml b/.github/workflows/lock.yml
index c4617d852..d6caf4e61 100644
--- a/.github/workflows/lock.yml
+++ b/.github/workflows/lock.yml
@@ -2,7 +2,7 @@ name: 'Lock Threads'
 
 on:
   schedule:
-    - cron: '0 * * * *'
+    - cron: '0 1 * * *'
 
 permissions:
   issues: write
diff --git a/CHANGES.md b/CHANGES.md
index ada6b6be5..9d1536665 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,5 +1,18 @@
 # Changes
 
+## edge-21.8.1
+
+This releases includes initial changes w.r.t addition of Authorization into
+Linkerd. It includes adding the new `policy.linkerd.io` CRDs to the core install.
+This also includes numerous dependency updates both in the web and dashboard.
+
+* Added `servers.policy.linkerd.io` and `serverauthorizations.policy.linkerd.io`
+  CRDs into the default Linkerd installation to support configuration and
+  discovery of inbound policies
+* Modified the proxy to support upcoming policy features
+* Updated several dashboard dependencies to latest versions
+* Updated several proxy dependencies to latest versions
+
 ## edge-21.7.5
 
 This release updates Linkerd to store the identity trust root in a ConfigMap to
diff --git a/cli/cmd/identity.go b/cli/cmd/identity.go
index 3b4724efd..6281c2850 100644
--- a/cli/cmd/identity.go
+++ b/cli/cmd/identity.go
@@ -191,7 +191,7 @@ func getContainerCertificate(k8sAPI *k8s.KubernetesAPI, pod corev1.Pod, containe
 }
 
 func getCertResponse(url string, pod corev1.Pod) ([]*x509.Certificate, error) {
-	serverName, err := getServerName(pod, k8s.ProxyContainerName)
+	serverName, err := k8s.PodIdentity(&pod)
 	if err != nil {
 		return nil, err
 	}
@@ -209,32 +209,6 @@ func getCertResponse(url string, pod corev1.Pod) ([]*x509.Certificate, error) {
 	return cert, nil
 }
 
-func getServerName(pod corev1.Pod, containerName string) (string, error) {
-	if pod.Status.Phase != corev1.PodRunning {
-		return "", fmt.Errorf("pod not running: %s", pod.GetName())
-	}
-
-	var l5dns string
-	var l5dtrustdomain string
-	podsa := pod.Spec.ServiceAccountName
-	podns := pod.ObjectMeta.Namespace
-	for _, c := range pod.Spec.Containers {
-		if c.Name == containerName {
-			for _, env := range c.Env {
-				if env.Name == "_l5d_ns" {
-					l5dns = env.Value
-				}
-				if env.Name == "_l5d_trustdomain" {
-					l5dtrustdomain = env.Value
-				}
-			}
-		}
-	}
-
-	serverName := fmt.Sprintf("%s.%s.serviceaccount.identity.%s.%s", podsa, podns, l5dns, l5dtrustdomain)
-	return serverName, nil
-}
-
 func getPods(ctx context.Context, clientset kubernetes.Interface, namespace string, selector string, args []string) ([]corev1.Pod, error) {
 	if len(args) > 0 {
 		var pods []corev1.Pod
diff --git a/pkg/k8s/k8s.go b/pkg/k8s/k8s.go
index f64f77693..a057409d6 100644
--- a/pkg/k8s/k8s.go
+++ b/pkg/k8s/k8s.go
@@ -3,6 +3,7 @@ package k8s
 import (
 	"fmt"
 
+	corev1 "k8s.io/api/core/v1"
 	"k8s.io/client-go/rest"
 	"k8s.io/client-go/tools/clientcmd"
 )
@@ -193,3 +194,30 @@ func KindToL5DLabel(k8sKind string) string {
 	}
 	return k8sKind
 }
+
+// PodIdentity returns the mesh TLS identity name of this pod, as constructed
+// from the pod's service account name and other metadata.
+func PodIdentity(pod *corev1.Pod) (string, error) {
+	if pod.Status.Phase != corev1.PodRunning {
+		return "", fmt.Errorf("pod not running: %s", pod.GetName())
+	}
+
+	podsa := pod.Spec.ServiceAccountName
+	podns := pod.ObjectMeta.Namespace
+	for _, c := range pod.Spec.Containers {
+		if c.Name == ProxyContainerName {
+			var l5dns string
+			var l5dtrustdomain string
+			for _, env := range c.Env {
+				if env.Name == "_l5d_ns" {
+					l5dns = env.Value
+				}
+				if env.Name == "_l5d_trustdomain" {
+					l5dtrustdomain = env.Value
+				}
+			}
+			return fmt.Sprintf("%s.%s.serviceaccount.identity.%s.%s", podsa, podns, l5dns, l5dtrustdomain), nil
+		}
+	}
+	return "", nil
+}
diff --git a/viz/metrics-api/edges.go b/viz/metrics-api/edges.go
index 1f4627ce1..7faa1fbb9 100644
--- a/viz/metrics-api/edges.go
+++ b/viz/metrics-api/edges.go
@@ -2,19 +2,18 @@ package api
 
 import (
 	"context"
-	"errors"
 	"fmt"
 	"sort"
-	"strings"
 
+	"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"
+	v1 "k8s.io/api/core/v1"
 )
 
 const (
-	inboundIdentityQuery  = "count(%s%s) by (%s, client_id, namespace, no_tls_reason)"
-	outboundIdentityQuery = "count(%s%s) by (%s, dst_%s, server_id, namespace, dst_namespace, no_tls_reason)"
+	edgesQuery = "sum(%s%s) by (%s, dst_%s, pod, server_id, namespace, dst_namespace, no_tls_reason)"
 )
 
 var formatMsg = map[string]string{
@@ -26,48 +25,78 @@ var formatMsg = map[string]string{
 	"not_provided_by_service_discovery": "Not Provided By Service Discovery",
 }
 
+type edgeKey struct {
+	src   string
+	srcNs string
+	dst   string
+	dstNs string
+}
+
 func (s *grpcServer) Edges(ctx context.Context, req *pb.EdgesRequest) (*pb.EdgesResponse, error) {
 	log.Debugf("Edges request: %+v", req)
 	if req.GetSelector().GetResource() == nil {
 		return edgesError(req, "Edges request missing Selector Resource"), nil
 	}
 
-	edgesHTTP, err := s.getEdges(ctx, req, "response_total")
-	if err != nil {
-		return edgesError(req, err.Error()), nil
-	}
-	edgesTCP, err := s.getEdges(ctx, req, "tcp_open_total")
+	resourceType := promResourceType(req.GetSelector().GetResource())
+	dstResourceType := "dst_" + resourceType
+	labelsOutbound := promDirectionLabels("outbound")
+	labelsOutboundStr := generateLabelStringWithExclusion(labelsOutbound, string(resourceType), string(dstResourceType))
+	query := fmt.Sprintf(edgesQuery, "tcp_open_connections", labelsOutboundStr, resourceType, resourceType)
+
+	promResult, err := s.queryProm(ctx, query)
 	if err != nil {
 		return edgesError(req, err.Error()), nil
 	}
 
-	edges := []*pb.Edge{}
-	// iterate over tcp_open_total metrics
-	for _, edgeTCP := range edgesTCP {
-		edgeHTTPIndex := -1
-		// find the corresponding response_total entry
-		for i, edgeHTTP := range edgesHTTP {
-			if equalEdges(edgeTCP, edgeHTTP) {
-				edgeHTTPIndex = i
-				break
+	edgeMap := make(map[edgeKey]*pb.Edge)
+
+	for _, sample := range promResult {
+		if sample.Value == 0.0 {
+			continue
+		}
+		key := edgeKey{
+			src:   string(sample.Metric[resourceType]),
+			srcNs: string(sample.Metric[model.LabelName("namespace")]),
+			dst:   string(sample.Metric[dstResourceType]),
+			dstNs: string(sample.Metric[model.LabelName("dst_namespace")]),
+		}
+		requestedNs := req.GetSelector().GetResource().GetNamespace()
+		if requestedNs != v1.NamespaceAll {
+			if requestedNs != key.srcNs && requestedNs != key.dstNs {
+				continue
 			}
 		}
+		if _, ok := edgeMap[key]; !ok {
 
-		edge := edgeTCP
-		if edgeHTTPIndex > -1 {
-			// if tcp_open_total doesn't have client_id info,
-			// use the response_total metric instead
-			if edge.ClientId == "" {
-				edge = edgesHTTP[edgeHTTPIndex]
+			clientID, err := s.getPodIdentity(string(sample.Metric[model.LabelName("pod")]), key.srcNs)
+			if err != nil {
+				log.Warnf("failed to get pod identity for %s: %v", sample.Metric[model.LabelName("pod")], err)
+				continue
+			}
+
+			edgeMap[key] = &pb.Edge{
+				Src: &pb.Resource{
+					Namespace: key.srcNs,
+					Name:      key.src,
+					Type:      string(resourceType),
+				},
+				Dst: &pb.Resource{
+					Namespace: key.dstNs,
+					Name:      key.dst,
+					Type:      string(resourceType),
+				},
+				ServerId:      string(sample.Metric[model.LabelName("server_id")]),
+				ClientId:      clientID,
+				NoIdentityMsg: formatMsg[string(sample.Metric[model.LabelName("no_tls_reason")])],
 			}
-			// trick to remove element quickly
-			edgesHTTP[edgeHTTPIndex] = edgesHTTP[len(edgesHTTP)-1]
-			edgesHTTP = edgesHTTP[:len(edgesHTTP)-1]
 		}
-		edges = append(edges, edge)
 	}
 
-	edges = append(edges, edgesHTTP...)
+	edges := []*pb.Edge{}
+	for _, edge := range edgeMap {
+		edges = append(edges, edge)
+	}
 	edges = sortEdgeRows(edges)
 
 	return &pb.EdgesResponse{
@@ -90,144 +119,12 @@ func edgesError(req *pb.EdgesRequest, message string) *pb.EdgesResponse {
 	}
 }
 
-func (s *grpcServer) getEdges(ctx context.Context, req *pb.EdgesRequest, metric string) ([]*pb.Edge, error) {
-	labelNames := promGroupByLabelNames(req.Selector.Resource)
-	if len(labelNames) != 2 {
-		return nil, errors.New("unexpected resource selector")
-	}
-	selectedNamespace := req.Selector.Resource.Namespace
-	resourceType := string(labelNames[1]) // skipping first name which is always namespace
-	labelsOutbound := promDirectionLabels("outbound")
-	labelsInbound := promDirectionLabels("inbound")
-
-	// checking that data for the specified resource type exists
-	labelsOutboundStr := generateLabelStringWithExclusion(labelsOutbound, resourceType)
-	labelsInboundStr := generateLabelStringWithExclusion(labelsInbound, resourceType)
-
-	outboundQuery := fmt.Sprintf(outboundIdentityQuery, metric, labelsOutboundStr, resourceType, resourceType)
-	inboundQuery := fmt.Sprintf(inboundIdentityQuery, metric, labelsInboundStr, resourceType)
-
-	inboundResult, err := s.queryProm(ctx, inboundQuery)
-	if err != nil {
-		return nil, err
-	}
-
-	outboundResult, err := s.queryProm(ctx, outboundQuery)
+func (s *grpcServer) getPodIdentity(pod string, namespace string) (string, error) {
+	po, err := s.k8sAPI.Pod().Lister().Pods(namespace).Get(pod)
 	if err != nil {
-		return nil, err
+		return "", err
 	}
-
-	edges := processEdgeMetrics(inboundResult, outboundResult, resourceType, selectedNamespace)
-	return edges, nil
-}
-
-func processEdgeMetrics(inbound, outbound model.Vector, resourceType, selectedNamespace string) []*pb.Edge {
-	edges := []*pb.Edge{}
-	dstIndex := map[model.LabelValue]model.Metric{}
-	srcIndex := map[model.LabelValue][]model.Metric{}
-	resourceReplacementInbound := resourceType
-	resourceReplacementOutbound := "dst_" + resourceType
-
-	for _, sample := range inbound {
-		// skip inbound results without a clientID because we cannot construct edge
-		// information
-		if clientID, ok := sample.Metric[model.LabelName("client_id")]; ok {
-			dstResource := string(sample.Metric[model.LabelName(resourceReplacementInbound)])
-
-			// format of clientId is id.namespace.serviceaccount.cluster.local
-			clientIDSlice := strings.Split(string(clientID), ".")
-			srcNs := clientIDSlice[1]
-			key := model.LabelValue(fmt.Sprintf("%s.%s", dstResource, srcNs))
-			dstIndex[key] = sample.Metric
-		}
-	}
-
-	for _, sample := range outbound {
-		// e.g. tcp_open_total from older proxies don't have enough labels
-		if _, ok := sample.Metric[model.LabelName("dst_namespace")]; !ok {
-			continue
-		}
-		dstResource := sample.Metric[model.LabelName(resourceReplacementOutbound)]
-		srcNs := sample.Metric[model.LabelName("namespace")]
-
-		key := model.LabelValue(fmt.Sprintf("%s.%s", dstResource, srcNs))
-		if _, ok := srcIndex[key]; !ok {
-			srcIndex[key] = []model.Metric{}
-		}
-		srcIndex[key] = append(srcIndex[key], sample.Metric)
-	}
-
-	for key, sources := range srcIndex {
-		for _, src := range sources {
-			srcNamespace := string(src[model.LabelName("namespace")])
-
-			dst, ok := dstIndex[key]
-
-			// if no destination, build edge entirely from source data
-			if !ok {
-				dstNamespace := string(src[model.LabelName("dst_namespace")])
-
-				// skip if selected namespace is given and neither the source nor
-				// destination is in the selected namespace
-				if selectedNamespace != "" && srcNamespace != selectedNamespace &&
-					dstNamespace != selectedNamespace {
-					continue
-				}
-
-				srcResource := string(src[model.LabelName(resourceType)])
-				dstResource := string(src[model.LabelName(resourceReplacementOutbound)])
-
-				// skip if source or destination resource is not present
-				if srcResource == "" || dstResource == "" {
-					continue
-				}
-
-				msg := formatMsg[string(src[model.LabelName("no_tls_reason")])]
-				edge := &pb.Edge{
-					Src: &pb.Resource{
-						Namespace: srcNamespace,
-						Name:      srcResource,
-						Type:      resourceType,
-					},
-					Dst: &pb.Resource{
-						Namespace: dstNamespace,
-						Name:      dstResource,
-						Type:      resourceType,
-					},
-					NoIdentityMsg: msg,
-				}
-				edges = append(edges, edge)
-				continue
-			}
-
-			dstNamespace := string(dst[model.LabelName("namespace")])
-
-			// skip if selected namespace is given and neither the source nor
-			// destination is in the selected namespace
-			if selectedNamespace != "" && srcNamespace != selectedNamespace &&
-				dstNamespace != selectedNamespace {
-				continue
-			}
-
-			edge := &pb.Edge{
-				Src: &pb.Resource{
-					Namespace: srcNamespace,
-					Name:      string(src[model.LabelName(resourceType)]),
-					Type:      resourceType,
-				},
-				Dst: &pb.Resource{
-					Namespace: dstNamespace,
-					Name:      string(dst[model.LabelName(resourceType)]),
-					Type:      resourceType,
-				},
-				ClientId: string(dst[model.LabelName("client_id")]),
-				ServerId: string(src[model.LabelName("server_id")]),
-			}
-			edges = append(edges, edge)
-		}
-	}
-
-	return edges
+	return k8s.PodIdentity(po)
 }
 
 func sortEdgeRows(rows []*pb.Edge) []*pb.Edge {
@@ -238,8 +135,3 @@ func sortEdgeRows(rows []*pb.Edge) []*pb.Edge {
 	})
 	return rows
 }
-
-func equalEdges(a, b *pb.Edge) bool {
-	return a.Src.Namespace == b.Src.Namespace && a.Src.Name == b.Src.Name && a.Src.Type == b.Src.Type &&
-		a.Dst.Namespace == b.Dst.Namespace && a.Dst.Name == b.Dst.Name && a.Dst.Type == b.Dst.Type
-}
diff --git a/viz/metrics-api/prometheus.go b/viz/metrics-api/prometheus.go
index c7c1269a4..bdacb3f97 100644
--- a/viz/metrics-api/prometheus.go
+++ b/viz/metrics-api/prometheus.go
@@ -143,12 +143,14 @@ func promDstQueryLabels(resource *pb.Resource) model.LabelSet {
 // 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.
-func generateLabelStringWithExclusion(l model.LabelSet, labelName string) string {
+func generateLabelStringWithExclusion(l model.LabelSet, labelNames ...string) string {
 	lstrs := make([]string, 0, len(l))
 	for l, v := range l {
 		lstrs = append(lstrs, fmt.Sprintf("%s=%q", l, v))
 	}
-	lstrs = append(lstrs, fmt.Sprintf(`%s!=""`, labelName))
+	for _, labelName := range labelNames {
+		lstrs = append(lstrs, fmt.Sprintf(`%s!=""`, labelName))
+	}
 
 	sort.Strings(lstrs)
 	return fmt.Sprintf("{%s}", strings.Join(lstrs, ", "))
diff --git a/web/app/js/components/BreadcrumbHeader.jsx b/web/app/js/components/BreadcrumbHeader.jsx
index 09b70defd..a3381503f 100644
--- a/web/app/js/components/BreadcrumbHeader.jsx
+++ b/web/app/js/components/BreadcrumbHeader.jsx
@@ -15,6 +15,7 @@ const routeToCrumbTitle = {
   routes: <Trans>menuItemRoutes</Trans>,
   community: <Trans>menuItemCommunity</Trans>,
   gateways: <Trans>menuItemGateway</Trans>,
+  extensions: <Trans>menuItemExtension</Trans>,
 };
 
 class BreadcrumbHeader extends React.Component {
diff --git a/web/app/js/components/Extensions.jsx b/web/app/js/components/Extensions.jsx
new file mode 100644
index 000000000..f449211a3
--- /dev/null
+++ b/web/app/js/components/Extensions.jsx
@@ -0,0 +1,99 @@
+import 'whatwg-fetch';
+
+import React from 'react';
+import ErrorBanner from './ErrorBanner.jsx';
+import Button from '@material-ui/core/Button';
+import Paper from '@material-ui/core/Paper';
+import Grid from '@material-ui/core/Grid';
+import { withStyles } from '@material-ui/core/styles';
+import Typography from '@material-ui/core/Typography';
+import { Trans } from '@lingui/macro';
+
+const styles = theme => ({
+  extensionCard: {
+    padding: theme.spacing(2),
+    maxWidth: '1200px',
+  },
+  screenshot: {
+    margin: 'auto',
+    display: 'block',
+    height: '100%',
+    width: '100%',
+    borderRadius: '8px',
+  },
+});
+
+class Extensions extends React.Component {
+  constructor(props) {
+    super(props);
+    this.loadFromServer = this.loadFromServer.bind(this);
+    this.state = {
+      extensions: [],
+    };
+  }
+
+  componentDidMount() {
+    this.loadFromServer();
+  }
+
+  loadFromServer() {
+    fetch('https://linkerd.io/extensions/index.json', { cache: 'no-store' })
+      .then(res => res.json())
+      .then(json => {
+        this.setState({ extensions: json.data });
+      }).catch(err => this.handleApiError(err));
+  }
+
+  handleApiError = e => {
+    if (e.isCanceled) {
+      return;
+    }
+    this.setState({
+      error: e,
+    });
+  };
+
+  render() {
+    const { classes } = this.props;
+    const { extensions, error } = this.state;
+
+    if (error) {
+      return <ErrorBanner message={error} onHideMessage={() => this.setState({ error: null })} />;
+    }
+
+    return (
+      <Grid container direction="column" spacing={3} justifyContent="center" alignItems="center">
+        <Grid item xs={12}>
+          <Typography variant="h6">
+            <Trans>extensionsPageMsg</Trans>
+          </Typography>
+        </Grid>
+        {extensions.map(ext => (
+          <Grid key={ext.name} item xs={12}>
+            <Paper elevation={3} className={classes.extensionCard}>
+              <Grid container spacing={1} direction="row" justifyContent="space-between" alignItems="center">
+                <Grid container spacing={3} item xs={8} direction="column" justifyContent="center">
+                  <Grid item><Typography variant="h5">{ext.name}</Typography></Grid>
+                  <Grid item><Typography variant="body1">{ext.description}</Typography></Grid>
+                  <Grid item>
+                    <Button
+                      color="primary"
+                      href={ext.docLink}
+                      target="_blank">
+                      <Trans>buttonLearnMore</Trans>
+                    </Button>
+                  </Grid>
+                </Grid>
+                <Grid item xs={4}>
+                  <img className={classes.screenshot} src={ext.screenshotURL} alt={`${ext.name} extension screenshot`} />
+                </Grid>
+              </Grid>
+            </Paper>
+          </Grid>
+        ))}
+      </Grid>
+    );
+  }
+}
+
+export default withStyles(styles)(Extensions);
diff --git a/web/app/js/components/Navigation.jsx b/web/app/js/components/Navigation.jsx
index 44f74783f..85ecc7fab 100644
--- a/web/app/js/components/Navigation.jsx
+++ b/web/app/js/components/Navigation.jsx
@@ -36,6 +36,7 @@ import { faMicroscope } from '@fortawesome/free-solid-svg-icons/faMicroscope';
 import { faRandom } from '@fortawesome/free-solid-svg-icons/faRandom';
 import { faSmile } from '@fortawesome/free-regular-svg-icons/faSmile';
 import { faStream } from '@fortawesome/free-solid-svg-icons/faStream';
+import { faPuzzlePiece } from '@fortawesome/free-solid-svg-icons/faPuzzlePiece';
 import grey from '@material-ui/core/colors/grey';
 import { processSingleResourceRollup } from './util/MetricUtils.jsx';
 import { regexFilterString } from './util/Utils.js';
@@ -373,7 +374,8 @@ class NavigationBase extends React.Component {
 
   handleFilterInputChange = event => {
     this.setState({
-      formattedNamespaceFilter: regexFilterString(event.target.value) });
+      formattedNamespaceFilter: regexFilterString(event.target.value),
+    });
   }
 
   handleAutocompleteClick = event => {
@@ -455,11 +457,11 @@ class NavigationBase extends React.Component {
     const drawer = (
       <div>
         { !mobileSidebarOpen &&
-        <div className={classes.navToolbar}>
-          <div className={classes.linkerdNavLogo}>
-            <Link to="/namespaces">{linkerdWordLogo}</Link>
+          <div className={classes.navToolbar}>
+            <div className={classes.linkerdNavLogo}>
+              <Link to="/namespaces">{linkerdWordLogo}</Link>
+            </div>
           </div>
-        </div>
         }
         <Divider />
         <MenuList>
@@ -562,6 +564,10 @@ class NavigationBase extends React.Component {
               <FontAwesomeIcon icon={faSmile} className={classes.shrinkIcon} />
             </Badge>, this.handleCommunityClick) }
 
+          { this.menuItem('/extensions', <Trans>menuItemExtension</Trans>,
+            <Badge classes={{ badge: classes.badge }}><FontAwesomeIcon icon={faPuzzlePiece} className={classes.shrinkIcon} /></Badge>)
+          }
+
           <MenuItem component="a" href="https://linkerd.io/2/overview/" target="_blank" className={classes.navMenuItem}>
             <ListItemIcon><LibraryBooksIcon className={classes.shrinkIcon} /></ListItemIcon>
             <ListItemText primary={<Trans>menuItemDocumentation</Trans>} />
@@ -624,11 +630,11 @@ class NavigationBase extends React.Component {
                 {linkerdWordLogo}
               </div>
               { !mobileSidebarOpen && // mobile view but no sidebar
-              <React.Fragment>
-                <IconButton onClick={this.handleDrawerClick} className={classes.bars}>
-                  <FontAwesomeIcon icon={faBars} />
-                </IconButton>
-              </React.Fragment>
+                <React.Fragment>
+                  <IconButton onClick={this.handleDrawerClick} className={classes.bars}>
+                    <FontAwesomeIcon icon={faBars} />
+                  </IconButton>
+                </React.Fragment>
               }
             </Toolbar>
           </AppBar>
diff --git a/web/app/js/index.js b/web/app/js/index.js
index e77104efc..7aad7f336 100644
--- a/web/app/js/index.js
+++ b/web/app/js/index.js
@@ -3,12 +3,13 @@ import '../img/favicon.png'; // needs to be referenced somewhere so webpack bund
 
 import { BrowserRouter, Redirect, Route, Switch } from 'react-router-dom';
 import { DETECTORS, LocaleResolver, TRANSFORMERS } from 'locales-detector';
-import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles';
+import { MuiThemeProvider, createTheme } from '@material-ui/core/styles';
 
 import ApiHelpers from './components/util/ApiHelpers.jsx';
 import AppContext from './components/util/AppContext.jsx';
 import Community from './components/Community.jsx';
 import CssBaseline from '@material-ui/core/CssBaseline';
+import Extensions from './components/Extensions.jsx';
 import Gateway from './components/Gateway.jsx';
 import { I18nProvider } from '@lingui/react';
 import { i18n } from '@lingui/core';
@@ -113,7 +114,7 @@ class App extends React.Component {
 }
 
 function AppHTML() {
-  const theme = createMuiTheme(dashboardTheme);
+  const theme = createTheme(dashboardTheme);
 
   return (
     <React.Fragment>
@@ -211,6 +212,9 @@ function AppHTML() {
               <Route
                 path={`${pathPrefix}/community`}
                 render={props => <Navigation {...props} ChildComponent={Community} />} />
+              <Route
+                path={`${pathPrefix}/extensions`}
+                render={props => <Navigation {...props} ChildComponent={Extensions} />} />
               <Route component={NoMatch} />
             </Switch>
           </QueryParamProvider>
diff --git a/web/app/js/locales/en/messages.json b/web/app/js/locales/en/messages.json
index 42797ab0d..5c3fe9f43 100644
--- a/web/app/js/locales/en/messages.json
+++ b/web/app/js/locales/en/messages.json
@@ -19,6 +19,7 @@
   "buttonClose": "Close",
   "buttonCreateServiceProfile": "Create Service Profile",
   "buttonDownload": "Download",
+  "buttonLearnMore": "Learn More",
   "buttonReRunCheck": "Re-Run Check",
   "buttonReset": "Reset",
   "buttonRunLinkerdCheck": "Run Linkerd Check",
@@ -74,6 +75,7 @@
   "connectResourceMsg {resource}": "Connect your first {resource}",
   "controllerInstalledMsg": "Controller successfully installed",
   "createNewProfileMsg": "You can also create a new profile",
+  "extensionsPageMsg": "Linkerd provides a mix of built-in and third-party extensions to add additional functionality to the base installation. The following is the list of known extensions:",
   "formAuthority": "Authority",
   "formAuthorityHelpText": "Display requests with this :authority",
   "formCreateServiceProfileHelpText": "To create a service profile, download a profile and then apply it with `kubectl apply`.",
@@ -113,6 +115,7 @@
   "menuItemDaemonSets": "Daemon Sets",
   "menuItemDeployments": "Deployments",
   "menuItemDocumentation": "Documentation",
+  "menuItemExtension": "Extensions",
   "menuItemGateway": "Gateway",
   "menuItemGitHub": "GitHub",
   "menuItemJobs": "Jobs",
diff --git a/web/app/js/locales/es/messages.json b/web/app/js/locales/es/messages.json
index cbd6d03d7..62a01ea8f 100644
--- a/web/app/js/locales/es/messages.json
+++ b/web/app/js/locales/es/messages.json
@@ -19,6 +19,7 @@
   "buttonClose": "Cerrar",
   "buttonCreateServiceProfile": "Crear Service Profile",
   "buttonDownload": "Descargar",
+  "buttonLearnMore": "Aprende Más",
   "buttonReRunCheck": "Volver a Check",
   "buttonReset": "Restablecer",
   "buttonRunLinkerdCheck": "Ejecutar Linkerd Check",
@@ -113,6 +114,7 @@
   "menuItemDaemonSets": "Daemon Sets",
   "menuItemDeployments": "Deployments",
   "menuItemDocumentation": "Documentación",
+  "menuItemExtension": "Extensions",
   "menuItemGateway": "Gateway",
   "menuItemGitHub": "GitHub",
   "menuItemJobs": "Jobs",
diff --git a/web/app/package.json b/web/app/package.json
index 0eea37c29..c37d94c22 100644
--- a/web/app/package.json
+++ b/web/app/package.json
@@ -6,7 +6,7 @@
   "dependencies": {
     "@babel/eslint-plugin": "7.14.5",
     "@fortawesome/fontawesome-svg-core": "1.2.35",
-    "@fortawesome/free-regular-svg-icons": "5.15.3",
+    "@fortawesome/free-regular-svg-icons": "5.15.4",
     "@fortawesome/free-solid-svg-icons": "5.15.3",
     "@fortawesome/react-fontawesome": "0.1.15",
     "@lingui/core": "^3.10.4",
diff --git a/web/app/yarn.lock b/web/app/yarn.lock
index b1b4ffb6c..72acfa542 100644
--- a/web/app/yarn.lock
+++ b/web/app/yarn.lock
@@ -1037,10 +1037,10 @@
     minimatch "^3.0.4"
     strip-json-comments "^3.1.1"
 
-"@fortawesome/fontawesome-common-types@^0.2.35":
-  version "0.2.35"
-  resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.35.tgz#01dd3d054da07a00b764d78748df20daf2b317e9"
-  integrity sha512-IHUfxSEDS9dDGqYwIW7wTN6tn/O8E0n5PcAHz9cAaBoZw6UpG20IG/YM3NNLaGPwPqgjBAFjIURzqoQs3rrtuw==
+"@fortawesome/fontawesome-common-types@^0.2.35", "@fortawesome/fontawesome-common-types@^0.2.36":
+  version "0.2.36"
+  resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz#b44e52db3b6b20523e0c57ef8c42d315532cb903"
+  integrity sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg==
 
 "@fortawesome/fontawesome-svg-core@1.2.35":
   version "1.2.35"
@@ -1049,12 +1049,12 @@
   dependencies:
     "@fortawesome/fontawesome-common-types" "^0.2.35"
 
-"@fortawesome/free-regular-svg-icons@5.15.3":
-  version "5.15.3"
-  resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.15.3.tgz#1ec4f2410ff638db549c5c5484fc60b66407dbe6"
-  integrity sha512-q4/p8Xehy9qiVTdDWHL4Z+o5PCLRChePGZRTXkl+/Z7erDVL8VcZUuqzJjs6gUz6czss4VIPBRdCz6wP37/zMQ==
+"@fortawesome/free-regular-svg-icons@5.15.4":
+  version "5.15.4"
+  resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.15.4.tgz#b97edab436954333bbeac09cfc40c6a951081a02"
+  integrity sha512-9VNNnU3CXHy9XednJ3wzQp6SwNwT3XaM26oS4Rp391GsxVYA+0oDR2J194YCIWf7jNRCYKjUCOduxdceLrx+xw==
   dependencies:
-    "@fortawesome/fontawesome-common-types" "^0.2.35"
+    "@fortawesome/fontawesome-common-types" "^0.2.36"
 
 "@fortawesome/free-solid-svg-icons@5.15.3":
   version "5.15.3"
diff --git a/web/srv/server.go b/web/srv/server.go
index b2b288b9d..8219b3c62 100644
--- a/web/srv/server.go
+++ b/web/srv/server.go
@@ -176,6 +176,7 @@ func NewServer(
 	server.router.GET("/top", handler.handleIndex)
 	server.router.GET("/community", handler.handleIndex)
 	server.router.GET("/routes", handler.handleIndex)
+	server.router.GET("/extensions", handler.handleIndex)
 	server.router.GET("/profiles/new", handler.handleProfileDownload)
 
 	// add catch-all parameter to match all files in dir


Test patch:
diff --git a/test/integration/edges/edges_test.go b/test/integration/edges/edges_test.go
index c184de3d6..bde2de6de 100644
--- a/test/integration/edges/edges_test.go
+++ b/test/integration/edges/edges_test.go
@@ -26,12 +26,12 @@ func TestMain(m *testing.M) {
 // and linkerd-controller for edges to have been registered, which is the
 // case when running this test in the context of the other integration tests.
 
-// This test has been disabled because it can fail due to
-// https://github.com/linkerd/linkerd2/issues/3706
-// This test should be updated and re-enabled when that issue is addressed.
-/*
 func TestEdges(t *testing.T) {
 	ns := TestHelper.GetLinkerdNamespace()
+	promNs := TestHelper.GetVizNamespace()
+	if TestHelper.ExternalPrometheus() {
+		promNs = "external-prometheus"
+	}
 	cmd := []string{
 		"edges",
 		"-n", ns,
@@ -44,7 +44,10 @@ func TestEdges(t *testing.T) {
 	}
 
 	tpl := template.Must(template.ParseFiles("testdata/linkerd_edges.golden"))
-	vars := struct{ Ns string }{ns}
+	vars := struct {
+		Ns     string
+		PromNs string
+	}{ns, promNs}
 	var b bytes.Buffer
 	if err := tpl.Execute(&b, vars); err != nil {
 		t.Fatalf("failed to parse linkerd_edges.golden template: %s", err)
@@ -55,7 +58,6 @@ func TestEdges(t *testing.T) {
 		t.Errorf("Expected output:\n%s\nactual:\n%s", b.String(), out)
 	}
 }
-*/
 
 // TestDirectEdges deploys a terminus and then generates a load generator which
 // sends traffic directly to the pod ip of the terminus pod.
diff --git a/test/integration/edges/testdata/linkerd_edges.golden b/test/integration/edges/testdata/linkerd_edges.golden
index b8df0f117..5753225a5 100644
--- a/test/integration/edges/testdata/linkerd_edges.golden
+++ b/test/integration/edges/testdata/linkerd_edges.golden
@@ -1,20 +1,29 @@
 \[
   \{
-    "src": "linkerd\-controller",
-    "src_namespace": "{{.Ns}}",
-    "dst": "linkerd\-prometheus",
+    "src": "prometheus",
+    "src_namespace": "{{.PromNs}}",
+    "dst": "linkerd\-destination",
     "dst_namespace": "{{.Ns}}",
-    "client_id": "linkerd\-controller.{{.Ns}}",
-    "server_id": "linkerd\-prometheus.{{.Ns}}",
+    "client_id": "prometheus.{{.PromNs}}",
+    "server_id": "linkerd\-destination.{{.Ns}}",
     "no_tls_reason": ""
   \},
   \{
-    "src": "linkerd\-web",
-    "src_namespace": "{{.Ns}}",
-    "dst": "linkerd\-controller",
+    "src": "prometheus",
+    "src_namespace": "{{.PromNs}}",
+    "dst": "linkerd\-identity",
     "dst_namespace": "{{.Ns}}",
-    "client_id": "linkerd\-web.{{.Ns}}",
-    "server_id": "linkerd\-controller.{{.Ns}}",
+    "client_id": "prometheus.{{.PromNs}}",
+    "server_id": "linkerd\-identity.{{.Ns}}",
+    "no_tls_reason": ""
+  \},
+  \{
+    "src": "prometheus",
+    "src_namespace": "{{.PromNs}}",
+    "dst": "linkerd\-proxy\-injector",
+    "dst_namespace": "{{.Ns}}",
+    "client_id": "prometheus.{{.PromNs}}",
+    "server_id": "linkerd\-proxy\-injector.{{.Ns}}",
     "no_tls_reason": ""
   \}
 \]
\ No newline at end of file
diff --git a/viz/metrics-api/edges_test.go b/viz/metrics-api/edges_test.go
index c147d5458..119eca87f 100644
--- a/viz/metrics-api/edges_test.go
+++ b/viz/metrics-api/edges_test.go
@@ -2,6 +2,7 @@ package api
 
 import (
 	"context"
+	"fmt"
 	"testing"
 
 	"github.com/golang/protobuf/proto"
@@ -11,9 +12,9 @@ import (
 )
 
 const (
-	clientIDLabel = model.LabelName("client_id")
 	serverIDLabel = model.LabelName("server_id")
 	resourceLabel = model.LabelName("deployment")
+	podLabel      = model.LabelName("pod")
 )
 
 type edgesExpected struct {
@@ -22,18 +23,6 @@ type edgesExpected struct {
 	expectedResponse *pb.EdgesResponse // the edges response we expect
 }
 
-func genInboundPromSample(resourceNamespace, resourceName, clientID string) *model.Sample {
-	return &model.Sample{
-		Metric: model.Metric{
-			resourceLabel:  model.LabelValue(resourceName),
-			namespaceLabel: model.LabelValue(resourceNamespace),
-			clientIDLabel:  model.LabelValue(clientID),
-		},
-		Value:     123,
-		Timestamp: 456,
-	}
-}
-
 func genOutboundPromSample(resourceNamespace, resourceName, resourceNameDst, resourceNamespaceDst, serverID string) *model.Sample {
 	dstResourceLabel := "dst_" + resourceLabel
 
@@ -44,12 +33,33 @@ func genOutboundPromSample(resourceNamespace, resourceName, resourceNameDst, res
 			dstNamespaceLabel: model.LabelValue(resourceNamespaceDst),
 			dstResourceLabel:  model.LabelValue(resourceNameDst),
 			serverIDLabel:     model.LabelValue(serverID),
+			podLabel:          model.LabelValue(resourceName + "-0"),
 		},
 		Value:     123,
 		Timestamp: 456,
 	}
 }
 
+func genPod(name, namespace, sa string) string {
+	return fmt.Sprintf(`apiVersion: v1
+kind: Pod
+metadata:
+  name: %s
+  namespace: %s
+spec:
+  containers:
+  - name: linkerd-proxy
+    env:
+    - name: _l5d_ns
+      value: linkerd
+    - name: _l5d_trustdomain
+      value: cluster.local
+  serviceAccountName: %s
+status:
+  phase: Running
+`, name, namespace, sa)
+}
+
 func testEdges(t *testing.T, expectations []edgesExpected) {
 	for _, exp := range expectations {
 		mockProm, fakeGrpcServer, err := newMockGrpcServer(exp.expectedStatRPC)
@@ -94,15 +104,15 @@ func testEdges(t *testing.T, expectations []edgesExpected) {
 
 func TestEdges(t *testing.T) {
 	mockPromResponse := model.Vector{
-		genInboundPromSample("emojivoto", "emoji", "web.emojivoto.serviceaccount.identity.linkerd.cluster.local"),
-		genInboundPromSample("emojivoto", "voting", "web.emojivoto.serviceaccount.identity.linkerd.cluster.local"),
-		genInboundPromSample("emojivoto", "web", "default.emojivoto.serviceaccount.identity.linkerd.cluster.local"),
-		genInboundPromSample("linkerd", "linkerd-prometheus", "linkerd-identity.linkerd.identity.linkerd.cluster.local"),
-
 		genOutboundPromSample("emojivoto", "web", "emoji", "emojivoto", "emoji.emojivoto.serviceaccount.identity.linkerd.cluster.local"),
 		genOutboundPromSample("emojivoto", "web", "voting", "emojivoto", "voting.emojivoto.serviceaccount.identity.linkerd.cluster.local"),
 		genOutboundPromSample("emojivoto", "vote-bot", "web", "emojivoto", "web.emojivoto.serviceaccount.identity.linkerd.cluster.local"),
-		genOutboundPromSample("linkerd", "linkerd-identity", "linkerd-prometheus", "linkerd", "linkerd-prometheus.linkerd.identity.linkerd.cluster.local"),
+		genOutboundPromSample("linkerd", "linkerd-identity", "linkerd-prometheus", "linkerd", "linkerd-prometheus.linkerd.serviceaccount.identity.linkerd.cluster.local"),
+	}
+	pods := []string{
+		genPod("web-0", "emojivoto", "web"),
+		genPod("vote-bot-0", "emojivoto", "default"),
+		genPod("linkerd-identity-0", "linkerd", "linkerd-identity"),
 	}
 
 	t.Run("Successfully returns edges for resource type Deployment and namespace emojivoto", func(t *testing.T) {
@@ -111,6 +121,7 @@ func TestEdges(t *testing.T) {
 				expectedStatRPC: expectedStatRPC{
 					err:              nil,
 					mockPromResponse: mockPromResponse,
+					k8sConfigs:       pods,
 				},
 				req: &pb.EdgesRequest{
 					Selector: &pb.ResourceSelection{
@@ -132,6 +143,7 @@ func TestEdges(t *testing.T) {
 				expectedStatRPC: expectedStatRPC{
 					err:              nil,
 					mockPromResponse: mockPromResponse,
+					k8sConfigs:       pods,
 				},
 				req: &pb.EdgesRequest{
 					Selector: &pb.ResourceSelection{
@@ -153,6 +165,7 @@ func TestEdges(t *testing.T) {
 				expectedStatRPC: expectedStatRPC{
 					err:              nil,
 					mockPromResponse: mockPromResponse,
+					k8sConfigs:       pods,
 				},
 				req: &pb.EdgesRequest{
 					Selector: &pb.ResourceSelection{
diff --git a/viz/metrics-api/test_helper.go b/viz/metrics-api/test_helper.go
index 58a65c70b..533383b46 100644
--- a/viz/metrics-api/test_helper.go
+++ b/viz/metrics-api/test_helper.go
@@ -249,8 +249,8 @@ var linkerdEdgeRows = []*mockEdgeRow{
 		dst:          "linkerd-prometheus",
 		srcNamespace: "linkerd",
 		dstNamespace: "linkerd",
-		clientID:     "linkerd-identity.linkerd.identity.linkerd.cluster.local",
-		serverID:     "linkerd-prometheus.linkerd.identity.linkerd.cluster.local",
+		clientID:     "linkerd-identity.linkerd.serviceaccount.identity.linkerd.cluster.local",
+		serverID:     "linkerd-prometheus.linkerd.serviceaccount.identity.linkerd.cluster.local",
 		msg:          "",
 	},
 }