Repository: linkerd/linkerd2
Pull request #4780 (2020-07-22 17:22:03)

service topologies: topology-aware service routing
[Link to RFC](https://github.com/linkerd/rfc/pull/23)
**Updated** (_29 Jul_): Initially, this PR was supposed to be only for adding topology preference to `servicePublisher` and for collecting topology information from slices. However, the PR (now marked as a draft) covers the prioritisation of endpoints for topology-aware service routing.

### What
---
* PR that puts together all past pieces of the puzzle to deliver topology-aware service routing, as specified in the [Kubernetes docs](https://kubernetes.io/docs/concepts/services-networking/service-topology/) but with a much better load balancing algorithm and all the coolness of linkerd :) 
* The first piece of this PR is focused on adding topology metadata: topology preference for services and topology `<k,v>` pairs for endpoints.
* The second piece of this PR puts together the new context format and fetching the source node topology metadata in order to allow for endpoints filtering.
* The final part is doing the filtering -- passing all of the metadata to the listener and on every `Add` filtering endpoints based on the topology preference of the service, topology `<k,v>` pairs of endpoints and topology of the source (again `<k,v>` pairs).

### How
---

* **Collecting metadata**:
   -  Services do not have values for topology keys -- the topological keys defined in a service's spec are only there to dictate locality preference for routing; as such, I decided to store them in an array, they will be taken exactly as they are found in the service spec, this ensures we respect the preference order.

   - For EndpointSlices, we are using a map -- an EndpointSlice has locality information in the form of `<k,v>` pair, where the key is a topological key (similar to what's listed in the service) and the value is the locality information -- e.g `hostname: minikube`. For each address we now have a map of topology values which gets populated when we translate the endpoints to an address set. Because normal Endpoints do not have any topology information, we create each address with an empty map which is subsequently populated ONLY for slices in the `endpointSliceToAddressSet` function.

* **Filtering endpoints**:
  - This was a tricky part and filled me with doubts. I think there are a few ways to do this, but this is how I "envisioned" it. First, the `endpoint_translator.go` should be the one to do the filtering; this means that on subscription, we need to feed all of the relevant metadata to the listener. To do this, I created a new function `AddTopologyFilter` as part of the listener interface.

  - To complement the `AddTopologyFilter` function, I created a new `TopologyFilter` struct in `endpoints_watcher.go`. I then embedded this structure in all listeners that implement the interface. The structure holds the source topology (source node), a boolean to tell if slices are activated in case we need to double check (or write tests for the function) and the service preference. We create the filter on Subscription -- we have access to the k8s client here as well as the service, so it's the best point to collect all of this data together. Addresses all have their own topology added to them so they do not have to be collected by the filter.

  - When we add a new set of addresses, we check to see if slices are enabled -- chances are if slices are enabled, service topology might be too. This lets us skip this step if the latest version is not adopted. Prior to sending an `Add` we filter the endpoints -- if the preference is registered by the filter we strictly enforce it, otherwise nothing changes.

And that's pretty much it. 

### Points of concern
---

* I have been thinking about this for a while, I think this architectural choice should work fine but is there any cause of concern when it comes to scaling? What would happen when a cluster has 20 services with 5 EndpointSlices each, would adding extra metadata (topology map) degrade performance? I would appreciate some rigid feedback in this area to figure out if this is the right choice.

* Again, related to the architecture, I am not sure what other ways we have to collect all of the data together other than having a dedicated structure or storing the data in the listener structure. There are a couple of pain points here, we could pass everything as an argument to `Add` but there is no point where we will send an `Add` AND we have access to the k8s client and service in question. We could initialise a client and get all of this data but I find that it will slow everything down. Doing everything in `Subscribe` to me seems to be the simpler and more efficient way, but as always I look forward to finding a better and cleaner way to do this.

 * Finally, I left some scattered comments throughout the PR -- this is intentional, there are alternative concerns over performance or behaviour that I have. Will clean them up when the draft is in a good state, feel free to ignore them or debate them, wanted to show what my reasoning is or ask indirect questions of the reviewer.

 * At the moment, if a service changes its topology, in order for it to be enforced all pods have to be re subscribed (so deleted). This isn't good (?).

### Tests
---

* Testing is not very straightforward so, I'll detail how I tested everything. I'm collating more cases, for now I have just tested the preference is enforced.

* To test this, minikube won't help much -- we need at least two nodes with different hostnames, regions and zones. Kind was the best option for me, since it's bootstrapped with `kubeadm` -- you can label each node as you want on creation so you can test multiple scenarios. You can find my [kind config here](https://gist.github.com/Matei207/ba318acf576703cfd151b41af162dc30).

* Next, I decided to use `emojivoto` as my test services. `web-svc` feeds into `emoji-svc`, so to test topology I edited `emoji deployment`: scaled replicas to 2 (I only used 2 nodes, 3+ kind nodes make my laptop take off) -- there is no need for affinity in this case because Kubernetes will automatically spread for resilience. For 3 nodes, scale to 3 replicas etc. You can use anti affinity to space the pods out if you wish. Next is adding the topological preference to the service, in `emoji-svc` add :
```yaml
topologyKeys:
  - "kubernetes.io/hostname"
  - <other keys>
```

Supported labels are `["kubernetes.io/hostname", "topology.kubernetes.io/region", "topology.kubernetes.io/zone","*"]`

**Note**: `*` has to go last. You can re-edit topologies! To change preference you must first delete the topology and then re-write it. This was the case with me; I edited the service using `kubectl edit` though.

* Final step is get the logs for the destination service, to kick everything into gear I deleted the `web` pod to force re subscriptions. I have added quite a few debug statements so you can observe what's going on -- these will not stay in but it helps to figure what the call trace is like. Make sure you start the controllers with debug log level.


For my test, this is what we get for 2 replicas of `emoji` spread across 2 nodes, and 1 of `web`connecting to a topology of node local:

```
time="2020-07-29T16:11:10Z" level=info msg="Establishing endpoint topology filter with preference: [kubernetes.io/hostname]" addr=":8086" component=endpoints-watcher
time="2020-07-29T16:11:10Z" level=debug msg="Adding topology filter with preference [kubernetes.io/hostname] and node topology map[kubernetes.io/hostname:kind-worker2 topology.kubernetes.io/region:east topology.kubernetes.io/zone:east-1c]" addr=":8086" component=endpoint-translator remote="127.0.0.1:37800" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-07-29T16:11:10Z" level=debug msg="Filtering through address set with preference [kubernetes.io/hostname]" addr=":8086" component=endpoint-translator remote="127.0.0.1:37800" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-07-29T16:11:10Z" level=debug msg="Locality [kubernetes.io/hostname]" addr=":8086" component=endpoint-translator remote="127.0.0.1:37800" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-07-29T16:11:10Z" level=debug msg="Address [10.244.2.29:emoji-7dc976587b-qfgfr]: Topology is map[kubernetes.io/hostname:kind-worker topology.kubernetes.io/region:west topology.kubernetes.io/zone:west-1a] " addr=":8086" component=endpoint-translator remote="127.0.0.1:37800" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-07-29T16:11:10Z" level=debug msg="Address [10.244.1.29:emoji-7dc976587b-nb2rc]: Topology is map[kubernetes.io/hostname:kind-worker2 topology.kubernetes.io/region:east topology.kubernetes.io/zone:east-1c] " addr=":8086" component=endpoint-translator remote="127.0.0.1:37800" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-07-29T16:11:10Z" level=debug msg="Filtered 1 from a total of 2" addr=":8086" component=endpoint-translator remote="127.0.0.1:37800" service="emoji-svc.emojivoto.svc.cluster
time="2020-07-29T16:11:10Z" level=debug msg="Sending destination add: add:{addrs:{addr:{ip:{ipv4:183763229}  port:8080}  weight:10000  metric_labels:{key:\"control_plane_ns\"  value:\"linkerd\"}  metric_labels:{key:\"deployment\"  value:\"emoji\"}  metric_labels:{key:\"pod\"  value:\"emoji-7dc976587b-nb2rc\"}  metric_labels:{key:\"pod_template_hash\"  value:\"7dc976587b\"}  metric_labels:{key:\"serviceaccount\"  value:\"emoji\"}  tls_identity:{dns_like_identity:{name:\"emoji.emojivoto.serviceaccount.identity.linkerd.cluster.local\"}}  protocol_hint:{h2:{}}}  metric_labels:{key:\"namespace\"  value:\"emojivoto\"}  metric_labels:{key:\"service\"  value:\"emoji-svc\"}}" addr=":8086" component=endpoint-translator remote="127.0.0.1:37800" service="emoji-svc.emojivoto.svc.cluster.local:8080"
```

To show it also works as normal with no pref specified, I edited the svc, deleted the pref, re-started web pod:
```
time="2020-07-29T16:16:41Z" level=info msg="Establishing endpoint topology filter with preference: []" addr=":8086" component=endpoints-watcher
time="2020-07-29T16:16:41Z" level=debug msg="Adding topology filter with preference [] and node topology map[kubernetes.io/hostname:kind-worker2 topology.kubernetes.io/region:east topology.kubernetes.io/zone:east-1c]" addr=":8086" component=endpoint-translator remote="127.0.0.1:47182" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-07-29T16:16:41Z" level=debug msg="Filtering through address set with preference []" addr=":8086" component=endpoint-translator remote="127.0.0.1:47182" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-07-29T16:16:41Z" level=debug msg="Did not filter!" addr=":8086" component=endpoint-translator remote="127.0.0.1:47182" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-07-29T16:16:41Z" level=debug msg="Sending destination add: add:{addrs:{addr:{ip:{ipv4:183763485}  port:8080}  weight:10000  metric_labels:{key:\"control_plane_ns\"  value:\"linkerd\"}  metric_labels:{key:\"deployment\"  value:\"emoji\"}  metric_labels:{key:\"pod\"  value:\"emoji-7dc976587b-qfgfr\"}  metric_labels:{key:\"pod_template_hash\"  value:\"7dc976587b\"}  metric_labels:{key:\"serviceaccount\"  value:\"emoji\"}  tls_identity:{dns_like_identity:{name:\"emoji.emojivoto.serviceaccount.identity.linkerd.cluster.local\"}}  protocol_hint:{h2:{}}}  addrs:{addr:{ip:{ipv4:183763229}  port:8080}  weight:10000  metric_labels:{key:\"control_plane_ns\"  value:\"linkerd\"}  metric_labels:{key:\"deployment\"  value:\"emoji\"}  metric_labels:{key:\"pod\"  value:\"emoji-7dc976587b-nb2rc\"}  metric_labels:{key:\"pod_template_hash\"  value:\"7dc976587b\"}  metric_labels:{key:\"serviceaccount\"  value:\"emoji\"}  tls_identity:{dns_like_identity:{name:\"emoji.emojivoto.serviceaccount.identity.linkerd.cluster.local\"}}  protocol_hint:{h2:{}}}  metric_labels:{key:\"namespace\"  value:\"emojivoto\"}  metric_labels:{key:\"service\"  value:\"emoji-svc\"}}" addr=":8086" component=endpoint-translator remote="127.0.0.1:47182" service="emoji-svc.emojivoto.svc.cluster.local:8080"
```

Matei207: > This looks good to me but I'd like to see it in the context of the full topology aware routing feature. It's a small enough change that we might consider just rolling it into a more feature complete PR rather than reviewing and merging it on its own. What do you think?

I think this sounds perfect. I've marked this PR as a draft, once #4771 is merged I can start adding more to this PR and discuss things further.
Matei207: Updated!

@adleong if you'd be so kind 😄 
Matei207: @adleong thank you for your thoroughness, as always. Your observations are excellent and I don't have much to add to it. 


>     * the endpointTranslator struct holds the topology map of the source node.  In other words `newEndpointTranslator` would add a k8s client parameter and a nodeName parameter, look up the topology label map for that node, and store the resulting map in the endpointTranslator struct. 

I thought of the same approach at first but decided to go with the filter structure. I didn't know if it would be preferred to make another client and keep the node information in the translator -- I took it as a separation of concerns sort of situation where the listener should not know anything about the k8s API (incl clientset & idea of a node) and just deal with `diffing` addresses. On the other hand, having the concept of a filter to me seemed like it still enforces this "separation". Seeing as there is no cause for concern I'm more than happy to change it though, I think it's a better idea!

> I think it is a fair assumption that these node labels will not change during the lifetime of the node.

Precisely. I think the only way this would be affected is if the labels are manually changed.

>     * Add the array of topology key preferences to the `AddressSet` struct.  I think this makes sense because these preferences come from the service and this will make our life easier.

...I completely missed this 🤕. An amazing suggestion I think. 


>     * This allows the endpoints watcher to be totally unaware of the filtering entirely and the endpoint translator has all the information it needs to do filtering inline.  It knows the source nodes topology values, it knows the topology key preference from the Added AddressSet, and it knows the topology values for each address.
> 
>     * This lets us get rid of the TopologyFilter type and the AddTopologyFilter function.
> 
> 
> Here's where things get complicated, though:
> 
> Adding filtering introduces some new complexity to our diff-based (Add, Remove) API. Let me illustrate with an example: suppose that you're subscribed to a service with 1 endpoint local to your node and 3 endpoints on other nodes in your zone. If your topology preference says that you prefer node then zone, then the Destination API will return you an `Add` with the 1 node local endpoint. If that endpoint is later deleted, however, the endpoint watcher will call `Remove` to remove this endpoint, but we also need to somehow trigger an `Add` of the 3 zone local endpoints in order to fall back to these once no node local endpoints exist.
> 


I think that I really missed how simple this could have been done and for sure needlessly complicated everything. 

> then the Destination API will return you an `Add` with the 1 node local endpoint. If that endpoint is later deleted, however, the endpoint watcher will call `Remove` to remove this endpoint, but we also need to somehow trigger an `Add` of the 3 zone local endpoints in order to fall back to these once no node local endpoints exist.

Hm, this is a design choice that we haven't really covered. I think my initial assumption was a bit different.
  1. based on your scenario, my understanding is that when we have a list of preferences, we `Add` the endpoints for the first preference -- should the endpoint(s) fail, and no remaining endpoints satisfy our preference, we fall back on the next.

 2. my assumption was a more contract-oriented way (using the word contract very loosely) -- once our preference is satisfied we only stick to that preference, if the endpoints go down we don't fall back on the next preference.

I suppose the second approach has the advantage of fewer side effects since we know the preference that has been satisfied will be enforced throughout the duration of the pod's life (as an ephemeral object this shouldn't an issue, pods are short-lived). This invites a bit more interaction from the operator; in the case that the first preference has endpoints come up you have to delete the (source) pods to make the subscription work again. This also means less code complexity and (what I completely assume to be) better performance (??). The simplicity here stems from having to deal with no alternatives -- for example, what happens if in the first instance we fallback to our second choice but then the first choice magically has a pod spin up -- do we terminate the connection and switch back to the first preference?

The first approach that you have described is what I like best. It comes closer to what I thought this feature to be. Would we only fallback or also switch back to the first preference (like described above)? I think the main advantage of this is that it provides actual topology-aware routing, fallback mechanisms and just overall makes everything better and more fun. The main downside I can think of is having the listener be more stateful, there are more things that can potentially go wrong.

This is all just at first glance, I'm operating on surface knowledge here so not sure if I'm right about it, but thought it'd be cool to talk about it.

> This is tricky. One approach would be to have the endpoint translator become more statful and keep track of the total set of endpoints it knows about AND the filtered set of endpoints it has told the client about. When it receives an update (an Add, Remove, or NoEndpoints), it does the following:
> 
>     1. Updates the total set of endpoints based on this update
> 
>     2. Using the topology preference, calculates the filtered set of endpoints from the total set
> 
>     3. Diffs the calculated filtered set against the previously stored filtered set
> 
>     4. Transmits this diff to the client and updates the stored filtered set
> 
> 
> Let me know what you think.

To be honest, I think this is a very good way to do it. I will make all of the changes so you can see how it would look like in practice and we can take it from there? Perhaps more things will become more apparent once the code and my inconsistencies are out in public view, heh.


Matei207: @adleong (and whoever else feels like going through this PR) I have compiled a list of basic scenarios to ensure locality and fallback functionality are enforced when filtering. I have also uncovered a nasty bug where on update, slices wouldn't have any topology preferences assigned to them. The issue here was that the set in `Add(set)` came from the diff function, so I did not pass the topology pref to that particular set.

### Tests:
---

When testing, I sought to:
 - ensure diff based API works (`endpoint_translator.go`)
 - ensure locality is enforced (i.e service topology)
 - ensure fallback works as expected (falling back, switching back, etc. check Alex's post above where he describes this w/ a scenario)
 - ensure wildcard is enforced & `NoEndpoints` sent when no suitable preference is found.

For these tests scenarios, my preference has been one of the three:
1.  `[kubernetes.io/hostname`]
2. `[kubernetes.io/hostname`, `topology.kubernetes.io/region`]
3. `[kubernetes.io/hostname`, `*`]

* For a quick `kind` configuration for node labels (topology-labels) check the main post.
* To "simulate" different spreads I used affinity, here are two helpful snippets to force pods to have a regional spread and to force all pods to be local:
```yaml
# add this to deployment (I used emoji-svc which is accessed by web pods)

# affinity to force local, replace kind-worker2 with your preferred node
       affinity:
         nodeAffinity:
           requiredDuringSchedulingIgnoredDuringExecution:
             nodeSelectorTerms:
             - matchExpressions:
               - key: kubernetes.io/hostname
                 operator: In
                 values:
                 - kind-worker2

# affinity to allow local + region
# replace kind-worker2 with regional node
# replace kind-worker with local node

       affinity:
         nodeAffinity:
           preferredDuringSchedulingIgnoredDuringExecution:
           - preference:
               matchExpressions:
               - key: kubernetes.io/hostname
                 operator: In
                 values:
                 - kind-worker
             weight: 0
           - preference:
               matchExpressions:
               - key: kubernetes.io/hostname
                 operator: In
                 values:
                 - kind-worker2
             weight: 100

# Note regional and local are according to your web pod(s) if using emoji svc
```

### Test scenarios
---

Based on the above, here are my scenarios (along w/ logs)

1. ✅ **Prefer local**
  1.a. on subscribe, for a set of endpoints {A:local,B:local,C:regional}
         translator recv: Add(A,B,C)
         client recv: Add(A,B)

```bash
		time="2020-08-05T16:08:03Z" level=debug msg="Preferring endpoints with same topology kubernetes.io/hostname as source kind-worker2" addr=":8086" component=endpoint-translator remote="127.0.0.1:58348" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:08:03Z" level=debug msg="Considering address [10.244.1.68] with topology labels [map[kubernetes.io/hostname:kind-worker2 topology.kubernetes.io/region:east topology.kubernetes.io/zone:east-1c]] for filtering" addr=":8086" component=endpoint-translator remote="127.0.0.1:58348" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:08:03Z" level=debug msg="Considering address [10.244.1.70] with topology labels [map[kubernetes.io/hostname:kind-worker2 topology.kubernetes.io/region:east topology.kubernetes.io/zone:east-1c]] for filtering" addr=":8086" component=endpoint-translator remote="127.0.0.1:58348" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:08:03Z" level=debug msg="Considering address [10.244.2.74] with topology labels [map[kubernetes.io/hostname:kind-worker topology.kubernetes.io/region:west topology.kubernetes.io/zone:west-1a]] for filtering" addr=":8086" component=endpoint-translator remote="127.0.0.1:58348" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:08:03Z" level=debug msg="Filtered 2 from a total of 3" addr=":8086" component=endpoint-translator remote="127.0.0.1:58348" service="emoji-svc.emojivoto.svc.cluster.local:8080"
*Send dest add*

NAME                        READY   STATUS        RESTARTS   AGE   IP            NODE           NOMINATED NODE   READINESS GATES
emoji-788fdcd8-2h846        2/2     Running       0          63m   10.244.2.74   kind-worker    <none>           <none>
emoji-788fdcd8-fcmss        2/2     Running       0          65m   10.244.1.70   kind-worker2   <none>           <none>
emoji-788fdcd8-j5wqv        2/2     Running       0          65m   10.244.1.68   kind-worker2   <none>           <none>
```

  1.b remove a local endpoint from {A, B, C}
       translator recv: Remove(B)
       client recv: Remove(B)

```bash
time="2020-08-05T16:11:03Z" level=debug msg="Filtering through address set with preference [kubernetes.io/hostname]" addr=":8086" component=endpoint-translator remote="127.0.0.1:58348" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:11:03Z" level=debug msg="Preferring endpoints with same topology kubernetes.io/hostname as source kind-worker2" addr=":8086" component=endpoint-translator remote="127.0.0.1:58348" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:11:03Z" level=debug msg="Considering address [10.244.1.70] with topology labels [map[kubernetes.io/hostname:kind-worker2 topology.kubernetes.io/region:east topology.kubernetes.io/zone:east-1c]] for filtering" addr=":8086" component=endpoint-translator remote="127.0.0.1:58348" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:11:03Z" level=debug msg="Considering address [10.244.2.74] with topology labels [map[kubernetes.io/hostname:kind-worker topology.kubernetes.io/region:west topology.kubernetes.io/zone:west-1a]] for filtering" addr=":8086" component=endpoint-translator remote="127.0.0.1:58348" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:11:03Z" level=debug msg="Filtered 1 from a total of 2" addr=":8086" component=endpoint-translator remote="127.0.0.1:58348" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:11:03Z" level=debug msg="Sending destination remove: remove:{addrs:{ip:{ipv4:183763268} port:8080}}" addr=":8086" component=endpoint-translator remote="127.0.0.1:58348" service="emoji-svc.emojivoto.svc.cluster.local:8080"

# (Note: filtered 1 from a total of 2 because at the start, we remove B from the list of total endpoints which is used for filtering)
```

  1.c add a new local endpoint to {A,C}
       translator recv: Add(X)
       client recv: Add(X)

  ```bash
	time="2020-08-05T16:19:18Z" level=debug msg="Updating EndpointSlice for emojivoto/emoji-svc" addr=":8086" component=service-publisher ns=emojivoto svc=emoji-svc
time="2020-08-05T16:19:18Z" level=debug msg="Filtering through address set with preference [kubernetes.io/hostname]" addr=":8086" component=endpoint-translator remote="127.0.0.1:58348" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:19:18Z" level=debug msg="Preferring endpoints with same topology kubernetes.io/hostname as source kind-worker2" addr=":8086" component=endpoint-translator remote="127.0.0.1:58348" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:19:18Z" level=debug msg="Considering address [10.244.1.73] with topology labels [map[kubernetes.io/hostname:kind-worker2 topology.kubernetes.io/region:east topology.kubernetes.io/zone:east-1c]] for filtering" addr=":8086" component=endpoint-translator remote="127.0.0.1:58348" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:19:18Z" level=debug msg="Considering address [10.244.1.74] with topology labels [map[kubernetes.io/hostname:kind-worker2 topology.kubernetes.io/region:east topology.kubernetes.io/zone:east-1c]] for filtering" addr=":8086" component=endpoint-translator remote="127.0.0.1:58348" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:19:18Z" level=debug msg="Considering address [10.244.2.74] with topology labels [map[kubernetes.io/hostname:kind-worker topology.kubernetes.io/region:west topology.kubernetes.io/zone:west-1a]] for filtering" addr=":8086" component=endpoint-translator remote="127.0.0.1:58348" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:19:18Z" level=debug msg="Considering address [10.244.1.72] with topology labels [map[kubernetes.io/hostname:kind-worker2 topology.kubernetes.io/region:east topology.kubernetes.io/zone:east-1c]] for filtering" addr=":8086" component=endpoint-translator remote="127.0.0.1:58348" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:19:18Z" level=debug msg="Filtered 3 from a total of 4" addr=":8086" component=endpoint-translator remote="127.0.0.1:58348" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:19:18Z" level=debug msg="Sending destination add: add:{addrs:{addr:{ip:{ipv4:183763274} port:8080}

NAME                        READY   STATUS        RESTARTS   AGE   IP            NODE           NOMINATED NODE   READINESS GATES
emoji-785f4cbb66-g29kx      2/2     Running       0          47s   10.244.1.72   kind-worker2   <none>           <none>
emoji-785f4cbb66-hmkxr      2/2     Running       0          38s   10.244.1.73   kind-worker2   <none>           <none>
emoji-785f4cbb66-sbjpz      2/2     Running       0          26s   10.244.1.74   kind-worker2   <none>           <none>
emoji-788fdcd8-2h846        2/2     Terminating   0          74m   10.244.2.74   kind-worker    <none>           <none>
emoji-788fdcd8-fcmss        1/2     Terminating   0          76m   10.244.1.70   kind-worker2   <none>           <none>

# (Note: for the last one used affinity which is why we had so many pods; without affinity the pod kept popping up on worker2)
```

2. ✅ **Prefer local but fallback to region**
     _To have this work, update service topology to `[local, region]` then add affinity rules as described at the top of this post._
    2.a remove the local endpoint from {A: local, B: region, C: region}
          translator recv: Remove(A)
          client recv: Remove(A), Add(B,C)

 ```bash
NAME                        READY   STATUS    RESTARTS   AGE     IP            NODE           NOMINATED NODE   READINESS GATES
emoji-78ffc6d99f-2zmdp      2/2     Running   0          3m5s    10.244.1.80   kind-worker2   <none>           <none>
emoji-78ffc6d99f-548pg      2/2     Running   0          3m17s   10.244.2.81   kind-worker    <none>           <none>
emoji-78ffc6d99f-86wq6      2/2     Running   0          2m51s   10.244.1.81   kind-worker2   <none>           <none>
vote-bot-6b5f6d9b8f-xbpms   2/2     Running   0          112m    10.244.1.67   kind-worker2   <none>           <none>
voting-755f879fd7-99tpt     2/2     Running   0          112m    10.244.1.69   kind-worker2   <none>           <none>
web-76868b75cb-m8594        2/2     Running   0          13m     10.244.2.79   kind-worker    <none>           <none>

time="2020-08-05T16:56:18Z" level=debug msg="Updating EndpointSlice for emojivoto/emoji-svc" addr=":8086" component=service-publisher ns=emojivoto svc=emoji-svc
time="2020-08-05T16:56:18Z" level=debug msg="Filtering through address set with preference [kubernetes.io/hostname topology.kubernetes.io/region]" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:56:18Z" level=debug msg="Preferring endpoints with same topology kubernetes.io/hostname as source kind-worker" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:56:18Z" level=debug msg="Considering address [10.244.1.80] with topology labels [map[kubernetes.io/hostname:kind-worker2 topology.kubernetes.io/region:west topology.kubernetes.io/zone:east-1c]] for filtering" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:56:18Z" level=debug msg="Considering address [10.244.1.81] with topology labels [map[kubernetes.io/hostname:kind-worker2 topology.kubernetes.io/region:west topology.kubernetes.io/zone:east-1c]] for filtering" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:56:18Z" level=debug msg="Preferring endpoints with same topology topology.kubernetes.io/region as source west" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:56:18Z" level=debug msg="Considering address [10.244.1.81] with topology labels [map[kubernetes.io/hostname:kind-worker2 topology.kubernetes.io/region:west topology.kubernetes.io/zone:east-1c]] for filtering" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:56:18Z" level=debug msg="Considering address [10.244.1.80] with topology labels [map[kubernetes.io/hostname:kind-worker2 topology.kubernetes.io/region:west topology.kubernetes.io/zone:east-1c]] for filtering" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:56:18Z" level=debug msg="Filtered 2 from a total of 2" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:56:18Z" level=debug msg="Sending destination add: add:{addrs:{addr:{ip:{ipv4:183763281} port:8080} weight:10000 metric_labels:{key:\"control_plane_ns\" value:\"linkerd\"} metric_labels:{key:\"deployment\" value:\"emoji\"} metric_labels:{key:\"pod\" value:\"emoji-78ffc6d99f-86wq6\"} metric_labels:{key:\"pod_template_hash\" value:\"78ffc6d99f\"} metric_labels:{key:\"serviceaccount\" value:\"emoji\"} tls_identity:{dns_like_identity:{name:\"emoji.emojivoto.serviceaccount.identity.linkerd.cluster.local\"}} protocol_hint:{h2:{}}} addrs:{addr:{ip:{ipv4:183763280} port:8080} weight:10000 metric_labels:{key:\"control_plane_ns\" value:\"linkerd\"} metric_labels:{key:\"deployment\" value:\"emoji\"} metric_labels:{key:\"pod\" value:\"emoji-78ffc6d99f-2zmdp\"} metric_labels:{key:\"pod_template_hash\" value:\"78ffc6d99f\"} metric_labels:{key:\"serviceaccount\" value:\"emoji\"} tls_identity:{dns_like_identity:{name:\"emoji.emojivoto.serviceaccount.identity.linkerd.cluster.local\"}} protocol_hint:{h2:{}}}}" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:56:18Z" level=debug msg="Sending destination remove: remove:{addrs:{ip:{ipv4:183763537} port:8080}}" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
```

   2.b add new local endpoint and switch traffic to local instead of region in {B: region, C: region}
         translator recv: Add(X: local)
         client recv: Remove(B,C) add (X)
         _This was done automatically as soon as the other case ended, since I deleted the pod and a new one came up_
```bash
time="2020-08-05T16:56:33Z" level=debug msg="Updating EndpointSlice for emojivoto/emoji-svc" addr=":8086" component=service-publisher ns=emojivoto svc=emoji-svc
time="2020-08-05T16:56:33Z" level=debug msg="Filtering through address set with preference [kubernetes.io/hostname topology.kubernetes.io/region]" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:56:33Z" level=debug msg="Preferring endpoints with same topology kubernetes.io/hostname as source kind-worker" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:56:33Z" level=debug msg="Considering address [10.244.1.80] with topology labels [map[kubernetes.io/hostname:kind-worker2 topology.kubernetes.io/region:west topology.kubernetes.io/zone:east-1c]] for filtering" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:56:33Z" level=debug msg="Considering address [10.244.1.81] with topology labels [map[kubernetes.io/hostname:kind-worker2 topology.kubernetes.io/region:west topology.kubernetes.io/zone:east-1c]] for filtering" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:56:33Z" level=debug msg="Considering address [10.244.2.82] with topology labels [map[kubernetes.io/hostname:kind-worker topology.kubernetes.io/region:west topology.kubernetes.io/zone:west-1a]] for filtering" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:56:33Z" level=debug msg="Filtered 1 from a total of 3" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:56:33Z" level=debug msg="Sending destination add: add:{addrs:{addr:{ip:{ipv4:183763538} port:8080} weight:10000 metric_labels:{key:\"control_plane_ns\" value:\"linkerd\"} metric_labels:{key:\"deployment\" value:\"emoji\"} metric_labels:{key:\"pod\" value:\"emoji-78ffc6d99f-nt79c\"} metric_labels:{key:\"pod_template_hash\" value:\"78ffc6d99f\"} metric_labels:{key:\"serviceaccount\" value:\"emoji\"} tls_identity:{dns_like_identity:{name:\"emoji.emojivoto.serviceaccount.identity.linkerd.cluster.local\"}} protocol_hint:{h2:{}}}}" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T16:56:33Z" level=debug msg="Sending destination remove: remove:{addrs:{ip:{ipv4:183763280} port:8080} addrs:{ip:{ipv4:183763281} port:8080}}" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"

NAME                        READY   STATUS    RESTARTS   AGE     IP            NODE           NOMINATED NODE   READINESS GATES
emoji-78ffc6d99f-2zmdp      2/2     Running   0          6m12s   10.244.1.80   kind-worker2   <none>           <none>
emoji-78ffc6d99f-86wq6      2/2     Running   0          5m58s   10.244.1.81   kind-worker2   <none>           <none>
emoji-78ffc6d99f-nt79c      2/2     Running   0          2m44s   10.244.2.82   kind-worker    <none>           <none>
vote-bot-6b5f6d9b8f-xbpms   2/2     Running   0          115m    10.244.1.67   kind-worker2   <none>           <none>
voting-755f879fd7-99tpt     2/2     Running   0          115m    10.244.1.69   kind-worker2   <none>           <none>
web-76868b75cb-m8594        2/2     Running   0          16m     10.244.2.79   kind-worker    <none>           <none>
```


3. ❌ `NoEndpoints` -- this works well when endpoints don't fit preference but does not seem to work correctly all the time. Mostly it sends a `remove` when deleting the only viable endpoint but never a `NoEndpoints`. Needs a closer look on my side

4. ✅ **Use wildcard** `*`
    _Add topology [local, *]_
    When deleting the local pod (same as 2.a) then we will get the rest of the pods available regardless of their locality
  
```bash
NAME                        READY   STATUS    RESTARTS   AGE     IP            NODE           NOMINATED NODE   READINESS GATES
emoji-78ffc6d99f-2zmdp      2/2     Running   0          24m     10.244.1.80   kind-worker2   <none>           <none>
emoji-78ffc6d99f-6f8dj      2/2     Running   0          2m13s   10.244.2.84   kind-worker    <none>           <none>
emoji-78ffc6d99f-86wq6      2/2     Running   0          23m     10.244.1.81   kind-worker2   <none>           <none>
vote-bot-6b5f6d9b8f-xbpms   2/2     Running   0          133m    10.244.1.67   kind-worker2   <none>           <none>
voting-755f879fd7-99tpt     2/2     Running   0          133m    10.244.1.69   kind-worker2   <none>           <none>
web-76868b75cb-m8594        2/2     Running   0          34m     10.244.2.79   kind-worker    <none>           <none>


time="2020-08-05T17:17:41Z" level=debug msg="Updating EndpointSlice for emojivoto/emoji-svc" addr=":8086" component=service-publisher ns=emojivoto svc=emoji-svc
time="2020-08-05T17:17:41Z" level=debug msg="Filtering through address set with preference [kubernetes.io/hostname *]" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T17:17:41Z" level=debug msg="Preferring endpoints with same topology kubernetes.io/hostname as source kind-worker" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T17:17:41Z" level=debug msg="Considering address [10.244.1.80] with topology labels [map[kubernetes.io/hostname:kind-worker2 topology.kubernetes.io/region:west topology.kubernetes.io/zone:east-1c]] for filtering" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T17:17:41Z" level=debug msg="Considering address [10.244.1.81] with topology labels [map[kubernetes.io/hostname:kind-worker2 topology.kubernetes.io/region:west topology.kubernetes.io/zone:east-1c]] for filtering" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T17:17:41Z" level=debug msg="Sending destination add: add:{addrs:{addr:{ip:{ipv4:183763280} port:8080} weight:10000 metric_labels:{key:\"control_plane_ns\" value:\"linkerd\"} metric_labels:{key:\"deployment\" value:\"emoji\"} metric_labels:{key:\"pod\" value:\"emoji-78ffc6d99f-2zmdp\"} metric_labels:{key:\"pod_template_hash\" value:\"78ffc6d99f\"} metric_labels:{key:\"serviceaccount\" value:\"emoji\"} tls_identity:{dns_like_identity:{name:\"emoji.emojivoto.serviceaccount.identity.linkerd.cluster.local\"}} protocol_hint:{h2:{}}} addrs:{addr:{ip:{ipv4:183763281} port:8080} weight:10000 metric_labels:{key:\"control_plane_ns\" value:\"linkerd\"} metric_labels:{key:\"deployment\" value:\"emoji\"} metric_labels:{key:\"pod\" value:\"emoji-78ffc6d99f-86wq6\"} metric_labels:{key:\"pod_template_hash\" value:\"78ffc6d99f\"} metric_labels:{key:\"serviceaccount\" value:\"emoji\"} tls_identity:{dns_like_identity:{name:\"emoji.emojivoto.serviceaccount.identity.linkerd.cluster.local\"}} protocol_hint:{h2:{}}}}" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-05T17:17:41Z" level=debug msg="Sending destination remove: remove:{addrs:{ip:{ipv4:183763540} port:8080}}" addr=":8086" component=endpoint-translator remote="127.0.0.1:38562" service="emoji-svc.emojivoto.svc.cluster.local:8080"

# Note: no debug message for when we choose *, this works removed deleted pod and added 2 pods (both on kind-worker2)
```


Matei207: @adleong resolved feedback comments, changed some unmentioned code comments for clarity and moved function blocks around to make the file more readable. Let me know what you think.
Matei207: **Update 11th of August:** I have updated the test fixtures so unit tests can pass (modified RBAC). I spoke briefly to @zaharidichev  today and thought it would be a good idea to get this out of the draft stage.

@adleong  and @zaharidichev  please let me know when you have time how the testing went and if there is anything that should be changed for this PR!
adleong: It doesn't seem like endpoint slices can be enabled during an upgrade:

```
bin/linkerd --context=kind-kind upgrade --enable-endpoint-slices=true --controller-log-level=debug | grep "endpointSliceEnabled"
    {"linkerdNamespace":"linkerd","cniEnabled":false,"version":"git-68d7c62b","identityContext":{"trustDomain":"cluster.local","trustAnchorsPem":"-----BEGIN CERTIFICATE-----\nMIIBvzCCAWagAwIBAgIQdU7b86RL5QlGUvONIdoMKTAKBggqhkjOPQQDAjApMScw\nJQYDVQQDEx5pZGVudGl0eS5saW5rZXJkLmNsdXN0ZXIubG9jYWwwHhcNMjAwNTE1\nMjEwNzQyWhcNMzAwNTEzMjEwNzQyWjApMScwJQYDVQQDEx5pZGVudGl0eS5saW5r\nZXJkLmNsdXN0ZXIubG9jYWwwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQZ+JdN\ng0VpwxYdfzuxJKA0rw3GOT71Ywf5zy4boMlGvvPoUzme31yxPD0H0oSJcC7VRuJI\nP4QEj/snUSpyIgVBo3AwbjAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB\n/wIBATAdBgNVHQ4EFgQUKa3lmDnBeD2Jd2RZAXrTtIuL76UwKQYDVR0RBCIwIIIe\naWRlbnRpdHkubGlua2VyZC5jbHVzdGVyLmxvY2FsMAoGCCqGSM49BAMCA0cAMEQC\nIFbNkryVCJOikJnpXJXmo6msX9i84bwzyxq3XuPQ7ybVAiA7eKeksZCCqUmvoLss\nKwkCnm9z772sm+5zaHJ3ewIcIQ==\n-----END CERTIFICATE-----\n","issuanceLifetime":"86400s","clockSkewAllowance":"20s","scheme":"linkerd.io/tls"},"autoInjectContext":null,"omitWebhookSideEffects":false,"clusterDomain":"cluster.local","endpointSliceEnabled":false}
```
adleong: After playing with this for a little bit it seems to work great!  The only issue I was able to find was that if you update a service and change its topology preference, that change doesn't take effect.  I think this is because when we update a service in the endpointsWatcher, we don't propagate the topology preferences from the service to the current `pp.addresses`.  Additionally, I think we would need to issue an empty `Add` to the endpoint translator to get it to pick up the new preferences.
Matei207: @adleong thank you for both of your comments!

I have opened up a PR to resolve the upgrade issue.

For the service update in the watcher, I will have a closer look and try to solve it asap. I made the mistake of thinking that when we update a service we automatically update all ports (which in turn re adds all of the slices => new pp.addresses). I was wrong though because the updates happen only when the port is changed in the service.

I suppose we would need to send an empty Add to re-trigger the filtering rather than update the preference? Either way, I think sending an empty Add with the updated preference is less of an expensive operation compared to re-adding all of the slices in.
Matei207: @adleong @zaharidichev I have pushed some new changes to fix the issue with services not updating. I would appreciate it if you could give me some feedback in terms of the code quality & style, as well as running through some test scenarios to ensure this works as expected.

* **Note**: for tests, if you are using `kind` and replicating the scenario(s) detailed below, you will need to edit one of the nodes to make sure both (or all) worker nodes belong to the same region.

* **Note 2**: commits are squashed now :) thought it was about time to clean up all those messy commit messages.

