Repository: vmware-tanzu/octant
Pull request #553 (2020-01-21 00:00:19)

Add resource viewer to workload
re: #351

<img width="1616" alt="Screen Shot 2020-01-19 at 5 57 51 PM" src="https://user-images.githubusercontent.com/240/72764635-ecfb0700-3b9d-11ea-9e36-9611bc25dca4.png">


Signed-off-by: bryanl <bryanliles@gmail.com>

bryanl: @GuessWhoSamFoo I removed the navigator. It isn't ready for prime time.

Patch:
diff --git a/build.go b/build.go
index 15d5a2ef..a98e82ea 100644
--- a/build.go
+++ b/build.go
@@ -13,6 +13,7 @@ import (
 	"os/exec"
 	"path/filepath"
 	"runtime"
+	"strings"
 	"sync"
 	"time"
 )
@@ -146,7 +147,12 @@ func build() {
 }
 
 func runDev() {
-	runCmd("build/octant", nil)
+	env := make(map[string]string)
+	for _, e := range os.Environ() {
+		parts := strings.SplitN(e, "=", 2)
+		env[parts[0]] = parts[1]
+	}
+	runCmd("build/octant", env)
 }
 
 func test() {
diff --git a/changelogs/unreleased/551-bryanl b/changelogs/unreleased/551-bryanl
new file mode 100644
index 00000000..0e793821
--- /dev/null
+++ b/changelogs/unreleased/551-bryanl
@@ -0,0 +1,1 @@
+Show resource viewer when workload is selected
diff --git a/internal/describer/object.go b/internal/describer/object.go
index 152b4bf8..60c7a16b 100644
--- a/internal/describer/object.go
+++ b/internal/describer/object.go
@@ -11,6 +11,7 @@ import (
 	"strings"
 
 	"k8s.io/apimachinery/pkg/api/meta"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/client-go/kubernetes/scheme"
 
@@ -189,22 +190,14 @@ func (d *Object) addSummaryTab(ctx context.Context, object runtime.Object, cr *c
 
 func (d *Object) addResourceViewerTab(ctx context.Context, object runtime.Object, cr *component.ContentResponse, options Options) error {
 	if !d.disableResourceViewer {
-
-		rv, err := resourceviewer.New(options.Dash, resourceviewer.WithDefaultQueryer(options.Dash, options.Queryer))
-		if err != nil {
-			return err
-		}
-
-		handler, err := resourceviewer.NewHandler(options.Dash)
+		m, err := runtime.DefaultUnstructuredConverter.ToUnstructured(object)
 		if err != nil {
-			return err
+			component.NewError(component.TitleFromString("Show resource viewer for object"), err)
 		}
 
-		if err := rv.Visit(ctx, object, handler); err != nil {
-			return err
-		}
+		u := &unstructured.Unstructured{Object: m}
 
-		resourceViewerComponent, err := resourceviewer.GenerateComponent(ctx, handler, "")
+		resourceViewerComponent, err := resourceviewer.Create(ctx, options.Dash, options.Queryer, u)
 		if err != nil {
 			return err
 		}
diff --git a/internal/modules/workloads/detail_describer.go b/internal/modules/workloads/detail_describer.go
new file mode 100644
index 00000000..445e53ac
--- /dev/null
+++ b/internal/modules/workloads/detail_describer.go
@@ -0,0 +1,169 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ */
+
+package workloads
+
+import (
+	"context"
+	"fmt"
+	"path"
+
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+
+	"github.com/vmware-tanzu/octant/internal/describer"
+	"github.com/vmware-tanzu/octant/internal/octant"
+	"github.com/vmware-tanzu/octant/internal/resourceviewer"
+	"github.com/vmware-tanzu/octant/pkg/view/component"
+)
+
+type DetailDescriber struct{}
+
+var _ describer.Describer = (*DetailDescriber)(nil)
+
+func NewDetailDescriber() (*DetailDescriber, error) {
+	d := &DetailDescriber{}
+
+	return d, nil
+}
+
+func (d *DetailDescriber) loadWorkloads(ctx context.Context, namespace string, options describer.Options) ([]octant.Workload, error) {
+	pml, err := octant.NewClusterPodMetricsLoader(options.Dash.ClusterClient())
+	if err != nil {
+		return nil, fmt.Errorf("create pod metrics loader")
+	}
+
+	loader, err := octant.NewClusterWorkloadLoader(options.Dash.ObjectStore(), pml)
+	if err != nil {
+		return nil, fmt.Errorf("create workload loader")
+	}
+
+	workloads, err := loader.Load(ctx, namespace)
+	if err != nil {
+		return nil, fmt.Errorf("load workloads")
+	}
+
+	return workloads, nil
+}
+
+func (d *DetailDescriber) Describe(ctx context.Context, namespace string, options describer.Options) (component.ContentResponse, error) {
+	name := options.Fields["name"]
+
+	workloads, err := d.loadWorkloads(ctx, namespace, options)
+	if err != nil {
+		cr := d.createResponse(
+			component.NewError(component.TitleFromString("Unable to load workloads"), err),
+		)
+		return cr, nil
+	}
+
+	found := false
+	var cur octant.Workload
+	for _, workload := range workloads {
+		if workload.Name != name {
+			continue
+		}
+
+		found = true
+		cur = workload
+	}
+
+	if !found {
+		cr := d.createResponse(
+			component.NewError(component.TitleFromString("Workload not found"),
+				fmt.Errorf("unable to find workload %s", name)),
+		)
+		return cr, nil
+	}
+
+	layout := component.NewFlexLayout("Workload layout")
+
+	summary, err := octant.CreateWorkloadSummary(&cur, component.DonutChartSizeSmall)
+	if err != nil {
+		cr := d.createResponse(
+			component.NewError(component.TitleFromString("Create summary for workload"), err),
+		)
+		return cr, nil
+	}
+
+	workloadName := fmt.Sprintf(`
+### %s
+_%s_
+`, cur.Name, cur.Owner.GroupVersionKind())
+
+	headerSection := component.FlexLayoutSection{
+		{
+			Width: component.WidthQuarter,
+			View:  component.NewMarkdownText(workloadName),
+		},
+		{
+			Width:  component.WidthQuarter,
+			Height: "6em",
+			Margin: "0 0 2em 0",
+			View:   summary.Summary,
+		},
+	}
+
+	if summary.MetricsEnabled {
+		headerSection = append(headerSection, []component.FlexLayoutItem{
+			{
+				Width: component.WidthQuarter,
+				View:  summary.Memory,
+			},
+			{
+				Width: component.WidthQuarter,
+				View:  summary.CPU,
+			},
+		}...)
+	}
+
+	var objects []*unstructured.Unstructured
+	for _, pod := range cur.Pods().Items {
+		objects = append(objects, &pod)
+	}
+
+	rv, err := resourceviewer.Create(ctx, options.Dash, options.Queryer, objects...)
+	if err != nil {
+		cr := d.createResponse(
+			component.NewError(component.TitleFromString("Unable to create resource viewer"), err),
+		)
+		return cr, nil
+	}
+
+	viewerSection := component.FlexLayoutSection{
+		{
+			Width: component.WidthFull,
+			View:  rv,
+		},
+	}
+	layout.AddSections(headerSection, viewerSection)
+
+	cr := d.createResponse(
+		layout,
+	)
+
+	return cr, nil
+}
+
+func (d *DetailDescriber) createResponse(components ...component.Component) component.ContentResponse {
+	cr := component.ContentResponse{
+		Title:      component.TitleFromString("Workload X"),
+		Components: components,
+		IconName:   "",
+		IconSource: "",
+	}
+
+	return cr
+}
+
+func (d *DetailDescriber) PathFilters() []describer.PathFilter {
+	return []describer.PathFilter{
+		*describer.NewPathFilter(path.Join("/detail", describer.ResourceNameRegex), d),
+	}
+}
+
+func (d DetailDescriber) Reset(_ context.Context) error {
+	return nil
+}
diff --git a/internal/modules/workloads/home_describer.go b/internal/modules/workloads/home_describer.go
index b0478232..0921a325 100644
--- a/internal/modules/workloads/home_describer.go
+++ b/internal/modules/workloads/home_describer.go
@@ -33,26 +33,37 @@ func NewHomeDescriber(options ...HomeDescriberOption) (*HomeDescriber, error) {
 	return d, nil
 }
 
-// Describe creates a content response for workloads.
-func (h *HomeDescriber) Describe(ctx context.Context, namespace string, options describer.Options) (component.ContentResponse, error) {
+func loadCards(ctx context.Context, namespace string, options describer.Options) ([]*component.Card, bool, error) {
 	pml, err := octant.NewClusterPodMetricsLoader(options.Dash.ClusterClient())
 	if err != nil {
-		return component.EmptyContentResponse, fmt.Errorf("create pod metrics loader")
+		return nil, false, fmt.Errorf("create pod metrics loader")
 	}
 
 	loader, err := octant.NewClusterWorkloadLoader(options.Dash.ObjectStore(), pml)
 	if err != nil {
-		return component.EmptyContentResponse, fmt.Errorf("create workload loader")
+		return nil, false, fmt.Errorf("create workload loader")
 	}
 
 	collector, err := octant.NewWorkloadCardCollector(loader)
 	if err != nil {
-		return component.EmptyContentResponse, fmt.Errorf("create card collector")
+		return nil, false, fmt.Errorf("create card collector")
 	}
 
 	cards, fullMetrics, err := collector.Collect(ctx, namespace)
 	if err != nil {
-		return component.EmptyContentResponse, fmt.Errorf("collect workload cards: %w", err)
+		return nil, false, fmt.Errorf("collect workload cards: %w", err)
+
+	}
+
+	return cards, fullMetrics, nil
+}
+
+// Describe creates a content response for workloads.
+func (h *HomeDescriber) Describe(ctx context.Context, namespace string, options describer.Options) (component.ContentResponse, error) {
+
+	cards, fullMetrics, err := loadCards(ctx, namespace, options)
+	if err != nil {
+		return component.EmptyContentResponse, fmt.Errorf("load cards cards: %w", err)
 	}
 
 	cardWidth := component.WidthHalf
diff --git a/internal/modules/workloads/module.go b/internal/modules/workloads/module.go
index 5cc9e74d..85089cc2 100644
--- a/internal/modules/workloads/module.go
+++ b/internal/modules/workloads/module.go
@@ -48,6 +48,15 @@ func New(ctx context.Context, options Options) (*Module, error) {
 		pm.Register(ctx, pf)
 	}
 
+	detailDescriber, err := NewDetailDescriber()
+	if err != nil {
+		return nil, fmt.Errorf("create detail describer: %w", err)
+	}
+
+	for _, pf := range detailDescriber.PathFilters() {
+		pm.Register(ctx, pf)
+	}
+
 	m := &Module{
 		Options:     options,
 		pathMatcher: pm,
diff --git a/internal/octant/workload.go b/internal/octant/workload.go
index c26bbb19..87f13f12 100644
--- a/internal/octant/workload.go
+++ b/internal/octant/workload.go
@@ -8,6 +8,7 @@ package octant
 import (
 	"context"
 	"fmt"
+	"path"
 	"sort"
 	"sync"
 
@@ -44,6 +45,8 @@ type Workload struct {
 	IconName string
 	// Name is the name of the workload
 	Name string
+	// Owner is the ancestor that ultimately own the workload.
+	Owner *unstructured.Unstructured
 
 	SegmentCounter map[component.NodeStatus][]PodWithMetric
 
@@ -64,7 +67,7 @@ func NewWorkload(name, iconName string) *Workload {
 	return w
 }
 
-func (w *Workload) DonutChart() (*component.DonutChart, error) {
+func (w *Workload) DonutChart(size component.DonutChartSize) (*component.DonutChart, error) {
 	chart := component.NewDonutChart()
 
 	segments := w.donutSegments()
@@ -74,6 +77,7 @@ func (w *Workload) DonutChart() (*component.DonutChart, error) {
 
 	chart.SetSegments(segments)
 	chart.SetLabels("Pods", "Pod")
+	chart.SetSize(size)
 
 	return chart, nil
 }
@@ -87,6 +91,16 @@ func (w *Workload) PodsWithMetrics() []PodWithMetric {
 	return list
 }
 
+func (w *Workload) Pods() *unstructured.UnstructuredList {
+	list := &unstructured.UnstructuredList{}
+
+	for _, pwm := range w.PodsWithMetrics() {
+		list.Items = append(list.Items, *pwm.Pod)
+	}
+
+	return list
+}
+
 // AddStatus adds a pod status to the workload.
 func (w *Workload) AddPodStatus(status component.NodeStatus, object *unstructured.Unstructured, resourceList corev1.ResourceList) {
 	w.mu.Lock()
@@ -235,59 +249,68 @@ func (wc *WorkloadCardCollector) Collect(ctx context.Context, namespace string)
 
 	for i := range workloads {
 		workload := &workloads[i]
-		summaryChart, err := workload.DonutChart()
+
+		card, supportsMetrics, err := CreateCard(workload, namespace)
 		if err != nil {
-			return nil, false, fmt.Errorf("create workload summary: %w", err)
+			return nil, false, fmt.Errorf("create workload card: %w", err)
 		}
 
-		section := component.FlexLayoutSection{
-			{
-				Width: component.WidthFull,
-				View:  summaryChart,
-			},
+		if !supportsMetrics {
+			fullMetrics = false
 		}
 
-		if workload.PodMetricsEnabled() {
-			memoryStat, err := PodMemoryStat(workload)
-			if err != nil {
-				return nil, false, fmt.Errorf("create workload memory chart: %w", err)
-			}
+		cards = append(cards, card)
+	}
 
-			cpuStat, err := PodCPUStat(workload)
-			if err != nil {
-				return nil, false, fmt.Errorf("create workload cpu chart: %w", err)
-			}
+	return cards, fullMetrics, nil
+}
 
-			section = component.FlexLayoutSection{
-				{
-					Width: component.WidthThird,
-					View:  summaryChart,
-				},
-				{
-					Width: component.WidthThird,
-					View:  memoryStat,
-				},
-				{
-					Width: component.WidthThird,
-					View:  cpuStat,
-				},
-			}
-			section = append(section, []component.FlexLayoutItem{}...)
-		} else {
-			fullMetrics = false
+func CreateCard(workload *Workload, namespace string) (*component.Card, bool, error) {
+	supportsMetrics := true
+
+	workloadSummary, err := CreateWorkloadSummary(workload, component.DonutChartSizeMedium)
+	if err != nil {
+		return nil, false, fmt.Errorf("create workload summary: %w", err)
+	}
+
+	var section component.FlexLayoutSection
+
+	if workloadSummary.MetricsEnabled {
+		section = component.FlexLayoutSection{
+			{
+				Width: component.WidthThird,
+				View:  workloadSummary.Summary,
+			},
+			{
+				Width: component.WidthThird,
+				View:  workloadSummary.Memory,
+			},
+			{
+				Width: component.WidthThird,
+				View:  workloadSummary.CPU,
+			},
+		}
+	} else {
+		section = component.FlexLayoutSection{
+			{
+				Width: component.WidthFull,
+				View:  workloadSummary.Summary,
+			},
 		}
+		supportsMetrics = false
+	}
 
-		card := component.NewCard(component.TitleFromString(workload.Name))
+	cardPath := path.Join("/workloads/namespace", namespace, "detail", workload.Name)
+	cardTitle := component.NewLink("", workload.Name, cardPath)
 
-		layout := component.NewFlexLayout(workload.Name)
+	card := component.NewCard([]component.TitleComponent{cardTitle})
 
-		layout.AddSections(section)
-		card.SetBody(layout)
+	layout := component.NewFlexLayout(workload.Name)
 
-		cards = append(cards, card)
-	}
+	layout.AddSections(section)
+	card.SetBody(layout)
 
-	return cards, fullMetrics, nil
+	return card, supportsMetrics, nil
 }
 
 // ClusterWorkloadLoaderOption is option for configuring ClusterWorkloadLoader.
@@ -362,6 +385,7 @@ func (wl *ClusterWorkloadLoader) Load(ctx context.Context, namespace string) ([]
 		}
 
 		workload := workloadTracker[uid]
+		workload.Owner = owner
 
 		resourceList, err := wl.podMetrics(object)
 		if err != nil {
@@ -564,3 +588,46 @@ func summarizePodResources(object *unstructured.Unstructured) (*corev1.ResourceR
 
 	return &requirements, nil
 }
+
+type WorkloadSummary struct {
+	Summary        component.Component
+	Memory         component.Component
+	CPU            component.Component
+	MetricsEnabled bool
+}
+
+func CreateWorkloadSummary(workload *Workload, summarySize component.DonutChartSize) (WorkloadSummary, error) {
+	if workload == nil {
+		return WorkloadSummary{}, fmt.Errorf("can't create summary for nil workload")
+	}
+
+	summaryChart, err := workload.DonutChart(summarySize)
+	if err != nil {
+		return WorkloadSummary{}, fmt.Errorf("create workload summary component: %w", err)
+	}
+
+	if !workload.PodMetricsEnabled() {
+		return WorkloadSummary{
+			Summary:        summaryChart,
+			MetricsEnabled: false,
+		}, nil
+
+	}
+
+	memoryStat, err := PodMemoryStat(workload)
+	if err != nil {
+		return WorkloadSummary{}, fmt.Errorf("create workload memory component: %w", err)
+	}
+
+	cpuStat, err := PodCPUStat(workload)
+	if err != nil {
+		return WorkloadSummary{}, fmt.Errorf("create workload cpu component: %w", err)
+	}
+
+	return WorkloadSummary{
+		Summary:        summaryChart,
+		Memory:         memoryStat,
+		CPU:            cpuStat,
+		MetricsEnabled: true,
+	}, nil
+}
diff --git a/internal/resourceviewer/resourceviewer.go b/internal/resourceviewer/resourceviewer.go
index 3fc2741e..08232651 100644
--- a/internal/resourceviewer/resourceviewer.go
+++ b/internal/resourceviewer/resourceviewer.go
@@ -7,6 +7,7 @@ package resourceviewer
 
 import (
 	"context"
+	"fmt"
 	"time"
 
 	"github.com/pkg/errors"
@@ -19,6 +20,7 @@ import (
 	"github.com/vmware-tanzu/octant/internal/objectvisitor"
 	"github.com/vmware-tanzu/octant/internal/queryer"
 	"github.com/vmware-tanzu/octant/internal/util/kubernetes"
+	"github.com/vmware-tanzu/octant/pkg/view/component"
 )
 
 const (
@@ -47,6 +49,38 @@ type ResourceViewer struct {
 	visitor    objectvisitor.Visitor
 }
 
+// Create creates a resource viewer given a list objects.
+func Create(ctx context.Context, dashConfig config.Dash, q queryer.Queryer, objects ...*unstructured.Unstructured) (*component.ResourceViewer, error) {
+	rv, err := New(dashConfig, WithDefaultQueryer(dashConfig, q))
+	if err != nil {
+		return nil, fmt.Errorf("create resource viewer: %w", err)
+	}
+
+	handler, err := NewHandler(dashConfig)
+	if err != nil {
+		return nil, fmt.Errorf("create resource viewer handler: %w", err)
+	}
+
+	for _, object := range objects {
+		if object == nil {
+			continue
+		}
+		if err := rv.Visit(ctx, object, handler); err != nil {
+			return nil, fmt.Errorf("unable to visit %s %s: %w",
+				object.GroupVersionKind(),
+				object.GetName(),
+				err)
+		}
+	}
+
+	c, err := GenerateComponent(ctx, handler, "")
+	if err != nil {
+		return nil, fmt.Errorf("generate resource viewer component: %w", err)
+	}
+
+	return c, nil
+}
+
 // New creates an instance of ResourceViewer.
 func New(dashConfig config.Dash, opts ...ViewerOpt) (*ResourceViewer, error) {
 	rv := &ResourceViewer{
diff --git a/pkg/view/component/donut_chart.go b/pkg/view/component/donut_chart.go
index 3dc9a4cd..51cca5a4 100644
--- a/pkg/view/component/donut_chart.go
+++ b/pkg/view/component/donut_chart.go
@@ -7,6 +7,13 @@ package component
 
 import "encoding/json"
 
+type DonutChartSize int
+
+const (
+	DonutChartSizeSmall  DonutChartSize = 21
+	DonutChartSizeMedium DonutChartSize = 42
+)
+
 type DonutChartLabels struct {
 	Plural   string `json:"plural"`
 	Singular string `json:"singular"`
@@ -20,6 +27,7 @@ type DonutSegment struct {
 type DonutChartConfig struct {
 	Segments []DonutSegment   `json:"segments"`
 	Labels   DonutChartLabels `json:"labels"`
+	Size     DonutChartSize   `json:"size"`
 }
 
 type DonutChart struct {
@@ -50,6 +58,10 @@ func (dc *DonutChart) SetLabels(plural string, singular string) {
 	}
 }
 
+func (dc *DonutChart) SetSize(size DonutChartSize) {
+	dc.Config.Size = size
+}
+
 func (dc *DonutChart) MarshalJSON() ([]byte, error) {
 	m := donutChartMarshal(*dc)
 	m.Metadata.Type = typeDonutChart
diff --git a/pkg/view/component/flexlayout.go b/pkg/view/component/flexlayout.go
index 26ed0e31..25921fa5 100644
--- a/pkg/view/component/flexlayout.go
+++ b/pkg/view/component/flexlayout.go
@@ -20,8 +20,10 @@ const (
 
 // FlexLayoutItem is an item in a flex layout.
 type FlexLayoutItem struct {
-	Width int       `json:"width,omitempty"`
-	View  Component `json:"view,omitempty"`
+	Width  int       `json:"width,omitempty"`
+	Height string    `json:"height,omitempty"`
+	Margin string    `json:"margin,omitempty"`
+	View   Component `json:"view,omitempty"`
 }
 
 func (fli *FlexLayoutItem) UnmarshalJSON(data []byte) error {
diff --git a/pkg/view/flexlayout/section.go b/pkg/view/flexlayout/section.go
index 0868b0a7..668ac0e3 100644
--- a/pkg/view/flexlayout/section.go
+++ b/pkg/view/flexlayout/section.go
@@ -6,8 +6,8 @@ SPDX-License-Identifier: Apache-2.0
 package flexlayout
 
 import (
-	"github.com/vmware-tanzu/octant/pkg/view/component"
 	"github.com/pkg/errors"
+	"github.com/vmware-tanzu/octant/pkg/view/component"
 )
 
 const (
diff --git a/web/src/app/app.component.ts b/web/src/app/app.component.ts
index fd8c65ba..c6c6dd37 100644
--- a/web/src/app/app.component.ts
+++ b/web/src/app/app.component.ts
@@ -13,7 +13,7 @@ import { Navigation } from './models/navigation';
 import { WebsocketService } from './modules/overview/services/websocket/websocket.service';
 import { IconService } from './modules/overview/services/icon.service';
 import { SliderService } from './services/slider/slider.service';
-import { Router, NavigationStart } from '@angular/router';
+import { NavigationStart, Router } from '@angular/router';
 
 @Component({
   selector: 'app-root',
@@ -23,7 +23,6 @@ import { Router, NavigationStart } from '@angular/router';
 export class AppComponent implements OnInit, OnDestroy {
   @ViewChild('scrollTarget', { static: false }) scrollTarget: ElementRef;
   navigation: Navigation;
-  previousUrl: string;
   style: object = {};
 
   constructor(
diff --git a/web/src/app/models/content.ts b/web/src/app/models/content.ts
index 46f73a92..346164e8 100644
--- a/web/src/app/models/content.ts
+++ b/web/src/app/models/content.ts
@@ -24,6 +24,16 @@ export interface View {
   metadata: Metadata;
 }
 
+export interface TitleMetadata {
+  type: 'text' | 'link';
+  title?: TitleView[];
+  accessor?: string;
+}
+
+export interface TitleView {
+  metadata: TitleMetadata;
+}
+
 export interface AnnotationsView extends View {
   config: {
     annotations: { [key: string]: string };
@@ -69,6 +79,7 @@ export interface DonutChartView extends View {
   config: {
     segments: DonutSegment[];
     labels: DonutChartLabels;
+    size: number;
   };
 }
 
@@ -80,6 +91,7 @@ export interface GraphvizView extends View {
 
 export interface FlexLayoutItem {
   width: number;
+  height: number;
   view: View;
 }
 
diff --git a/web/src/app/modules/overview/components/card/card.component.html b/web/src/app/modules/overview/components/card/card.component.html
index 34cce8e7..02ddd418 100644
--- a/web/src/app/modules/overview/components/card/card.component.html
+++ b/web/src/app/modules/overview/components/card/card.component.html
@@ -22,7 +22,9 @@
                     </div>
                 </div>
             </ng-container>
-            <h4 class="card-title">{{ title }}</h4>
+            <h4 class="card-title">
+                <app-view-title [views]="title"></app-view-title>
+            </h4>
 
             <div [ngSwitch]="body.metadata.type" class="card-text">
                 <app-content-switcher [view]="body"></app-content-switcher>
diff --git a/web/src/app/modules/overview/components/card/card.component.spec.ts b/web/src/app/modules/overview/components/card/card.component.spec.ts
index 15b5d4bf..953d98a7 100644
--- a/web/src/app/modules/overview/components/card/card.component.spec.ts
+++ b/web/src/app/modules/overview/components/card/card.component.spec.ts
@@ -104,7 +104,9 @@ describe('CardComponent', () => {
           },
         },
       },
-      metadata: undefined,
+      metadata: {
+        type: 'card',
+      },
     };
 
     component.view = view;
@@ -114,7 +116,6 @@ describe('CardComponent', () => {
     });
     fixture.detectChanges();
 
-    expect(component.title).toBe('Just a title');
     expect(component.body).toBe(view.config.body);
   });
 
diff --git a/web/src/app/modules/overview/components/card/card.component.ts b/web/src/app/modules/overview/components/card/card.component.ts
index 0d4311ee..af3eeea1 100644
--- a/web/src/app/modules/overview/components/card/card.component.ts
+++ b/web/src/app/modules/overview/components/card/card.component.ts
@@ -5,10 +5,9 @@ import {
   SimpleChanges,
   ViewChild,
 } from '@angular/core';
-import { Action, CardView, View } from '../../../../models/content';
+import { Action, CardView, TitleView, View } from '../../../../models/content';
 import { FormGroup } from '@angular/forms';
 import { ActionService } from '../../services/action/action.service';
-import { ViewService } from '../../services/view/view.service';
 import { FormComponent } from '../form/form.component';
 
 @Component({
@@ -29,22 +28,19 @@ export class CardComponent implements OnChanges {
 
   @ViewChild('appForm', { static: false }) appForm: FormComponent;
 
-  title: string;
+  title: TitleView[];
 
   body: View;
 
   currentAction: Action;
 
-  constructor(
-    private actionService: ActionService,
-    private viewService: ViewService
-  ) {}
+  constructor(private actionService: ActionService) {}
 
   ngOnChanges(changes: SimpleChanges): void {
     if (changes.view.currentValue) {
       const view: CardView = changes.view.currentValue;
       if (view) {
-        this.title = this.viewService.viewTitleAsText(view);
+        this.title = view.metadata.title as TitleView[];
         this.body = view.config.body;
       }
     }
@@ -65,7 +61,7 @@ export class CardComponent implements OnChanges {
     this.currentAction = action;
   }
 
-  trackByFn(index, item) {
+  trackByFn(index, _) {
     return index;
   }
 }
diff --git a/web/src/app/modules/overview/components/donut-chart/donut-chart.component.spec.ts b/web/src/app/modules/overview/components/donut-chart/donut-chart.component.spec.ts
index bc2bd77b..30419950 100644
--- a/web/src/app/modules/overview/components/donut-chart/donut-chart.component.spec.ts
+++ b/web/src/app/modules/overview/components/donut-chart/donut-chart.component.spec.ts
@@ -31,6 +31,7 @@ describe('DonutChartComponent', () => {
           plural: 'items',
           singular: 'item',
         },
+        size: 25,
       },
     };
     component.view = view;
diff --git a/web/src/app/modules/overview/components/donut-chart/donut-chart.component.ts b/web/src/app/modules/overview/components/donut-chart/donut-chart.component.ts
index 8cb27b95..07d14dd6 100644
--- a/web/src/app/modules/overview/components/donut-chart/donut-chart.component.ts
+++ b/web/src/app/modules/overview/components/donut-chart/donut-chart.component.ts
@@ -28,7 +28,7 @@ export class DonutChartComponent implements OnInit {
   }
 
   @Input() circumference = 100;
-  @Input() height = 42;
+  height = 42;
 
   constructor() {}
 
diff --git a/web/src/app/modules/overview/components/flexlayout/flexlayout.component.html b/web/src/app/modules/overview/components/flexlayout/flexlayout.component.html
index 01c0ec49..391cec50 100644
--- a/web/src/app/modules/overview/components/flexlayout/flexlayout.component.html
+++ b/web/src/app/modules/overview/components/flexlayout/flexlayout.component.html
@@ -1,5 +1,3 @@
-
-
 <div class="clr-row">
     <div class="clr-col-sm-4 clr-offset-sm-8">
         <div style="float: right; margin-top: 1em">
@@ -9,10 +7,13 @@
 </div>
 
 <ng-container *ngIf="v?.config?.sections">
-  <div class="clr-row" *ngFor="let section of sections; trackBy: identifySection">
-    <div class="clr-col-md-{{ item.width / 2 }} content-card-parent" *ngFor="let item of section; trackBy: identifySection">
-      <app-content-switcher [view]="item.view"></app-content-switcher>
+    <div *ngFor="let section of sections; trackBy: identifySection" class="clr-row">
+        <div
+                *ngFor="let item of section; trackBy: identifySection"
+                [ngStyle]="sectionStyle(item)"
+                class="clr-col-md-{{ item.width / 2 }} content-card-parent">
+            <app-content-switcher [view]="item.view"></app-content-switcher>
+        </div>
     </div>
-  </div>
 </ng-container>
 
diff --git a/web/src/app/modules/overview/components/flexlayout/flexlayout.component.ts b/web/src/app/modules/overview/components/flexlayout/flexlayout.component.ts
index 0ceb31ee..9bce7e6d 100644
--- a/web/src/app/modules/overview/components/flexlayout/flexlayout.component.ts
+++ b/web/src/app/modules/overview/components/flexlayout/flexlayout.component.ts
@@ -34,4 +34,14 @@ export class FlexlayoutComponent {
   sections: FlexLayoutItem[][];
 
   identifySection = trackByIndex;
+
+  sectionStyle(item: FlexLayoutItem) {
+    return ['height', 'margin'].reduce((previousValue, currentValue) => {
+      if (!item[currentValue]) {
+        return previousValue;
+      }
+
+      return { ...previousValue, [currentValue]: item[currentValue] };
+    }, {});
+  }
 }
diff --git a/web/src/app/modules/overview/components/presentation/title/title.component.html b/web/src/app/modules/overview/components/presentation/title/title.component.html
new file mode 100644
index 00000000..260cf4ec
--- /dev/null
+++ b/web/src/app/modules/overview/components/presentation/title/title.component.html
@@ -0,0 +1,14 @@
+<ng-container *ngFor="let view of views; let i = index; trackBy: trackBy">
+    <ng-container [ngSwitch]="view.metadata.type">
+        <ng-container *ngSwitchCase="'text'">
+            <app-view-text [view]="view"></app-view-text>
+        </ng-container>
+        <ng-container *ngSwitchCase="'link'">
+            <app-view-link [view]="view"></app-view-link>
+        </ng-container>
+    </ng-container>
+    <span *ngIf="i < views.length - 1">
+        /
+    </span>
+</ng-container>
+
diff --git a/web/src/app/modules/overview/components/presentation/title/title.component.scss b/web/src/app/modules/overview/components/presentation/title/title.component.scss
new file mode 100644
index 00000000..faaf56d5
--- /dev/null
+++ b/web/src/app/modules/overview/components/presentation/title/title.component.scss
@@ -0,0 +1,6 @@
+/*!
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ */
+
diff --git a/web/src/app/modules/overview/components/presentation/title/title.component.spec.ts b/web/src/app/modules/overview/components/presentation/title/title.component.spec.ts
new file mode 100644
index 00000000..fc43a670
--- /dev/null
+++ b/web/src/app/modules/overview/components/presentation/title/title.component.spec.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ */
+
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { TitleComponent } from './title.component';
+import { TextComponent } from '../../text/text.component';
+import { LinkComponent } from '../../link/link.component';
+
+describe('TitleComponent', () => {
+  let component: TitleComponent;
+  let fixture: ComponentFixture<TitleComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [TitleComponent, TextComponent, LinkComponent],
+    }).compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(TitleComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/web/src/app/modules/overview/components/presentation/title/title.component.ts b/web/src/app/modules/overview/components/presentation/title/title.component.ts
new file mode 100644
index 00000000..08a495c9
--- /dev/null
+++ b/web/src/app/modules/overview/components/presentation/title/title.component.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ */
+
+import { Component, Input, OnInit } from '@angular/core';
+import { TitleView } from '../../../../../models/content';
+
+@Component({
+  selector: 'app-view-title',
+  templateUrl: './title.component.html',
+  styleUrls: ['./title.component.scss'],
+})
+export class TitleComponent implements OnInit {
+  @Input() views: TitleView[];
+
+  constructor() {}
+
+  ngOnInit() {}
+
+  trackBy(index, item) {
+    return index;
+  }
+}
diff --git a/web/src/app/modules/overview/components/text/text.component.html b/web/src/app/modules/overview/components/text/text.component.html
index df1b6a0b..235e9083 100644
--- a/web/src/app/modules/overview/components/text/text.component.html
+++ b/web/src/app/modules/overview/components/text/text.component.html
@@ -3,5 +3,5 @@
 </ng-container>
 
 <ng-template #markdown>
-    <div markdown ngPreserveWhitespaces [data]="value"></div>
+    <div markdown ngPreserveWhitespaces [data]="value" class="markdown"></div>
 </ng-template>
diff --git a/web/src/app/modules/overview/components/text/text.component.scss b/web/src/app/modules/overview/components/text/text.component.scss
index e78526e0..dfe0f169 100644
--- a/web/src/app/modules/overview/components/text/text.component.scss
+++ b/web/src/app/modules/overview/components/text/text.component.scss
@@ -1,3 +1,4 @@
 /* Copyright (c) 2019 the Octant contributors. All Rights Reserved.
  * SPDX-License-Identifier: Apache-2.0
  */
+
diff --git a/web/src/app/modules/overview/overview.component.html b/web/src/app/modules/overview/overview.component.html
index 2bbf7684..bf4a96f1 100644
--- a/web/src/app/modules/overview/overview.component.html
+++ b/web/src/app/modules/overview/overview.component.html
@@ -1,8 +1,8 @@
-<div class="overview-component" #scrollTarget>
+<div #scrollTarget class="overview-component">
     <ng-container *ngIf="hasReceivedContent">
         <ng-container *ngIf="hasTabs; then withTabs; else withoutTabs"></ng-container>
         <ng-template #withTabs>
-            <app-object-tabs [views]="views" [title]="title" [iconName]="iconName"></app-object-tabs>
+            <app-object-tabs [iconName]="iconName" [title]="title" [views]="views"></app-object-tabs>
         </ng-template>
         <ng-template #withoutTabs>
             <app-content-switcher [view]="singleView"></app-content-switcher>
diff --git a/web/src/app/modules/overview/overview.component.ts b/web/src/app/modules/overview/overview.component.ts
index 5696335a..a8404e80 100644
--- a/web/src/app/modules/overview/overview.component.ts
+++ b/web/src/app/modules/overview/overview.component.ts
@@ -10,10 +10,10 @@ import {
   ViewChild,
 } from '@angular/core';
 import { ActivatedRoute, Params, Router, UrlSegment } from '@angular/router';
-import { ContentResponse, View, ExtensionView } from 'src/app/models/content';
+import { ContentResponse, ExtensionView, View } from 'src/app/models/content';
 import { IconService } from './services/icon.service';
 import { ViewService } from './services/view/view.service';
-import { BehaviorSubject, combineLatest } from 'rxjs';
+import { combineLatest } from 'rxjs';
 import { untilDestroyed } from 'ngx-take-until-destroy';
 import { ContentService } from './services/content/content.service';
 import { WebsocketService } from './services/websocket/websocket.service';
@@ -21,14 +21,6 @@ import { KubeContextService } from './services/kube-context/kube-context.service
 import { take } from 'rxjs/operators';
 import isEqual from 'lodash/isEqual';
 
-const emptyContentResponse: ContentResponse = {
-  content: {
-    extensionComponent: null,
-    viewComponents: [],
-    title: [],
-  },
-};
-
 interface LocationCallbackOptions {
   segments: UrlSegment[];
   params: Params;
@@ -43,7 +35,6 @@ interface LocationCallbackOptions {
   changeDetection: ChangeDetectionStrategy.Default,
 })
 export class OverviewComponent implements OnInit, OnDestroy {
-  behavior = new BehaviorSubject<ContentResponse>(emptyContentResponse);
   @ViewChild('scrollTarget', { static: true }) scrollTarget: ElementRef;
   hasTabs = false;
   hasReceivedContent = false;
@@ -151,8 +142,7 @@ export class OverviewComponent implements OnInit, OnDestroy {
       return;
     }
 
-    const view = contentResponse.content.extensionComponent;
-    this.extView = view;
+    this.extView = contentResponse.content.extensionComponent;
 
     this.hasTabs = views.length > 1;
     if (this.hasTabs) {
diff --git a/web/src/app/modules/overview/overview.module.ts b/web/src/app/modules/overview/overview.module.ts
index b10971f4..395f807d 100644
--- a/web/src/app/modules/overview/overview.module.ts
+++ b/web/src/app/modules/overview/overview.module.ts
@@ -61,6 +61,7 @@ import { SliderViewComponent } from './components/slider-view/slider-view.compon
 import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
 import { DonutChartComponent } from './components/donut-chart/donut-chart.component';
 import { SingleStatComponent } from './components/single-stat/single-stat.component';
+import { TitleComponent } from './components/presentation/title/title.component';
 
 export function hljsLanguages() {
   return [
@@ -119,6 +120,7 @@ export function hljsLanguages() {
     SliderViewComponent,
     DonutChartComponent,
     SingleStatComponent,
+    TitleComponent,
   ],
   imports: [
     BrowserAnimationsModule,


Test patch:
diff --git a/internal/octant/workload_test.go b/internal/octant/workload_test.go
index faf8bda9..68236b3c 100644
--- a/internal/octant/workload_test.go
+++ b/internal/octant/workload_test.go
@@ -134,6 +134,7 @@ func TestClusterWorkloadLoader(t *testing.T) {
 				{
 					Name:     "pod-1",
 					IconName: "application",
+					Owner:    pod1,
 					SegmentCounter: map[component.NodeStatus][]octant.PodWithMetric{
 						component.NodeStatusOK: {
 							{
@@ -146,6 +147,7 @@ func TestClusterWorkloadLoader(t *testing.T) {
 				{
 					Name:     "rs-1",
 					IconName: "application",
+					Owner:    replicaSet,
 					SegmentCounter: map[component.NodeStatus][]octant.PodWithMetric{
 						component.NodeStatusOK: {
 							{
@@ -166,6 +168,7 @@ func TestClusterWorkloadLoader(t *testing.T) {
 				{
 					Name:     "pod-1",
 					IconName: "application",
+					Owner:    pod1,
 					SegmentCounter: map[component.NodeStatus][]octant.PodWithMetric{
 						component.NodeStatusOK: {
 							{
@@ -178,6 +181,7 @@ func TestClusterWorkloadLoader(t *testing.T) {
 				{
 					Name:     "rs-1",
 					IconName: "application",
+					Owner:    replicaSet,
 					SegmentCounter: map[component.NodeStatus][]octant.PodWithMetric{
 						component.NodeStatusOK: {
 							{
@@ -198,6 +202,7 @@ func TestClusterWorkloadLoader(t *testing.T) {
 				{
 					Name:     "pod-3",
 					IconName: "application",
+					Owner:    pod3,
 					SegmentCounter: map[component.NodeStatus][]octant.PodWithMetric{
 						component.NodeStatusOK: {
 							{
@@ -262,6 +267,7 @@ func TestWorkloadSummaryChart(t *testing.T) {
 			workload: w,
 			expected: &component.DonutChart{
 				Config: component.DonutChartConfig{
+					Size: component.DonutChartSizeMedium,
 					Segments: []component.DonutSegment{
 						{
 							Count:  3,
@@ -287,7 +293,7 @@ func TestWorkloadSummaryChart(t *testing.T) {
 
 	for _, c := range cases {
 		t.Run(c.name, func(t *testing.T) {
-			actual, err := c.workload.DonutChart()
+			actual, err := c.workload.DonutChart(component.DonutChartSizeMedium)
 			if c.isErr {
 				require.Error(t, err)
 				return