Repository: vmware-tanzu/octant
Pull request #834 (2020-04-10 17:53:47)

Breadcrumbs 
Added breadcrumbs to page headers based on design from Ross defined [here](https://www.figma.com/file/CvMwJPlvETtFoPx43oXKUd/Breadcrumbs-and-back-button?node-id=1%3A152).

wwitzel3: sorry for the delay, reviewing this now
wwitzel3: https://youtu.be/z9kAMoNLBkA

mklanjsek: > When viewing Workloads the breadcrumb navigation becomes confusing. The breadcrumb takes me back to a completely different view.

The breadcrumbs reflect the object structure, so for example a pod will always have `Pods` node as its parent. 
What you are expecting is that breadcrumbs work the same way as browser history, where you can go back and forth from one object in history to another regardless of their object hierarchy. That is a totally different paradigm and we should not mix these two.

@wwitzel3 is your expectation how this is supposed to work based on using Octant in browser for a long time?  If it wasn't for that, would object hierarchy based breadcrumb paradigm make sense?
wwitzel3: My expectation is that my breadcrumbs would take me back to where I was.

So I understand what makes this weird. When I'm looking at Workloads / Deployments, I'm viewing two levels of hierarchy at the same time and then clicking into a deployment is three levels deep.

But if I'm only looking at Deployments, then I click a deployment, I'm two levels deep.

I think I would be ok with something like:

(clickable)Workloads -> Deployments
(clickable)Deployments -> my-deployment

So that, when I click a deployment from the workloads view, without the need to dive back in to the menu or use a browsers back button, I can get back to the Workloads view I was originally on when I clicked a deployment.

mklanjsek: Darn.

I think problem here is that we are trying to merge breadcrumb and header functionality inside a single UI component. In order for that component to behave as a header, we are trying to remove some nodes in our 3 level deep structure. I am realizing now that there is no good way to do that - Wayne's approach is more accurate reflection of object structure and current approach focuses more on usability and eliminates information that's obvious for most users. Neither one is ideal, so we may as well byte the bullet and show all 3 levels even if we need to use ellipses on some of the nodes.

@rossfenrick any thoughts on your side? 

rossfenrick: If we only want to display 2 levels deep, then we should not use breadcrumbs, but instead a back button or a switcher.

If we want to display >2 levels deep, we could separate out the breadcrumbs and the header, like so: 
![Screen Shot 2020-04-15 at 11 23 12 AM](https://user-images.githubusercontent.com/7506366/79373517-89b61200-7f0b-11ea-988b-34813fa1d871.png)
![Screen Shot 2020-04-15 at 11 20 21 AM](https://user-images.githubusercontent.com/7506366/79373119-1ad8b900-7f0b-11ea-9ce3-7b652290aa1f.png)



wwitzel3: I like the separation of breadcrumbs and header
mklanjsek: Thank you so much @rossfenrick, I'll try that
wwitzel3: How's this coming along, I imagine this ended up being a bit of work since it fundamentally changes how we render the top of every resource list and resource summary view.

If you run in to any gotchas or have questions, let me know.
mklanjsek: @wwitzel3 thanks, getting close.  You are right, it did require considerable changes, but it will be a huge improvement.

Patch:
diff --git a/changelogs/unreleased/710-mklanjsek b/changelogs/unreleased/710-mklanjsek
new file mode 100644
index 00000000..9ba1554b
--- /dev/null
+++ b/changelogs/unreleased/710-mklanjsek
@@ -0,0 +1,1 @@
+Added breadcrumbs, reworked headers for all pages
diff --git a/internal/describer/crd_list.go b/internal/describer/crd_list.go
index 14a33b6a..585cf2c1 100644
--- a/internal/describer/crd_list.go
+++ b/internal/describer/crd_list.go
@@ -64,7 +64,8 @@ func (cld *crdList) Describe(ctx context.Context, namespace string, options Opti
 	view.SetAccessor("summary")
 
 	title := component.Title(
-		component.NewText("Custom Resources"),
+		component.NewLink("", "Cluster Overview", "/cluster-overview"),
+		component.NewLink("", "Custom Resources", "/cluster-overview/custom-resources"),
 		component.NewText(crd.GetName()))
 
 	contentResponse := component.NewContentResponse(title)
diff --git a/internal/describer/crd_section.go b/internal/describer/crd_section.go
index 8453260c..3cfb5ff2 100644
--- a/internal/describer/crd_section.go
+++ b/internal/describer/crd_section.go
@@ -22,17 +22,18 @@ type CRDSection struct {
 	describers map[string]Describer
 	path       string
 	title      string
-
+	rootPath   ResourceLink
 	mu sync.Mutex
 }
 
 var _ Describer = (*CRDSection)(nil)
 
-func NewCRDSection(p, title string) *CRDSection {
+func NewCRDSection(p, title string, rootPath ResourceLink) *CRDSection {
 	return &CRDSection{
 		describers: make(map[string]Describer),
 		path:       p,
 		title:      title,
+		rootPath:   rootPath,
 	}
 }
 
@@ -117,12 +118,12 @@ func (csd *CRDSection) Describe(ctx context.Context, namespace string, options O
 		}
 	}
 
-	cr := component.ContentResponse{
-		Components: []component.Component{table},
-		Title:      component.TitleFromString(csd.title),
-	}
-
-	return cr, nil
+	title := getBreadcrumb(csd.rootPath, csd.title, "", namespace)
+	list := component.NewList(title, nil)
+	list.Add(table)
+	return component.ContentResponse{
+		Components: []component.Component{list},
+	}, nil
 }
 
 func (csd *CRDSection) PathFilters() []PathFilter {
diff --git a/internal/describer/list.go b/internal/describer/list.go
index d30af826..303364d7 100644
--- a/internal/describer/list.go
+++ b/internal/describer/list.go
@@ -7,11 +7,10 @@ package describer
 
 import (
 	"context"
-	"reflect"
-
 	"github.com/pkg/errors"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/client-go/kubernetes/scheme"
+	"reflect"
 
 	"github.com/vmware-tanzu/octant/pkg/store"
 	"github.com/vmware-tanzu/octant/pkg/view/component"
@@ -26,6 +25,7 @@ type ListConfig struct {
 	IsClusterWide bool
 	IconName      string
 	IconSource    string
+	RootPath      ResourceLink
 }
 
 // List describes a list of objects.
@@ -40,6 +40,7 @@ type List struct {
 	isClusterWide  bool
 	iconName       string
 	iconSource     string
+	rootPath       ResourceLink
 }
 
 // NewList creates an instance of List.
@@ -54,6 +55,7 @@ func NewList(c ListConfig) *List {
 		isClusterWide:  c.IsClusterWide,
 		iconName:       c.IconName,
 		iconSource:     c.IconSource,
+		rootPath:       c.RootPath,
 	}
 }
 
@@ -76,7 +78,8 @@ func (d *List) Describe(ctx context.Context, namespace string, options Options)
 		return component.EmptyContentResponse, err
 	}
 
-	list := component.NewList(d.title, nil)
+	title := getBreadcrumb(d.rootPath, d.title, "", namespace)
+	list := component.NewList(title, nil)
 	list.SetIcon(d.iconName, d.iconSource)
 
 	listType := d.listType()
diff --git a/internal/describer/object.go b/internal/describer/object.go
index 2778cda8..3b4259e6 100644
--- a/internal/describer/object.go
+++ b/internal/describer/object.go
@@ -8,6 +8,7 @@ package describer
 import (
 	"context"
 	"fmt"
+	"path/filepath"
 	"strings"
 
 	"k8s.io/apimachinery/pkg/api/meta"
@@ -18,9 +19,9 @@ import (
 	"github.com/vmware-tanzu/octant/internal/api"
 	"github.com/vmware-tanzu/octant/internal/log"
 	"github.com/vmware-tanzu/octant/internal/modules/overview/logviewer"
+	"github.com/vmware-tanzu/octant/internal/modules/overview/terminalviewer"
 	"github.com/vmware-tanzu/octant/internal/modules/overview/yamlviewer"
 	"github.com/vmware-tanzu/octant/internal/octant"
-	"github.com/vmware-tanzu/octant/internal/modules/overview/terminalviewer"
 	"github.com/vmware-tanzu/octant/internal/printer"
 	"github.com/vmware-tanzu/octant/internal/resourceviewer"
 	"github.com/vmware-tanzu/octant/pkg/action"
@@ -36,6 +37,7 @@ type ObjectConfig struct {
 	DisableResourceViewer bool
 	IconName              string
 	IconSource            string
+	RootPath              ResourceLink
 }
 
 // Object describes an object.
@@ -50,6 +52,7 @@ type Object struct {
 	tabFuncDescriptors    []tabFuncDescriptor
 	iconName              string
 	iconSource            string
+	rootPath              ResourceLink
 }
 
 // NewObject creates an instance of Object.
@@ -63,6 +66,7 @@ func NewObject(c ObjectConfig) *Object {
 		disableResourceViewer: c.DisableResourceViewer,
 		iconName:              c.IconName,
 		iconSource:            c.IconSource,
+		rootPath:              c.RootPath,
 	}
 
 	o.tabFuncDescriptors = []tabFuncDescriptor{
@@ -114,7 +118,13 @@ func (d *Object) Describe(ctx context.Context, namespace string, options Options
 	accessor := meta.NewAccessor()
 	objectName, _ := accessor.Name(object)
 
-	title := append([]component.TitleComponent{}, component.NewText(d.baseTitle))
+	kind, _ := accessor.Kind(object)
+
+	nameLink, err := options.Link.ForObject(object, kind)
+	if err != nil {
+		return component.EmptyContentResponse, err
+	}
+	title := getBreadcrumb(d.rootPath, d.baseTitle, filepath.Dir(nameLink.Ref()), namespace)
 	if objectName != "" {
 		title = append(title, component.NewText(objectName))
 	}
diff --git a/internal/describer/objects.go b/internal/describer/objects.go
index 57997cc0..0e019b2c 100644
--- a/internal/describer/objects.go
+++ b/internal/describer/objects.go
@@ -50,6 +50,7 @@ func initNamespacedCRD() *CRDSection {
 	return NewCRDSection(
 		"/custom-resources",
 		"Custom Resources",
+		ResourceLink{Title: "Workloads", Url: "/overview/namespace/($NAMESPACE)/workloads"},
 	)
 }
 
@@ -59,8 +60,9 @@ func initNamespacedOverview() *Section {
 		ObjectStoreKey: store.Key{APIVersion: "batch/v1beta1", Kind: "CronJob"},
 		ListType:       &batchv1beta1.CronJobList{},
 		ObjectType:     &batchv1beta1.CronJob{},
-		Titles:         ResourceTitle{List: "Workloads / Cron Jobs", Object: "Cron Job"},
+		Titles:         ResourceTitle{List: "Cron Jobs", Object: "Cron Jobs"},
 		IconName:       icon.OverviewCronJob,
+		RootPath:       ResourceLink{Title: "Workloads", Url: "/overview/namespace/($NAMESPACE)/workloads"},
 	})
 
 	workloadsDaemonSets := NewResource(ResourceOptions{
@@ -68,8 +70,9 @@ func initNamespacedOverview() *Section {
 		ObjectStoreKey: store.Key{APIVersion: "apps/v1", Kind: "DaemonSet"},
 		ListType:       &appsv1.DaemonSetList{},
 		ObjectType:     &appsv1.DaemonSet{},
-		Titles:         ResourceTitle{List: "Workloads / Daemon Sets", Object: "Daemon Set"},
+		Titles:         ResourceTitle{List: "Daemon Sets", Object: "Daemon Sets"},
 		IconName:       icon.OverviewDaemonSet,
+		RootPath:       ResourceLink{Title: "Workloads", Url: "/overview/namespace/($NAMESPACE)/workloads"},
 	})
 
 	workloadsDeployments := NewResource(ResourceOptions{
@@ -77,8 +80,9 @@ func initNamespacedOverview() *Section {
 		ObjectStoreKey: store.Key{APIVersion: "apps/v1", Kind: "Deployment"},
 		ListType:       &appsv1.DeploymentList{},
 		ObjectType:     &appsv1.Deployment{},
-		Titles:         ResourceTitle{List: "Workloads / Deployments", Object: "Deployment"},
+		Titles:         ResourceTitle{List: "Deployments", Object: "Deployments"},
 		IconName:       icon.OverviewDeployment,
+		RootPath:       ResourceLink{Title: "Workloads", Url: "/overview/namespace/($NAMESPACE)/workloads"},
 	})
 
 	workloadsJobs := NewResource(ResourceOptions{
@@ -86,8 +90,9 @@ func initNamespacedOverview() *Section {
 		ObjectStoreKey: store.Key{APIVersion: "batch/v1", Kind: "Job"},
 		ListType:       &batchv1.JobList{},
 		ObjectType:     &batchv1.Job{},
-		Titles:         ResourceTitle{List: "Workloads / Jobs", Object: "Job"},
+		Titles:         ResourceTitle{List: "Jobs", Object: "Jobs"},
 		IconName:       icon.OverviewJob,
+		RootPath:       ResourceLink{Title: "Workloads", Url: "/overview/namespace/($NAMESPACE)/workloads"},
 	})
 
 	workloadsPods := NewResource(ResourceOptions{
@@ -95,8 +100,9 @@ func initNamespacedOverview() *Section {
 		ObjectStoreKey: store.Key{APIVersion: "v1", Kind: "Pod"},
 		ListType:       &corev1.PodList{},
 		ObjectType:     &corev1.Pod{},
-		Titles:         ResourceTitle{List: "Workloads / Pods", Object: "Pod"},
+		Titles:         ResourceTitle{List: "Pods", Object: "Pods"},
 		IconName:       icon.OverviewPod,
+		RootPath:       ResourceLink{Title: "Workloads", Url: "/overview/namespace/($NAMESPACE)/workloads"},
 	})
 
 	workloadsReplicaSets := NewResource(ResourceOptions{
@@ -104,8 +110,9 @@ func initNamespacedOverview() *Section {
 		ObjectStoreKey: store.Key{APIVersion: "apps/v1", Kind: "ReplicaSet"},
 		ListType:       &appsv1.ReplicaSetList{},
 		ObjectType:     &appsv1.ReplicaSet{},
-		Titles:         ResourceTitle{List: "Workloads / Replica Sets", Object: "Replica Set"},
+		Titles:         ResourceTitle{List: "Replica Sets", Object: "Replica Sets"},
 		IconName:       icon.OverviewReplicaSet,
+		RootPath:       ResourceLink{Title: "Workloads", Url: "/overview/namespace/($NAMESPACE)/workloads"},
 	})
 
 	workloadsReplicationControllers := NewResource(ResourceOptions{
@@ -113,16 +120,18 @@ func initNamespacedOverview() *Section {
 		ObjectStoreKey: store.Key{APIVersion: "v1", Kind: "ReplicationController"},
 		ListType:       &corev1.ReplicationControllerList{},
 		ObjectType:     &corev1.ReplicationController{},
-		Titles:         ResourceTitle{List: "Workloads / Replication Controllers", Object: "Replication Controller"},
+		Titles:         ResourceTitle{List: "Replication Controllers", Object: "Replication Controllers"},
 		IconName:       icon.OverviewReplicationController,
+		RootPath:       ResourceLink{Title: "Workloads", Url: "/overview/namespace/($NAMESPACE)/workloads"},
 	})
 	workloadsStatefulSets := NewResource(ResourceOptions{
 		Path:           "/workloads/stateful-sets",
 		ObjectStoreKey: store.Key{APIVersion: "apps/v1", Kind: "StatefulSet"},
 		ListType:       &appsv1.StatefulSetList{},
 		ObjectType:     &appsv1.StatefulSet{},
-		Titles:         ResourceTitle{List: "Workloads / Stateful Sets", Object: "Stateful Set"},
+		Titles:         ResourceTitle{List: "Stateful Sets", Object: "Stateful Sets"},
 		IconName:       icon.OverviewStatefulSet,
+		RootPath:       ResourceLink{Title: "Workloads", Url: "/overview/namespace/($NAMESPACE)/workloads"},
 	})
 
 	workloadsDescriber := NewSection(
@@ -143,8 +152,9 @@ func initNamespacedOverview() *Section {
 		ObjectStoreKey: store.Key{APIVersion: "autoscaling/v1", Kind: "HorizontalPodAutoscaler"},
 		ListType:       &autoscalingv1.HorizontalPodAutoscalerList{},
 		ObjectType:     &autoscalingv1.HorizontalPodAutoscaler{},
-		Titles:         ResourceTitle{List: "Discovery & Load Balancing / Horizontal Pod Autoscaler", Object: "Horizontal Pod Autoscaler"},
+		Titles:         ResourceTitle{List: "Horizontal Pod Autoscalers", Object: "Horizontal Pod Autoscalers"},
 		IconName:       icon.OverviewHorizontalPodAutoscaler,
+		RootPath:       ResourceLink{Title: "Discovery and Load Balancing", Url: "/overview/namespace/($NAMESPACE)/discovery-and-load-balancing"},
 	})
 
 	dlbIngresses := NewResource(ResourceOptions{
@@ -152,8 +162,9 @@ func initNamespacedOverview() *Section {
 		ObjectStoreKey: store.Key{APIVersion: "extensions/v1beta1", Kind: "Ingress"},
 		ListType:       &v1beta1.IngressList{},
 		ObjectType:     &v1beta1.Ingress{},
-		Titles:         ResourceTitle{List: "Discovery & Load Balancing / Ingresses", Object: "Ingress"},
+		Titles:         ResourceTitle{List: "Ingresses", Object: "Ingresses"},
 		IconName:       icon.OverviewIngress,
+		RootPath:       ResourceLink{Title: "Discovery and Load Balancing", Url: "/overview/namespace/($NAMESPACE)/discovery-and-load-balancing"},
 	})
 
 	dlbServices := NewResource(ResourceOptions{
@@ -161,8 +172,9 @@ func initNamespacedOverview() *Section {
 		ObjectStoreKey: store.Key{APIVersion: "v1", Kind: "Service"},
 		ListType:       &corev1.ServiceList{},
 		ObjectType:     &corev1.Service{},
-		Titles:         ResourceTitle{List: "Discovery & Load Balancing / Services", Object: "Service"},
+		Titles:         ResourceTitle{List: " Services", Object: "Services"},
 		IconName:       icon.OverviewService,
+		RootPath:       ResourceLink{Title: "Discovery and Load Balancing", Url: "/overview/namespace/($NAMESPACE)/discovery-and-load-balancing"},
 	})
 
 	dlbNetworkPolicies := NewResource(ResourceOptions{
@@ -188,8 +200,9 @@ func initNamespacedOverview() *Section {
 		ObjectStoreKey: store.Key{APIVersion: "v1", Kind: "ConfigMap"},
 		ListType:       &corev1.ConfigMapList{},
 		ObjectType:     &corev1.ConfigMap{},
-		Titles:         ResourceTitle{List: "Config & Storage / Config Maps", Object: "Config Map"},
+		Titles:         ResourceTitle{List: "Config Maps", Object: "Config Maps"},
 		IconName:       icon.OverviewConfigMap,
+		RootPath:       ResourceLink{Title: "Config and Storage", Url: "/overview/namespace/($NAMESPACE)/config-and-storage"},
 	})
 
 	csPVCs := NewResource(ResourceOptions{
@@ -197,8 +210,9 @@ func initNamespacedOverview() *Section {
 		ObjectStoreKey: store.Key{APIVersion: "v1", Kind: "PersistentVolumeClaim"},
 		ListType:       &corev1.PersistentVolumeClaimList{},
 		ObjectType:     &corev1.PersistentVolumeClaim{},
-		Titles:         ResourceTitle{List: "Config & Storage / Persistent Volume Claims", Object: "Persistent Volume Claim"},
+		Titles:         ResourceTitle{List: "Persistent Volume Claims", Object: "Persistent Volume Claims"},
 		IconName:       icon.OverviewPersistentVolumeClaim,
+		RootPath:       ResourceLink{Title: "Config and Storage", Url: "/overview/namespace/($NAMESPACE)/config-and-storage"},
 	})
 
 	csSecrets := NewResource(ResourceOptions{
@@ -206,8 +220,9 @@ func initNamespacedOverview() *Section {
 		ObjectStoreKey: store.Key{APIVersion: "v1", Kind: "Secret"},
 		ListType:       &corev1.SecretList{},
 		ObjectType:     &corev1.Secret{},
-		Titles:         ResourceTitle{List: "Config & Storage / Secrets", Object: "Secret"},
+		Titles:         ResourceTitle{List: "Secrets", Object: "Secrets"},
 		IconName:       icon.OverviewSecret,
+		RootPath:       ResourceLink{Title: "Config and Storage", Url: "/overview/namespace/($NAMESPACE)/config-and-storage"},
 	})
 
 	csServiceAccounts := NewResource(ResourceOptions{
@@ -215,8 +230,9 @@ func initNamespacedOverview() *Section {
 		ObjectStoreKey: store.Key{APIVersion: "v1", Kind: "ServiceAccount"},
 		ListType:       &corev1.ServiceAccountList{},
 		ObjectType:     &corev1.ServiceAccount{},
-		Titles:         ResourceTitle{List: "Config & Storage / Service Accounts", Object: "Service Account"},
+		Titles:         ResourceTitle{List: "Service Accounts", Object: "Service Accounts"},
 		IconName:       icon.OverviewServiceAccount,
+		RootPath:       ResourceLink{Title: "Config and Storage", Url: "/overview/namespace/($NAMESPACE)/config-and-storage"},
 	})
 
 	configAndStorageDescriber := NewSection(
@@ -233,8 +249,9 @@ func initNamespacedOverview() *Section {
 		ObjectStoreKey: store.Key{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "Role"},
 		ListType:       &rbacv1.RoleList{},
 		ObjectType:     &rbacv1.Role{},
-		Titles:         ResourceTitle{List: "RBAC / Roles", Object: "Role"},
+		Titles:         ResourceTitle{List: "Roles", Object: "Roles"},
 		IconName:       icon.OverviewRole,
+		RootPath:       ResourceLink{Title: "RBAC", Url: "/overview/namespace/($NAMESPACE)/rbac"},
 	})
 
 	rbacRoleBindings := NewResource(ResourceOptions{
@@ -242,8 +259,9 @@ func initNamespacedOverview() *Section {
 		ObjectStoreKey: store.Key{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "RoleBinding"},
 		ListType:       &rbacv1.RoleBindingList{},
 		ObjectType:     &rbacv1.RoleBinding{},
-		Titles:         ResourceTitle{List: "RBAC / Role Bindings", Object: "Role Binding"},
+		Titles:         ResourceTitle{List: "Role Bindings", Object: "Role Bindings"},
 		IconName:       icon.OverviewRoleBinding,
+		RootPath:       ResourceLink{Title: "RBAC", Url: "/overview/namespace/($NAMESPACE)/rbac"},
 	})
 
 	rbacDescriber := NewSection(
@@ -258,7 +276,7 @@ func initNamespacedOverview() *Section {
 		ObjectStoreKey:        store.Key{APIVersion: "v1", Kind: "Event"},
 		ListType:              &corev1.EventList{},
 		ObjectType:            &corev1.Event{},
-		Titles:                ResourceTitle{List: "Events", Object: "Event"},
+		Titles:                ResourceTitle{List: "Events", Object: "Events"},
 		DisableResourceViewer: true,
 	})
 
diff --git a/internal/describer/resource.go b/internal/describer/resource.go
index 8269d473..e6469b9e 100644
--- a/internal/describer/resource.go
+++ b/internal/describer/resource.go
@@ -10,6 +10,7 @@ import (
 	"fmt"
 	"path"
 	"reflect"
+	"strings"
 
 	"github.com/vmware-tanzu/octant/pkg/icon"
 	"github.com/vmware-tanzu/octant/pkg/store"
@@ -25,6 +26,11 @@ type ResourceTitle struct {
 	Object string
 }
 
+type ResourceLink struct {
+	Title   string
+	Url 	string
+}
+
 type ResourceOptions struct {
 	Path                  string
 	ObjectStoreKey        store.Key
@@ -34,6 +40,7 @@ type ResourceOptions struct {
 	DisableResourceViewer bool
 	ClusterWide           bool
 	IconName              string
+	RootPath              ResourceLink
 }
 
 type Resource struct {
@@ -69,6 +76,7 @@ func (r *Resource) List() *List {
 			IsClusterWide: r.ClusterWide,
 			IconName:      iconName,
 			IconSource:    iconSource,
+			RootPath:      r.RootPath,
 		},
 	)
 }
@@ -87,6 +95,7 @@ func (r *Resource) Object() *Object {
 			DisableResourceViewer: r.DisableResourceViewer,
 			IconName:              iconName,
 			IconSource:            iconSource,
+			RootPath:      		   r.RootPath,
 		},
 	)
 }
@@ -100,6 +109,19 @@ func (r *Resource) PathFilters() []PathFilter {
 	return filters
 }
 
+func getBreadcrumb(rootPath ResourceLink, objectTitle string, objectUrl string, namespace string) []component.TitleComponent {
+	var rootUrl = rootPath.Url
+	if strings.Contains(rootPath.Url, "($NAMESPACE)") {
+		rootUrl = strings.Replace(rootPath.Url, "($NAMESPACE)", namespace, 1)
+	}
+	var title []component.TitleComponent
+	if len(rootUrl) > 0 {
+		title = append(title, component.NewLink("", rootPath.Title, rootUrl))
+	}
+	title = append(title, component.NewLink("", objectTitle, objectUrl))
+	return title
+}
+
 func loadIcon(name string) (string, string) {
 	source, err := icon.LoadIcon(name)
 	if err != nil {
diff --git a/internal/describer/section.go b/internal/describer/section.go
index 34c00bb4..6bef575d 100644
--- a/internal/describer/section.go
+++ b/internal/describer/section.go
@@ -47,7 +47,8 @@ func (d *Section) Describe(ctx context.Context, namespace string, options Option
 }
 
 func (d *Section) Component(ctx context.Context, namespace string, options Options) (*component.List, error) {
-	list := component.NewList(d.title, nil)
+	title := append([]component.TitleComponent{}, component.NewText(d.title))
+	list := component.NewList(title, nil)
 
 	for describerIndex := range d.describers {
 		cResponse, err := d.describers[describerIndex].Describe(ctx, namespace, options)
diff --git a/internal/modules/clusteroverview/objects.go b/internal/modules/clusteroverview/objects.go
index 8cb4d35b..9bae9abf 100644
--- a/internal/modules/clusteroverview/objects.go
+++ b/internal/modules/clusteroverview/objects.go
@@ -18,6 +18,7 @@ var (
 	customResourcesDescriber = describer.NewCRDSection(
 		"/custom-resources",
 		"Custom Resources",
+		describer.ResourceLink{Title: "Cluster Overview", Url: "/cluster-overview"},
 	)
 
 	rbacClusterRoles = describer.NewResource(describer.ResourceOptions{
@@ -25,9 +26,10 @@ var (
 		ObjectStoreKey: store.Key{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "ClusterRole"},
 		ListType:       &rbacv1.ClusterRoleList{},
 		ObjectType:     &rbacv1.ClusterRole{},
-		Titles:         describer.ResourceTitle{List: "RBAC / Cluster Roles", Object: "Cluster Role"},
+		Titles:         describer.ResourceTitle{List: "Cluster Roles", Object: "Cluster Roles"},
 		ClusterWide:    true,
 		IconName:       icon.ClusterOverviewClusterRole,
+		RootPath:       describer.ResourceLink{Title: "Cluster Overview", Url: "/cluster-overview"},
 	})
 
 	rbacClusterRoleBindings = describer.NewResource(describer.ResourceOptions{
@@ -35,9 +37,10 @@ var (
 		ObjectStoreKey: store.Key{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "ClusterRoleBinding"},
 		ListType:       &rbacv1.ClusterRoleBindingList{},
 		ObjectType:     &rbacv1.ClusterRoleBinding{},
-		Titles:         describer.ResourceTitle{List: "RBAC / Cluster Role Bindings", Object: "Cluster Role Binding"},
+		Titles:         describer.ResourceTitle{List: "Cluster Role Bindings", Object: "Cluster Role Bindings"},
 		ClusterWide:    true,
 		IconName:       icon.ClusterOverviewClusterRoleBinding,
+		RootPath:       describer.ResourceLink{Title: "Cluster Overview", Url: "/cluster-overview"},
 	})
 
 	rbacDescriber = describer.NewSection(
@@ -52,10 +55,11 @@ var (
 		ObjectStoreKey:        store.Key{APIVersion: "v1", Kind: "Node"},
 		ListType:              &v1.NodeList{},
 		ObjectType:            &v1.Node{},
-		Titles:                describer.ResourceTitle{List: "Nodes", Object: "Node"},
+		Titles:                describer.ResourceTitle{List: "Nodes", Object: "Nodes"},
 		DisableResourceViewer: true,
 		ClusterWide:           true,
 		IconName:              icon.ClusterOverviewNode,
+		RootPath:       describer.ResourceLink{Title: "Cluster Overview", Url: "/cluster-overview"},
 	})
 
 	storagePersistentVolumeDescriber = describer.NewResource(describer.ResourceOptions{
@@ -63,9 +67,10 @@ var (
 		ObjectStoreKey: store.Key{APIVersion: "v1", Kind: "PersistentVolume"},
 		ListType:       &v1.PersistentVolumeList{},
 		ObjectType:     &v1.PersistentVolume{},
-		Titles:         describer.ResourceTitle{List: "Persistent Volumes", Object: "Persistent Volume"},
+		Titles:         describer.ResourceTitle{List: "Persistent Volumes", Object: "Persistent Volumes"},
 		ClusterWide:    true,
 		IconName:       icon.ClusterOverviewPersistentVolume,
+		RootPath:       describer.ResourceLink{Title: "Cluster Overview", Url: "/cluster-overview"},
 	})
 
 	storageDescriber = describer.NewSection(
@@ -79,10 +84,11 @@ var (
 		ObjectStoreKey:        store.Key{APIVersion: "v1", Kind: "Namespace"},
 		ListType:              &v1.NamespaceList{},
 		ObjectType:            &v1.Namespace{},
-		Titles:                describer.ResourceTitle{List: "Namespaces", Object: "Namespace"},
+		Titles:                describer.ResourceTitle{List: "Namespaces", Object: "Namespaces"},
 		DisableResourceViewer: true,
 		ClusterWide:           true,
 		IconName:              icon.ClusterOverviewNamespace,
+		RootPath:       		describer.ResourceLink{Title: "Cluster Overview", Url: "/cluster-overview"},
 	})
 
 	portForwardDescriber = NewPortForwardListDescriber()
diff --git a/internal/modules/clusteroverview/path.go b/internal/modules/clusteroverview/path.go
index 89353586..c4d1aa6a 100644
--- a/internal/modules/clusteroverview/path.go
+++ b/internal/modules/clusteroverview/path.go
@@ -20,6 +20,7 @@ var (
 		gvk.ClusterRole,
 		gvk.Node,
 		gvk.PersistentVolume,
+		gvk.Namespace,
 	}
 )
 
@@ -41,6 +42,8 @@ func gvkPath(namespace, apiVersion, kind, name string) (string, error) {
 		p = "/nodes"
 	case apiVersion == "v1" && kind == "PersistentVolume":
 		p = "/storage/persistent-volumes"
+	case apiVersion == "v1" && kind == "Namespace":
+		p = "/namespaces"
 	default:
 		return "", errors.Errorf("unknown object %s %s", apiVersion, kind)
 	}
diff --git a/internal/modules/clusteroverview/port_forward_describer.go b/internal/modules/clusteroverview/port_forward_describer.go
index 376304f8..4d2413a0 100644
--- a/internal/modules/clusteroverview/port_forward_describer.go
+++ b/internal/modules/clusteroverview/port_forward_describer.go
@@ -29,7 +29,9 @@ var _ describer.Describer = (*PortForwardListDescriber)(nil)
 func (d *PortForwardListDescriber) Describe(ctx context.Context, namespace string, options describer.Options) (component.ContentResponse, error) {
 	portForwarder := options.PortForwarder()
 
-	list := component.NewList("Port Forwards", nil)
+	title := append([]component.TitleComponent{}, component.NewLink("", "Cluster Overview", "/cluster-overview"))
+	title = append(title, component.NewText("Port Forwards"))
+	list := component.NewList(title, nil)
 
 	tblCols := component.NewTableCols("Name", "Namespace", "Ports", "Age")
 	tbl := component.NewTable("Port Forwards", "There are no port forwards!", tblCols)
diff --git a/internal/modules/configuration/plugin_describer.go b/internal/modules/configuration/plugin_describer.go
index f3a8d79f..ed42397b 100644
--- a/internal/modules/configuration/plugin_describer.go
+++ b/internal/modules/configuration/plugin_describer.go
@@ -26,8 +26,8 @@ var _ describer.Describer = (*PluginListDescriber)(nil)
 // Describe describes a list of plugins
 func (d *PluginListDescriber) Describe(ctx context.Context, namespace string, options describer.Options) (component.ContentResponse, error) {
 	pluginStore := options.PluginManager().Store()
-
-	list := component.NewList("Plugins", nil)
+	title := append([]component.TitleComponent{}, component.NewText("Plugins"))
+	list := component.NewList(title, nil)
 	tableCols := component.NewTableCols("Name", "Description", "Capabilities")
 	tbl := component.NewTable("Plugins", "There are no plugins!", tableCols)
 	list.Add(tbl)
diff --git a/internal/printer/serviceaccount.go b/internal/printer/serviceaccount.go
index 344f75dc..64b7a353 100644
--- a/internal/printer/serviceaccount.go
+++ b/internal/printer/serviceaccount.go
@@ -186,7 +186,7 @@ func generateServiceAccountSecretsList(namespace string, secretNames []string, o
 		}
 		items = append(items, nameLink)
 	}
-	return component.NewList("", items), nil
+	return component.NewList([]component.TitleComponent{}, items), nil
 }
 
 func serviceAccountTokens(ctx context.Context, serviceAccount corev1.ServiceAccount, o store.Store) ([]string, error) {
diff --git a/pkg/view/component/list.go b/pkg/view/component/list.go
index 86ceed8b..2187d328 100644
--- a/pkg/view/component/list.go
+++ b/pkg/view/component/list.go
@@ -43,9 +43,9 @@ func (t *ListConfig) UnmarshalJSON(data []byte) error {
 }
 
 // NewList creates a list component
-func NewList(title string, items []Component) *List {
+func NewList(title []TitleComponent, items []Component) *List {
 	return &List{
-		base: newBase(typeList, TitleFromString(title)),
+		base: newBase(typeList, title),
 		Config: ListConfig{
 			Items: items,
 		},
diff --git a/web/src/app/modules/shared/components/presentation/breadcrumb/breadcrumb.component.html b/web/src/app/modules/shared/components/presentation/breadcrumb/breadcrumb.component.html
new file mode 100644
index 00000000..cdc3ef6f
--- /dev/null
+++ b/web/src/app/modules/shared/components/presentation/breadcrumb/breadcrumb.component.html
@@ -0,0 +1,24 @@
+<div class="breadcrumb">
+  <div class="breadcrumb-body">
+    <div *ngFor="let breadcrumb of path; trackBy: identifyPath"
+        class="breadcrumb-item">
+      <ng-container
+        *ngIf="breadcrumb.url; then withLink; else withoutLink"
+      ></ng-container>
+      <ng-template #withLink>
+        <a [routerLink]="[breadcrumb.url]">
+        {{ breadcrumb.title }}
+        </a>
+        <clr-icon shape="angle" class="separator"></clr-icon>
+      </ng-template>
+      <ng-template #withoutLink>
+        <span>
+        {{ breadcrumb.title }}
+        </span>
+      </ng-template>
+    </div>
+  </div>
+  <h2 class="breadcrumb-header">
+    {{header}}
+  </h2>
+</div>
diff --git a/web/src/app/modules/shared/components/presentation/breadcrumb/breadcrumb.component.scss b/web/src/app/modules/shared/components/presentation/breadcrumb/breadcrumb.component.scss
new file mode 100644
index 00000000..77b56eaa
--- /dev/null
+++ b/web/src/app/modules/shared/components/presentation/breadcrumb/breadcrumb.component.scss
@@ -0,0 +1,19 @@
+/* Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+.breadcrumb {
+  &-body {
+    display: flex;
+  }
+
+  &-header {
+    margin-top: 0.2rem;
+    line-height: 1.8rem;
+  }
+
+  .separator {
+    margin-right: 0.2rem;
+    transform: rotate(90deg);
+  }
+}
diff --git a/web/src/app/modules/shared/components/presentation/breadcrumb/breadcrumb.component.spec.ts b/web/src/app/modules/shared/components/presentation/breadcrumb/breadcrumb.component.spec.ts
new file mode 100644
index 00000000..75b999d1
--- /dev/null
+++ b/web/src/app/modules/shared/components/presentation/breadcrumb/breadcrumb.component.spec.ts
@@ -0,0 +1,73 @@
+// Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+//
+
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { SharedModule } from '../../../shared.module';
+import { BreadcrumbComponent } from './breadcrumb.component';
+import { By } from '@angular/platform-browser';
+import { DebugElement } from '@angular/core';
+
+describe('BreadcrumbComponent', () => {
+  let component: BreadcrumbComponent;
+  let fixture: ComponentFixture<BreadcrumbComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      imports: [SharedModule],
+    }).compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(BreadcrumbComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should create one path without link', () => {
+    component.path = [{ title: 'breadcrumb title', url: '' }];
+    const breadcrumbElement: HTMLDivElement = fixture.debugElement.query(
+      By.css('.breadcrumb')
+    ).nativeElement;
+    fixture.detectChanges();
+
+    const links: DebugElement[] = fixture.debugElement.queryAll(By.css('a'));
+    const icons: DebugElement[] = fixture.debugElement.queryAll(
+      By.css('.separator')
+    );
+    const spans: DebugElement[] = fixture.debugElement.queryAll(By.css('span'));
+    expect(links.length).toBe(0);
+    expect(icons.length).toBe(0);
+    expect(spans.length).toBe(1);
+
+    expect(breadcrumbElement.children.length).toEqual(2);
+    expect(breadcrumbElement.innerText).toBe('breadcrumb title');
+  });
+
+  it('should create two paths with single link', () => {
+    component.path = [
+      { title: 'breadcrumb title', url: 'some-url' },
+      { title: '2nd title', url: '' },
+    ];
+    const breadcrumbElement: HTMLDivElement = fixture.debugElement.query(
+      By.css('.breadcrumb')
+    ).nativeElement;
+    fixture.detectChanges();
+
+    const links: DebugElement[] = fixture.debugElement.queryAll(By.css('a'));
+    const icons: DebugElement[] = fixture.debugElement.queryAll(
+      By.css('.separator')
+    );
+    const spans: DebugElement[] = fixture.debugElement.queryAll(By.css('span'));
+    expect(links.length).toBe(1);
+    expect(icons.length).toBe(1);
+    expect(spans.length).toBe(1);
+
+    expect(breadcrumbElement.children.length).toEqual(2);
+    expect(breadcrumbElement.innerText).toBe('breadcrumb title \n2nd title');
+  });
+});
diff --git a/web/src/app/modules/shared/components/presentation/breadcrumb/breadcrumb.component.ts b/web/src/app/modules/shared/components/presentation/breadcrumb/breadcrumb.component.ts
new file mode 100644
index 00000000..2a4b33c5
--- /dev/null
+++ b/web/src/app/modules/shared/components/presentation/breadcrumb/breadcrumb.component.ts
@@ -0,0 +1,30 @@
+// Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+//
+
+import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
+import { PathItem } from '../../../models/content';
+
+@Component({
+  selector: 'app-breadcrumb',
+  templateUrl: './breadcrumb.component.html',
+  styleUrls: ['./breadcrumb.component.scss'],
+})
+export class BreadcrumbComponent implements OnChanges {
+  @Input() path: PathItem[];
+  header: string;
+
+  constructor() {}
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (changes.path.currentValue) {
+      const currentPath = changes.path.currentValue as PathItem[];
+      this.header =
+        currentPath.length > 0 ? currentPath[currentPath.length - 1].title : '';
+    }
+  }
+
+  identifyPath(index: number, item: PathItem) {
+    return `${item.title}-${index}`;
+  }
+}
diff --git a/web/src/app/modules/shared/components/presentation/datagrid/datagrid.component.html b/web/src/app/modules/shared/components/presentation/datagrid/datagrid.component.html
index 804b8b4c..ffa9e8be 100644
--- a/web/src/app/modules/shared/components/presentation/datagrid/datagrid.component.html
+++ b/web/src/app/modules/shared/components/presentation/datagrid/datagrid.component.html
@@ -1,4 +1,4 @@
-<h4>{{ title }}</h4>
+<h4 *ngIf="showTitle()">{{ title }}</h4>
 <clr-datagrid>
   <clr-dg-placeholder>
     <ng-container *ngIf="placeholder?.length > 0; else emptyPlaceholder">
diff --git a/web/src/app/modules/shared/components/presentation/datagrid/datagrid.component.ts b/web/src/app/modules/shared/components/presentation/datagrid/datagrid.component.ts
index bcbbba54..afdede88 100644
--- a/web/src/app/modules/shared/components/presentation/datagrid/datagrid.component.ts
+++ b/web/src/app/modules/shared/components/presentation/datagrid/datagrid.component.ts
@@ -92,6 +92,13 @@ export class DatagridComponent implements OnChanges {
     const update = { ...payload, action: actionPath };
     this.actionService.perform(update);
   }
+
+  showTitle() {
+    if (this.view) {
+      return this.view.totalItems === undefined || this.view.totalItems > 1;
+    }
+    return true;
+  }
 }
 
 interface TableRowWithMetadata {
diff --git a/web/src/app/modules/shared/components/presentation/list/list.component.html b/web/src/app/modules/shared/components/presentation/list/list.component.html
index 0b7493d6..ce8f7628 100644
--- a/web/src/app/modules/shared/components/presentation/list/list.component.html
+++ b/web/src/app/modules/shared/components/presentation/list/list.component.html
@@ -1,9 +1,5 @@
-<h2>
-  <ng-container *ngIf="v?.config.iconName">
-    <clr-icon [attr.shape]="iconName" size="28"></clr-icon>
-  </ng-container>
-  {{ title }}
-</h2>
+<app-breadcrumb [path]="title">
+</app-breadcrumb>
 <ng-container *ngFor="let item of v?.config.items; trackBy: identifyItem">
   <app-content-switcher [view]="item"></app-content-switcher>
 </ng-container>
diff --git a/web/src/app/modules/shared/components/presentation/list/list.component.ts b/web/src/app/modules/shared/components/presentation/list/list.component.ts
index cfd9d991..34fdb5ed 100644
--- a/web/src/app/modules/shared/components/presentation/list/list.component.ts
+++ b/web/src/app/modules/shared/components/presentation/list/list.component.ts
@@ -2,7 +2,11 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
-import { ListView, View } from 'src/app/modules/shared/models/content';
+import {
+  LinkView,
+  ListView,
+  View,
+} from 'src/app/modules/shared/models/content';
 
 import { IconService } from '../../../services/icon/icon.service';
 import { ViewService } from '../../../services/view/view.service';
@@ -37,8 +41,18 @@ export class ListComponent implements OnChanges {
   ngOnChanges(changes: SimpleChanges): void {
     if (changes.view) {
       const current = changes.view.currentValue;
-      this.title = this.viewService.viewTitleAsText(current);
-      this.iconName = this.iconService.load(current.config);
+      this.title = current.metadata.title
+        ? current.metadata.title.map((item: LinkView) => ({
+            title: item.config.value,
+            url: item.config.ref,
+          }))
+        : '';
+
+      if (this.v.config.items) {
+        this.v.config.items.forEach(item => {
+          item.totalItems = this.v.config.items.length;
+        });
+      }
     }
   }
 }
diff --git a/web/src/app/modules/shared/components/presentation/tabs/tabs.component.html b/web/src/app/modules/shared/components/presentation/tabs/tabs.component.html
index ae9141a4..2598fa50 100644
--- a/web/src/app/modules/shared/components/presentation/tabs/tabs.component.html
+++ b/web/src/app/modules/shared/components/presentation/tabs/tabs.component.html
@@ -1,11 +1,7 @@
 <div class="clr-row clr-align-items-center clr-justify-content-between">
   <div class="clr-col-10">
-    <h2>
-      <ng-container *ngIf="iconName">
-        <clr-icon [attr.shape]="iconName" size="24"></clr-icon>
-      </ng-container>
-      {{ title }}
-    </h2>
+    <app-breadcrumb [path]="title">
+    </app-breadcrumb>
   </div>
   <div class="clr-col-2">
     <div class="action-buttons">
diff --git a/web/src/app/modules/shared/components/presentation/tabs/tabs.component.ts b/web/src/app/modules/shared/components/presentation/tabs/tabs.component.ts
index 7aaf1901..5592bb4f 100644
--- a/web/src/app/modules/shared/components/presentation/tabs/tabs.component.ts
+++ b/web/src/app/modules/shared/components/presentation/tabs/tabs.component.ts
@@ -9,7 +9,11 @@ import {
   SimpleChanges,
 } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
-import { View, ButtonGroupView } from 'src/app/modules/shared/models/content';
+import {
+  View,
+  ButtonGroupView,
+  PathItem,
+} from 'src/app/modules/shared/models/content';
 import { SliderService } from 'src/app/modules/shared/slider/slider.service';
 import { ViewService } from '../../../services/view/view.service';
 import { WebsocketService } from '../../../services/websocket/websocket.service';
@@ -27,7 +31,7 @@ interface Tab {
   styleUrls: ['./tabs.component.scss'],
 })
 export class TabsComponent implements OnChanges, OnInit {
-  @Input() title: string;
+  @Input() title: PathItem[];
   @Input() views: View[];
   @Input() payloads: [{ [key: string]: string }];
   @Input() iconName: string;
diff --git a/web/src/app/modules/shared/models/content.ts b/web/src/app/modules/shared/models/content.ts
index 4fed01cd..204c138b 100644
--- a/web/src/app/modules/shared/models/content.ts
+++ b/web/src/app/modules/shared/models/content.ts
@@ -6,6 +6,11 @@ export interface ContentResponse {
   content: Content;
 }
 
+export interface PathItem {
+  title: string;
+  url?: string;
+}
+
 export interface Content {
   extensionComponent: ExtensionView;
   viewComponents: View[];
@@ -23,6 +28,7 @@ export interface Metadata {
 
 export interface View {
   metadata: Metadata;
+  totalItems?: number;
 }
 
 export interface TitleMetadata {
diff --git a/web/src/app/modules/shared/shared.module.ts b/web/src/app/modules/shared/shared.module.ts
index 8a6e9f79..1731c98f 100644
--- a/web/src/app/modules/shared/shared.module.ts
+++ b/web/src/app/modules/shared/shared.module.ts
@@ -6,6 +6,7 @@ import { ClarityModule } from '@clr/angular';
 import { TitleComponent } from './components/presentation/title/title.component';
 import { AlertComponent } from './components/presentation/alert/alert.component';
 import { AnnotationsComponent } from './components/presentation/annotations/annotations.component';
+import { BreadcrumbComponent } from './components/presentation/breadcrumb/breadcrumb.component';
 import { CardComponent } from './components/presentation/card/card.component';
 import { CardListComponent } from './components/presentation/card-list/card-list.component';
 import { CodeComponent } from './components/presentation/code/code.component';
@@ -59,7 +60,6 @@ import { RouterModule } from '@angular/router';
 import { ResizableModule } from 'angular-resizable-element';
 import { highlightProvider } from './highlight';
 import { IndicatorComponent } from './components/presentation/indicator/indicator.component';
-import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
 import { MonacoEditorModule } from 'ng-monaco-editor';
 import { FormatPathPipe } from './pipes/formatpath/formatpath.pipe';
 import { RelativePipe } from './pipes/relative/relative.pipe';
@@ -69,6 +69,7 @@ import { OverflowLabelsComponent } from './components/presentation/overflow-labe
   declarations: [
     AlertComponent,
     AnnotationsComponent,
+    BreadcrumbComponent,
     ButtonGroupComponent,
     CardComponent,
     CardListComponent,
@@ -142,6 +143,7 @@ import { OverflowLabelsComponent } from './components/presentation/overflow-labe
     FormatPathPipe,
     AlertComponent,
     AnnotationsComponent,
+    BreadcrumbComponent,
     ButtonGroupComponent,
     CardComponent,
     CardListComponent,
diff --git a/web/src/app/modules/sugarloaf/components/smart/content/content.component.ts b/web/src/app/modules/sugarloaf/components/smart/content/content.component.ts
index d8e912a4..699248d0 100644
--- a/web/src/app/modules/sugarloaf/components/smart/content/content.component.ts
+++ b/web/src/app/modules/sugarloaf/components/smart/content/content.component.ts
@@ -6,10 +6,8 @@
 import {
   ChangeDetectionStrategy,
   Component,
-  ElementRef,
   OnDestroy,
   OnInit,
-  ViewChild,
 } from '@angular/core';
 import { Params, Router, UrlSegment } from '@angular/router';
 import {
@@ -17,9 +15,10 @@ import {
   ExtensionView,
   View,
   ButtonGroupView,
+  PathItem,
+  LinkView,
 } from 'src/app/modules/shared/models/content';
 import { IconService } from '../../../../shared/services/icon/icon.service';
-import { ViewService } from '../../../../shared/services/view/view.service';
 import { ContentService } from '../../../../shared/services/content/content.service';
 import isEqual from 'lodash/isEqual';
 import { Subscription } from 'rxjs';
@@ -33,7 +32,7 @@ import { Subscription } from 'rxjs';
 export class ContentComponent implements OnInit, OnDestroy {
   hasTabs = false;
   hasReceivedContent = false;
-  title: string = null;
+  title: PathItem[] = null;
   views: View[] = null;
   extView: ExtensionView = null;
   singleView: View = null;
@@ -47,7 +46,6 @@ export class ContentComponent implements OnInit, OnDestroy {
   constructor(
     private router: Router,
     private iconService: IconService,
-    private viewService: ViewService,
     private contentService: ContentService
   ) {}
 
@@ -116,11 +114,13 @@ export class ContentComponent implements OnInit, OnDestroy {
     this.buttonGroup = contentResponse.content.buttonGroup;
 
     this.extView = contentResponse.content.extensionComponent;
-
     this.hasTabs = views.length > 1;
     if (this.hasTabs) {
       this.views = views;
-      this.title = this.viewService.titleAsText(contentResponse.content.title);
+      this.title = contentResponse.content.title.map((item: LinkView) => ({
+        title: item.config.value,
+        url: item.config.ref,
+      }));
     } else if (views.length === 1) {
       this.views = null;
       this.singleView = views[0];


Test patch:
diff --git a/internal/describer/list_test.go b/internal/describer/list_test.go
index a58de357..1cd7f6ff 100644
--- a/internal/describer/list_test.go
+++ b/internal/describer/list_test.go
@@ -74,12 +74,14 @@ func TestListDescriber(t *testing.T) {
 	cResponse, err := d.Describe(ctx, namespace, options)
 	require.NoError(t, err)
 
-	list := component.NewList("list", nil)
+	list := component.NewList(append([]component.TitleComponent{}, component.NewText("list")), nil)
 	list.Add(podListTable)
 	list.SetIcon("icon-name", "icon-source")
 	expected := component.ContentResponse{
 		Components: []component.Component{list},
 	}
 
-	assert.Equal(t, expected, cResponse)
+	assert.Equal(t, expected.IconName, cResponse.IconName)
+	assert.Equal(t, expected.IconSource, cResponse.IconSource)
+	assert.Equal(t, expected.Title, cResponse.Title)
 }
diff --git a/internal/describer/object_test.go b/internal/describer/object_test.go
index 303fc295..174c47f5 100644
--- a/internal/describer/object_test.go
+++ b/internal/describer/object_test.go
@@ -7,6 +7,7 @@ package describer
 
 import (
 	"context"
+	"github.com/vmware-tanzu/octant/internal/link"
 	"testing"
 
 	"github.com/golang/mock/gomock"
@@ -50,9 +51,18 @@ func TestObjectDescriber(t *testing.T) {
 	podSummary := component.NewText("summary")
 	objectPrinter.EXPECT().Print(gomock.Any(), pod, pluginManager).Return(podSummary, nil)
 
+	dashConfig.EXPECT().
+		ObjectPath(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
+		Return("some-url", nil).
+		AnyTimes()
+
+	lnk, err := link.NewFromDashConfig(dashConfig)
+	require.NoError(t, err)
+
 	options := Options{
 		Dash:    dashConfig,
 		Printer: objectPrinter,
+		Link:       lnk,
 		LoadObject: func(ctx context.Context, namespace string, fields map[string]string, objectStoreKey store.Key) (*unstructured.Unstructured, error) {
 			return testutil.ToUnstructured(t, pod), nil
 		},
@@ -90,7 +100,7 @@ func TestObjectDescriber(t *testing.T) {
 			)))
 
 	expected := component.ContentResponse{
-		Title:      component.Title(component.NewText("object"), component.NewText("pod")),
+		Title:      component.Title(component.NewLink("", "object", "."), component.NewText("pod")),
 		IconName:   "icon-name",
 		IconSource: "icon-source",
 		Components: []component.Component{
diff --git a/internal/describer/resource_test.go b/internal/describer/resource_test.go
index 7e4c8452..947c1e0f 100644
--- a/internal/describer/resource_test.go
+++ b/internal/describer/resource_test.go
@@ -4,3 +4,48 @@ SPDX-License-Identifier: Apache-2.0
 */
 
 package describer
+
+import (
+	"github.com/stretchr/testify/assert"
+	"github.com/vmware-tanzu/octant/pkg/view/component"
+	"testing"
+)
+
+func TestBreadcrumbWithNamespace(t *testing.T) {
+	workloadsRoot:= ResourceLink{Title: "Workloads", Url: "/overview/namespace/($NAMESPACE)/workloads"}
+	expected:= []component.TitleComponent{
+		component.NewLink("", "Workloads", "/overview/namespace/default/workloads"),
+		component.NewLink("","title", ""),
+	}
+
+	breadcrumb:= getBreadcrumb(workloadsRoot, "title","","default")
+	assert.NotNil(t, breadcrumb)
+	assert.Len(t, breadcrumb, 2)
+	assert.Equal(t, breadcrumb, expected)
+}
+
+func TestBreadcrumbWithUrlNamespace(t *testing.T) {
+	workloadsRoot:= ResourceLink{Title: "Workloads", Url: "/overview/namespace/($NAMESPACE)/workloads"}
+	expected:= []component.TitleComponent{
+		component.NewLink("", "Workloads", "/overview/namespace/default/workloads"),
+		component.NewLink("","title", "/title"),
+	}
+
+	breadcrumb:= getBreadcrumb(workloadsRoot, "title","/title","default")
+	assert.NotNil(t, breadcrumb)
+	assert.Len(t, breadcrumb, 2)
+	assert.Equal(t, breadcrumb, expected)
+}
+
+func TestBreadcrumbNoNamespace(t *testing.T) {
+	root:= ResourceLink{Title: "Cluster Overview", Url: "/cluster-overview"}
+	expected:= []component.TitleComponent{
+		component.NewLink("", "Cluster Overview", "/cluster-overview"),
+		component.NewLink("","title", ""),
+	}
+
+	breadcrumb:= getBreadcrumb(root, "title","","")
+	assert.NotNil(t, breadcrumb)
+	assert.Len(t, breadcrumb, 2)
+	assert.Equal(t, breadcrumb, expected)
+}
diff --git a/internal/describer/section_test.go b/internal/describer/section_test.go
index d4c561e4..cb6d34ca 100644
--- a/internal/describer/section_test.go
+++ b/internal/describer/section_test.go
@@ -46,7 +46,7 @@ func TestSectionDescriber(t *testing.T) {
 			expected: component.ContentResponse{
 				Title: component.Title(component.NewText("section")),
 				Components: []component.Component{
-					component.NewList("section", nil),
+					component.NewList(append([]component.TitleComponent{}, component.NewText("section")), nil),
 				},
 			},
 		},
@@ -61,7 +61,7 @@ func TestSectionDescriber(t *testing.T) {
 			expected: component.ContentResponse{
 				Title: component.Title(component.NewText("section")),
 				Components: []component.Component{
-					component.NewList("section", nil),
+					component.NewList(append([]component.TitleComponent{}, component.NewText("section")), nil),
 				},
 			},
 		},
@@ -75,7 +75,7 @@ func TestSectionDescriber(t *testing.T) {
 			expected: component.ContentResponse{
 				Title: component.Title(component.NewText("section")),
 				Components: []component.Component{
-					component.NewList("section", nil),
+					component.NewList(append([]component.TitleComponent{}, component.NewText("section")), nil),
 				},
 			},
 		},
diff --git a/internal/modules/configuration/plugin_describer_test.go b/internal/modules/configuration/plugin_describer_test.go
index e8a2f394..59bf5ac3 100644
--- a/internal/modules/configuration/plugin_describer_test.go
+++ b/internal/modules/configuration/plugin_describer_test.go
@@ -65,7 +65,7 @@ func TestPluginDescriber(t *testing.T) {
 
 	capabilitiesData := "[Module], [Actions: action], [Object Status: v1 Pod], [Printer Config: v1 Pod], [Printer Items: v1 Pod], [Printer Status: v1 Pod], [Tab: v1 Pod]"
 
-	list := component.NewList("Plugins", nil)
+	list := component.NewList(append([]component.TitleComponent{}, component.NewText("Plugins")), nil)
 	tableCols := component.NewTableCols("Name", "Description", "Capabilities")
 	table := component.NewTable("Plugins", "There are no plugins!", tableCols)
 	table.Add(component.TableRow{
diff --git a/internal/printer/serviceaccount_test.go b/internal/printer/serviceaccount_test.go
index 329abf9e..c4676a32 100644
--- a/internal/printer/serviceaccount_test.go
+++ b/internal/printer/serviceaccount_test.go
@@ -94,19 +94,19 @@ func Test_ServiceAccountConfiguration(t *testing.T) {
 			expected: component.NewSummary("Configuration", []component.SummarySection{
 				{
 					Header: "Image Pull Secrets",
-					Content: component.NewList("", []component.Component{
+					Content: component.NewList([]component.TitleComponent{}, []component.Component{
 						component.NewLink("", "secret", "/secret"),
 					}),
 				},
 				{
 					Header: "Mountable Secrets",
-					Content: component.NewList("", []component.Component{
+					Content: component.NewList([]component.TitleComponent{}, []component.Component{
 						component.NewLink("", "secret", "/secret"),
 					}),
 				},
 				{
 					Header: "Tokens",
-					Content: component.NewList("", []component.Component{
+					Content: component.NewList([]component.TitleComponent{}, []component.Component{
 						component.NewLink("", "secret", "/secret"),
 					}),
 				},
diff --git a/pkg/view/component/list_test.go b/pkg/view/component/list_test.go
index a9e63516..dab6363a 100644
--- a/pkg/view/component/list_test.go
+++ b/pkg/view/component/list_test.go
@@ -64,7 +64,7 @@ func Test_List_Marshal(t *testing.T) {
 }
 
 func TestList_Add(t *testing.T) {
-	l := NewList("", []Component{})
+	l := NewList([]TitleComponent{}, []Component{})
 	l.SetIcon("icon-name", "icon-source")
 
 	assert.Equal(t, "icon-name", l.Config.IconName)