### Changes
---

I have made two-three changes that I will detail here just so you are aware of what my rationale has been.

* `endpoints_watcher.go`: when we update a service, for each `portPublisher` in that structure, we first make sure that it holds the latest preference. Each port then updates its respective `AddressSets`, done through the new `port.updateTopologyPreference()` method. When we update the topology preference, for all listeners of a `portPublisher` we send an empty `Add` with a newly created `AddressSet` that holds the latest topology pref but no actual addresses or labels.

* `endpoints_translator.go`: 
     - 1) I moved up the blocks that add/remove endpoints from the set of total available endpoints. This is done to make sure that if we start with no topological preference in a service, when we update the topology, we can filter enpoints. Put plainly, if the entire time we do not hold any state in the translator and then switch to filtering based on the current state -- well there won't be any state to filter on.
    - 2) I added a bit more logic to the if block related to no topological preferences in the set to be added. Before, if the set would have no topology => send all of them. This is not enough for updates, however, since a service that previously held a preference will send an empty add to the client but will not update the endpoints that have been sent until a slice is edited. 
_E.g from a total set [A,B,C], if we previously had only sent A due to topology, then once we switch to no pref, the client will still only have A as an endpoint until the slice is edited or a new slice is added._ 
To address this, we now check to see how many addresses the set to be added has -- when it has more than 0, we treat it like we used to. If it has 0 addresses it means its a topology update, so we diff based on the previously filtered endpoints and only send endpoints that have not been previously sent. This is again to make sure that if we go from a preference to none, all endpoints are made known to the client, so we simply send all of the endpoints that had previously been filtered.
  

### Tests:
---

* _Caveat_: to change a service's topology preference, I had to first delete the existing topology (e.g from local to no_pref) and then edit the service again and add the new keys. If I simply edited the service, I got this error:
```sh
A copy of your changes has been stored to "/var/folders/4j/vjq4hnmn6tlf89n_kl55c9zh0000gn/T/kubectl-edit-wv0fo.yaml"
error: unable to find api field in struct ServiceSpec for the json field "topologyKeys"
```
Not sure if this is because I edited with `kubectl edit svc` instead of `kubectl apply -f`

* To test I ran a long scenario where we go from `no_pref` to `region` to `local` and finally to `no_pref` again without deleting any pods or forcing the slice itself to update.

`Initial pod state`
```sh
# Pods
NAME                        READY   STATUS    RESTARTS   AGE     IP            NODE           NOMINATED NODE   READINESS GATES
emoji-b79947575-2c54l       2/2     Running   0          3m24s   10.244.1.95   kind-worker2   <none>           <none>
emoji-b79947575-b7xb2       2/2     Running   0          2m52s   10.244.1.97   kind-worker2   <none>           <none>
emoji-b79947575-f9s65       2/2     Running   0          45s     10.244.2.74   kind-worker    <none>           <none>
vote-bot-6cf96b6b76-nnql2   2/2     Running   0          3m24s   10.244.2.73   kind-worker    <none>           <none>
voting-79f4b5ccd4-5lt2f     2/2     Running   0          3m24s   10.244.2.72   kind-worker    <none>           <none>
web-66689dbb48-6mfw2        2/2     Running   0          3m24s   10.244.1.94   kind-worker2   <none>           <none>
```

* Based on our pod state, we have:
  - 3 regional -- emoji pods
  - 2 local -- emoji pods on kind-worker2

The pods are local and regional to the src pod `web-66689dbb48-6mfw2` running on kind-worker2

`From no_pref to [region]` --> because we go from nothing ever having been filtered, we re-send an add here. I think the easiest way to fix this would be to add the set to the `filterSnapshot` if we don't have a topology pref. Let me know and will add it in asap if this extra add is not preferred.
```sh
time="2020-08-13T13:53:09Z" level=debug msg="Updating service for emojivoto/emoji-svc" addr=":8086" component=service-publisher ns=emojivoto svc=emoji-svc
time="2020-08-13T13:53:09Z" level=debug msg="Filtering through address set with preference [topology.kubernetes.io/region]" addr=":8086" component=endpoint-translator remote="127.0.0.1:60626" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-13T13:53:09Z" level=debug msg="Filtered 3 from a total of 3" addr=":8086" component=endpoint-translator remote="127.0.0.1:60626" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-13T13:53:09Z" level=debug msg="Sending destination add: add:{addrs:{addr:{ip:{ipv4:183763297}  port:8080}  weight:10000  metric_labels:{key:\"control_plane_ns\"  value:\"linkerd\"}  metric_labels:{key:\"deployment\"  value:\"emoji\"}  metric_labels:{key:\"pod\"  value:\"emoji-b79947575-b7xb2\"}  metric_labels:{key:\"pod_template_hash\"  value:\"b79947575\"}  metric_labels:{key:\"serviceaccount\"  value:\"emoji\"}  tls_identity:{dns_like_identity:{name:\"emoji.emojivoto.serviceaccount.identity.linkerd.cluster.local\"}}  protocol_hint:{h2:{}}}  addrs:{addr:{ip:{ipv4:183763295}  port:8080}  weight:10000  metric_labels:{key:\"control_plane_ns\"  value:\"linkerd\"}  metric_labels:{key:\"deployment\"  value:\"emoji\"}  metric_labels:{key:\"pod\"  value:\"emoji-b79947575-2c54l\"}  metric_labels:{key:\"pod_template_hash\"  value:\"b79947575\"}  metric_labels:{key:\"serviceaccount\"  value:\"emoji\"}  tls_identity:{dns_like_identity:{name:\"emoji.emojivoto.serviceaccount.identity.linkerd.cluster.local\"}}  protocol_hint:{h2:{}}}  addrs:{addr:{ip:{ipv4:183763530}  port:8080}  weight:10000  metric_labels:{key:\"control_plane_ns\"  value:\"linkerd\"}  metric_labels:{key:\"deployment\"  value:\"emoji\"}  metric_labels:{key:\"pod\"  value:\"emoji-b79947575-f9s65\"}  metric_labels:{key:\"pod_template_hash\"  value:\"b79947575\"}  metric_labels:{key:\"serviceaccount\"  value:\"emoji\"}  tls_identity:{dns_like_identity:{name:\"emoji.emojivoto.serviceaccount.identity.linkerd.cluster.local\"}}  protocol_hint:{h2:{}}}}" addr=":8086" component=endpoint-translator remote="127.0.0.1:60626" service="emoji-svc.emojivoto.svc.cluster.local:8080"
```

`From [region] to [local]` --> should have 1 pod removed
```sh
# See caveat (first have to go region - empty)
time="2020-08-13T13:53:34Z" level=debug msg="Updating service for emojivoto/emoji-svc" addr=":8086" component=service-publisher ns=emojivoto svc=emoji-svc
time="2020-08-13T13:53:34Z" level=debug msg="Sending destination add: add:{}" addr=":8086" component=endpoint-translator remote="127.0.0.1:60626" service="emoji-svc.emojivoto.svc.cluster.local:8080" 
# (no diff) all 3 pods were regional, when we go to empty we don't add anything new

# edit svc and go to [local] pods
time="2020-08-13T13:54:05Z" level=debug msg="Updating service for emojivoto/emoji-svc" addr=":8086" component=service-publisher ns=emojivoto svc=emoji-svc
time="2020-08-13T13:54:05Z" level=debug msg="Filtering through address set with preference [kubernetes.io/hostname]" addr=":8086" component=endpoint-translator remote="127.0.0.1:60626" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-13T13:54:05Z" level=debug msg="Filtered 2 from a total of 3" addr=":8086" component=endpoint-translator remote="127.0.0.1:60626" service="emoji-svc.emojivoto.svc.cluster.local:8080"
time="2020-08-13T13:54:05Z" level=debug msg="Sending destination remove: remove:{addrs:{ip:{ipv4:183763530}  port:8080}}" addr=":8086" component=endpoint-translator remote="127.0.0.1:60626" service="emoji-svc.emojivoto.svc.cluster.local:8080"
# removed the extra pod that was region but not local!
```

`From [local] to no_pref` --> when we switched to local we had 2 pods left, we should now have all 3 again
```sh
time="2020-08-13T13:54:49Z" level=debug msg="Updating service for emojivoto/emoji-svc" addr=":8086" component=service-publisher ns=emojivoto svc=emoji-svc
time="2020-08-13T13:54:49Z" level=debug msg="Sending destination add: add:{addrs:{addr:{ip:{ipv4:183763530}  port:8080}  weight:10000  metric_labels:{key:\"control_plane_ns\"  value:\"linkerd\"}  metric_labels:{key:\"deployment\"  value:\"emoji\"}  metric_labels:{key:\"pod\"  value:\"emoji-b79947575-f9s65\"}  metric_labels:{key:\"pod_template_hash\"  value:\"b79947575\"}  metric_labels:{key:\"serviceaccount\"  value:\"emoji\"}  tls_identity:{dns_like_identity:{name:\"emoji.emojivoto.serviceaccount.identity.linkerd.cluster.local\"}}  protocol_hint:{h2:{}}}}" addr=":8086" component=endpoint-translator remote="127.0.0.1:60626" service="emoji-svc.emojivoto.svc.cluster.local:8080"
```

Let me know what you think! :) 
Matei207: @adleong resolved the comments but will reply here, just because there are some other things to consider when removing that check from inside the `Add` and `Remove` function.

When we move the `len(set.TopologyPref)` into the `filterAddresses()` method, we introduce a couple of side effects.

* first, if we return `et.availableEndpoints` we are essentially returning a reference to all available endpoints, which in turn will be attributed to the filter snapshot. This means that when we will have to remove an address if we do it without any topological preference the previously filtered snapshot will not include the address that we want to remove, so the change will actually not be propagated. It's easier to demonstrate with an example:

 suppose we have a set {A,B,C} of available endpoints, with no topological preference. This means that the following will be true:
```
     total = {A,B,C}  (Struct addr A1234)
     filterSnapshot = {A,B,C} (Same addr as total, A1234, because we returned `et.availableEndpoints` when we did the len check)
```
when we Remove(C), the following will happen:
```
    total = {A, B} (we remove C)
    filterSnapshot = {A,B} (same address as total, any change in total = change here)
    filter = {A, B} (`et.availableEndpoints`)
    => C is removed on the dest service side from available endpoints but change never propagated to proxy. there is no diff
``` 

 dealing with this is not particularly hard, but it comes at the expense of having to copy the map of addresses every time we don't use a topological preference. The advantage is that the code looks much cleaner now, removed more lines from both `Add(set)` and `Remove(set)` in favour of replacing the topology preference of `et.availableEndpoints` in the filtered update method.

tl;dr code looks nice and clean but we have to copy the map of addresses.

* second, I realised we never replaced the labels in the diff, we were returning only the AddressSets with addresses, so we never received any `metricLabels`. Also, we never overwrote (correct word?) the labels of the `et.availableEndpoints` set with the ones from the new set; this is now done in the filtered update. Crisis averted :)

* third, going this route means I had to change some tests. The `endpoint_translator` is truly stateful now, and this had to be reflected in the tests. For example, we can't remove an endpoint that was not there to begin with; the expected values and arguments to some functions had to be changed to reflect the diff based API.


My conclusion is that although we have a slight performance cost, the API is more unified and consistent, tests reflect better what is done. Before, the tests did not point out that in the diff we did not include the metric labels, for example. The code also looks cleaner.

Patch:
diff --git a/.github/workflows/cloud_integration.yml b/.github/workflows/cloud_integration.yml
index 7dc835aef..633e15d68 100644
--- a/.github/workflows/cloud_integration.yml
+++ b/.github/workflows/cloud_integration.yml
@@ -100,11 +100,7 @@ jobs:
         name: testing-${{ env.TAG }}-${{ github.run_id }}
         num_nodes: 2
     - name: Run integration tests
-      env:
-        GITCOOKIE_SH: ${{ secrets.GITCOOKIE_SH }}
-      run: |
-        echo "$GITCOOKIE_SH" | bash
-        bin/tests --skip-kind-create "$CMD"
+      run: bin/tests --skip-kind-create "$CMD"
     - name: CNI tests
       run: |
         export TAG="$($CMD version --client --short)"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c8e191012..d6e51c8d3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -183,11 +183,7 @@ jobs:
         name: testing-${{ steps.install_cli.outputs.tag }}-${{ github.run_id }}
         num_nodes: 2
     - name: Run integration tests
-      env:
-        GITCOOKIE_SH: ${{ secrets.GITCOOKIE_SH }}
-      run: |
-        echo "$GITCOOKIE_SH" | bash
-        bin/tests --skip-kind-create "$CMD"
+      run: bin/tests --skip-kind-create "$CMD"
     - name: CNI tests
       run: |
         export TAG="$($CMD version --client --short)"
diff --git a/.github/workflows/static_checks.yml b/.github/workflows/static_checks.yml
index f4867b521..1bfc23277 100644
--- a/.github/workflows/static_checks.yml
+++ b/.github/workflows/static_checks.yml
@@ -18,11 +18,7 @@ jobs:
       # actions/checkout@v2
       uses: actions/checkout@722adc6
     - name: Go lint
-      env:
-        GITCOOKIE_SH: ${{ secrets.GITCOOKIE_SH }}
-      run: |
-        echo "$GITCOOKIE_SH" | bash
-        bin/lint --verbose
+      run: bin/lint --verbose
   go_format:
     name: Go format
     runs-on: ubuntu-18.04
@@ -33,11 +29,18 @@ jobs:
       # actions/checkout@v2
       uses: actions/checkout@722adc6
     - name: Format
-      env:
-        GITCOOKIE_SH: ${{ secrets.GITCOOKIE_SH }}
-      run: |
-        echo "$GITCOOKIE_SH" | bash
-        bin/fmt
+      run: bin/fmt
+  proto_diff:
+    name: Proto diff
+    runs-on: ubuntu-18.04
+    container:
+      image: golang:1.13.4
+    steps:
+      - name: Checkout code
+        # actions/checkout@v2
+        uses: actions/checkout@722adc6
+      - name: Diff proto files
+        run: bin/protoc-diff
   shellcheck:
     name: shellcheck
     runs-on: ubuntu-18.04
diff --git a/BUILD.md b/BUILD.md
index 57565c725..a01ca399f 100644
--- a/BUILD.md
+++ b/BUILD.md
@@ -340,6 +340,24 @@ cd web/app
 yarn add [dep]
 ```
 
+#### Translations
+
+To add a locale:
+
+```bash
+cd web/app
+yarn lingui add-locale [locales...] # will create a messages.json file for new locale(s)
+```
+
+To extract message keys from existing components:
+
+```bash
+cd web/app
+yarn lingui extract
+...
+yarn lingui compile # done automatically in bin/web run
+```
+
 ### Rust
 
 All Rust development happens in the
diff --git a/CHANGES.md b/CHANGES.md
index 16633a5a7..6e25e2492 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,5 +1,20 @@
 # Changes
 
+## edge-20.8.2
+
+This edge release adds an internationalization framework to the dashboard,
+Spanish translations to the dashboard UI, and a `linkerd multicluster uninstall`
+command for graceful removal of the multicluster components.
+
+* Web UI
+  * Added Spanish translations to the dashboard
+  * Added a framework and documentation to simplify creation of new
+    translations
+* Multicluster
+  * Added a multicluster uninstall command
+  * Added a warning from `linkerd check --multicluster` if the multicluster
+    support is not installed
+
 ## edge-20.8.1
 
 This edge adds multi-arch support to Linkerd! Our docker images and CLI now
diff --git a/bin/_os.sh b/bin/_os.sh
new file mode 100644
index 000000000..c9ff22a61
--- /dev/null
+++ b/bin/_os.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+
+set -eu
+
+os() {
+  os=$(uname -s)
+  arch=""
+  case $os in
+    CYGWIN* | MINGW64*)
+      os=windows
+      ;;
+    Darwin)
+      os=darwin
+      ;;
+    Linux)
+      os=linux
+      arch=$(uname -m)
+      case $arch in
+        x86_64)
+          arch=amd64
+          ;;
+        armv8*)
+          arch=arm64
+          ;;
+        aarch64*)
+          arch=arm64
+          ;;
+        armv*)
+          arch=arm
+          ;;
+        amd64|arm64)
+          arch=$arch
+          ;;
+        *)
+          echo "unsupported architecture: $arch" >&2
+          exit 1
+          ;;
+      esac
+      ;;
+    *)
+      echo "unsupported os: $os" >&2
+      exit 1
+      ;;
+  esac
+
+  if [ -n "$arch" ]; then
+    echo "$os-$arch"
+  else
+    echo "$os"
+  fi
+}
diff --git a/bin/build-cli-bin b/bin/build-cli-bin
index 46566a221..fa955bbee 100755
--- a/bin/build-cli-bin
+++ b/bin/build-cli-bin
@@ -10,49 +10,13 @@ bindir=$( cd "${0%/*}" && pwd )
 rootdir=$( cd "$bindir"/.. && pwd )
 # shellcheck source=_tag.sh
 . "$bindir"/_tag.sh
-
-arch=""
-case $(uname) in
-	Darwin)
-		host_platform=darwin
-		;;
-	Linux)
-		host_platform=linux
-		arch=$(uname -m)
-		case $arch in
-			x86_64)
-				arch=amd64
-				;;
-			armv8*)
-				arch=arm64
-				;;
-			aarch64*)
-				arch=arm64
-				;;
-			armv*)
-				arch=arm
-				;;
-			amd64|arm64)
-				arch=$arch
-				;;
-			*)
-				echo "unsupported architecture: $arch" >&2
-				exit 1
-				;;
-		esac
-		;;
-	*)
-		host_platform=windows
-		;;
-esac
+# shellcheck source=_os.sh
+. "$bindir"/_os.sh
 
 (
     cd "$rootdir"
     cd "$(pwd -P)"
-    target=target/cli/$host_platform/linkerd
-    if [ -n "$arch" ]; then
-      target=target/cli/$host_platform-$arch/linkerd
-    fi
+    target=target/cli/$(os)/linkerd
     GO111MODULE=on go generate -mod=readonly ./pkg/charts/static # TODO: `go generate` does not honor -mod=readonly
     root_tag=$("$bindir"/root-tag)
     GO111MODULE=on CGO_ENABLED=0 go build -o $target -tags prod -mod=readonly -ldflags "-s -w -X github.com/linkerd/linkerd2/pkg/version.Version=$root_tag" ./cli
diff --git a/bin/linkerd b/bin/linkerd
index 94c40f21e..3e58ab104 100755
--- a/bin/linkerd
+++ b/bin/linkerd
@@ -4,39 +4,10 @@ set -eu
 
 bindir=$( cd "${0%/*}" && pwd )
 rootdir=$( cd "$bindir"/.. && pwd )
+# shellcheck source=_os.sh
+. "$bindir"/_os.sh
 
-system=$(uname -s)
-
-if [ "$system" = Darwin ]; then
-  bin=$rootdir/target/cli/darwin/linkerd
-elif [ "$system" = Linux ]; then
-  arch=$(uname -m)
-  case $arch in
-    x86_64)
-      arch=amd64
-      ;;
-    armv8*)
-      arch=arm64
-      ;;
-    aarch64*)
-      arch=arm64
-      ;;
-    armv*)
-      arch=arm
-      ;;
-    amd64|arm64)
-      arch=$arch
-      ;;
-    *)
-      echo "unsupported architecture: $arch" >&2
-      exit 1
-      ;;
-  esac
-  bin=$rootdir/target/cli/linux-$arch/linkerd
-else
-  echo "unknown system: $system" >&2
-  exit 1
-fi
+bin=$rootdir/target/cli/$(os)/linkerd
 
 # build linkerd executable if it does not exist
 if [ ! -f "$bin" ]; then
diff --git a/bin/protoc-diff b/bin/protoc-diff
new file mode 100755
index 000000000..e75a0a8e0
--- /dev/null
+++ b/bin/protoc-diff
@@ -0,0 +1,22 @@
+#!/usr/bin/env sh
+
+set -eu
+
+# Install unzip if not already installed (Linux only)
+system=$(uname -s)
+if [ "$system" = Linux ] && ! [ -x "$(command -v unzip)" ]; then
+  apt-get update
+  apt-get install unzip
+fi
+
+bin/protoc-go.sh
+
+dir_dirty=$(git diff HEAD)
+if [ -z "$dir_dirty" ]; then
+  echo "Protobuf definitions match generated code"
+  exit 0
+else
+  echo "Protobuf definitions diverge from generated code:"
+  echo "$(git status)"
+  exit 64
+fi
diff --git a/charts/linkerd2/templates/destination-rbac.yaml b/charts/linkerd2/templates/destination-rbac.yaml
index 3bab38e49..817488123 100644
--- a/charts/linkerd2/templates/destination-rbac.yaml
+++ b/charts/linkerd2/templates/destination-rbac.yaml
@@ -18,7 +18,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/controller/api/destination/endpoint_translator.go b/controller/api/destination/endpoint_translator.go
index 7a1811699..a25682da8 100644
--- a/controller/api/destination/endpoint_translator.go
+++ b/controller/api/destination/endpoint_translator.go
@@ -9,6 +9,9 @@ import (
 	"github.com/linkerd/linkerd2/pkg/addr"
 	"github.com/linkerd/linkerd2/pkg/k8s"
 	logging "github.com/sirupsen/logrus"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes"
 )
 
 const defaultWeight uint32 = 10000
@@ -19,8 +22,12 @@ type endpointTranslator struct {
 	controllerNS        string
 	identityTrustDomain string
 	enableH2Upgrade     bool
-	stream              pb.Destination_GetServer
-	log                 *logging.Entry
+	nodeTopologyLabels  map[string]string
+
+	availableEndpoints watcher.AddressSet
+	filteredSnapshot   watcher.AddressSet
+	stream             pb.Destination_GetServer
+	log                *logging.Entry
 }
 
 func newEndpointTranslator(
@@ -28,6 +35,8 @@ func newEndpointTranslator(
 	identityTrustDomain string,
 	enableH2Upgrade bool,
 	service string,
+	srcNodeName string,
+	k8sClient kubernetes.Interface,
 	stream pb.Destination_GetServer,
 	log *logging.Entry,
 ) *endpointTranslator {
@@ -36,10 +45,158 @@ func newEndpointTranslator(
 		"service":   service,
 	})
 
-	return &endpointTranslator{controllerNS, identityTrustDomain, enableH2Upgrade, stream, log}
+	nodeTopologyLabels, err := getK8sNodeTopology(k8sClient, srcNodeName)
+	if err != nil {
+		log.Errorf("Failed to get node topology for node %s: %s", srcNodeName, err)
+	}
+	availableEndpoints := newEmptyAddressSet()
+
+	filteredSnapshot := newEmptyAddressSet()
+
+	return &endpointTranslator{
+		controllerNS,
+		identityTrustDomain,
+		enableH2Upgrade,
+		nodeTopologyLabels,
+		availableEndpoints,
+		filteredSnapshot,
+		stream,
+		log,
+	}
 }
 
 func (et *endpointTranslator) Add(set watcher.AddressSet) {
+	for id, address := range set.Addresses {
+		et.availableEndpoints.Addresses[id] = address
+	}
+
+	et.sendFilteredUpdate(set)
+}
+
+func (et *endpointTranslator) Remove(set watcher.AddressSet) {
+	for id := range set.Addresses {
+		delete(et.availableEndpoints.Addresses, id)
+	}
+
+	et.sendFilteredUpdate(set)
+}
+
+func (et *endpointTranslator) sendFilteredUpdate(set watcher.AddressSet) {
+	et.availableEndpoints = watcher.AddressSet{
+		Addresses:       et.availableEndpoints.Addresses,
+		Labels:          set.Labels,
+		TopologicalPref: set.TopologicalPref,
+	}
+
+	filtered := et.filterAddresses()
+	diffAdd, diffRemove := et.diffEndpoints(filtered)
+
+	if len(diffAdd.Addresses) > 0 {
+		et.sendClientAdd(diffAdd)
+	}
+	if len(diffRemove.Addresses) > 0 {
+		et.sendClientRemove(diffRemove)
+	}
+
+	et.filteredSnapshot = filtered
+}
+
+// filterAddresses is responsible for filtering endpoints based on service topology preference.
+// The client will receive only endpoints with the same topology label value as the source node,
+// the order of labels is based on the topological preference elicited from the K8s service.
+func (et *endpointTranslator) filterAddresses() watcher.AddressSet {
+	if len(et.availableEndpoints.TopologicalPref) == 0 {
+		allAvailEndpoints := make(map[watcher.ID]watcher.Address)
+		for k, v := range et.availableEndpoints.Addresses {
+			allAvailEndpoints[k] = v
+		}
+		return watcher.AddressSet{
+			Addresses: allAvailEndpoints,
+			Labels:    et.availableEndpoints.Labels,
+		}
+	}
+
+	et.log.Debugf("Filtering through address set with preference %v", et.availableEndpoints.TopologicalPref)
+	filtered := make(map[watcher.ID]watcher.Address)
+	for _, pref := range et.availableEndpoints.TopologicalPref {
+		// '*' as a topology preference means all endpoints
+		if pref == "*" {
+			return et.availableEndpoints
+		}
+
+		srcLocality, ok := et.nodeTopologyLabels[pref]
+		if !ok {
+			continue
+		}
+
+		for id, address := range et.availableEndpoints.Addresses {
+			addrLocality := address.TopologyLabels[pref]
+			if addrLocality == srcLocality {
+				filtered[id] = address
+			}
+		}
+
+		// if we filtered at least one endpoint, it means that preference has been satisfied
+		if len(filtered) > 0 {
+			et.log.Debugf("Filtered %d from a total of %d", len(filtered), len(et.availableEndpoints.Addresses))
+			return watcher.AddressSet{
+				Addresses: filtered,
+				Labels:    et.availableEndpoints.Labels,
+			}
+		}
+	}
+
+	// if we have no filtered endpoints or the '*' preference then no topology pref is satisfied
+	return newEmptyAddressSet()
+}
+
+// diffEndpoints calculates the difference between the filtered set of endpoints in the current (Add/Remove) operation
+// and the snapshot of previously filtered endpoints. This diff allows the client to receive only the endpoints that
+// satisfy the topological preference, by adding new endpoints and removing stale ones.
+func (et *endpointTranslator) diffEndpoints(filtered watcher.AddressSet) (watcher.AddressSet, watcher.AddressSet) {
+	add := make(map[watcher.ID]watcher.Address)
+	remove := make(map[watcher.ID]watcher.Address)
+
+	for id, address := range filtered.Addresses {
+		if _, ok := et.filteredSnapshot.Addresses[id]; !ok {
+			add[id] = address
+		}
+	}
+
+	for id, address := range et.filteredSnapshot.Addresses {
+		if _, ok := filtered.Addresses[id]; !ok {
+			remove[id] = address
+		}
+	}
+
+	return watcher.AddressSet{
+			Addresses: add,
+			Labels:    filtered.Labels,
+		},
+		watcher.AddressSet{
+			Addresses: remove,
+			Labels:    filtered.Labels,
+		}
+}
+
+func (et *endpointTranslator) NoEndpoints(exists bool) {
+	et.log.Debugf("NoEndpoints(%+v)", exists)
+
+	u := &pb.Update{
+		Update: &pb.Update_NoEndpoints{
+			NoEndpoints: &pb.NoEndpoints{
+				Exists: exists,
+			},
+		},
+	}
+
+	et.log.Debugf("Sending destination no endpoints: %+v", u)
+	if err := et.stream.Send(u); err != nil {
+		et.log.Errorf("Failed to send address update: %s", err)
+	}
+}
+
+func (et *endpointTranslator) sendClientAdd(set watcher.AddressSet) {
 	addrs := []*pb.WeightedAddr{}
 	for _, address := range set.Addresses {
 		var (
@@ -103,7 +260,7 @@ func (et *endpointTranslator) Add(set watcher.AddressSet) {
 	}
 }
 
-func (et *endpointTranslator) Remove(set watcher.AddressSet) {
+func (et *endpointTranslator) sendClientRemove(set watcher.AddressSet) {
 	addrs := []*net.TcpAddress{}
 	for _, address := range set.Addresses {
 		tcpAddr, err := et.toAddr(address)
@@ -126,23 +283,6 @@ func (et *endpointTranslator) Remove(set watcher.AddressSet) {
 	}
 }
 
-func (et *endpointTranslator) NoEndpoints(exists bool) {
-	et.log.Debugf("NoEndpoints(%+v)", exists)
-
-	u := &pb.Update{
-		Update: &pb.Update_NoEndpoints{
-			NoEndpoints: &pb.NoEndpoints{
-				Exists: exists,
-			},
-		},
-	}
-
-	et.log.Debugf("Sending destination no endpoints: %+v", u)
-	if err := et.stream.Send(u); err != nil {
-		et.log.Errorf("Failed to send address update: %s", err)
-	}
-}
-
 func (et *endpointTranslator) toAddr(address watcher.Address) (*net.TcpAddress, error) {
 	ip, err := addr.ParseProxyIPV4(address.IP)
 	if err != nil {
@@ -203,3 +343,29 @@ func (et *endpointTranslator) toWeightedAddr(address watcher.Address) (*pb.Weigh
 		ProtocolHint: hint,
 	}, nil
 }
+
+func getK8sNodeTopology(k8sClient kubernetes.Interface, srcNode string) (map[string]string, error) {
+	nodeTopology := make(map[string]string)
+	node, err := k8sClient.CoreV1().Nodes().Get(srcNode, metav1.GetOptions{})
+	if err != nil {
+		return nodeTopology, err
+	}
+
+	for k, v := range node.Labels {
+		if k == corev1.LabelHostname ||
+			k == corev1.LabelZoneFailureDomainStable ||
+			k == corev1.LabelZoneRegionStable {
+			nodeTopology[k] = v
+		}
+	}
+
+	return nodeTopology, nil
+}
+
+func newEmptyAddressSet() watcher.AddressSet {
+	return watcher.AddressSet{
+		Addresses:       make(map[watcher.ID]watcher.Address),
+		Labels:          make(map[string]string),
+		TopologicalPref: []string{},
+	}
+}
diff --git a/controller/api/destination/server.go b/controller/api/destination/server.go
index 1896b4ced..0b789896d 100644
--- a/controller/api/destination/server.go
+++ b/controller/api/destination/server.go
@@ -30,6 +30,7 @@ type (
 		identityTrustDomain string
 		clusterDomain       string
 
+		k8sAPI   *k8s.API
 		log      *logging.Entry
 		shutdown <-chan struct{}
 	}
@@ -75,6 +76,7 @@ func NewServer(
 		controllerNS,
 		identityTrustDomain,
 		clusterDomain,
+		k8sAPI,
 		log,
 		shutdown,
 	}
@@ -93,11 +95,19 @@ func (s *server) Get(dest *pb.GetDestination, stream pb.Destination_GetServer) e
 	}
 	log.Debugf("Get %s", dest.GetPath())
 
+	var token contextToken
+	if dest.GetContextToken() != "" {
+		token = s.parseContextToken(dest.GetContextToken())
+		log.Debugf("Dest token: %v", token)
+	}
+
 	translator := newEndpointTranslator(
 		s.controllerNS,
 		s.identityTrustDomain,
 		s.enableH2Upgrade,
 		dest.GetPath(),
+		token.NodeName,
+		s.k8sAPI.Client,
 		stream,
 		log,
 	)
diff --git a/controller/api/destination/watcher/endpoints_watcher.go b/controller/api/destination/watcher/endpoints_watcher.go
index 225aff435..90db0c044 100644
--- a/controller/api/destination/watcher/endpoints_watcher.go
+++ b/controller/api/destination/watcher/endpoints_watcher.go
@@ -47,12 +47,14 @@ type (
 		OwnerKind         string
 		Identity          string
 		AuthorityOverride string
+		TopologyLabels    map[string]string
 	}
 
 	// AddressSet is a set of Address, indexed by ID.
 	AddressSet struct {
-		Addresses map[ID]Address
-		Labels    map[string]string
+		Addresses       map[ID]Address
+		Labels          map[string]string
+		TopologicalPref []string
 	}
 
 	portAndHostname struct {
@@ -88,7 +90,8 @@ type (
 		k8sAPI               *k8s.API
 		enableEndpointSlices bool
 
-		ports map[portAndHostname]*portPublisher
+		TopologyPref []string
+		ports        map[portAndHostname]*portPublisher
 		// All access to the servicePublisher and its portPublishers is explicitly synchronized by
 		// this mutex.
 		sync.Mutex
@@ -107,6 +110,7 @@ type (
 		log                  *logging.Entry
 		k8sAPI               *k8s.API
 		enableEndpointSlices bool
+		TopologyPref         []string
 
 		exists    bool
 		addresses AddressSet
@@ -393,6 +397,7 @@ func (ew *EndpointsWatcher) getOrNewServicePublisher(id ServiceID) *servicePubli
 				"svc":       id.Name,
 			}),
 			k8sAPI:               ew.k8sAPI,
+			TopologyPref:         make([]string, 0),
 			ports:                make(map[portAndHostname]*portPublisher),
 			enableEndpointSlices: ew.enableEndpointSlices,
 		}
@@ -462,12 +467,23 @@ func (sp *servicePublisher) updateService(newService *corev1.Service) {
 	defer sp.Unlock()
 	sp.log.Debugf("Updating service for %s", sp.id)
 
+	if sp.enableEndpointSlices {
+		sp.TopologyPref = make([]string, len(newService.Spec.TopologyKeys))
+		copy(sp.TopologyPref, newService.Spec.TopologyKeys)
+	}
+
 	for key, port := range sp.ports {
+		if sp.enableEndpointSlices {
+			port.TopologyPref = sp.TopologyPref
+			port.updateTopologyPreference()
+		}
+
 		newTargetPort := getTargetPort(newService, key.port)
 		if newTargetPort != port.targetPort {
 			port.updatePort(newTargetPort)
 		}
 	}
+
 }
 
 func (sp *servicePublisher) subscribe(srcPort Port, hostname string, listener EndpointUpdateListener) {
@@ -528,6 +544,7 @@ func (sp *servicePublisher) newPortPublisher(srcPort Port, hostname string) *por
 		log:                  log,
 		metrics:              endpointsVecs.newEndpointsMetrics(sp.metricsLabels(srcPort, hostname)),
 		enableEndpointSlices: sp.enableEndpointSlices,
+		TopologyPref:         sp.TopologyPref,
 	}
 
 	if port.enableEndpointSlices {
@@ -614,8 +631,9 @@ func (pp *portPublisher) addEndpointSlice(slice *discovery.EndpointSlice) {
 
 func (pp *portPublisher) updateEndpointSlice(oldSlice *discovery.EndpointSlice, newSlice *discovery.EndpointSlice) {
 	updatedAddressSet := AddressSet{
-		Addresses: make(map[ID]Address),
-		Labels:    pp.addresses.Labels,
+		Addresses:       make(map[ID]Address),
+		Labels:          pp.addresses.Labels,
+		TopologicalPref: pp.TopologyPref,
 	}
 
 	for id, address := range pp.addresses.Addresses {
@@ -715,6 +733,11 @@ func (pp *portPublisher) endpointSliceToAddresses(es *discovery.EndpointSlice) A
 				identity := es.Annotations[consts.RemoteGatewayIdentity]
 				address, id := pp.newServiceRefAddress(resolvedPort, IPAddr, serviceID.Name, es.Namespace)
 				address.Identity, address.AuthorityOverride = authorityOverride, identity
+
+				for k, v := range endpoint.Topology {
+					address.TopologyLabels[k] = v
+				}
+
 				addresses[id] = address
 			}
 
@@ -728,14 +751,21 @@ func (pp *portPublisher) endpointSliceToAddresses(es *discovery.EndpointSlice) A
 					pp.log.Errorf("Unable to create new address:%v", err)
 					continue
 				}
+
+				for k, v := range endpoint.Topology {
+					address.TopologyLabels[k] = v
+				}
+
 				addresses[id] = address
 			}
 		}
 
 	}
+
 	return AddressSet{
-		Addresses: addresses,
-		Labels:    metricLabels(es),
+		Addresses:       addresses,
+		Labels:          metricLabels(es),
+		TopologicalPref: pp.TopologyPref,
 	}
 }
 
@@ -773,8 +803,9 @@ func (pp *portPublisher) endpointsToAddresses(endpoints *corev1.Endpoints) Addre
 		}
 	}
 	return AddressSet{
-		Addresses: addresses,
-		Labels:    metricLabels(endpoints),
+		Addresses:       addresses,
+		Labels:          metricLabels(endpoints),
+		TopologicalPref: []string{},
 	}
 }
 
@@ -788,7 +819,7 @@ func (pp *portPublisher) newServiceRefAddress(endpointPort Port, endpointIP, ser
 		Namespace: serviceNamespace,
 	}
 
-	return Address{IP: endpointIP, Port: endpointPort}, id
+	return Address{IP: endpointIP, Port: endpointPort, TopologyLabels: make(map[string]string)}, id
 }
 
 func (pp *portPublisher) newPodRefAddress(endpointPort Port, endpointIP, podName, podNamespace string) (Address, PodID, error) {
@@ -802,11 +833,12 @@ func (pp *portPublisher) newPodRefAddress(endpointPort Port, endpointIP, podName
 	}
 	ownerKind, ownerName := pp.k8sAPI.GetOwnerKindAndName(pod, false)
 	addr := Address{
-		IP:        endpointIP,
-		Port:      endpointPort,
-		Pod:       pod,
-		OwnerName: ownerName,
-		OwnerKind: ownerKind,
+		IP:             endpointIP,
+		Port:           endpointPort,
+		Pod:            pod,
+		TopologyLabels: make(map[string]string),
+		OwnerName:      ownerName,
+		OwnerKind:      ownerKind,
 	}
 
 	return addr, id, nil
@@ -870,6 +902,22 @@ func (pp *portPublisher) updatePort(targetPort namedPort) {
 	}
 }
 
+// updateTopologyPreference is used when a service's topology preference changes. This method
+// propagates the changes to the portPublisher, the portPublisher's AddressSet and triggers
+// an (empty) update for all of its listeners to reflect the new preference changes.
+func (pp *portPublisher) updateTopologyPreference() {
+	pp.addresses.TopologicalPref = pp.TopologyPref
+
+	updatedAddrSet := AddressSet{
+		Addresses:       make(map[ID]Address),
+		Labels:          make(map[string]string),
+		TopologicalPref: pp.TopologyPref,
+	}
+	for _, listener := range pp.listeners {
+		listener.Add(updatedAddrSet)
+	}
+}
+
 func (pp *portPublisher) deleteEndpointSlice(es *discovery.EndpointSlice) {
 	addrSet := pp.endpointSliceToAddresses(es)
 	for id := range addrSet.Addresses {
@@ -993,13 +1041,15 @@ func diffAddresses(oldAddresses, newAddresses AddressSet) (add, remove AddressSe
 		}
 	}
 	add = AddressSet{
-		Addresses: addAddresses,
-		Labels:    newAddresses.Labels,
+		Addresses:       addAddresses,
+		Labels:          newAddresses.Labels,
+		TopologicalPref: newAddresses.TopologicalPref,
 	}
 	remove = AddressSet{
-		Addresses: removeAddresses,
+		Addresses:       removeAddresses,
+		TopologicalPref: newAddresses.TopologicalPref,
 	}
-	return
+	return add, remove
 }
 
 func getEndpointSliceServiceID(es *discovery.EndpointSlice) (ServiceID, error) {
diff --git a/pkg/healthcheck/healthcheck.go b/pkg/healthcheck/healthcheck.go
index 02f2b3280..ae362f65c 100644
--- a/pkg/healthcheck/healthcheck.go
+++ b/pkg/healthcheck/healthcheck.go
@@ -23,6 +23,7 @@ import (
 	"github.com/linkerd/linkerd2/pkg/tls"
 	"github.com/linkerd/linkerd2/pkg/version"
 	log "github.com/sirupsen/logrus"
+	admissionRegistration "k8s.io/api/admissionregistration/v1beta1"
 	appsv1 "k8s.io/api/apps/v1"
 	corev1 "k8s.io/api/core/v1"
 	kerrors "k8s.io/apimachinery/pkg/api/errors"
@@ -1208,7 +1209,11 @@ func (hc *HealthChecker) allCategories() []category {
 					hintAnchor:  "l5d-injection-disabled",
 					warning:     true,
 					check: func(context.Context) error {
-						if hc.isHA() {
+						policy, err := hc.getMutatingWebhookFailurePolicy()
+						if err != nil {
+							return err
+						}
+						if policy != nil && *policy == admissionRegistration.Fail {
 							return hc.checkHAMetadataPresentOnKubeSystemNamespace()
 						}
 						return &SkipError{Reason: "not run for non HA installs"}
@@ -1667,6 +1672,17 @@ func (hc *HealthChecker) checkCustomResourceDefinitions(shouldExist bool) error
 	return checkResources("CustomResourceDefinitions", objects, []string{"serviceprofiles.linkerd.io"}, shouldExist)
 }
 
+func (hc *HealthChecker) getMutatingWebhookFailurePolicy() (*admissionRegistration.FailurePolicyType, error) {
+	mwc, err := hc.kubeAPI.AdmissionregistrationV1beta1().MutatingWebhookConfigurations().Get(k8s.ProxyInjectorWebhookConfigName, metav1.GetOptions{})
+	if err != nil {
+		return nil, err
+	}
+	if len(mwc.Webhooks) != 1 {
+		return nil, fmt.Errorf("expected 1 webhooks, found %d", len(mwc.Webhooks))
+	}
+	return mwc.Webhooks[0].FailurePolicy, nil
+}
+
 func (hc *HealthChecker) checkMutatingWebhookConfigurations(shouldExist bool) error {
 	options := metav1.ListOptions{
 		LabelSelector: hc.controlPlaneComponentsSelector(),
@@ -1885,7 +1901,7 @@ func (hc *HealthChecker) checkHAMetadataPresentOnKubeSystemNamespace() error {
 
 	val, ok := ns.Labels[k8s.AdmissionWebhookLabel]
 	if !ok || val != "disabled" {
-		return fmt.Errorf("kube-system namespace needs to have the label %s: disabled if HA mode is enabled", k8s.AdmissionWebhookLabel)
+		return fmt.Errorf("kube-system namespace needs to have the label %s: disabled if injector webhook failure policy is Fail", k8s.AdmissionWebhookLabel)
 	}
 
 	return nil
diff --git a/web/app/js/components/BaseTable.jsx b/web/app/js/components/BaseTable.jsx
index 33573c46e..1418cd943 100644
--- a/web/app/js/components/BaseTable.jsx
+++ b/web/app/js/components/BaseTable.jsx
@@ -261,10 +261,10 @@ BaseTable.propTypes = {
     isNumeric: PropTypes.bool,
     render: PropTypes.func,
     sorter: PropTypes.func,
-    title: PropTypes.string,
+    title: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
   })).isRequired,
   tableRows: PropTypes.arrayOf(PropTypes.shape({})),
-  title: PropTypes.string,
+  title: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
 };
 
 BaseTable.defaultProps = {
diff --git a/web/app/js/components/CheckModal.jsx b/web/app/js/components/CheckModal.jsx
index 6edadd8ba..d284b2ac1 100644
--- a/web/app/js/components/CheckModal.jsx
+++ b/web/app/js/components/CheckModal.jsx
@@ -14,6 +14,7 @@ import PropTypes from 'prop-types';
 import React from 'react';
 import SimpleChip from './util/Chip.jsx';
 import Slide from '@material-ui/core/Slide';
+import { Trans } from '@lingui/macro';
 import Typography from '@material-ui/core/Typography';
 import withMobileDialog from '@material-ui/core/withMobileDialog';
 import { withStyles } from '@material-ui/core/styles';
@@ -254,7 +255,7 @@ class CheckModal extends React.Component {
               color="primary"
               disabled={running}
               onClick={this.runCheck}>
-              Run Linkerd Check
+              <Trans>buttonRunLinkerdCheck</Trans>
             </Button>
           </Grid>
         </Grid>
diff --git a/web/app/js/components/EdgesTable.jsx b/web/app/js/components/EdgesTable.jsx
index 6eb0735b7..10c86d493 100644
--- a/web/app/js/components/EdgesTable.jsx
+++ b/web/app/js/components/EdgesTable.jsx
@@ -3,6 +3,7 @@ import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
 import PropTypes from 'prop-types';
 import React from 'react';
 import Tooltip from '@material-ui/core/Tooltip';
+import { Trans } from '@lingui/macro';
 import WarningIcon from '@material-ui/icons/Warning';
 import { directionColumn } from './util/TapUtils.jsx';
 import { processedEdgesPropType } from './util/EdgesUtils.jsx';
@@ -25,7 +26,7 @@ const edgesColumnDefinitions = (PrefixedLink, namespace, type, classes) => {
       render: d => directionColumn(d.direction),
     },
     {
-      title: 'Namespace',
+      title: <Trans>columnTitleNamespace</Trans>,
       dataIndex: 'namespace',
       isNumeric: false,
       filter: d => d.namespace,
@@ -37,7 +38,7 @@ const edgesColumnDefinitions = (PrefixedLink, namespace, type, classes) => {
       sorter: d => d.namespace,
     },
     {
-      title: 'Name',
+      title: <Trans>columnTitleName</Trans>,
       dataIndex: 'name',
       isNumeric: false,
       filter: d => d.name,
@@ -56,7 +57,7 @@ const edgesColumnDefinitions = (PrefixedLink, namespace, type, classes) => {
       sorter: d => d.name,
     },
     {
-      title: 'Identity',
+      title: <Trans>columnTitleIdentity</Trans>,
       dataIndex: 'identity',
       isNumeric: false,
       filter: d => d.identity,
@@ -64,7 +65,7 @@ const edgesColumnDefinitions = (PrefixedLink, namespace, type, classes) => {
       sorter: d => d.identity,
     },
     {
-      title: 'Secured',
+      title: <Trans>columnTitleSecured</Trans>,
       dataIndex: 'message',
       isNumeric: true,
       render: d => {
@@ -83,12 +84,12 @@ const edgesColumnDefinitions = (PrefixedLink, namespace, type, classes) => {
 };
 
 const generateEdgesTableTitle = edges => {
-  let title = 'Edges';
+  let title = <Trans>tableTitleEdgesEmpty</Trans>;
   if (edges.length > 0) {
     let identity = edges[0].direction === 'INBOUND' ? edges[0].serverId : edges[0].clientId;
     if (identity) {
       identity = `${identity.split('.')[0]}.${identity.split('.')[1]}`;
-      title = `${title} (Identity: ${identity})`;
+      title = <Trans>tableTitleEdgesWithIdentity {identity}</Trans>;
     }
   }
   return title;
diff --git a/web/app/js/components/EmptyCard.jsx b/web/app/js/components/EmptyCard.jsx
index 4adeb7062..b3e7b8fe6 100644
--- a/web/app/js/components/EmptyCard.jsx
+++ b/web/app/js/components/EmptyCard.jsx
@@ -17,7 +17,7 @@ const EmptyCard = ({ classes }) => {
     <Card className={classes.card} elevation={3}>
       <CardContent>
         <Typography>
-          <Trans>No data to display</Trans>
+          <Trans>NoDataToDisplayMsg</Trans>
         </Typography>
       </CardContent>
     </Card>
diff --git a/web/app/js/components/ErrorModal.jsx b/web/app/js/components/ErrorModal.jsx
index c649b7d37..bdf223dbd 100644
--- a/web/app/js/components/ErrorModal.jsx
+++ b/web/app/js/components/ErrorModal.jsx
@@ -10,6 +10,7 @@ import PropTypes from 'prop-types';
 import React from 'react';
 import Switch from '@material-ui/core/Switch';
 import Tooltip from '@material-ui/core/Tooltip';
+import { Trans } from '@lingui/macro';
 import Typography from '@material-ui/core/Typography';
 import _each from 'lodash/each';
 import _get from 'lodash/get';
@@ -150,7 +151,9 @@ class ErrorModal extends React.Component {
 
     if (showInit) {
       return (
-        <Tooltip title="Pods are initializing"><CircularProgress size={20} thickness={4} /></Tooltip>
+        <Tooltip title={<Trans>podsAreInitializingMsg</Trans>}>
+          <CircularProgress size={20} thickness={4} />
+        </Tooltip>
       );
     } else {
       return (
diff --git a/web/app/js/components/ExpandableTable.jsx b/web/app/js/components/ExpandableTable.jsx
index 8244fed82..89af24afe 100644
--- a/web/app/js/components/ExpandableTable.jsx
+++ b/web/app/js/components/ExpandableTable.jsx
@@ -148,7 +148,7 @@ ExpandableTable.propTypes = {
   expandedRowRender: PropTypes.func.isRequired,
   tableClassName: PropTypes.string,
   tableColumns: PropTypes.arrayOf(PropTypes.shape({
-    title: PropTypes.string,
+    title: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
     isNumeric: PropTypes.bool,
     render: PropTypes.func,
   })).isRequired,
diff --git a/web/app/js/components/Gateway.jsx b/web/app/js/components/Gateway.jsx
index f41548cb3..ddee3a936 100644
--- a/web/app/js/components/Gateway.jsx
+++ b/web/app/js/components/Gateway.jsx
@@ -4,6 +4,7 @@ import MetricsTable from './MetricsTable.jsx';
 import PropTypes from 'prop-types';
 import React from 'react';
 import Spinner from './util/Spinner.jsx';
+import { Trans } from '@lingui/macro';
 import _isEmpty from 'lodash/isEmpty';
 import { processGatewayResults } from './util/MetricUtils.jsx';
 import { withContext } from './util/AppContext.jsx';
@@ -102,7 +103,7 @@ class Gateways extends React.Component {
             {noMetrics ? null : (
               <div className="page-section">
                 <MetricsTable
-                  title="Gateways"
+                  title={<Trans>tableTitleGateways</Trans>}
                   resource="gateway"
                   metrics={metrics} />
               </div>
diff --git a/web/app/js/components/MeshedStatusTable.jsx b/web/app/js/components/MeshedStatusTable.jsx
index 4fb24b4ab..dcff295df 100644
--- a/web/app/js/components/MeshedStatusTable.jsx
+++ b/web/app/js/components/MeshedStatusTable.jsx
@@ -5,6 +5,7 @@ import PropTypes from 'prop-types';
 import React from 'react';
 import { StyledProgress } from './util/Progress.jsx';
 import Tooltip from '@material-ui/core/Tooltip';
+import { Trans } from '@lingui/macro';
 import _isEmpty from 'lodash/isEmpty';
 import { withContext } from './util/AppContext.jsx';
 
@@ -20,7 +21,7 @@ const getClassification = (meshedPodCount, failedPodCount) => {
 
 const namespacesColumns = PrefixedLink => [
   {
-    title: 'Namespace',
+    title: <Trans>columnTitleNamespace</Trans>,
     dataIndex: 'namespace',
     sorter: d => d.namespace,
     render: d => {
@@ -37,12 +38,12 @@ const namespacesColumns = PrefixedLink => [
     },
   },
   {
-    title: 'Meshed pods',
+    title: <Trans>columnTitleMeshedPods</Trans>,
     dataIndex: 'meshedPodsStr',
     isNumeric: true,
   },
   {
-    title: 'Meshed Status',
+    title: <Trans>columnTitleMeshedStatus</Trans>,
     key: 'meshification',
     render: row => {
       const percent = row.meshedPercent.get();
diff --git a/web/app/js/components/MetricsTable.jsx b/web/app/js/components/MetricsTable.jsx
index cf2bf7abd..8c8415023 100644
--- a/web/app/js/components/MetricsTable.jsx
+++ b/web/app/js/components/MetricsTable.jsx
@@ -7,6 +7,7 @@ import JaegerLink from './JaegerLink.jsx';
 import PropTypes from 'prop-types';
 import React from 'react';
 import SuccessRateMiniChart from './util/SuccessRateMiniChart.jsx';
+import { Trans } from '@lingui/macro';
 import _cloneDeep from 'lodash/cloneDeep';
 import _each from 'lodash/each';
 import _get from 'lodash/get';
@@ -16,21 +17,21 @@ import { withContext } from './util/AppContext.jsx';
 
 const tcpStatColumns = [
   {
-    title: 'Connections',
+    title: <Trans>columnTitleOpenConnections</Trans>,
     dataIndex: 'tcp.openConnections',
     isNumeric: true,
     render: d => metricToFormatter.NO_UNIT(d.tcp.openConnections),
     sorter: d => d.tcp.openConnections,
   },
   {
-    title: 'Read Bytes / sec',
+    title: <Trans>columnTitleReadRate</Trans>,
     dataIndex: 'tcp.readRate',
     isNumeric: true,
     render: d => metricToFormatter.BYTES(d.tcp.readRate),
     sorter: d => d.tcp.readRate,
   },
   {
-    title: 'Write Bytes / sec',
+    title: <Trans>columnTitleWriteRate</Trans>,
     dataIndex: 'tcp.writeRate',
     isNumeric: true,
     render: d => metricToFormatter.BYTES(d.tcp.writeRate),
@@ -40,35 +41,35 @@ const tcpStatColumns = [
 
 const httpStatColumns = [
   {
-    title: 'Success Rate',
+    title: <Trans>columnTitleSuccessRate</Trans>,
     dataIndex: 'successRate',
     isNumeric: true,
     render: d => <SuccessRateMiniChart sr={d.successRate} />,
     sorter: d => d.successRate,
   },
   {
-    title: 'RPS',
+    title: <Trans>columnTitleRPS</Trans>,
     dataIndex: 'requestRate',
     isNumeric: true,
     render: d => metricToFormatter.NO_UNIT(d.requestRate),
     sorter: d => d.requestRate,
   },
   {
-    title: 'P50 Latency',
+    title: <Trans>columnTitleP50Latency</Trans>,
     dataIndex: 'P50',
     isNumeric: true,
     render: d => metricToFormatter.LATENCY(d.P50),
     sorter: d => d.P50,
   },
   {
-    title: 'P95 Latency',
+    title: <Trans>columnTitleP95Latency</Trans>,
     dataIndex: 'P95',
     isNumeric: true,
     render: d => metricToFormatter.LATENCY(d.P95),
     sorter: d => d.P95,
   },
   {
-    title: 'P99 Latency',
+    title: <Trans>columnTitleP99Latency</Trans>,
     dataIndex: 'P99',
     isNumeric: true,
     render: d => metricToFormatter.LATENCY(d.P99),
@@ -79,7 +80,7 @@ const httpStatColumns = [
 
 const trafficSplitDetailColumns = [
   {
-    title: 'Apex Service',
+    title: <Trans>columnTitleApexService</Trans>,
     dataIndex: 'apex',
     isNumeric: false,
     filter: d => !d.tsStats ? null : d.tsStats.apex,
@@ -87,7 +88,7 @@ const trafficSplitDetailColumns = [
     sorter: d => !d.tsStats ? null : d.tsStats.apex,
   },
   {
-    title: 'Leaf Service',
+    title: <Trans>columnTitleLeafService</Trans>,
     dataIndex: 'leaf',
     isNumeric: false,
     filter: d => !d.tsStats ? null : d.tsStats.leaf,
@@ -95,7 +96,7 @@ const trafficSplitDetailColumns = [
     sorter: d => !d.tsStats ? null : d.tsStats.leaf,
   },
   {
-    title: 'Weight',
+    title: <Trans>columnTitleWeight</Trans>,
     dataIndex: 'weight',
     isNumeric: true,
     filter: d => !d.tsStats ? null : d.tsStats.weight,
@@ -113,42 +114,42 @@ const trafficSplitDetailColumns = [
 
 const gatewayColumns = [
   {
-    title: 'ClusterName',
+    title: <Trans>columnTitleClusterName</Trans>,
     dataIndex: 'clusterName',
     isNumeric: false,
     render: d => !d.clusterName ? '---' : d.clusterName,
     sorter: d => !d.clusterName ? '---' : d.clusterName,
   },
   {
-    title: 'Alive',
+    title: <Trans>columnTitleAlive</Trans>,
     dataIndex: 'alive',
     isNumeric: false,
     render: d => !d.alive ? 'FALSE' : 'TRUE',
     sorter: d => d.alive,
   },
   {
-    title: 'Paired Services',
+    title: <Trans>columnTitlePairedServices</Trans>,
     dataIndex: 'pairedServices',
     isNumeric: false,
     render: d => d.pairedServices,
     sorter: d => d.pairedServices,
   },
   {
-    title: 'P50 Latency',
+    title: <Trans>columnTitleP50Latency</Trans>,
     dataIndex: 'P50',
     isNumeric: true,
     render: d => metricToFormatter.LATENCY(d.P50),
     sorter: d => d.P50,
   },
   {
-    title: 'P95 Latency',
+    title: <Trans>columnTitleP95Latency</Trans>,
     dataIndex: 'P95',
     isNumeric: true,
     render: d => metricToFormatter.LATENCY(d.P95),
     sorter: d => d.P95,
   },
   {
-    title: 'P99 Latency',
+    title: <Trans>columnTitleP99Latency</Trans>,
     dataIndex: 'P99',
     isNumeric: true,
     render: d => metricToFormatter.LATENCY(d.P99),
@@ -165,7 +166,7 @@ const columnDefinitions = (resource, showNamespaceColumn, showNameColumn, Prefix
 
   const nsColumn = [
     {
-      title: 'Namespace',
+      title: <Trans>columnTitleNamespace</Trans>,
       dataIndex: 'namespace',
       filter: d => d.namespace,
       isNumeric: false,
@@ -175,7 +176,7 @@ const columnDefinitions = (resource, showNamespaceColumn, showNameColumn, Prefix
   ];
 
   const meshedColumn = {
-    title: 'Meshed',
+    title: <Trans>columnTitleMeshed</Trans>,
     dataIndex: 'pods.totalPods',
     isNumeric: true,
     render: d => !d.pods ? null : `${d.pods.meshedPods}/${d.pods.totalPods}`,
@@ -183,7 +184,7 @@ const columnDefinitions = (resource, showNamespaceColumn, showNameColumn, Prefix
   };
 
   const grafanaColumn = {
-    title: 'Grafana',
+    title: <Trans>columnTitleGrafana</Trans>,
     key: 'grafanaDashboard',
     isNumeric: true,
     render: row => {
@@ -203,7 +204,7 @@ const columnDefinitions = (resource, showNamespaceColumn, showNameColumn, Prefix
 
 
   const jaegerColumn = {
-    title: 'Jaeger',
+    title: <Trans>columnTitleJaeger</Trans>,
     key: 'JaegerDashboard',
     isNumeric: true,
     render: row => {
@@ -327,7 +328,7 @@ MetricsTable.propTypes = {
   selectedNamespace: PropTypes.string.isRequired,
   showName: PropTypes.bool,
   showNamespaceColumn: PropTypes.bool,
-  title: PropTypes.string,
+  title: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
   grafana: PropTypes.string,
   jaeger: PropTypes.string,
 };
diff --git a/web/app/js/components/Namespace.jsx b/web/app/js/components/Namespace.jsx
index 65f32e77c..be6392f80 100644
--- a/web/app/js/components/Namespace.jsx
+++ b/web/app/js/components/Namespace.jsx
@@ -7,6 +7,7 @@ import NetworkGraph from './NetworkGraph.jsx';
 import PropTypes from 'prop-types';
 import React from 'react';
 import Spinner from './util/Spinner.jsx';
+import { Trans } from '@lingui/macro';
 import _filter from 'lodash/filter';
 import _get from 'lodash/get';
 import _isEmpty from 'lodash/isEmpty';
@@ -147,7 +148,7 @@ class Namespaces extends React.Component {
         {!error ? null : <ErrorBanner message={error} />}
         {!loaded ? <Spinner /> : (
           <div>
-            {noMetrics ? <div>No resources detected.</div> : null}
+            {noMetrics ? <div><Trans>noResourcesDetectedMsg</Trans></div> : null}
             {
               _isEmpty(deploymentsWithMetrics) ? null :
               <NetworkGraph namespace={ns} deployments={metrics.deployment} />
@@ -166,7 +167,7 @@ class Namespaces extends React.Component {
               noMetrics ? null :
               <div className="page-section">
                 <MetricsTable
-                  title="TCP"
+                  title={<Trans>tableTitleTCP</Trans>}
                   resource="pod"
                   metrics={metrics.pod}
                   isTcpTable />
diff --git a/web/app/js/components/Navigation.jsx b/web/app/js/components/Navigation.jsx
index 91e0884cb..d6034d770 100644
--- a/web/app/js/components/Navigation.jsx
+++ b/web/app/js/components/Navigation.jsx
@@ -22,6 +22,7 @@ import React from 'react';
 import ReactRouterPropTypes from 'react-router-prop-types';
 import TextField from '@material-ui/core/TextField';
 import Toolbar from '@material-ui/core/Toolbar';
+import { Trans } from '@lingui/macro';
 import Typography from '@material-ui/core/Typography';
 import Version from './Version.jsx';
 import _maxBy from 'lodash/maxBy';
@@ -450,15 +451,15 @@ class NavigationBase extends React.Component {
         <Divider />
         <MenuList>
           <Typography variant="button" component="div" className={classes.sidebarHeading}>
-            Cluster
+            <Trans>sidebarHeadingCluster</Trans>
           </Typography>
-          { this.menuItem('/namespaces', 'Namespaces', namespaceIcon) }
+          { this.menuItem('/namespaces', <Trans>menuItemNamespaces</Trans>, namespaceIcon) }
 
 
-          { this.menuItem('/controlplane', 'Control Plane',
+          { this.menuItem('/controlplane', <Trans>menuItemControlPlane</Trans>,
             <FontAwesomeIcon icon={faCloud} className={classes.shrinkCloudIcon} />) }
 
-          { this.menuItem('/gateways', 'Gateway',
+          { this.menuItem('/gateways', <Trans>menuItemGateway</Trans>,
             <FontAwesomeIcon icon={faDungeon} className={classes.shrinkIcon} />) }
 
         </MenuList>
@@ -500,48 +501,48 @@ class NavigationBase extends React.Component {
 
         <MenuList>
           <Typography variant="button" component="div" className={classes.sidebarHeading}>
-            Workloads
+            <Trans>sidebarHeadingWorkloads</Trans>
           </Typography>
 
-          { this.menuItem(`/namespaces/${selectedNamespace}/cronjobs`, 'Cron Jobs', cronJobIcon) }
+          { this.menuItem(`/namespaces/${selectedNamespace}/cronjobs`, <Trans>menuItemCronJobs</Trans>, cronJobIcon) }
 
-          { this.menuItem(`/namespaces/${selectedNamespace}/daemonsets`, 'Daemon Sets', daemonsetIcon) }
+          { this.menuItem(`/namespaces/${selectedNamespace}/daemonsets`, <Trans>menuItemDaemonSets</Trans>, daemonsetIcon) }
 
-          { this.menuItem(`/namespaces/${selectedNamespace}/deployments`, 'Deployments', deploymentIcon) }
+          { this.menuItem(`/namespaces/${selectedNamespace}/deployments`, <Trans>menuItemDeployments</Trans>, deploymentIcon) }
 
-          { this.menuItem(`/namespaces/${selectedNamespace}/jobs`, 'Jobs', jobIcon) }
+          { this.menuItem(`/namespaces/${selectedNamespace}/jobs`, <Trans>menuItemJobs</Trans>, jobIcon) }
 
-          { this.menuItem(`/namespaces/${selectedNamespace}/pods`, 'Pods', podIcon) }
+          { this.menuItem(`/namespaces/${selectedNamespace}/pods`, <Trans>menuItemPods</Trans>, podIcon) }
 
-          { this.menuItem(`/namespaces/${selectedNamespace}/replicasets`, 'Replica Sets', replicaSetIcon) }
+          { this.menuItem(`/namespaces/${selectedNamespace}/replicasets`, <Trans>menuItemReplicaSets</Trans>, replicaSetIcon) }
 
-          { this.menuItem(`/namespaces/${selectedNamespace}/replicationcontrollers`, 'Replication Controllers', replicaSetIcon) }
+          { this.menuItem(`/namespaces/${selectedNamespace}/replicationcontrollers`, <Trans>menuItemReplicationControllers</Trans>, replicaSetIcon) }
 
-          { this.menuItem(`/namespaces/${selectedNamespace}/statefulsets`, 'Stateful Sets', statefulSetIcon) }
+          { this.menuItem(`/namespaces/${selectedNamespace}/statefulsets`, <Trans>menuItemStatefulSets</Trans>, statefulSetIcon) }
         </MenuList>
 
         <MenuList>
           <Typography variant="button" component="div" className={classes.sidebarHeading}>
-            Configuration
+            <Trans>sidebarHeadingConfiguration</Trans>
           </Typography>
 
-          { this.menuItem(`/namespaces/${selectedNamespace}/trafficsplits`, 'Traffic Splits', <FontAwesomeIcon icon={faFilter} className={classes.shrinkIcon} />) }
+          { this.menuItem(`/namespaces/${selectedNamespace}/trafficsplits`, <Trans>menuItemTrafficSplits</Trans>, <FontAwesomeIcon icon={faFilter} className={classes.shrinkIcon} />) }
 
         </MenuList>
         <Divider />
         <MenuList>
           <Typography variant="button" component="div" className={classes.sidebarHeading}>
-            Tools
+            <Trans>sidebarHeadingTools</Trans>
           </Typography>
 
-          { this.menuItem('/tap', 'Tap', <FontAwesomeIcon icon={faMicroscope} className={classes.shrinkIcon} />) }
-          { this.menuItem('/top', 'Top', <FontAwesomeIcon icon={faStream} className={classes.shrinkIcon} />) }
-          { this.menuItem('/routes', 'Routes', <FontAwesomeIcon icon={faRandom} className={classes.shrinkIcon} />) }
+          { this.menuItem('/tap', <Trans>menuItemTap</Trans>, <FontAwesomeIcon icon={faMicroscope} className={classes.shrinkIcon} />) }
+          { this.menuItem('/top', <Trans>menuItemTop</Trans>, <FontAwesomeIcon icon={faStream} className={classes.shrinkIcon} />) }
+          { this.menuItem('/routes', <Trans>menuItemRoutes</Trans>, <FontAwesomeIcon icon={faRandom} className={classes.shrinkIcon} />) }
 
         </MenuList>
         <Divider />
         <MenuList>
-          { this.menuItem('/community', 'Community',
+          { this.menuItem('/community', <Trans>menuItemCommunity</Trans>,
             <Badge
               classes={{ badge: classes.badge }}
               invisible={hideUpdateBadge}
@@ -551,25 +552,25 @@ class NavigationBase extends React.Component {
 
           <MenuItem component="a" href="https://linkerd.io/2/overview/" target="_blank" className={classes.navMenuItem}>
             <ListItemIcon><LibraryBooksIcon className={classes.shrinkIcon} /></ListItemIcon>
-            <ListItemText primary="Documentation" />
+            <ListItemText primary={<Trans>menuItemDocumentation</Trans>} />
             <FontAwesomeIcon icon={faExternalLinkAlt} className={classes.externalLinkIcon} size="xs" />
           </MenuItem>
 
           <MenuItem component="a" href="https://github.com/linkerd/linkerd2/issues/new/choose" target="_blank" className={classes.navMenuItem}>
             <ListItemIcon>{githubIcon}</ListItemIcon>
-            <ListItemText primary="GitHub" />
+            <ListItemText primary={<Trans>menuItemGitHub</Trans>} />
             <FontAwesomeIcon icon={faExternalLinkAlt} className={classes.externalLinkIcon} size="xs" />
           </MenuItem>
 
           <MenuItem component="a" href="https://lists.cncf.io/g/cncf-linkerd-users" target="_blank" className={classes.navMenuItem}>
             <ListItemIcon><EmailIcon className={classes.shrinkIcon} /></ListItemIcon>
-            <ListItemText primary="Mailing List" />
+            <ListItemText primary={<Trans>menuItemMailingList</Trans>} />
             <FontAwesomeIcon icon={faExternalLinkAlt} className={classes.externalLinkIcon} size="xs" />
           </MenuItem>
 
           <MenuItem component="a" href="https://slack.linkerd.io" target="_blank" className={classes.navMenuItem}>
             <ListItemIcon>{slackIcon}</ListItemIcon>
-            <ListItemText primary="Slack" />
+            <ListItemText primary={<Trans>menuItemSlack</Trans>} />
             <FontAwesomeIcon icon={faExternalLinkAlt} className={classes.externalLinkIcon} size="xs" />
           </MenuItem>
 
diff --git a/web/app/js/components/NoMatch.jsx b/web/app/js/components/NoMatch.jsx
index b68fec913..1e2d0d19c 100644
--- a/web/app/js/components/NoMatch.jsx
+++ b/web/app/js/components/NoMatch.jsx
@@ -1,9 +1,14 @@
 import React from 'react';
+import { Trans } from '@lingui/macro';
 
 const NoMatch = () => (
   <div>
     <h3>404</h3>
-    <div>Page not found.</div>
+    <div>
+      <Trans>
+        404Msg
+      </Trans>
+    </div>
   </div>
 );
 
diff --git a/web/app/js/components/ResourceDetail.jsx b/web/app/js/components/ResourceDetail.jsx
index 24fe481a4..3b0edeb9e 100644
--- a/web/app/js/components/ResourceDetail.jsx
+++ b/web/app/js/components/ResourceDetail.jsx
@@ -14,6 +14,7 @@ import SimpleChip from './util/Chip.jsx';
 import Spinner from './util/Spinner.jsx';
 import TopRoutesTabs from './TopRoutesTabs.jsx';
 import TrafficSplitDetail from './TrafficSplitDetail.jsx';
+import { Trans } from '@lingui/macro';
 import Typography from '@material-ui/core/Typography';
 import _filter from 'lodash/filter';
 import _get from 'lodash/get';
@@ -402,14 +403,14 @@ export class ResourceDetailBase extends React.Component {
         {_isEmpty(upstreams) ? null :
         <MetricsTable
           resource="multi_resource"
-          title="Inbound"
+          title={<Trans>tableTitleInbound</Trans>}
           metrics={upstreamDisplayMetrics} />
         }
 
         {_isEmpty(downstreamDisplayMetrics) ? null :
         <MetricsTable
           resource="multi_resource"
-          title="Outbound"
+          title={<Trans>tableTitleOutbound</Trans>}
           metrics={downstreamDisplayMetrics} />
         }
 
@@ -417,13 +418,13 @@ export class ResourceDetailBase extends React.Component {
           resourceType === 'pod' || isTcpOnly ? null :
           <MetricsTable
             resource="pod"
-            title="Pods"
+            title={<Trans>tableTitlePods</Trans>}
             metrics={podMetrics} />
         }
 
         <MetricsTable
           resource="pod"
-          title="TCP"
+          title={<Trans>tableTitleTCP</Trans>}
           isTcpTable
           metrics={podMetrics} />
 
diff --git a/web/app/js/components/ResourceList.jsx b/web/app/js/components/ResourceList.jsx
index 89545d7b0..c1e48aacb 100644
--- a/web/app/js/components/ResourceList.jsx
+++ b/web/app/js/components/ResourceList.jsx
@@ -7,6 +7,7 @@ import MetricsTable from './MetricsTable.jsx';
 import PropTypes from 'prop-types';
 import React from 'react';
 import Spinner from './util/Spinner.jsx';
+import { Trans } from '@lingui/macro';
 import { apiErrorPropType } from './util/ApiHelpers.jsx';
 import withREST from './util/withREST.jsx';
 
@@ -33,14 +34,14 @@ export class ResourceListBase extends React.Component {
         <MetricsTable
           resource={resource}
           metrics={processedMetrics}
-          title="HTTP metrics" />
+          title={<Trans>tableTitleHTTPMetrics</Trans>} />
 
         {resource !== 'trafficsplit' &&
         <MetricsTable
           resource={resource}
           isTcpTable
           metrics={processedMetrics}
-          title="TCP metrics" />
+          title={<Trans>tableTitleTCPMetrics</Trans>} />
         }
       </React.Fragment>
     );
diff --git a/web/app/js/components/ServiceMesh.jsx b/web/app/js/components/ServiceMesh.jsx
index e3d445f44..92a28d1eb 100644
--- a/web/app/js/components/ServiceMesh.jsx
+++ b/web/app/js/components/ServiceMesh.jsx
@@ -13,6 +13,7 @@ import PropTypes from 'prop-types';
 import React from 'react';
 import Spinner from './util/Spinner.jsx';
 import StatusTable from './StatusTable.jsx';
+import { Trans } from '@lingui/macro';
 import Typography from '@material-ui/core/Typography';
 import _compact from 'lodash/compact';
 import _countBy from 'lodash/countBy';
@@ -35,11 +36,11 @@ const styles = {
 
 const serviceMeshDetailsColumns = [
   {
-    title: 'Name',
+    title: <Trans>columnTitleName</Trans>,
     dataIndex: 'name',
   },
   {
-    title: 'Value',
+    title: <Trans>columnTitleValue</Trans>,
     dataIndex: 'value',
     isNumeric: true,
   },
@@ -107,9 +108,9 @@ class ServiceMesh extends React.Component {
 
     return [
       { key: 1, name: `${productName} version`, value: releaseVersion },
-      { key: 2, name: `${productName} namespace`, value: controllerNamespace },
-      { key: 3, name: 'Control plane components', value: components.length },
-      { key: 4, name: 'Data plane proxies', value: this.proxyCount() },
+      { key: 2, name: <Trans>{productName} namespace</Trans>, value: controllerNamespace },
+      { key: 3, name: <Trans>Control plane components</Trans>, value: components.length },
+      { key: 4, name: <Trans>Data plane proxies</Trans>, value: this.proxyCount() },
     ];
   }
 
@@ -225,7 +226,7 @@ class ServiceMesh extends React.Component {
             <Typography variant="h6">Control plane</Typography>
           </Grid>
           <Grid item xs={3}>
-            <Typography align="right">Components</Typography>
+            <Typography align="right"><Trans>componentsMsg</Trans></Typography>
             <Typography align="right">{components.length}</Typography>
           </Grid>
         </Grid>
diff --git a/web/app/js/components/StatusTable.jsx b/web/app/js/components/StatusTable.jsx
index 32ed39744..6ba5089db 100644
--- a/web/app/js/components/StatusTable.jsx
+++ b/web/app/js/components/StatusTable.jsx
@@ -2,6 +2,7 @@ import BaseTable from './BaseTable.jsx';
 import PropTypes from 'prop-types';
 import React from 'react';
 import Tooltip from '@material-ui/core/Tooltip';
+import { Trans } from '@lingui/macro';
 import _get from 'lodash/get';
 import _merge from 'lodash/merge';
 import classNames from 'classnames';
@@ -75,18 +76,18 @@ StatusDot.propTypes = {
 
 const columns = {
   resourceName: {
-    title: 'Deployment',
+    title: <Trans>columnTitleDeployment</Trans>,
     dataIndex: 'name',
   },
   pods: {
-    title: 'Pods',
+    title: <Trans>columnTitlePods</Trans>,
     key: 'numEntities',
     isNumeric: true,
     render: d => d.pods.length,
   },
   status: (name, classes) => {
     return {
-      title: name,
+      title: <Trans>columnTitlePodStatus</Trans>,
       key: 'status',
       render: d => {
         return d.pods.map(status => (
diff --git a/web/app/js/components/TapEventTable.jsx b/web/app/js/components/TapEventTable.jsx
index 3972fa598..e07c1b852 100644
--- a/web/app/js/components/TapEventTable.jsx
+++ b/web/app/js/components/TapEventTable.jsx
@@ -11,6 +11,7 @@ import ListItem from '@material-ui/core/ListItem';
 import ListItemText from '@material-ui/core/ListItemText';
 import PropTypes from 'prop-types';
 import React from 'react';
+import { Trans } from '@lingui/macro';
 import Typography from '@material-ui/core/Typography';
 import _get from 'lodash/get';
 import _isEmpty from 'lodash/isEmpty';
@@ -53,7 +54,7 @@ const formatTapLatency = str => {
 };
 
 const httpStatusCol = {
-  title: 'HTTP status',
+  title: <Trans>columnTitleHTTPStatus</Trans>,
   key: 'http-status',
   render: datum => {
     const d = _get(datum, 'responseInit.http.responseInit');
@@ -62,7 +63,7 @@ const httpStatusCol = {
 };
 
 const responseInitLatencyCol = {
-  title: 'Latency',
+  title: <Trans>columnTitleLatency</Trans>,
   key: 'rsp-latency',
   isNumeric: true,
   render: datum => {
@@ -72,7 +73,7 @@ const responseInitLatencyCol = {
 };
 
 const grpcStatusCol = {
-  title: 'GRPC status',
+  title: <Trans>columnTitleGRPCStatus</Trans>,
   key: 'grpc-status',
   render: datum => {
     const d = _get(datum, 'responseEnd.http.responseEnd');
@@ -82,7 +83,7 @@ const grpcStatusCol = {
 };
 
 const pathCol = {
-  title: 'Path',
+  title: <Trans>columnTitlePath</Trans>,
   key: 'path',
   render: datum => {
     const d = _get(datum, 'requestInit.http.requestInit');
@@ -91,7 +92,7 @@ const pathCol = {
 };
 
 const methodCol = {
-  title: 'Method',
+  title: <Trans>columnTitleMethod</Trans>,
   key: 'method',
   render: datum => {
     const d = _get(datum, 'requestInit.http.requestInit');
@@ -101,12 +102,12 @@ const methodCol = {
 
 const topLevelColumns = (resourceType, ResourceLink) => [
   {
-    title: 'Direction',
+    title: <Trans>columnTitleDirection</Trans>,
     key: 'direction',
     render: d => directionColumn(d.base.proxyDirection),
   },
   {
-    title: 'Name',
+    title: <Trans>columnTitleName</Trans>,
     key: 'src-dst',
     render: d => {
       const datum = {
diff --git a/web/app/js/components/TapQueryForm.jsx b/web/app/js/components/TapQueryForm.jsx
index 55173e646..e4d5e97dd 100644
--- a/web/app/js/components/TapQueryForm.jsx
+++ b/web/app/js/components/TapQueryForm.jsx
@@ -24,6 +24,7 @@ import QueryToCliCmd from './QueryToCliCmd.jsx';
 import React from 'react';
 import Select from '@material-ui/core/Select';
 import TextField from '@material-ui/core/TextField';
+import { Trans } from '@lingui/macro';
 import Typography from '@material-ui/core/Typography';
 import _flatten from 'lodash/flatten';
 import _isEmpty from 'lodash/isEmpty';
@@ -287,9 +288,17 @@ class TapQueryForm extends React.Component {
     const { handleTapStart, handleTapStop } = this.props;
 
     if (tapIsClosing) {
-      return (<Button variant="outlined" color="primary" className="tap-ctrl tap-stop" disabled>Stop</Button>);
+      return (
+        <Button variant="outlined" color="primary" className="tap-ctrl tap-stop" disabled>
+          <Trans>buttonStop</Trans>
+        </Button>
+      );
     } else if (tapInProgress) {
-      return (<Button variant="outlined" color="primary" className="tap-ctrl tap-stop" onClick={handleTapStop}>Stop</Button>);
+      return (
+        <Button variant="outlined" color="primary" className="tap-ctrl tap-stop" onClick={handleTapStop}>
+          <Trans>buttonStop</Trans>
+        </Button>
+      );
     } else {
       return (
         <Button
@@ -298,7 +307,7 @@ class TapQueryForm extends React.Component {
           className="tap-ctrl tap-start"
           disabled={!query.namespace || !query.resource}
           onClick={handleTapStart}>
-          Start
+          <Trans>buttonStart</Trans>
         </Button>
       );
     }
@@ -330,7 +339,7 @@ class TapQueryForm extends React.Component {
         <Grid container spacing={3}>
           <Grid item xs={6} md={3} className={classes.formControlWrapper}>
             <FormControl className={classes.formControl}>
-              {this.renderNamespaceSelect('To Namespace', 'toNamespace', 'toResource')}
+              {this.renderNamespaceSelect(<Trans>formToNamespace</Trans>, 'toNamespace', 'toResource')}
             </FormControl>
           </Grid>
           <Grid item xs={6} md={3} className={classes.formControlWrapper}>
@@ -343,7 +352,7 @@ class TapQueryForm extends React.Component {
         <Grid container spacing={3}>
           <Grid item xs={6} md={3} classes={{ item: classes.formControlWrapper }}>
             <FormControl className={classes.formControl}>
-              <InputLabel htmlFor="authority">Authority</InputLabel>
+              <InputLabel htmlFor="authority"><Trans>formAuthority</Trans></InputLabel>
               <Select
                 value={query.authority}
                 onChange={this.handleFormChange('authority')}
@@ -359,20 +368,20 @@ class TapQueryForm extends React.Component {
             </FormControl>
           </Grid>
           <Grid item xs={6} md={3} className={classes.formControlWrapper}>
-            { this.renderTextInput('Path', 'path', 'Display requests with paths that start with this prefix') }
+            { this.renderTextInput(<Trans>formPath</Trans>, 'path', 'Display requests with paths that start with this prefix') }
           </Grid>
         </Grid>
 
         <Grid container spacing={3}>
           <Grid item xs={6} md={3} className={classes.formControlWrapper}>
-            { this.renderTextInput('Scheme', 'scheme', 'Display requests with this scheme') }
+            { this.renderTextInput(<Trans>formScheme</Trans>, 'scheme', 'Display requests with this scheme') }
           </Grid>
           <Grid item xs={6} md={3} className={classes.formControlWrapper}>
-            { this.renderTextInput('Max RPS', 'maxRps', `Maximum requests per second to tap. Default ${defaultMaxRps}`) }
+            { this.renderTextInput(<Trans>formMaxRPS</Trans>, 'maxRps', `Maximum requests per second to tap. Default ${defaultMaxRps}`) }
           </Grid>
           <Grid item xs={6} md={3} className={classes.formControlWrapper}>
             <FormControl className={classes.formControl}>
-              <InputLabel htmlFor="method">HTTP method</InputLabel>
+              <InputLabel htmlFor="method"><Trans>formHTTPMethod</Trans></InputLabel>
               <Select
                 value={query.method}
                 onChange={this.handleFormChange('method')}
@@ -400,7 +409,7 @@ class TapQueryForm extends React.Component {
       <ExpansionPanel expanded={advancedFormExpanded} onChange={this.handleAdvancedFormExpandClick} elevation={3}>
         <ExpansionPanelSummary expandIcon={<ExpandMoreIcon />}>
           <Typography variant="caption" gutterBottom>
-            {advancedFormExpanded ? 'Hide filters' : 'Show more filters'}
+            {advancedFormExpanded ? <Trans>formHideFilters</Trans> : <Trans>formShowFilters</Trans>}
           </Typography>
         </ExpansionPanelSummary>
 
@@ -421,7 +430,7 @@ class TapQueryForm extends React.Component {
           <Grid container spacing={3}>
             <Grid item xs={6} md="auto" className={classes.formControlWrapper}>
               <FormControl className={classes.formControl} fullWidth>
-                {this.renderNamespaceSelect('Namespace', 'namespace', 'resource')}
+                {this.renderNamespaceSelect(<Trans>formNamespace</Trans>, 'namespace', 'resource')}
               </FormControl>
             </Grid>
 
@@ -433,7 +442,9 @@ class TapQueryForm extends React.Component {
 
             <Grid item>
               { this.renderTapButton(tapRequestInProgress, tapIsClosing) }
-              <Button onClick={this.resetTapForm} disabled={tapRequestInProgress} className={classes.resetButton}>Reset</Button>
+              <Button onClick={this.resetTapForm} disabled={tapRequestInProgress} className={classes.resetButton}>
+                <Trans>buttonReset</Trans>
+              </Button>
             </Grid>
           </Grid>
         </CardContent>
diff --git a/web/app/js/components/TopEventTable.jsx b/web/app/js/components/TopEventTable.jsx
index af262e71b..8638f2677 100644
--- a/web/app/js/components/TopEventTable.jsx
+++ b/web/app/js/components/TopEventTable.jsx
@@ -5,6 +5,7 @@ import BaseTable from './BaseTable.jsx';
 import PropTypes from 'prop-types';
 import React from 'react';
 import SuccessRateMiniChart from './util/SuccessRateMiniChart.jsx';
+import { Trans } from '@lingui/macro';
 import _isEmpty from 'lodash/isEmpty';
 import _isNil from 'lodash/isNil';
 import { withContext } from './util/AppContext.jsx';
@@ -16,7 +17,7 @@ const topColumns = (resourceType, ResourceLink, PrefixedLink) => [
     render: d => directionColumn(d.direction),
   },
   {
-    title: 'Name',
+    title: <Trans>columnTitleName</Trans>,
     filter: d => {
       const [labels, display] = extractDisplayName(d);
       return _isEmpty(labels[resourceType]) ?
@@ -27,33 +28,33 @@ const topColumns = (resourceType, ResourceLink, PrefixedLink) => [
     render: d => srcDstColumn(d, resourceType, ResourceLink),
   },
   {
-    title: 'Method',
+    title: <Trans>columnTitleMethod</Trans>,
     dataIndex: 'httpMethod',
     filter: d => d.httpMethod,
     sorter: d => d.httpMethod,
   },
   {
-    title: 'Path',
+    title: <Trans>columnTitlePath</Trans>,
     dataIndex: 'path',
     filter: d => d.path,
     sorter: d => d.path,
   },
   {
-    title: 'Count',
+    title: <Trans>columnTitleCount</Trans>,
     dataIndex: 'count',
     isNumeric: true,
     defaultSortOrder: 'desc',
     sorter: d => d.count,
   },
   {
-    title: 'Best',
+    title: <Trans>columnTitleBest</Trans>,
     dataIndex: 'best',
     isNumeric: true,
     render: d => formatLatencySec(d.best),
     sorter: d => d.best,
   },
   {
-    title: 'Worst',
+    title: <Trans>columnTitleWorst</Trans>,
     dataIndex: 'worst',
     isNumeric: true,
     defaultSortOrder: 'desc',
@@ -61,14 +62,14 @@ const topColumns = (resourceType, ResourceLink, PrefixedLink) => [
     sorter: d => d.worst,
   },
   {
-    title: 'Last',
+    title: <Trans>columnTitleLast</Trans>,
     dataIndex: 'last',
     isNumeric: true,
     render: d => formatLatencySec(d.last),
     sorter: d => d.last,
   },
   {
-    title: 'Success Rate',
+    title: <Trans>columnTitleSuccessRate</Trans>,
     dataIndex: 'successRate',
     isNumeric: true,
     render: d => _isNil(d) || _isNil(d.successRate) ? '---' :
@@ -76,7 +77,7 @@ const topColumns = (resourceType, ResourceLink, PrefixedLink) => [
     sorter: d => d.successRate.get(),
   },
   {
-    title: 'Tap',
+    title: <Trans>columnTitleTap</Trans>,
     key: 'tap',
     isNumeric: true,
     render: d => tapLink(d, resourceType, PrefixedLink),
diff --git a/web/app/js/components/TopRoutes.jsx b/web/app/js/components/TopRoutes.jsx
index 82705058b..f3fd5f6ce 100644
--- a/web/app/js/components/TopRoutes.jsx
+++ b/web/app/js/components/TopRoutes.jsx
@@ -17,6 +17,7 @@ import QueryToCliCmd from './QueryToCliCmd.jsx';
 import React from 'react';
 import Select from '@material-ui/core/Select';
 import TopRoutesModule from './TopRoutesModule.jsx';
+import { Trans } from '@lingui/macro';
 import Typography from '@material-ui/core/Typography';
 import _get from 'lodash/get';
 import _isEmpty from 'lodash/isEmpty';
@@ -232,11 +233,11 @@ class TopRoutes extends React.Component {
 
           <Grid item container spacing={4} alignItems="center" justify="flex-start">
             <Grid item>
-              { this.renderNamespaceDropdown('To Namespace', 'to_namespace', 'Namespace of target resource') }
+              { this.renderNamespaceDropdown(<Trans>formToNamespace</Trans>, 'to_namespace', 'Namespace of target resource') }
             </Grid>
 
             <Grid item>
-              { this.renderResourceDropdown('To Resource', 'to_name', 'to_type', 'Target resource') }
+              { this.renderResourceDropdown(<Trans>formToResource</Trans>, 'to_name', 'to_type', 'Target resource') }
             </Grid>
           </Grid>
         </Grid>
diff --git a/web/app/js/components/TrafficSplitDetail.jsx b/web/app/js/components/TrafficSplitDetail.jsx
index a24bd83c7..da9521769 100644
--- a/web/app/js/components/TrafficSplitDetail.jsx
+++ b/web/app/js/components/TrafficSplitDetail.jsx
@@ -3,6 +3,7 @@ import MetricsTable from './MetricsTable.jsx';
 import Octopus from './Octopus.jsx';
 import PropTypes from 'prop-types';
 import React from 'react';
+import { Trans } from '@lingui/macro';
 import Typography from '@material-ui/core/Typography';
 import _each from 'lodash/each';
 import _isNil from 'lodash/isNil';
@@ -73,7 +74,7 @@ const TrafficSplitDetail = ({ resourceMetrics, resourceName, resourceRsp, resour
         metrics={resourceMetrics}
         showName={false}
         showNamespaceColumn={false}
-        title="Leaf Services" />
+        title={<Trans>tableTitleLeafServices</Trans>} />
     </div>
   );
 };
diff --git a/web/app/js/components/util/CopyUtils.jsx b/web/app/js/components/util/CopyUtils.jsx
index b62d45e1b..dbb769ff1 100644
--- a/web/app/js/components/util/CopyUtils.jsx
+++ b/web/app/js/components/util/CopyUtils.jsx
@@ -6,7 +6,7 @@ import Typography from '@material-ui/core/Typography';
 * Instructions for adding resources to service mesh
 */
 export const incompleteMeshMessage = name => {
-  const unspecifiedResources = <Trans>one or more resources</Trans>;
+  const unspecifiedResources = <Trans>unspecifiedResourcesMsg</Trans>;
   const inject = <code>linkerd inject k8s.yml | kubectl apply -f -</code>;
 
   return (
diff --git a/web/app/js/locales/en/messages.json b/web/app/js/locales/en/messages.json
index 59ed9efd0..fac755632 100644
--- a/web/app/js/locales/en/messages.json
+++ b/web/app/js/locales/en/messages.json
@@ -1,5 +1,104 @@
 {
+  "404Msg": "Page not found.",
   "Add {0} to the k8s.yml file<0/><1/>Then run {inject} to add it to the service mesh": "Add {0} to the k8s.yml file<0/><1/>Then run {inject} to add it to the service mesh",
-  "No data to display": "No data to display",
-  "one or more resources": "one or more resources"
+  "All namespaces have a {productName} install.": "All namespaces have a {productName} install.",
+  "Control plane components": "Control plane components",
+  "Data plane proxies": "Data plane proxies",
+  "NoDataToDisplayMsg": "No data to display",
+  "buttonReset": "Reset",
+  "buttonRunLinkerdCheck": "Run Linkerd Check",
+  "buttonStart": "Start",
+  "buttonStop": "Stop",
+  "columnTitleAlive": "Alive",
+  "columnTitleApexService": "Apex Service",
+  "columnTitleBest": "Best",
+  "columnTitleClusterName": "Cluster Name",
+  "columnTitleCount": "Count",
+  "columnTitleDeployment": "Deployment",
+  "columnTitleDirection": "Direction",
+  "columnTitleGRPCStatus": "GRPC Status",
+  "columnTitleGrafana": "Grafana",
+  "columnTitleHTTPStatus": "HTTP Status",
+  "columnTitleIdentity": "Identity",
+  "columnTitleJaeger": "Jaeger",
+  "columnTitleLast": "Last",
+  "columnTitleLatency": "Latency",
+  "columnTitleLeafService": "Leaf Service",
+  "columnTitleMeshed": "Meshed",
+  "columnTitleMeshedPods": "Meshed Pods",
+  "columnTitleMeshedStatus": "Meshed Status",
+  "columnTitleMethod": "Method",
+  "columnTitleName": "Name",
+  "columnTitleNamespace": "Namespace",
+  "columnTitleOpenConnections": "Connections",
+  "columnTitleP50Latency": "P50 Latency",
+  "columnTitleP95Latency": "P95 Latency",
+  "columnTitleP99Latency": "P99 Latency",
+  "columnTitlePairedServices": "Paired Services",
+  "columnTitlePath": "Path",
+  "columnTitlePodStatus": "Pod Status",
+  "columnTitlePods": "Pods",
+  "columnTitleRPS": "RPS",
+  "columnTitleReadRate": "Read Bytes / sec",
+  "columnTitleRoute": "Route",
+  "columnTitleSecured": "Secured",
+  "columnTitleService": "Service",
+  "columnTitleSuccessRate": "Success Rate",
+  "columnTitleTap": "Tap",
+  "columnTitleValue": "Value",
+  "columnTitleWeight": "Weight",
+  "columnTitleWorst": "Worst",
+  "columnTitleWriteRate": "Write Bytes / sec",
+  "componentsMsg": "Components",
+  "formAuthority": "Authority",
+  "formHTTPMethod": "HTTP Method",
+  "formHideFilters": "Hide filters",
+  "formMaxRPS": "Max RPS",
+  "formNamespace": "Namespace",
+  "formPath": "Path",
+  "formScheme": "Scheme",
+  "formShowFilters": "Show more filters",
+  "formToNamespace": "To Namespace",
+  "formToResource": "To Resource",
+  "menuItemCommunity": "Community",
+  "menuItemControlPlane": "Control Plane",
+  "menuItemCronJobs": "Cron Jobs",
+  "menuItemDaemonSets": "Daemon Sets",
+  "menuItemDeployments": "Deployments",
+  "menuItemDocumentation": "Documentation",
+  "menuItemGateway": "Gateway",
+  "menuItemGitHub": "GitHub",
+  "menuItemJobs": "Jobs",
+  "menuItemMailingList": "Mailing List",
+  "menuItemNamespaces": "Namespaces",
+  "menuItemPods": "Pods",
+  "menuItemReplicaSets": "Replica Sets",
+  "menuItemReplicationControllers": "Replication Controllers",
+  "menuItemRoutes": "Routes",
+  "menuItemSlack": "Slack",
+  "menuItemStatefulSets": "Stateful Sets",
+  "menuItemTap": "Tap",
+  "menuItemTop": "Top",
+  "menuItemTrafficSplits": "Traffic Splits",
+  "noResourcesDetectedMsg": "No resources detected.",
+  "podsAreInitializingMsg": "Pods are initializing",
+  "sidebarHeadingCluster": "Cluster",
+  "sidebarHeadingConfiguration": "Configuration",
+  "sidebarHeadingTools": "Tools",
+  "sidebarHeadingWorkloads": "Workloads",
+  "tableTitleEdgesEmpty": "Edges",
+  "tableTitleEdgesWithIdentity {identity}": "Edges (Identity: {identity})",
+  "tableTitleGateways": "Gateways",
+  "tableTitleHTTPMetrics": "HTTP Metrics",
+  "tableTitleInbound": "Inbound",
+  "tableTitleLeafServices": "Leaf Services",
+  "tableTitleOutbound": "Outbound",
+  "tableTitlePods": "Pods",
+  "tableTitleTCP": "TCP",
+  "tableTitleTCPMetrics": "TCP Metrics",
+  "unspecifiedResourcesMsg": "one or more resources",
+  "{numUnadded} namespace has no meshed resources.": "{numUnadded} namespace has no meshed resources.",
+  "{numUnadded} namespaces have no meshed resources.": "{numUnadded} namespaces have no meshed resourcesß",
+  "{productName} namespace": "{productName} namespace",
+  "{productName} version": "{productName} version"
 }
diff --git a/web/app/js/locales/es/messages.json b/web/app/js/locales/es/messages.json
index a390f26d7..698825593 100644
--- a/web/app/js/locales/es/messages.json
+++ b/web/app/js/locales/es/messages.json
@@ -1,5 +1,104 @@
 {
-  "Add {0} to the k8s.yml file<0/><1/>Then run {inject} to add it to the service mesh": "Agregue {0} a la k8s.yml file<0/><1/>Luego ejecuta {inject} para inyectarla en la malla de servicios",
-  "No data to display": "No hay datos que mostrar",
-  "one or more resources": "uno más recursos más"
+  "404Msg": "Página no encontrada.",
+  "Add {0} to the k8s.yml file<0/><1/>Then run {inject} to add it to the service mesh": "Agrega {0} al archivo k8s.yml<0/><1/>Luego ejecuta {inject} para inyectarlo en la malla de servicios",
+  "All namespaces have a {productName} install.": "Todos los namespaces tienen una instalación de {productName}.",
+  "Control plane components": "Componentes del plano de control",
+  "Data plane proxies": "Proxies del plano de datos",
+  "NoDataToDisplayMsg": "No hay datos que mostrar",
+  "buttonReset": "Restablecer",
+  "buttonRunLinkerdCheck": "Ejecutar Linkerd Check",
+  "buttonStart": "Empezar",
+  "buttonStop": "Terminar",
+  "columnTitleAlive": "Vivo",
+  "columnTitleApexService": "Servicio Apice",
+  "columnTitleBest": "Mejor",
+  "columnTitleClusterName": "Nombre del Clúster",
+  "columnTitleCount": "Total",
+  "columnTitleDeployment": "Deployment",
+  "columnTitleDirection": "Dirección",
+  "columnTitleGRPCStatus": "Estado GRPC",
+  "columnTitleGrafana": "Grafana",
+  "columnTitleHTTPStatus": "Estado HTTP",
+  "columnTitleIdentity": "Identidad",
+  "columnTitleJaeger": "Jaeger",
+  "columnTitleLast": "Último",
+  "columnTitleLatency": "Latencia",
+  "columnTitleLeafService": "Servicio Hoja",
+  "columnTitleMeshed": "En la malla de servicios",
+  "columnTitleMeshedPods": "Pods en la malla de servicios",
+  "columnTitleMeshedStatus": "Estado de la malla de servicios",
+  "columnTitleMethod": "Método",
+  "columnTitleName": "Nombre",
+  "columnTitleNamespace": "Namespace",
+  "columnTitleOpenConnections": "Conexiones",
+  "columnTitleP50Latency": "Latencia P50",
+  "columnTitleP95Latency": "Latencia P95",
+  "columnTitleP99Latency": "Latencia P99",
+  "columnTitlePairedServices": "Servicios Emparejados",
+  "columnTitlePath": "Ruta",
+  "columnTitlePodStatus": "Estado del Pod",
+  "columnTitlePods": "Pods",
+  "columnTitleRPS": "PPS",
+  "columnTitleReadRate": "Lectura Bytes / seg",
+  "columnTitleRoute": "Ruta",
+  "columnTitleSecured": "Asegurado",
+  "columnTitleService": "Servicio",
+  "columnTitleSuccessRate": "Tasa de éxito",
+  "columnTitleTap": "Tap",
+  "columnTitleValue": "Valor",
+  "columnTitleWeight": "Peso",
+  "columnTitleWorst": "Peor",
+  "columnTitleWriteRate": "Escritura Bytes / seg",
+  "componentsMsg": "Componentes",
+  "formAuthority": "Authority",
+  "formHTTPMethod": "Método HTTP",
+  "formHideFilters": "Ocultar filtros",
+  "formMaxRPS": "Max PPS",
+  "formNamespace": "Namespace",
+  "formPath": "Ruta",
+  "formScheme": "Esquema",
+  "formShowFilters": "Mostrar más filtros",
+  "formToNamespace": "Al Namespace",
+  "formToResource": "Al Recurso",
+  "menuItemCommunity": "Comunidad",
+  "menuItemControlPlane": "Plano de Control",
+  "menuItemCronJobs": "Cron Jobs",
+  "menuItemDaemonSets": "Daemon Sets",
+  "menuItemDeployments": "Deployments",
+  "menuItemDocumentation": "Documentación",
+  "menuItemGateway": "Gateway",
+  "menuItemGitHub": "GitHub",
+  "menuItemJobs": "Jobs",
+  "menuItemMailingList": "Lista de Correo",
+  "menuItemNamespaces": "Namespaces",
+  "menuItemPods": "Pods",
+  "menuItemReplicaSets": "Replica Sets",
+  "menuItemReplicationControllers": "Replication Controllers",
+  "menuItemRoutes": "Rutas",
+  "menuItemSlack": "Slack",
+  "menuItemStatefulSets": "Stateful Sets",
+  "menuItemTap": "Tap",
+  "menuItemTop": "Top",
+  "menuItemTrafficSplits": "Traffic Splits",
+  "noResourcesDetectedMsg": "No se han encontrado recursos.",
+  "podsAreInitializingMsg": "Los pods se están inicializando",
+  "sidebarHeadingCluster": "Clúster",
+  "sidebarHeadingConfiguration": "Configuración",
+  "sidebarHeadingTools": "Herramientas",
+  "sidebarHeadingWorkloads": "Cargas de trabajo",
+  "tableTitleEdgesEmpty": "Bordes",
+  "tableTitleEdgesWithIdentity {identity}": "Bordes (Identidad: {identity})",
+  "tableTitleGateways": "Gateways",
+  "tableTitleHTTPMetrics": "Métricas HTTP",
+  "tableTitleInbound": "Entrada",
+  "tableTitleLeafServices": "Servicios Hoja",
+  "tableTitleOutbound": "Salida",
+  "tableTitlePods": "Pods",
+  "tableTitleTCP": "TCP",
+  "tableTitleTCPMetrics": "Métricas TCP",
+  "unspecifiedResourcesMsg": "uno o más recursos",
+  "{numUnadded} namespace has no meshed resources.": "El namespace {numUnadded} no tiene recursos en la malla de servicios.",
+  "{numUnadded} namespaces have no meshed resources.": "Los namespaces {numUnadded} no tienen recursos en la malla de servicios.",
+  "{productName} namespace": "Namespace de {productName}",
+  "{productName} version": "Versión de {productName}"
 }
diff --git a/web/app/package.json b/web/app/package.json
index 07d91ced8..7f85fc6fa 100644
--- a/web/app/package.json
+++ b/web/app/package.json
@@ -82,7 +82,7 @@
     "url-loader": "4.1.0",
     "wdio-chromedriver-service": "6.0.2",
     "webdriverio": "6.1.2",
-    "webpack": "4.43.0",
+    "webpack": "4.44.1",
     "webpack-bundle-analyzer": "3.7.0",
     "webpack-cli": "3.3.11",
     "webpack-dev-server": "3.11.0"
diff --git a/web/app/yarn.lock b/web/app/yarn.lock
index 7eb07a8ae..65f867e46 100644
--- a/web/app/yarn.lock
+++ b/web/app/yarn.lock
@@ -2411,14 +2411,15 @@ array.prototype.flat@^1.2.1, array.prototype.flat@^1.2.3:
     define-properties "^1.1.3"
     es-abstract "^1.17.0-next.1"
 
-asn1.js@^4.0.0:
-  version "4.10.1"
-  resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.10.1.tgz#b9c2bf5805f1e64aadeed6df3a2bfafb5a73f5a0"
-  integrity sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==
+asn1.js@^5.2.0:
+  version "5.4.1"
+  resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07"
+  integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==
   dependencies:
     bn.js "^4.0.0"
     inherits "^2.0.1"
     minimalistic-assert "^1.0.0"
+    safer-buffer "^2.1.0"
 
 asn1@~0.2.3:
   version "0.2.4"
@@ -2794,11 +2795,16 @@ bluebird@^3.5.5:
   resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
   integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
 
-bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
+bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.4.0:
   version "4.11.9"
   resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828"
   integrity sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==
 
+bn.js@^5.1.1:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.1.2.tgz#c9686902d3c9a27729f43ab10f9d79c2004da7b0"
+  integrity sha512-40rZaf3bUNKTVYu9sIeeEGOg7g14Yvnj9kH7b50EiwX0Q7A6umbvfI5tvHaOERH0XigqKkfLkFQxzb4e6CIXnA==
+
 body-parser@1.19.0:
   version "1.19.0"
   resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
@@ -2916,7 +2922,7 @@ browserify-des@^1.0.0:
     inherits "^2.0.1"
     safe-buffer "^5.1.2"
 
-browserify-rsa@^4.0.0:
+browserify-rsa@^4.0.0, browserify-rsa@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524"
   integrity sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=
@@ -2925,17 +2931,19 @@ browserify-rsa@^4.0.0:
     randombytes "^2.0.1"
 
 browserify-sign@^4.0.0:
-  version "4.0.4"
-  resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.0.4.tgz#aa4eb68e5d7b658baa6bf6a57e630cbd7a93d298"
-  integrity sha1-qk62jl17ZYuqa/alfmMMvXqT0pg=
-  dependencies:
-    bn.js "^4.1.1"
-    browserify-rsa "^4.0.0"
-    create-hash "^1.1.0"
-    create-hmac "^1.1.2"
-    elliptic "^6.0.0"
-    inherits "^2.0.1"
-    parse-asn1 "^5.0.0"
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.2.1.tgz#eaf4add46dd54be3bb3b36c0cf15abbeba7956c3"
+  integrity sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==
+  dependencies:
+    bn.js "^5.1.1"
+    browserify-rsa "^4.0.1"
+    create-hash "^1.2.0"
+    create-hmac "^1.1.7"
+    elliptic "^6.5.3"
+    inherits "^2.0.4"
+    parse-asn1 "^5.1.5"
+    readable-stream "^3.6.0"
+    safe-buffer "^5.2.0"
 
 browserify-zlib@^0.2.0:
   version "0.2.0"
@@ -3284,6 +3292,21 @@ chokidar@^3.0.0:
   optionalDependencies:
     fsevents "~2.1.2"
 
+chokidar@^3.4.1:
+  version "3.4.2"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.2.tgz#38dc8e658dec3809741eb3ef7bb0a47fe424232d"
+  integrity sha512-IZHaDeBeI+sZJRX7lGcXsdzgvZqKv6sECqsbErJA4mHWfpRrD8B97kSFN4cQz6nGBGiuFia1MKR4d6c1o8Cv7A==
+  dependencies:
+    anymatch "~3.1.1"
+    braces "~3.0.2"
+    glob-parent "~5.1.0"
+    is-binary-path "~2.1.0"
+    is-glob "~4.0.1"
+    normalize-path "~3.0.0"
+    readdirp "~3.4.0"
+  optionalDependencies:
+    fsevents "~2.1.2"
+
 chownr@^1.1.1:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
@@ -3698,14 +3721,14 @@ crc@^3.4.4:
     buffer "^5.1.0"
 
 create-ecdh@^4.0.0:
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.3.tgz#c9111b6f33045c4697f144787f9254cdc77c45ff"
-  integrity sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw==
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.4.tgz#d6e7f4bffa66736085a0762fd3a632684dabcc4e"
+  integrity sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==
   dependencies:
     bn.js "^4.1.0"
-    elliptic "^6.0.0"
+    elliptic "^6.5.3"
 
-create-hash@^1.1.0, create-hash@^1.1.2:
+create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196"
   integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==
@@ -3716,7 +3739,7 @@ create-hash@^1.1.0, create-hash@^1.1.2:
     ripemd160 "^2.0.1"
     sha.js "^2.4.0"
 
-create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4:
+create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7:
   version "1.1.7"
   resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff"
   integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==
@@ -4375,7 +4398,7 @@ electron-to-chromium@^1.3.413:
   resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.414.tgz#9d0a92defefda7cc1cf8895058b892795ddd6b41"
   integrity sha512-UfxhIvED++qLwWrAq9uYVcqF8FdeV9sU2S7qhiHYFODxzXRrd1GZRl/PjITHsTEejgibcWDraD8TQqoHb1aCBQ==
 
-elliptic@^6.0.0:
+elliptic@^6.5.3:
   version "6.5.3"
   resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6"
   integrity sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==
@@ -4436,10 +4459,10 @@ enhanced-resolve@4.1.0:
     memory-fs "^0.4.0"
     tapable "^1.0.0"
 
-enhanced-resolve@^4.1.0:
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.1.1.tgz#2937e2b8066cd0fe7ce0990a98f0d71a35189f66"
-  integrity sha512-98p2zE+rL7/g/DzMHMTF4zZlCgeVdJ7yr6xzEpJRYwFYrGi9ANdn5DnJURg6RpBkyk60XYDnWIv51VfIhfNGuA==
+enhanced-resolve@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.3.0.tgz#3b806f3bfafc1ec7de69551ef93cca46c1704126"
+  integrity sha512-3e87LvavsdxyoCfGusJnrZ5G8SLPOFeHSNpZI/ATL9a5leXo2k0w6MKnbqhdBad9qTobSfB20Ld7UmgoNbAZkQ==
   dependencies:
     graceful-fs "^4.1.2"
     memory-fs "^0.5.0"
@@ -5699,12 +5722,13 @@ has@^1.0.3:
     function-bind "^1.1.1"
 
 hash-base@^3.0.0:
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.0.4.tgz#5fc8686847ecd73499403319a6b0a3f3f6ae4918"
-  integrity sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.1.0.tgz#55c381d9e06e1d2997a883b4a3fddfe7f0d3af33"
+  integrity sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==
   dependencies:
-    inherits "^2.0.1"
-    safe-buffer "^5.0.1"
+    inherits "^2.0.4"
+    readable-stream "^3.6.0"
+    safe-buffer "^5.2.0"
 
 hash-sum@^1.0.2:
   version "1.0.2"
@@ -8171,7 +8195,12 @@ negotiator@0.6.2:
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
   integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
 
-neo-async@^2.5.0, neo-async@^2.6.1:
+neo-async@^2.5.0:
+  version "2.6.2"
+  resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
+  integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
+
+neo-async@^2.6.1:
   version "2.6.1"
   resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c"
   integrity sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==
@@ -8690,14 +8719,13 @@ parent-module@^1.0.0:
   dependencies:
     callsites "^3.0.0"
 
-parse-asn1@^5.0.0:
-  version "5.1.5"
-  resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.5.tgz#003271343da58dc94cace494faef3d2147ecea0e"
-  integrity sha512-jkMYn1dcJqF6d5CpU689bq7w/b5ALS9ROVSpQDPrZsqqesUJii9qutvoT5ltGedNXMO2e16YUWIghG9KxaViTQ==
+parse-asn1@^5.0.0, parse-asn1@^5.1.5:
+  version "5.1.6"
+  resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.6.tgz#385080a3ec13cb62a62d39409cb3e88844cdaed4"
+  integrity sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw==
   dependencies:
-    asn1.js "^4.0.0"
+    asn1.js "^5.2.0"
     browserify-aes "^1.0.0"
-    create-hash "^1.1.0"
     evp_bytestokey "^1.0.0"
     pbkdf2 "^3.0.3"
     safe-buffer "^5.1.1"
@@ -8873,9 +8901,9 @@ pathval@^1.1.0:
   integrity sha1-uULm1L3mUwBe9rcTYd74cn0GReA=
 
 pbkdf2@^3.0.3:
-  version "3.0.17"
-  resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.17.tgz#976c206530617b14ebb32114239f7b09336e93a6"
-  integrity sha512-U/il5MsrZp7mGg3mSQfn742na2T+1/vHDCG5/iTI3X9MKUuYUZVLQhyRsg06mCgDBTd57TxzgZt7P+fYfjRLtA==
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.1.tgz#cb8724b0fada984596856d1a6ebafd3584654b94"
+  integrity sha512-4Ejy1OPxi9f2tt1rRV7Go7zmfDQ+ZectEQz3VGUQhgq62HtIRPDyG/JtnwIxs6x3uNMwo2V7q1fMvKjb+Tnpqg==
   dependencies:
     create-hash "^1.1.2"
     create-hmac "^1.1.4"
@@ -9317,7 +9345,7 @@ randexp@0.4.6:
     discontinuous-range "1.0.0"
     ret "~0.1.10"
 
-randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5:
+randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
   integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==
@@ -9526,7 +9554,7 @@ read-pkg@^5.2.0:
     string_decoder "~1.1.1"
     util-deprecate "~1.0.1"
 
-readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0:
+readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0:
   version "3.6.0"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
   integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
@@ -9558,6 +9586,13 @@ readdirp@~3.3.0:
   dependencies:
     picomatch "^2.0.7"
 
+readdirp@~3.4.0:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.4.0.tgz#9fdccdf9e9155805449221ac645e8303ab5b9ada"
+  integrity sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ==
+  dependencies:
+    picomatch "^2.2.1"
+
 realpath-native@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.1.0.tgz#2003294fea23fb0672f2476ebe22fcf498a2d65c"
@@ -9949,12 +9984,12 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
   integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
 
-safe-buffer@>=5.1.0:
+safe-buffer@>=5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.2.0:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
   integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
 
-safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
+safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519"
   integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==
@@ -10076,10 +10111,12 @@ serialize-error@^6.0.0:
   dependencies:
     type-fest "^0.12.0"
 
-serialize-javascript@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61"
-  integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ==
+serialize-javascript@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa"
+  integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==
+  dependencies:
+    randombytes "^2.1.0"
 
 serialize-query-params@^1.1.0:
   version "1.1.0"
@@ -10768,21 +10805,30 @@ terminal-link@^2.0.0:
     supports-hyperlinks "^2.0.0"
 
 terser-webpack-plugin@^1.4.3:
-  version "1.4.3"
-  resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz#5ecaf2dbdc5fb99745fd06791f46fc9ddb1c9a7c"
-  integrity sha512-QMxecFz/gHQwteWwSo5nTc6UaICqN1bMedC5sMtUc7y3Ha3Q8y6ZO0iCR8pq4RJC8Hjf0FEPEHZqcMB/+DFCrA==
+  version "1.4.5"
+  resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz#a217aefaea330e734ffacb6120ec1fa312d6040b"
+  integrity sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==
   dependencies:
     cacache "^12.0.2"
     find-cache-dir "^2.1.0"
     is-wsl "^1.1.0"
     schema-utils "^1.0.0"
-    serialize-javascript "^2.1.2"
+    serialize-javascript "^4.0.0"
     source-map "^0.6.1"
     terser "^4.1.2"
     webpack-sources "^1.4.0"
     worker-farm "^1.7.0"
 
-terser@^4.1.2, terser@^4.6.3:
+terser@^4.1.2:
+  version "4.8.0"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17"
+  integrity sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==
+  dependencies:
+    commander "^2.20.0"
+    source-map "~0.6.1"
+    source-map-support "~0.5.12"
+
+terser@^4.6.3:
   version "4.6.11"
   resolved "https://registry.yarnpkg.com/terser/-/terser-4.6.11.tgz#12ff99fdd62a26de2a82f508515407eb6ccd8a9f"
   integrity sha512-76Ynm7OXUG5xhOpblhytE7X58oeNSmC8xnNhjWVo8CksHit0U0kO4hfNbPrrYwowLWFgM2n9L176VNx2QaHmtA==
@@ -11327,14 +11373,23 @@ walker@^1.0.7, walker@~1.0.5:
   dependencies:
     makeerror "1.0.x"
 
-watchpack@^1.6.1:
-  version "1.6.1"
-  resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.1.tgz#280da0a8718592174010c078c7585a74cd8cd0e2"
-  integrity sha512-+IF9hfUFOrYOOaKyfaI7h7dquUIOgyEMoQMLA7OP5FxegKA2+XdXThAZ9TU2kucfhDH7rfMHs1oPYziVGWRnZA==
+watchpack-chokidar2@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/watchpack-chokidar2/-/watchpack-chokidar2-2.0.0.tgz#9948a1866cbbd6cb824dea13a7ed691f6c8ddff0"
+  integrity sha512-9TyfOyN/zLUbA288wZ8IsMZ+6cbzvsNyEzSBp6e/zkifi6xxbl8SmQ/CxQq32k8NNqrdVEVUVSEf56L4rQ/ZxA==
   dependencies:
     chokidar "^2.1.8"
+
+watchpack@^1.7.4:
+  version "1.7.4"
+  resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.7.4.tgz#6e9da53b3c80bb2d6508188f5b200410866cd30b"
+  integrity sha512-aWAgTW4MoSJzZPAicljkO1hsi1oKj/RRq/OJQh2PKI2UKL04c2Bs+MBOB+BBABHTXJpf9mCwHN7ANCvYsvY2sg==
+  dependencies:
     graceful-fs "^4.1.2"
     neo-async "^2.5.0"
+  optionalDependencies:
+    chokidar "^3.4.1"
+    watchpack-chokidar2 "^2.0.0"
 
 wbuf@^1.1.0, wbuf@^1.7.3:
   version "1.7.3"
@@ -11496,10 +11551,10 @@ webpack-sources@^1.4.0, webpack-sources@^1.4.1:
     source-list-map "^2.0.0"
     source-map "~0.6.1"
 
-webpack@4.43.0:
-  version "4.43.0"
-  resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.43.0.tgz#c48547b11d563224c561dad1172c8aa0b8a678e6"
-  integrity sha512-GW1LjnPipFW2Y78OOab8NJlCflB7EFskMih2AHdvjbpKMeDJqEgSx24cXXXiPS65+WSwVyxtDsJH6jGX2czy+g==
+webpack@4.44.1:
+  version "4.44.1"
+  resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.44.1.tgz#17e69fff9f321b8f117d1fda714edfc0b939cc21"
+  integrity sha512-4UOGAohv/VGUNQJstzEywwNxqX417FnjZgZJpJQegddzPmTvph37eBIRbRTfdySXzVtJXLJfbMN3mMYhM6GdmQ==
   dependencies:
     "@webassemblyjs/ast" "1.9.0"
     "@webassemblyjs/helper-module-context" "1.9.0"
@@ -11509,7 +11564,7 @@ webpack@4.43.0:
     ajv "^6.10.2"
     ajv-keywords "^3.4.1"
     chrome-trace-event "^1.0.2"
-    enhanced-resolve "^4.1.0"
+    enhanced-resolve "^4.3.0"
     eslint-scope "^4.0.3"
     json-parse-better-errors "^1.0.2"
     loader-runner "^2.4.0"
@@ -11522,7 +11577,7 @@ webpack@4.43.0:
     schema-utils "^1.0.0"
     tapable "^1.1.3"
     terser-webpack-plugin "^1.4.3"
-    watchpack "^1.6.1"
+    watchpack "^1.7.4"
     webpack-sources "^1.4.1"
 
 websocket-driver@0.6.5:


Test patch:
diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml
index f8049d22d..261a8f4a5 100644
--- a/.github/workflows/unit_tests.yml
+++ b/.github/workflows/unit_tests.yml
@@ -18,10 +18,7 @@ jobs:
       # actions/checkout@v2
       uses: actions/checkout@722adc6
     - name: Go unit tests
-      env:
-        GITCOOKIE_SH: ${{ secrets.GITCOOKIE_SH }}
       run: |
-        echo "$GITCOOKIE_SH" | bash
         go get gotest.tools/gotestsum@v0.4.2
         # TODO: validate bin/protoc-go.sh does not dirty the repo
         gotestsum -- -cover -race -v -mod=readonly ./...
diff --git a/cli/cmd/testdata/install_addon_config.golden b/cli/cmd/testdata/install_addon_config.golden
index b3ab9c306..0441aaf3a 100644
--- a/cli/cmd/testdata/install_addon_config.golden
+++ b/cli/cmd/testdata/install_addon_config.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_config.golden b/cli/cmd/testdata/install_config.golden
index 47661e50d..1a2ebadc0 100644
--- a/cli/cmd/testdata/install_config.golden
+++ b/cli/cmd/testdata/install_config.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_controlplane_tracing_output.golden b/cli/cmd/testdata/install_controlplane_tracing_output.golden
index ac510bd24..b4d13dc21 100644
--- a/cli/cmd/testdata/install_controlplane_tracing_output.golden
+++ b/cli/cmd/testdata/install_controlplane_tracing_output.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_custom_registry.golden b/cli/cmd/testdata/install_custom_registry.golden
index c6f9d9d21..ab781ab85 100644
--- a/cli/cmd/testdata/install_custom_registry.golden
+++ b/cli/cmd/testdata/install_custom_registry.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_default.golden b/cli/cmd/testdata/install_default.golden
index 707a4327c..86fe5d572 100644
--- a/cli/cmd/testdata/install_default.golden
+++ b/cli/cmd/testdata/install_default.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_default_override_dst_get_nets.golden b/cli/cmd/testdata/install_default_override_dst_get_nets.golden
index a9d300087..3200e5b7c 100644
--- a/cli/cmd/testdata/install_default_override_dst_get_nets.golden
+++ b/cli/cmd/testdata/install_default_override_dst_get_nets.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_grafana_existing.golden b/cli/cmd/testdata/install_grafana_existing.golden
index 2e1b49a88..16e400d57 100644
--- a/cli/cmd/testdata/install_grafana_existing.golden
+++ b/cli/cmd/testdata/install_grafana_existing.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_ha_output.golden b/cli/cmd/testdata/install_ha_output.golden
index 7bb9e5583..9244e83c6 100644
--- a/cli/cmd/testdata/install_ha_output.golden
+++ b/cli/cmd/testdata/install_ha_output.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_ha_with_overrides_output.golden b/cli/cmd/testdata/install_ha_with_overrides_output.golden
index 4c2346194..f5541c720 100644
--- a/cli/cmd/testdata/install_ha_with_overrides_output.golden
+++ b/cli/cmd/testdata/install_ha_with_overrides_output.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_heartbeat_disabled_output.golden b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
index 8bd72dd20..7f9afa5e3 100644
--- a/cli/cmd/testdata/install_heartbeat_disabled_output.golden
+++ b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_helm_output.golden b/cli/cmd/testdata/install_helm_output.golden
index eab5f7c95..bcaf8fb6a 100644
--- a/cli/cmd/testdata/install_helm_output.golden
+++ b/cli/cmd/testdata/install_helm_output.golden
@@ -141,7 +141,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_helm_output_addons.golden b/cli/cmd/testdata/install_helm_output_addons.golden
index 1e1e67f58..13f27c404 100644
--- a/cli/cmd/testdata/install_helm_output_addons.golden
+++ b/cli/cmd/testdata/install_helm_output_addons.golden
@@ -141,7 +141,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_helm_output_ha.golden b/cli/cmd/testdata/install_helm_output_ha.golden
index d3dadd5a3..f246e1664 100644
--- a/cli/cmd/testdata/install_helm_output_ha.golden
+++ b/cli/cmd/testdata/install_helm_output_ha.golden
@@ -141,7 +141,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_no_init_container.golden b/cli/cmd/testdata/install_no_init_container.golden
index 8ed1fc04e..95f589030 100644
--- a/cli/cmd/testdata/install_no_init_container.golden
+++ b/cli/cmd/testdata/install_no_init_container.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_output.golden b/cli/cmd/testdata/install_output.golden
index 970839f68..baaaf6d86 100644
--- a/cli/cmd/testdata/install_output.golden
+++ b/cli/cmd/testdata/install_output.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_prometheus_overwrite.golden b/cli/cmd/testdata/install_prometheus_overwrite.golden
index 169e0cea4..9771b5ee6 100644
--- a/cli/cmd/testdata/install_prometheus_overwrite.golden
+++ b/cli/cmd/testdata/install_prometheus_overwrite.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_proxy_ignores.golden b/cli/cmd/testdata/install_proxy_ignores.golden
index 0be71ad9b..46da2cc7e 100644
--- a/cli/cmd/testdata/install_proxy_ignores.golden
+++ b/cli/cmd/testdata/install_proxy_ignores.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_restricted_dashboard.golden b/cli/cmd/testdata/install_restricted_dashboard.golden
index 91c82964d..220bc8103 100644
--- a/cli/cmd/testdata/install_restricted_dashboard.golden
+++ b/cli/cmd/testdata/install_restricted_dashboard.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_tracing.golden b/cli/cmd/testdata/install_tracing.golden
index f4bf34a9f..f288befe5 100644
--- a/cli/cmd/testdata/install_tracing.golden
+++ b/cli/cmd/testdata/install_tracing.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/cli/cmd/testdata/install_tracing_overwrite.golden b/cli/cmd/testdata/install_tracing_overwrite.golden
index af33fc7cf..f957cec59 100644
--- a/cli/cmd/testdata/install_tracing_overwrite.golden
+++ b/cli/cmd/testdata/install_tracing_overwrite.golden
@@ -133,7 +133,7 @@ rules:
   resources: ["jobs"]
   verbs: ["list", "get", "watch"]
 - apiGroups: [""]
-  resources: ["pods", "endpoints", "services"]
+  resources: ["pods", "endpoints", "services", "nodes"]
   verbs: ["list", "get", "watch"]
 - apiGroups: ["linkerd.io"]
   resources: ["serviceprofiles"]
diff --git a/controller/api/destination/endpoint_translator_test.go b/controller/api/destination/endpoint_translator_test.go
index 0ec60d527..d032942c2 100644
--- a/controller/api/destination/endpoint_translator_test.go
+++ b/controller/api/destination/endpoint_translator_test.go
@@ -10,6 +10,7 @@ import (
 	pb "github.com/linkerd/linkerd2-proxy-api/go/destination"
 	"github.com/linkerd/linkerd2-proxy-api/go/net"
 	"github.com/linkerd/linkerd2/controller/api/destination/watcher"
+	pkgk8s "github.com/linkerd/linkerd2/controller/k8s"
 	"github.com/linkerd/linkerd2/pkg/addr"
 	"github.com/linkerd/linkerd2/pkg/k8s"
 	logging "github.com/sirupsen/logrus"
@@ -115,12 +116,37 @@ var (
 )
 
 func makeEndpointTranslator(t *testing.T) (*mockDestinationGetServer, *endpointTranslator) {
+	k8sAPI, err := pkgk8s.NewFakeAPI(`
+apiVersion: v1
+kind: Node
+metadata:
+  annotations:
+    kubeadm.alpha.kubernetes.io/cri-socket: /run/containerd/containerd.sock
+    node.alpha.kubernetes.io/ttl: "0"
+  labels:
+    beta.kubernetes.io/arch: amd64
+    beta.kubernetes.io/os: linux
+    kubernetes.io/arch: amd64
+    kubernetes.io/hostname: kind-worker
+    kubernetes.io/os: linux
+    topology.kubernetes.io/region: west
+    topology.kubernetes.io/zone: west-1a
+  name: test-123
+`,
+	)
+	if err != nil {
+		t.Fatalf("NewFakeAPI returned an error: %s", err)
+	}
+	k8sAPI.Sync(nil)
+
 	mockGetServer := &mockDestinationGetServer{updatesReceived: []*pb.Update{}}
 	translator := newEndpointTranslator(
 		"linkerd",
 		"trust.domain",
 		true,
 		"service-name.service-ns",
+		"test-123",
+		k8sAPI.Client,
 		mockGetServer,
 		logging.WithField("test", t.Name()),
 	)
@@ -131,7 +157,7 @@ func TestEndpointTranslatorForRemoteGateways(t *testing.T) {
 	t.Run("Sends one update for add and another for remove", func(t *testing.T) {
 		mockGetServer, translator := makeEndpointTranslator(t)
 
-		translator.Add(mkAddressSetForServices(remoteGatewayWithNoTLS))
+		translator.Add(mkAddressSetForServices(remoteGatewayWithNoTLS, remoteGatewayWithTLS))
 		translator.Remove(mkAddressSetForServices(remoteGatewayWithTLS))
 
 		expectedNumUpdates := 2
@@ -236,7 +262,7 @@ func TestEndpointTranslatorForPods(t *testing.T) {
 	t.Run("Sends one update for add and another for remove", func(t *testing.T) {
 		mockGetServer, translator := makeEndpointTranslator(t)
 
-		translator.Add(mkAddressSetForPods(normalPod))
+		translator.Add(mkAddressSetForPods(normalPod, tlsOptionalPod))
 		translator.Remove(mkAddressSetForPods(tlsOptionalPod))
 
 		expectedNumUpdates := 2
@@ -249,12 +275,12 @@ func TestEndpointTranslatorForPods(t *testing.T) {
 	t.Run("Sends addresses as removed or added", func(t *testing.T) {
 		mockGetServer, translator := makeEndpointTranslator(t)
 
-		translator.Add(mkAddressSetForPods(normalPod, tlsOptionalPod))
+		translator.Add(mkAddressSetForPods(normalPod, tlsOptionalPod, tlsDisabledPod))
 		translator.Remove(mkAddressSetForPods(tlsDisabledPod))
 
 		addressesAdded := mockGetServer.updatesReceived[0].GetAdd().Addrs
 		actualNumberOfAdded := len(addressesAdded)
-		expectedNumberOfAdded := 2
+		expectedNumberOfAdded := 3
 		if actualNumberOfAdded != expectedNumberOfAdded {
 			t.Fatalf("Expecting [%d] addresses to be added, got [%d]: %v", expectedNumberOfAdded, actualNumberOfAdded, addressesAdded)
 		}
@@ -365,8 +391,9 @@ func TestEndpointTranslatorForPods(t *testing.T) {
 
 func mkAddressSetForServices(gatewayAddresses ...watcher.Address) watcher.AddressSet {
 	set := watcher.AddressSet{
-		Addresses: make(map[watcher.ServiceID]watcher.Address),
-		Labels:    map[string]string{"service": "service-name", "namespace": "service-ns"},
+		Addresses:       make(map[watcher.ServiceID]watcher.Address),
+		Labels:          map[string]string{"service": "service-name", "namespace": "service-ns"},
+		TopologicalPref: []string{},
 	}
 	for _, a := range gatewayAddresses {
 		a := a // pin
@@ -384,8 +411,9 @@ func mkAddressSetForServices(gatewayAddresses ...watcher.Address) watcher.Addres
 
 func mkAddressSetForPods(podAddresses ...watcher.Address) watcher.AddressSet {
 	set := watcher.AddressSet{
-		Addresses: make(map[watcher.PodID]watcher.Address),
-		Labels:    map[string]string{"service": "service-name", "namespace": "service-ns"},
+		Addresses:       make(map[watcher.PodID]watcher.Address),
+		Labels:          map[string]string{"service": "service-name", "namespace": "service-ns"},
+		TopologicalPref: []string{},
 	}
 	for _, p := range podAddresses {
 		id := watcher.PodID{Name: p.Pod.Name, Namespace: p.Pod.Namespace}
diff --git a/controller/api/destination/server_test.go b/controller/api/destination/server_test.go
index 9caffde05..f93e5f767 100644
--- a/controller/api/destination/server_test.go
+++ b/controller/api/destination/server_test.go
@@ -115,6 +115,7 @@ spec:
 		"linkerd",
 		"trust.domain",
 		"mycluster.local",
+		k8sAPI,
 		log,
 		make(<-chan struct{}),
 	}
diff --git a/pkg/healthcheck/healthcheck_test.go b/pkg/healthcheck/healthcheck_test.go
index a44226cc4..32b0e467b 100644
--- a/pkg/healthcheck/healthcheck_test.go
+++ b/pkg/healthcheck/healthcheck_test.go
@@ -2335,17 +2335,8 @@ metadata:
 	})
 }
 
-func getConfigAndKubeSystemNamespace(ha bool, nsLabel string) []string {
+func getWebhookAndKubeSystemNamespace(nsLabel string, failurePolicy string) []string {
 	return []string{fmt.Sprintf(`
-kind: ConfigMap
-apiVersion: v1
-metadata:
-  name: linkerd-config
-  namespace: linkerd
-data:
-  install: |
-    {"cliVersion":"dev-undefined","flags":[{"name":"ha","value":"%v"}]}`, ha),
-		fmt.Sprintf(`
 apiVersion: v1
 kind: Namespace
 metadata:
@@ -2353,6 +2344,35 @@ metadata:
   labels:
     %s
   name: kube-system`, nsLabel),
+		fmt.Sprintf(`
+apiVersion: admissionregistration.k8s.io/v1beta1
+kind: MutatingWebhookConfiguration
+metadata:
+  name: linkerd-proxy-injector-webhook-config
+  labels:
+    linkerd.io/control-plane-component: proxy-injector
+    linkerd.io/control-plane-ns: linkerd
+webhooks:
+- name: linkerd-proxy-injector.linkerd.io
+  namespaceSelector:
+    matchExpressions:
+    - key: config.linkerd.io/admission-webhooks
+      operator: NotIn
+      values:
+      - disabled
+  clientConfig:
+    service:
+      name: linkerd-proxy-injector
+      namespace: linkerd
+      path: "/"
+    caBundle: cHJveHkgaW5qZWN0b3IgQ0EgYnVuZGxl
+  failurePolicy: %s
+  rules:
+  - operations: [ "CREATE" ]
+    apiGroups: [""]
+    apiVersions: ["v1"]
+    resources: ["pods"]
+  sideEffects: None`, failurePolicy),
 	}
 }
 
@@ -2363,24 +2383,24 @@ func TestKubeSystemNamespaceInHA(t *testing.T) {
 		expectedOutput  string
 	}{
 		{
-			"passes when HA is not enabled",
-			getConfigAndKubeSystemNamespace(false, ""),
+			"passes when webhook policy is Ignore is not enabled",
+			getWebhookAndKubeSystemNamespace("", "Ignore"),
 			"",
 		},
 		{
-			"passes when HA is enabled and namespace has required metadata",
-			getConfigAndKubeSystemNamespace(true, "config.linkerd.io/admission-webhooks: disabled"),
+			"passes when webhook policy is Fail and namespace has required metadata",
+			getWebhookAndKubeSystemNamespace("config.linkerd.io/admission-webhooks: disabled", "Fail"),
 			"l5d-injection-disabled pod injection disabled on kube-system",
 		},
 		{
-			"fails when HA and admission hooks are enabled",
-			getConfigAndKubeSystemNamespace(true, "config.linkerd.io/admission-webhooks: enabled"),
-			"l5d-injection-disabled pod injection disabled on kube-system: kube-system namespace needs to have the label config.linkerd.io/admission-webhooks: disabled if HA mode is enabled",
+			"fails when webhook policy is Fail and admission hooks are enabled",
+			getWebhookAndKubeSystemNamespace("config.linkerd.io/admission-webhooks: enabled", "Fail"),
+			"l5d-injection-disabled pod injection disabled on kube-system: kube-system namespace needs to have the label config.linkerd.io/admission-webhooks: disabled if injector webhook failure policy is Fail",
 		},
 		{
-			"fails when HA is enabled and metadata is missing",
-			getConfigAndKubeSystemNamespace(true, ""),
-			"l5d-injection-disabled pod injection disabled on kube-system: kube-system namespace needs to have the label config.linkerd.io/admission-webhooks: disabled if HA mode is enabled",
+			"fails when webhook policy is Fail and metadata is missing",
+			getWebhookAndKubeSystemNamespace("", "Fail"),
+			"l5d-injection-disabled pod injection disabled on kube-system: kube-system namespace needs to have the label config.linkerd.io/admission-webhooks: disabled if injector webhook failure policy is Fail",
 		},
 	}
 
@@ -2391,13 +2411,7 @@ func TestKubeSystemNamespaceInHA(t *testing.T) {
 			hc := NewHealthChecker([]CategoryID{}, &Options{})
 			hc.ControlPlaneNamespace = "linkerd"
 
-			var err error
 			hc.kubeAPI, _ = k8s.NewFakeAPI(tc.k8sConfigs...)
-			_, hc.linkerdConfig, err = hc.checkLinkerdConfigConfigMap()
-			if err != nil {
-				t.Fatalf("Unexpected error: %q", err)
-			}
-
 			hc.addCheckAsCategory("l5d-injection-disabled", LinkerdHAChecks, "pod injection disabled on kube-system")
 
 			obs := newObserver()
diff --git a/web/app/js/components/TopRoutesTable.jsx b/web/app/js/components/TopRoutesTable.jsx
index 266d9afac..d13f929a8 100644
--- a/web/app/js/components/TopRoutesTable.jsx
+++ b/web/app/js/components/TopRoutesTable.jsx
@@ -2,52 +2,53 @@ import BaseTable from './BaseTable.jsx';
 import PropTypes from 'prop-types';
 import React from 'react';
 import SuccessRateMiniChart from './util/SuccessRateMiniChart.jsx';
+import { Trans } from '@lingui/macro';
 import { metricToFormatter } from './util/Utils.js';
 
 const routesColumns = [
   {
-    title: 'Route',
+    title: <Trans>columnTitleRoute</Trans>,
     dataIndex: 'route',
     filter: d => d.route,
     sorter: d => d.route,
   },
   {
-    title: 'Service',
+    title: <Trans>columnTitleService</Trans>,
     tooltip: 'hostname:port used when communicating with this target',
     dataIndex: 'authority',
     filter: d => d.authority,
     sorter: d => d.authority,
   },
   {
-    title: 'Success Rate',
+    title: <Trans>columnTitleSuccessRate</Trans>,
     dataIndex: 'successRate',
     isNumeric: true,
     render: d => <SuccessRateMiniChart sr={d.successRate} />,
     sorter: d => d.successRate,
   },
   {
-    title: 'RPS',
+    title: <Trans>columnTitleRPS</Trans>,
     dataIndex: 'requestRate',
     isNumeric: true,
     render: d => metricToFormatter.NO_UNIT(d.requestRate),
     sorter: d => d.requestRate,
   },
   {
-    title: 'P50 Latency',
+    title: <Trans>columnTitleP50Latency</Trans>,
     dataIndex: 'latency.P50',
     isNumeric: true,
     render: d => metricToFormatter.LATENCY(d.latency.P50),
     sorter: d => d.latency.P50,
   },
   {
-    title: 'P95 Latency',
+    title: <Trans>columnTitleP95Latency</Trans>,
     dataIndex: 'latency.P95',
     isNumeric: true,
     render: d => metricToFormatter.LATENCY(d.latency.P95),
     sorter: d => d.latency.P95,
   },
   {
-    title: 'P99 Latency',
+    title: <Trans>columnTitleP99Latency</Trans>,
     dataIndex: 'latency.P99',
     isNumeric: true,
     render: d => metricToFormatter.LATENCY(d.latency.P99),