Repository: vmware-tanzu/octant
Pull request #1242 (2020-08-11 21:08:40)

Dynamic components
**What this PR does / why we need it**:

This change is an attempt to start moving from the switch case plus component rendering system to something more dynamic. We'll need this hook to support the ability for plugins to have their own components. While deciding on a path, I noticed that rendering components felt slower than necessary, so I started at the beginning to understand what was going on. 

The first thing I noticed that was our usage of the discovery client was taxing the cluster. This manifested in a throttle message that was issued by client-go periodically.

From there I re-evaluated how generating components works. The good news is that in the overwhelming majority of cases, Octant generates a component response in less than 30ms on my workstation. I noticed that in certain cases we were not stopping the polling of a path when the websocket client went away. To fix this, I added a new channel that we can close explicitly to stop the poller.

Our slowness is all in the front end. From my benchmarking, the overview page can take 3500+ ms to render. With further research, I noticed that Clarity datagrids can take 150-200ms to render. In a cursory pass, I could not find a way to make this faster, but since this was not the goal, I moved on.

Previously, Octant used a case statement in multiple locations to render components in a dynamic fashion. Since components are not known ahead of time, this is how we compensated. This change removes that concept and introduces a new method based on Angular's component factory mechanism. Instead of iterating through a switch, instead, Octant now creates an instance of a component and inserts it into the page dynamically. This doesn't appear to be any slower than what we had before, but it does give us more flexibility around what we can add.

All view components now inherit from a common base, `AbstractViewComponent`. This component handles the basics of view management. This means that view components can only focus on their business logic.

This change includes:

- Use cached discovery client. This should solve the `Throttled` messages printed to standard err
- Ensure websocket client stops when reader connection ends. This should make Octant not have dangling websockets connections that send to no where.
- Move to dynamic components. Components are now loaded dynamically instead of using the content switcher and its long switch case.



mklanjsek: This is dope 🎆 
bryanl: @mklanjsek I believe all the issues have been addressed. The loading indicator should be revisited in a future change, but it should not be broken now.
wwitzel3: This works well for me. I tested terminals, logging, port forwards, and resource viewer.
The load time is WAY faster on the initial load and after the initial load, even on a fairly large overview, coming back to it from a summary page is almost instant.
mklanjsek: Looks good to me as well, nicely done! Just a couple of issues that I run into:
- Datagrid initial update is much better now (although I feel we should put a spinner inside the datagrid until it receives the data), but all updates after that are confusing. We should not make data disappear from grids on updates.
  
- I am seeing a bunch of `No component factory` errors in Storybook. They get dumped to console when switching between components, but the easiest way to repro is to select the List component: nothing shows up in Canvas and following error is in console:
```
Error: No component factory found for DatagridComponent. Did you add it to @NgModule.entryComponents?
    at noComponentFactoryError (core.js:19825)```


Patch:
diff --git a/build.go b/build.go
index 7d795bf8..2383b0a3 100644
--- a/build.go
+++ b/build.go
@@ -264,7 +264,7 @@ func build() {
 	if runtime.GOOS == "windows" {
 		artifact = "octant.exe"
 	}
-	runCmd("go", nil, "build", "-o", "build/"+artifact, GO_FLAGS, "-v", "./cmd/octant")
+	runCmd("go", nil, "build", "-mod=vendor", "-o", "build/"+artifact, GO_FLAGS, "-v", "./cmd/octant")
 }
 
 func runDev() {
diff --git a/internal/api/content_manager.go b/internal/api/content_manager.go
index 9b1b1933..cfb1198a 100644
--- a/internal/api/content_manager.go
+++ b/internal/api/content_manager.go
@@ -7,6 +7,8 @@ package api
 
 import (
 	"context"
+	"crypto/sha256"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"hash/fnv"
@@ -37,9 +39,18 @@ type ContentManagerOption func(manager *ContentManager)
 // if the action should be be immediately rerun.
 type ContentGenerateFunc func(ctx context.Context, state octant.State) (Content, bool, error)
 
+// Content is a content to be sent to clients.
 type Content struct {
+	// Response is a content response.
 	Response component.ContentResponse
-	Path     string
+	// Path is the path of the content.
+	Path string
+}
+
+// Checksum return's the content's checksum.
+func (c *Content) Checksum() string {
+	b, _ := json.MarshalIndent(c, "", "  ")
+	return fmt.Sprintf("%s", sha256.Sum256(b))
 }
 
 var (
@@ -102,15 +113,24 @@ func (cm *ContentManager) Start(ctx context.Context, state octant.State, s Octan
 		close(cm.updateContentCh)
 	}()
 
+	ctx, cancel := context.WithCancel(ctx)
+
 	updateCancel := state.OnContentPathUpdate(func(contentPath string) {
 		cm.updateContentCh <- struct{}{}
 	})
-	defer updateCancel()
+
+	go func() {
+		<-s.StopCh()
+		updateCancel()
+		cancel()
+	}()
 
 	cm.poller.Run(ctx, cm.updateContentCh, cm.runUpdate(state, s), event.DefaultScheduleDelay)
 }
 
 func (cm *ContentManager) runUpdate(state octant.State, s OctantClient) PollerFunc {
+	previousChecksum := ""
+
 	return func(ctx context.Context) bool {
 		contentPath := state.GetContentPath()
 		if contentPath == "" {
@@ -135,6 +155,12 @@ func (cm *ContentManager) runUpdate(state octant.State, s OctantClient) PollerFu
 			return false
 		}
 
+		checksum := content.Checksum()
+		if checksum == previousChecksum {
+			return false
+		}
+		previousChecksum = checksum
+
 		if ctx.Err() == nil {
 			if content.Path == state.GetContentPath() {
 				s.Send(CreateContentEvent(content.Response, state.GetNamespace(), contentPath, state.GetQueryParams()))
@@ -224,8 +250,7 @@ func (cm *ContentManager) SetNamespace(state octant.State, payload action.Payloa
 		return fmt.Errorf("extract namespace from payload: %w", err)
 	}
 	state.SetNamespace(namespace)
-	state.Dispatch(cm.ctx, action.RequestSetNamespace, payload)
-	return nil
+	return state.Dispatch(cm.ctx, action.RequestSetNamespace, payload)
 }
 
 // SetContentPath sets the current content path.
@@ -238,8 +263,6 @@ func (cm *ContentManager) SetContentPath(state octant.State, payload action.Payl
 		return fmt.Errorf("extract query params from payload: %w", err)
 	}
 
-	cm.logger.With("content-path", contentPath).Debugf("setting content path")
-
 	state.SetContentPath(contentPath)
 	return nil
 }
diff --git a/internal/api/fake/mock_octant_client.go b/internal/api/fake/mock_octant_client.go
index 09f3a7cb..14ff44fa 100644
--- a/internal/api/fake/mock_octant_client.go
+++ b/internal/api/fake/mock_octant_client.go
@@ -60,3 +60,17 @@ func (mr *MockOctantClientMockRecorder) Send(arg0 interface{}) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Send", reflect.TypeOf((*MockOctantClient)(nil).Send), arg0)
 }
+
+// StopCh mocks base method
+func (m *MockOctantClient) StopCh() <-chan struct{} {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "StopCh")
+	ret0, _ := ret[0].(<-chan struct{})
+	return ret0
+}
+
+// StopCh indicates an expected call of StopCh
+func (mr *MockOctantClientMockRecorder) StopCh() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopCh", reflect.TypeOf((*MockOctantClient)(nil).StopCh))
+}
diff --git a/internal/api/websocket_client.go b/internal/api/websocket_client.go
index 08151db9..2be185e5 100644
--- a/internal/api/websocket_client.go
+++ b/internal/api/websocket_client.go
@@ -53,6 +53,7 @@ type WebsocketClient struct {
 	state    octant.State
 	handlers map[string][]octant.ClientRequestHandler
 	id       uuid.UUID
+	stopCh   chan struct{}
 }
 
 var _ OctantClient = (*WebsocketClient)(nil)
@@ -74,6 +75,7 @@ func NewWebsocketClient(ctx context.Context, conn *websocket.Conn, manager *Webs
 		dashConfig: dashConfig,
 		logger:     logger,
 		handlers:   make(map[string][]octant.ClientRequestHandler),
+		stopCh:     make(chan struct{}, 1),
 	}
 
 	state := NewWebsocketState(dashConfig, actionDispatcher, client)
@@ -107,6 +109,7 @@ func NewTemporaryWebsocketClient(ctx context.Context, conn *websocket.Conn, mana
 		manager:  manager,
 		logger:   logger,
 		handlers: make(map[string][]octant.ClientRequestHandler),
+		stopCh:   make(chan struct{}, 1),
 	}
 
 	state := NewTemporaryWebsocketState(actionDispatcher, client)
@@ -164,6 +167,8 @@ func (c *WebsocketClient) readPump() {
 			c.logger.WithErr(err).Errorf("Handle websocket message")
 		}
 	}
+
+	close(c.stopCh)
 }
 
 func (c *WebsocketClient) handle(message []byte) error {
@@ -271,6 +276,11 @@ func (c *WebsocketClient) Send(ev event.Event) {
 	}
 }
 
+// StopCh returns the client's stop channel. It will be closed when the WebsocketClient is closed.
+func (c *WebsocketClient) StopCh() <-chan struct{} {
+	return c.stopCh
+}
+
 func (c *WebsocketClient) RegisterHandler(handler octant.ClientRequestHandler) {
 	c.handlers[handler.RequestType] = append(c.handlers[handler.RequestType], handler)
 }
diff --git a/internal/api/websocket_state.go b/internal/api/websocket_state.go
index cf33060c..17e90c5d 100644
--- a/internal/api/websocket_state.go
+++ b/internal/api/websocket_state.go
@@ -56,6 +56,7 @@ func defaultStateManagers(clientID string, dashConfig config.Dash) []StateManage
 type OctantClient interface {
 	Send(event event.Event)
 	ID() string
+	StopCh() <-chan struct{}
 }
 
 type atomicString struct {
diff --git a/internal/cluster/cluster.go b/internal/cluster/cluster.go
index a3f1ea71..23b1c918 100644
--- a/internal/cluster/cluster.go
+++ b/internal/cluster/cluster.go
@@ -105,11 +105,6 @@ func newCluster(ctx context.Context, clientConfig clientcmd.ClientConfig, restCl
 		return nil, errors.Wrap(err, "create dynamic client")
 	}
 
-	discoveryClient, err := discovery.NewDiscoveryClientForConfig(restClient)
-	if err != nil {
-		return nil, errors.Wrap(err, "create discovery client")
-	}
-
 	dir, err := ioutil.TempDir("", "octant")
 	if err != nil {
 		return nil, errors.Wrap(err, "create temp directory")
@@ -134,7 +129,7 @@ func newCluster(ctx context.Context, clientConfig clientcmd.ClientConfig, restCl
 		restConfig:         restClient,
 		kubernetesClient:   kubernetesClient,
 		dynamicClient:      dynamicClient,
-		discoveryClient:    discoveryClient,
+		discoveryClient:    cachedDiscoveryClient,
 		restMapper:         restMapper,
 		logger:             internalLog.From(ctx),
 		defaultNamespace:   defaultNamespace,
diff --git a/web/package-lock.json b/web/package-lock.json
index f225ce4e..c5d85569 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1253,7 +1253,7 @@
         },
         "source-map": {
           "version": "0.5.7",
-          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+          "resolved": "https://build-artifactory.eng.vmware.com/api/npm/npm/source-map/-/source-map-0.5.7.tgz",
           "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=",
           "dev": true
         }
@@ -13339,14 +13339,14 @@
     },
     "character-entities": {
       "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz",
-      "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==",
+      "resolved": "http://build-artifactory.eng.vmware.com/api/npm/npm/character-entities/-/character-entities-1.2.4.tgz",
+      "integrity": "sha1-4Sw5Obfq9OWxXnrUxeKOHUjFsWs=",
       "dev": true
     },
     "character-entities-legacy": {
       "version": "1.1.4",
-      "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz",
-      "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==",
+      "resolved": "http://build-artifactory.eng.vmware.com/api/npm/npm/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz",
+      "integrity": "sha1-lLwYRdznClu50uzHSHJWYSk9j8E=",
       "dev": true
     },
     "character-reference-invalid": {
@@ -15227,11 +15227,6 @@
         "ip-regex": "^2.1.0"
       }
     },
-    "default-passive-events": {
-      "version": "1.0.10",
-      "resolved": "https://registry.npmjs.org/default-passive-events/-/default-passive-events-1.0.10.tgz",
-      "integrity": "sha1-KK0yaWSKdqAVj0E9ZuN68k2tBTo="
-    },
     "defaults": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz",
@@ -20260,8 +20255,8 @@
     },
     "latest-version": {
       "version": "5.1.0",
-      "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-5.1.0.tgz",
-      "integrity": "sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==",
+      "resolved": "http://build-artifactory.eng.vmware.com/api/npm/npm/latest-version/-/latest-version-5.1.0.tgz",
+      "integrity": "sha1-EZ3+kI/jjRXfpD7NE/oS7Igy+s4=",
       "dev": true,
       "requires": {
         "package-json": "^6.3.0"
diff --git a/web/package.json b/web/package.json
index 174b8d69..be058a0a 100644
--- a/web/package.json
+++ b/web/package.json
@@ -59,13 +59,12 @@
     "d3-graphviz": "^2.6.1",
     "d3-zoom": "^1.8.3",
     "dagre-d3": "^0.6.4",
-    "default-passive-events": "^1.0.10",
     "highlight.js": "^10.1.1",
     "lodash": "^4.17.15",
     "lodash-es": "^4.17.15",
     "monaco-editor": "^0.20.0",
     "ng-monaco-editor": "^2.2.2",
-    "ngx-highlightjs": "^4.0.2",
+    "ngx-highlightjs": "^4.1.1",
     "ngx-markdown": "^8.2.1",
     "rxjs": "^6.6.0",
     "serialize-javascript": "^3.0.0",
diff --git a/web/src/app/app.module.ts b/web/src/app/app.module.ts
index b70c42d6..a68bde30 100644
--- a/web/src/app/app.module.ts
+++ b/web/src/app/app.module.ts
@@ -1,7 +1,7 @@
 // Copyright (c) 2019 the Octant contributors. All Rights Reserved.
 // SPDX-License-Identifier: Apache-2.0
 //
-import { Location } from '@angular/common';
+import { CommonModule, Location } from '@angular/common';
 import { HttpClientModule } from '@angular/common/http';
 import { Injectable, NgModule } from '@angular/core';
 import { RouteReuseStrategy, RouterModule } from '@angular/router';
@@ -10,7 +10,7 @@ import { AppRoutingModule } from './app-routing.module';
 import { BrowserModule } from '@angular/platform-browser';
 import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
 import { highlightProvider } from './modules/shared/highlight';
-import { MonacoEditorModule, MonacoProviderService } from 'ng-monaco-editor';
+import { MonacoEditorModule } from 'ng-monaco-editor';
 import { ComponentReuseStrategy } from './modules/shared/component-reuse.strategy';
 
 @Injectable()
@@ -23,6 +23,7 @@ export class UnstripTrailingSlashLocation extends Location {
 @NgModule({
   declarations: [HomeComponent],
   imports: [
+    CommonModule,
     BrowserModule,
     BrowserAnimationsModule,
     HttpClientModule,
diff --git a/web/src/app/modules/shared/components/abstract-view/abstract-view.component.ts b/web/src/app/modules/shared/components/abstract-view/abstract-view.component.ts
new file mode 100644
index 00000000..1cf41bd4
--- /dev/null
+++ b/web/src/app/modules/shared/components/abstract-view/abstract-view.component.ts
@@ -0,0 +1,66 @@
+import {
+  AfterViewInit,
+  EventEmitter,
+  Input,
+  OnInit,
+  Output,
+} from '@angular/core';
+import { View } from '../../models/content';
+
+export abstract class AbstractViewComponent<T>
+  implements OnInit, AfterViewInit {
+  v: T;
+
+  @Input() set view(v: View) {
+    this.v = (v as unknown) as T;
+    this.update();
+  }
+
+  get view() {
+    return (this.v as unknown) as View;
+  }
+
+  @Output() viewInit: EventEmitter<void> = new EventEmitter<void>();
+
+  private hasChildren = false;
+  private initialChildCountActual = 0;
+
+  protected set initialChildCount(n: number) {
+    this.waitCountActual = n;
+    if (n > 0) {
+      this.hasChildren = true;
+      this.initialChildCountActual = n;
+    }
+  }
+
+  private waitCountActual = 0;
+  protected set waitCount(n: number) {
+    this.waitCountActual = n;
+    if (n === 0 && this.hasChildren) {
+      this.viewInit.emit();
+      return;
+    }
+  }
+
+  protected get waitCount(): number {
+    return this.waitCountActual;
+  }
+
+  protected abstract update(): void;
+
+  ngOnInit(): void {}
+
+  ngAfterViewInit() {
+    if (this.initialChildCountActual === 0) {
+      this.viewInit.emit();
+    }
+  }
+
+  ping() {
+    this.update();
+  }
+
+  initDone() {
+    this.waitCount = this.waitCount - 1;
+  }
+}
diff --git a/web/src/app/modules/shared/components/missing-component/missing-component.component.html b/web/src/app/modules/shared/components/missing-component/missing-component.component.html
new file mode 100644
index 00000000..e0da4c47
--- /dev/null
+++ b/web/src/app/modules/shared/components/missing-component/missing-component.component.html
@@ -0,0 +1,6 @@
+<div class="alert alert-danger static">
+  <span class="alert-icon-wrapper">
+    <clr-icon class="alert-icon" shape="exclamation-circle"></clr-icon>
+  </span>
+  <span class="alert-text"> {{ name }} not implemented </span>
+</div>
diff --git a/web/src/app/modules/shared/components/missing-component/missing-component.component.sass b/web/src/app/modules/shared/components/missing-component/missing-component.component.sass
new file mode 100644
index 00000000..e69de29b
diff --git a/web/src/app/modules/shared/components/missing-component/missing-component.component.spec.ts b/web/src/app/modules/shared/components/missing-component/missing-component.component.spec.ts
new file mode 100644
index 00000000..09443750
--- /dev/null
+++ b/web/src/app/modules/shared/components/missing-component/missing-component.component.spec.ts
@@ -0,0 +1,27 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { MissingComponentComponent } from './missing-component.component';
+import { BrowserModule } from '@angular/platform-browser';
+import { CommonModule } from '@angular/common';
+
+describe('MissingComponentComponent', () => {
+  let component: MissingComponentComponent;
+  let fixture: ComponentFixture<MissingComponentComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [MissingComponentComponent],
+      imports: [CommonModule],
+    }).compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(MissingComponentComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/web/src/app/modules/shared/components/missing-component/missing-component.component.ts b/web/src/app/modules/shared/components/missing-component/missing-component.component.ts
new file mode 100644
index 00000000..3978d54a
--- /dev/null
+++ b/web/src/app/modules/shared/components/missing-component/missing-component.component.ts
@@ -0,0 +1,12 @@
+import { Component, Input } from '@angular/core';
+
+@Component({
+  selector: 'app-missing-component',
+  templateUrl: './missing-component.component.html',
+  styleUrls: ['./missing-component.component.sass'],
+})
+export class MissingComponentComponent {
+  @Input() name: string;
+
+  constructor() {}
+}
diff --git a/web/src/app/modules/shared/components/presentation/annotations/annotations.component.ts b/web/src/app/modules/shared/components/presentation/annotations/annotations.component.ts
index fcaed6d5..c8bd36b9 100644
--- a/web/src/app/modules/shared/components/presentation/annotations/annotations.component.ts
+++ b/web/src/app/modules/shared/components/presentation/annotations/annotations.component.ts
@@ -2,39 +2,30 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 
-import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
-import { AnnotationsView, View } from 'src/app/modules/shared/models/content';
+import { Component } from '@angular/core';
+import { AnnotationsView } from 'src/app/modules/shared/models/content';
 import trackByIdentity from 'src/app/util/trackBy/trackByIdentity';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-annotations',
   templateUrl: './annotations.component.html',
   styleUrls: ['./annotations.component.scss'],
 })
-export class AnnotationsComponent implements OnChanges {
-  private v: AnnotationsView;
-
-  @Input() set view(v: View) {
-    this.v = v as AnnotationsView;
-  }
-
-  get view() {
-    return this.v;
-  }
-
+export class AnnotationsComponent extends AbstractViewComponent<
+  AnnotationsView
+> {
   annotations: { [key: string]: string };
   annotationKeys: string[];
   trackByIdentity = trackByIdentity;
 
-  constructor() {}
+  constructor() {
+    super();
+  }
 
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view.currentValue) {
-      const view = changes.view.currentValue as AnnotationsView;
-      this.annotations = view.config.annotations;
-      this.annotationKeys = this.annotations
-        ? Object.keys(this.annotations)
-        : [];
-    }
+  update() {
+    const view = this.v;
+    this.annotations = view.config.annotations;
+    this.annotationKeys = this.annotations ? Object.keys(this.annotations) : [];
   }
 }
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
index a64754a4..d901b855 100644
--- a/web/src/app/modules/shared/components/presentation/breadcrumb/breadcrumb.component.scss
+++ b/web/src/app/modules/shared/components/presentation/breadcrumb/breadcrumb.component.scss
@@ -7,7 +7,7 @@
 }
 
 :host-context(body.dark) {
-  --creadcrumb-link-color: #49AFD9;
+  --creadcrumb-link-color: #49afd9;
 }
 
 .breadcrumb {
diff --git a/web/src/app/modules/shared/components/presentation/button-group/button-group.component.html b/web/src/app/modules/shared/components/presentation/button-group/button-group.component.html
index fef6cfff..bc33678c 100644
--- a/web/src/app/modules/shared/components/presentation/button-group/button-group.component.html
+++ b/web/src/app/modules/shared/components/presentation/button-group/button-group.component.html
@@ -1,6 +1,6 @@
-<clr-button-group *ngIf="view?.config.buttons" class="btn-primary">
+<clr-button-group *ngIf="v.config.buttons" class="btn-primary">
   <clr-button
-    *ngFor="let button of view.config.buttons; trackBy: trackByFn"
+    *ngFor="let button of v.config.buttons; trackBy: trackByFn"
     class="{{ class }}"
     (click)="onClick(button.payload, button.confirmation)"
   >
diff --git a/web/src/app/modules/shared/components/presentation/button-group/button-group.component.spec.ts b/web/src/app/modules/shared/components/presentation/button-group/button-group.component.spec.ts
index db358cea..e442cfe9 100644
--- a/web/src/app/modules/shared/components/presentation/button-group/button-group.component.spec.ts
+++ b/web/src/app/modules/shared/components/presentation/button-group/button-group.component.spec.ts
@@ -1,6 +1,7 @@
 import { async, ComponentFixture, TestBed } from '@angular/core/testing';
 
 import { ButtonGroupComponent } from './button-group.component';
+import { ButtonGroupView } from '../../../models/content';
 
 describe('ButtonGroupComponent', () => {
   let component: ButtonGroupComponent;
@@ -15,6 +16,13 @@ describe('ButtonGroupComponent', () => {
   beforeEach(() => {
     fixture = TestBed.createComponent(ButtonGroupComponent);
     component = fixture.componentInstance;
+
+    component.view = {
+      config: {
+        buttons: [],
+      },
+    } as ButtonGroupView;
+
     fixture.detectChanges();
   });
 
diff --git a/web/src/app/modules/shared/components/presentation/button-group/button-group.component.ts b/web/src/app/modules/shared/components/presentation/button-group/button-group.component.ts
index 933d5355..da63978e 100644
--- a/web/src/app/modules/shared/components/presentation/button-group/button-group.component.ts
+++ b/web/src/app/modules/shared/components/presentation/button-group/button-group.component.ts
@@ -1,22 +1,16 @@
-import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
+import { Component, EventEmitter, Output } from '@angular/core';
 import { ButtonGroupView, Confirmation } from '../../../models/content';
 import { ActionService } from '../../../services/action/action.service';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-button-group',
   templateUrl: './button-group.component.html',
   styleUrls: ['./button-group.component.scss'],
 })
-export class ButtonGroupComponent implements OnInit {
-  v: ButtonGroupView;
-
-  @Input() set view(v: ButtonGroupView) {
-    this.v = v as ButtonGroupView;
-  }
-  get view() {
-    return this.v;
-  }
-
+export class ButtonGroupComponent extends AbstractViewComponent<
+  ButtonGroupView
+> {
   @Output() buttonLoad: EventEmitter<boolean> = new EventEmitter(true);
 
   isModalOpen = false;
@@ -25,11 +19,13 @@ export class ButtonGroupComponent implements OnInit {
   payload = {};
   class = '';
 
-  constructor(private actionService: ActionService) {}
+  constructor(private actionService: ActionService) {
+    super();
+  }
 
-  ngOnInit() {
-    if (this.view && this.view.config.buttons) {
-      this.view.config.buttons.forEach(button => {
+  update() {
+    if (this.v.config.buttons) {
+      this.v.config.buttons.forEach(button => {
         if (button.confirmation) {
           this.class = 'btn-danger-outline btn-sm';
         } else {
diff --git a/web/src/app/modules/shared/components/presentation/card-list/card-list.component.ts b/web/src/app/modules/shared/components/presentation/card-list/card-list.component.ts
index 26e4eb19..3aef4089 100644
--- a/web/src/app/modules/shared/components/presentation/card-list/card-list.component.ts
+++ b/web/src/app/modules/shared/components/presentation/card-list/card-list.component.ts
@@ -1,23 +1,19 @@
-import { Component, Input } from '@angular/core';
-import { CardListView, CardView, View } from '../../../models/content';
+import { Component } from '@angular/core';
+import { CardListView, CardView } from '../../../models/content';
 import { ViewService } from '../../../services/view/view.service';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-card-list',
   templateUrl: './card-list.component.html',
   styleUrls: ['./card-list.component.scss'],
 })
-export class CardListComponent {
-  v: CardListView;
-
-  @Input() set view(v: View) {
-    this.v = v as CardListView;
-  }
-  get view() {
-    return this.v;
+export class CardListComponent extends AbstractViewComponent<CardListView> {
+  constructor(private viewService: ViewService) {
+    super();
   }
 
-  constructor(private viewService: ViewService) {}
+  update() {}
 
   identifyCard = (index: number, item: CardView): string => {
     return [index, this.viewService.viewTitleAsText(item)].join(',');
diff --git a/web/src/app/modules/shared/components/presentation/card/card.component.html b/web/src/app/modules/shared/components/presentation/card/card.component.html
index 9921edd2..07a1406e 100644
--- a/web/src/app/modules/shared/components/presentation/card/card.component.html
+++ b/web/src/app/modules/shared/components/presentation/card/card.component.html
@@ -16,11 +16,11 @@
 <ng-template #content>
   <div class="card">
     <div class="card-block">
-      <ng-container *ngIf="view.config.alert">
-        <div class="alert alert-{{ view.config.alert.type }} alert-sm">
+      <ng-container *ngIf="v.config.alert">
+        <div class="alert alert-{{ v.config.alert.type }} alert-sm">
           <div class="alert-item static">
             <span class="alert-text">
-              {{ view.config.alert.message }}
+              {{ v.config.alert.message }}
             </span>
           </div>
         </div>
@@ -29,15 +29,13 @@
         <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>
+      <div class="card-text">
+        <app-view-container [view]="body"></app-view-container>
       </div>
     </div>
 
-    <div class="card-footer" *ngIf="view.config.actions?.length > 0">
-      <ng-container
-        *ngFor="let action of view.config.actions; trackBy: trackByFn"
-      >
+    <div class="card-footer" *ngIf="v.config.actions?.length > 0">
+      <ng-container *ngFor="let action of v.config.actions; trackBy: trackByFn">
         <button class="btn btn-sm btn-link" (click)="setAction(action)">
           {{ action.name }}
         </button>
diff --git a/web/src/app/modules/shared/components/presentation/card/card.component.spec.ts b/web/src/app/modules/shared/components/presentation/card/card.component.spec.ts
index 9b6e0afb..16021f17 100644
--- a/web/src/app/modules/shared/components/presentation/card/card.component.spec.ts
+++ b/web/src/app/modules/shared/components/presentation/card/card.component.spec.ts
@@ -8,8 +8,7 @@ import {
 
 import { CardComponent } from './card.component';
 import { Action, CardView, TextView } from '../../../models/content';
-import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
-import { SimpleChange } from '@angular/core';
+import { FormBuilder, FormGroup } from '@angular/forms';
 import { ViewService } from '../../../services/view/view.service';
 import { viewServiceStub } from 'src/app/testing/view-service.stub';
 import { SharedModule } from '../../../shared.module';
@@ -52,15 +51,16 @@ describe('CardComponent', () => {
       },
     };
 
-    const cardView: CardView = {
+    component.view = {
       config: {
         actions: [],
         body: textView,
       },
-      metadata: undefined,
-    };
-
-    component.view = cardView;
+      metadata: {
+        type: 'card',
+        title: [textView],
+      },
+    } as CardView;
     component.currentAction = action;
   });
 
@@ -110,10 +110,6 @@ describe('CardComponent', () => {
     };
 
     component.view = view;
-
-    component.ngOnChanges({
-      view: new SimpleChange(null, component.view, false),
-    });
     fixture.detectChanges();
 
     expect(component.body).toBe(view.config.body);
diff --git a/web/src/app/modules/shared/components/presentation/card/card.component.ts b/web/src/app/modules/shared/components/presentation/card/card.component.ts
index 01c4acf8..6bd22a01 100644
--- a/web/src/app/modules/shared/components/presentation/card/card.component.ts
+++ b/web/src/app/modules/shared/components/presentation/card/card.component.ts
@@ -1,31 +1,16 @@
-import {
-  Component,
-  Input,
-  OnChanges,
-  SimpleChanges,
-  ViewChild,
-} from '@angular/core';
+import { Component, Input, ViewChild } from '@angular/core';
 import { Action, CardView, TitleView, View } from '../../../models/content';
 import { FormGroup } from '@angular/forms';
 import { ActionService } from '../../../services/action/action.service';
 import { FormComponent } from '../form/form.component';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-card',
   templateUrl: './card.component.html',
   styleUrls: ['./card.component.scss'],
 })
-export class CardComponent implements OnChanges {
-  private v: CardView;
-
-  @Input() set view(v: View) {
-    this.v = v as CardView;
-  }
-
-  get view() {
-    return this.v;
-  }
-
+export class CardComponent extends AbstractViewComponent<CardView> {
   @ViewChild('appForm') appForm: FormComponent;
 
   title: TitleView[];
@@ -34,16 +19,13 @@ export class CardComponent implements OnChanges {
 
   currentAction: Action;
 
-  constructor(private actionService: ActionService) {}
+  constructor(private actionService: ActionService) {
+    super();
+  }
 
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view.currentValue) {
-      const view: CardView = changes.view.currentValue;
-      if (view) {
-        this.title = view.metadata.title as TitleView[];
-        this.body = view.config.body;
-      }
-    }
+  update() {
+    this.title = this.v.metadata.title as TitleView[];
+    this.body = this.v.config.body;
   }
 
   onActionSubmit(formGroup: FormGroup) {
diff --git a/web/src/app/modules/shared/components/presentation/code/code.component.ts b/web/src/app/modules/shared/components/presentation/code/code.component.ts
index 8f344863..8c0da091 100644
--- a/web/src/app/modules/shared/components/presentation/code/code.component.ts
+++ b/web/src/app/modules/shared/components/presentation/code/code.component.ts
@@ -3,34 +3,24 @@ Copyright (c) 2020 the Octant contributors. All Rights Reserved.
 SPDX-License-Identifier: Apache-2.0
 */
 
-import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
-import { CodeView, View } from 'src/app/modules/shared/models/content';
+import { Component } from '@angular/core';
+import { CodeView } from 'src/app/modules/shared/models/content';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-code',
   templateUrl: './code.component.html',
   styleUrls: ['./code.component.scss'],
 })
-export class CodeComponent implements OnChanges {
-  private v: CodeView;
-
-  @Input() set view(v: View) {
-    this.v = v as CodeView;
-  }
-
-  get view() {
-    return this.v;
-  }
-
+export class CodeComponent extends AbstractViewComponent<CodeView> {
   value: string;
 
-  constructor() {}
+  constructor() {
+    super();
+  }
 
-  ngOnChanges(changes: SimpleChanges) {
-    if (changes.view.currentValue) {
-      const view = changes.view.currentValue as CodeView;
-      this.value = view.config.value;
-    }
+  update() {
+    this.value = this.v.config.value;
   }
 
   copyToClipboard(text) {
diff --git a/web/src/app/modules/shared/components/presentation/containers/containers.component.ts b/web/src/app/modules/shared/components/presentation/containers/containers.component.ts
index 14edf743..096d28d2 100644
--- a/web/src/app/modules/shared/components/presentation/containers/containers.component.ts
+++ b/web/src/app/modules/shared/components/presentation/containers/containers.component.ts
@@ -2,37 +2,27 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 
-import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
+import { Component } from '@angular/core';
 import {
   ContainerDef,
   ContainersView,
-  View,
 } from 'src/app/modules/shared/models/content';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-containers',
   templateUrl: './containers.component.html',
   styleUrls: ['./containers.component.scss'],
 })
-export class ContainersComponent implements OnChanges {
-  private v: ContainersView;
-
-  @Input() set view(v: View) {
-    this.v = v as ContainersView;
-  }
-  get view() {
-    return this.v;
-  }
-
+export class ContainersComponent extends AbstractViewComponent<ContainersView> {
   containers: ContainerDef[];
 
-  constructor() {}
+  constructor() {
+    super();
+  }
 
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view.currentValue) {
-      const view = changes.view.currentValue as ContainersView;
-      this.containers = view.config.containers;
-    }
+  update() {
+    this.containers = this.v.config.containers;
   }
 
   trackItem(index: number, item: ContainerDef): string {
diff --git a/web/src/app/modules/shared/components/presentation/content-switcher/content-switcher.component.html b/web/src/app/modules/shared/components/presentation/content-switcher/content-switcher.component.html
deleted file mode 100644
index c9554baa..00000000
--- a/web/src/app/modules/shared/components/presentation/content-switcher/content-switcher.component.html
+++ /dev/null
@@ -1,111 +0,0 @@
-<ng-container *ngIf="view?.metadata.type">
-  <ng-container [ngSwitch]="view.metadata?.type">
-    <ng-container *ngSwitchCase="'annotations'">
-      <app-view-annotations [view]="view"></app-view-annotations>
-    </ng-container>
-    <ng-container *ngSwitchCase="'buttonGroup'">
-      <app-button-group [view]="view"></app-button-group>
-    </ng-container>
-    <ng-container *ngSwitchCase="'card'">
-      <app-view-card [view]="view"></app-view-card>
-    </ng-container>
-    <ng-container *ngSwitchCase="'cardList'">
-      <app-view-card-list [view]="view"></app-view-card-list>
-    </ng-container>
-    <ng-container *ngSwitchCase="'containers'">
-      <app-view-containers [view]="view"></app-view-containers>
-    </ng-container>
-    <ng-container *ngSwitchCase="'codeBlock'">
-      <app-view-code [view]="view"></app-view-code>
-    </ng-container>
-    <ng-container *ngSwitchCase="'donutChart'">
-      <app-view-donut-chart [view]="view"></app-view-donut-chart>
-    </ng-container>
-    <ng-container *ngSwitchCase="'editor'">
-      <app-view-editor [view]="view"></app-view-editor>
-    </ng-container>
-    <ng-container *ngSwitchCase="'expressionSelector'">
-      <app-view-expression-selector
-        [view]="view"
-      ></app-view-expression-selector>
-    </ng-container>
-    <ng-container *ngSwitchCase="'graphviz'">
-      <app-view-graphviz [view]="view"></app-view-graphviz>
-    </ng-container>
-    <ng-container *ngSwitchCase="'flexlayout'">
-      <app-view-flexlayout [view]="view"></app-view-flexlayout>
-    </ng-container>
-    <ng-container *ngSwitchCase="'labels'">
-      <app-view-labels [view]="view"></app-view-labels>
-    </ng-container>
-    <ng-container *ngSwitchCase="'labelSelector'">
-      <app-view-label-selector [view]="view"></app-view-label-selector>
-    </ng-container>
-    <ng-container *ngSwitchCase="'loading'">
-      <app-view-loading [view]="view"></app-view-loading>
-    </ng-container>
-    <ng-container *ngSwitchCase="'error'">
-      <app-view-error [view]="view"></app-view-error>
-    </ng-container>
-    <ng-container *ngSwitchCase="'iframe'">
-      <app-iframe [view]="view"></app-iframe>
-    </ng-container>
-    <ng-container *ngSwitchCase="'link'">
-      <app-view-link [view]="view"></app-view-link>
-    </ng-container>
-    <ng-container *ngSwitchCase="'list'">
-      <app-view-list [view]="view"></app-view-list>
-    </ng-container>
-    <ng-container *ngSwitchCase="'logs'">
-      <app-logs [view]="view"></app-logs>
-    </ng-container>
-    <ng-container *ngSwitchCase="'podStatus'">
-      <app-pod-status [view]="view"></app-pod-status>
-    </ng-container>
-    <ng-container *ngSwitchCase="'portforward'">
-      <app-view-port-forward [view]="view"></app-view-port-forward>
-    </ng-container>
-    <ng-container *ngSwitchCase="'ports'">
-      <app-view-ports [view]="view"></app-view-ports>
-    </ng-container>
-    <ng-container *ngSwitchCase="'quadrant'">
-      <app-view-quadrant [view]="view"></app-view-quadrant>
-    </ng-container>
-    <ng-container *ngSwitchCase="'resourceViewer'">
-      <app-view-resource-viewer [view]="view"></app-view-resource-viewer>
-    </ng-container>
-    <ng-container *ngSwitchCase="'selectors'">
-      <app-view-selectors [view]="view"></app-view-selectors>
-    </ng-container>
-    <ng-container *ngSwitchCase="'singleStat'">
-      <app-single-stat [view]="view"></app-single-stat>
-    </ng-container>
-    <ng-container *ngSwitchCase="'summary'">
-      <app-view-summary [view]="view"></app-view-summary>
-    </ng-container>
-    <ng-container *ngSwitchCase="'table'">
-      <app-view-datagrid [view]="view"></app-view-datagrid>
-    </ng-container>
-    <ng-container *ngSwitchCase="'text'">
-      <app-view-text [view]="view"></app-view-text>
-    </ng-container>
-    <ng-container *ngSwitchCase="'timestamp'">
-      <app-view-timestamp [view]="view"></app-view-timestamp>
-    </ng-container>
-    <ng-container *ngSwitchCase="'yaml'">
-      <app-view-yaml [view]="view"></app-view-yaml>
-    </ng-container>
-    <ng-container *ngSwitchCase="'terminal'">
-      <app-terminal [view]="view"></app-terminal>
-    </ng-container>
-    <ng-container *ngSwitchCase="'stepper'">
-      <app-stepper [view]="view"></app-stepper>
-    </ng-container>
-    <div class="alert alert-danger static" *ngSwitchDefault>
-      <span class="alert-icon-wrapper">
-        <clr-icon class="alert-icon" shape="exclamation-circle"></clr-icon>
-      </span>
-      <span class="alert-text"> {{ view.metadata.type }} not implemented </span>
-    </div>
-  </ng-container>
-</ng-container>
diff --git a/web/src/app/modules/shared/components/presentation/content-switcher/content-switcher.component.scss b/web/src/app/modules/shared/components/presentation/content-switcher/content-switcher.component.scss
deleted file mode 100644
index e78526e0..00000000
--- a/web/src/app/modules/shared/components/presentation/content-switcher/content-switcher.component.scss
+++ /dev/null
@@ -1,3 +0,0 @@
-/* Copyright (c) 2019 the Octant contributors. All Rights Reserved.
- * SPDX-License-Identifier: Apache-2.0
- */
diff --git a/web/src/app/modules/shared/components/presentation/content-switcher/content-switcher.component.spec.ts b/web/src/app/modules/shared/components/presentation/content-switcher/content-switcher.component.spec.ts
deleted file mode 100644
index 92a737c0..00000000
--- a/web/src/app/modules/shared/components/presentation/content-switcher/content-switcher.component.spec.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (c) 2019 the Octant contributors. All Rights Reserved.
-// SPDX-License-Identifier: Apache-2.0
-//
-
-import { async, ComponentFixture, TestBed } from '@angular/core/testing';
-
-import { ContentSwitcherComponent } from './content-switcher.component';
-import { SharedModule } from '../../../shared.module';
-
-describe('ContentSwitcherComponent', () => {
-  let component: ContentSwitcherComponent;
-  let fixture: ComponentFixture<ContentSwitcherComponent>;
-
-  beforeEach(async(() => {
-    TestBed.configureTestingModule({
-      imports: [SharedModule],
-    }).compileComponents();
-  }));
-
-  beforeEach(() => {
-    fixture = TestBed.createComponent(ContentSwitcherComponent);
-    component = fixture.componentInstance;
-    fixture.detectChanges();
-  });
-
-  it('should create', () => {
-    expect(component).toBeTruthy();
-  });
-});
diff --git a/web/src/app/modules/shared/components/presentation/content-switcher/content-switcher.component.ts b/web/src/app/modules/shared/components/presentation/content-switcher/content-switcher.component.ts
deleted file mode 100644
index 19b1be6f..00000000
--- a/web/src/app/modules/shared/components/presentation/content-switcher/content-switcher.component.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-// Copyright (c) 2019 the Octant contributors. All Rights Reserved.
-// SPDX-License-Identifier: Apache-2.0
-//
-
-import { Component, Input } from '@angular/core';
-import { View } from 'src/app/modules/shared/models/content';
-import fixPassiveEvents from '../../fixPassiveEvents';
-
-@Component({
-  selector: 'app-content-switcher',
-  templateUrl: './content-switcher.component.html',
-  styleUrls: ['./content-switcher.component.scss'],
-})
-export class ContentSwitcherComponent {
-  @Input() view: View;
-  constructor() {
-    fixPassiveEvents();
-  }
-}
diff --git a/web/src/app/modules/shared/components/presentation/content-text-filter/content-text-filter.component.html b/web/src/app/modules/shared/components/presentation/content-text-filter/content-text-filter.component.html
index 380618b5..a54b8743 100644
--- a/web/src/app/modules/shared/components/presentation/content-text-filter/content-text-filter.component.html
+++ b/web/src/app/modules/shared/components/presentation/content-text-filter/content-text-filter.component.html
@@ -4,6 +4,10 @@
   (ngModelChange)="onFilterChange($event)"
   [(ngModel)]="text"
 />
-<button  class="btn btn-sm btn-danger-outline reset-filter" [disabled]="!isActive()" (click)="reset()">
+<button
+  class="btn btn-sm btn-danger-outline reset-filter"
+  [disabled]="!isActive()"
+  (click)="reset()"
+>
   Reset
-</button>
\ No newline at end of file
+</button>
diff --git a/web/src/app/modules/shared/components/presentation/content-text-filter/content-text-filter.component.scss b/web/src/app/modules/shared/components/presentation/content-text-filter/content-text-filter.component.scss
index 9c6a8ffc..231cc390 100644
--- a/web/src/app/modules/shared/components/presentation/content-text-filter/content-text-filter.component.scss
+++ b/web/src/app/modules/shared/components/presentation/content-text-filter/content-text-filter.component.scss
@@ -1,4 +1,4 @@
 .reset-filter {
-    display: block;
-    width: 100%;
-}
\ No newline at end of file
+  display: block;
+  width: 100%;
+}
diff --git a/web/src/app/modules/shared/components/presentation/cytoscape/cytoscape.component.ts b/web/src/app/modules/shared/components/presentation/cytoscape/cytoscape.component.ts
index 3bb67cd0..1a8e023d 100644
--- a/web/src/app/modules/shared/components/presentation/cytoscape/cytoscape.component.ts
+++ b/web/src/app/modules/shared/components/presentation/cytoscape/cytoscape.component.ts
@@ -46,7 +46,7 @@ export class CytoscapeComponent implements OnChanges, OnDestroy {
 
   private instance: cytoscape.Core;
 
-  constructor(private renderer: Renderer2, private el: ElementRef) {
+  constructor(private renderer: Renderer2) {
     this.layout = this.layout || {
       name: 'grid',
       directed: true,
@@ -63,7 +63,7 @@ export class CytoscapeComponent implements OnChanges, OnDestroy {
   }
 
   ngOnDestroy(): void {
-    if (!this.instance.destroyed()) {
+    if (this.instance && !this.instance.destroyed()) {
       this.instance.destroy();
     }
   }
diff --git a/web/src/app/modules/shared/components/presentation/cytoscape2/cytoscape2.component.ts b/web/src/app/modules/shared/components/presentation/cytoscape2/cytoscape2.component.ts
index 2b2c60bc..03a39b9f 100644
--- a/web/src/app/modules/shared/components/presentation/cytoscape2/cytoscape2.component.ts
+++ b/web/src/app/modules/shared/components/presentation/cytoscape2/cytoscape2.component.ts
@@ -50,7 +50,7 @@ export class Cytoscape2Component implements OnChanges {
   applied = false;
   moveStarted = false;
 
-  constructor(private renderer: Renderer2, private el: ElementRef) {
+  constructor(private renderer: Renderer2) {
     this.layout = this.layout || {
       name: 'grid',
       directed: true,
@@ -90,7 +90,7 @@ export class Cytoscape2Component implements OnChanges {
       localSelect.emit(node.data());
     });
 
-    this.cytoscape.on('layoutstop', e => {
+    this.cytoscape.on('layoutstop', _ => {
       if (!this.applied) {
         this.applied = true;
         this.cytoscape
diff --git a/web/src/app/modules/shared/components/presentation/cytoscape2/shape.ts b/web/src/app/modules/shared/components/presentation/cytoscape2/shape.ts
index e1ddc150..d1e0c5e3 100644
--- a/web/src/app/modules/shared/components/presentation/cytoscape2/shape.ts
+++ b/web/src/app/modules/shared/components/presentation/cytoscape2/shape.ts
@@ -1,17 +1,16 @@
-
 export abstract class BaseShape {
-  protected constructor(public id: string) {
-  }
+  protected constructor(public id: string) {}
 
   toNode(shapes: BaseShape[]) {}
 }
 
 export class Edge extends BaseShape {
-
-  constructor( id: string,
-                  public sourceId: string,
-                  public targetId: string,
-                  public classes?: string ) {
+  constructor(
+    id: string,
+    public sourceId: string,
+    public targetId: string,
+    public classes?: string
+  ) {
     super(id);
   }
 
@@ -29,10 +28,9 @@ export class Edge extends BaseShape {
       locked: false,
       grabbable: true,
       pannable: false,
-      classes: this.classes
-    }
+      classes: this.classes,
+    };
   }
-
 }
 
 export abstract class Shape extends BaseShape {
@@ -42,68 +40,76 @@ export abstract class Shape extends BaseShape {
 
   textMeasureCanvas: any;
 
-  constructor(id: string,
-              public label: string,
-              public width: any,
-              public height: any,
-              public shape: string,
-              public hasChildren: boolean,
-              public parentId?: string) {
+  constructor(
+    id: string,
+    public label: string,
+    public width: any,
+    public height: any,
+    public shape: string,
+    public hasChildren: boolean,
+    public parentId?: string
+  ) {
     super(id);
   }
 
-  abstract preferredPosition(): {x: number, y: number};
+  abstract preferredPosition(): { x: number; y: number };
 
   ports: Shape[] = [];
 
   nextPortPosition(shapes: BaseShape[], port: Port, prefered: number): number {
-    if(this.ports.length === 0) {
+    if (this.ports.length === 0) {
       this.ports.push(port);
       return prefered;
     }
 
-    if(this.ports.includes(port)) {
+    if (this.ports.includes(port)) {
       return port.y;
     }
 
-    const total= this.ports.filter( (shape: Port) => shape.parentId === this.id && shape.location === port.location).length;
+    const total = this.ports.filter(
+      (shape: Port) =>
+        shape.parentId === this.id && shape.location === port.location
+    ).length;
     this.ports.push(port);
     return total > 0 ? prefered + this.height / 3 : prefered;
   }
 
-
   preferredPortPosition(shapes: BaseShape[]): number {
-    return(this.preferredPosition().y - this.height / 6);
+    return this.preferredPosition().y - this.height / 6;
   }
 
-  getPosition(shapes: BaseShape[]): {x: number, y: number}{
+  getPosition(shapes: BaseShape[]): { x: number; y: number } {
     return this.preferredPosition();
   }
 
-  getPortPosition(shapes: Shape[], port: Port): {x: number, y: number}{
-    const textWidth= this.getTextWidth(port.label) + 10;
-    const {x, y} = this.getPosition(shapes);
-    const portY= this.nextPortPosition(shapes, port, this.preferredPortPosition(shapes));
+  getPortPosition(shapes: Shape[], port: Port): { x: number; y: number } {
+    const textWidth = this.getTextWidth(port.label) + 10;
+    const { x, y } = this.getPosition(shapes);
+    const portY = this.nextPortPosition(
+      shapes,
+      port,
+      this.preferredPortPosition(shapes)
+    );
 
     switch (port.location) {
       default:
       case 'left':
-        return {x: x - this.width / 2 + textWidth / 2, y: portY}
+        return { x: x - this.width / 2 + textWidth / 2, y: portY };
       case 'right':
-        return {x: x + this.width / 2 - textWidth / 2, y: portY}
+        return { x: x + this.width / 2 - textWidth / 2, y: portY };
     }
   }
 
-  isMovable():boolean {
+  isMovable(): boolean {
     return this.parentId == undefined;
   }
 
-  getTextWidth(txt){
-    if(!this.textMeasureCanvas) {
-      this.textMeasureCanvas = document.createElement("canvas")
-    };
-    const context = this.textMeasureCanvas.getContext("2d");
-    context.font = "14px Metropolis";
+  getTextWidth(txt) {
+    if (!this.textMeasureCanvas) {
+      this.textMeasureCanvas = document.createElement('canvas');
+    }
+    const context = this.textMeasureCanvas.getContext('2d');
+    context.font = '14px Metropolis';
     const metrics = context.measureText(txt);
     return metrics.width - 2;
   }
@@ -128,102 +134,139 @@ export abstract class Shape extends BaseShape {
       locked: false,
       grabbable: this.isMovable(),
       pannable: false,
-      classes: this.classes
-    }
+      classes: this.classes,
+    };
   }
 }
 
 export class Deployment extends Shape {
-  constructor(id: string, label: string, hasChildren: boolean, parentId?: string) {
+  constructor(
+    id: string,
+    label: string,
+    hasChildren: boolean,
+    parentId?: string
+  ) {
     super(id, label, 800, 600, 'rectangle', hasChildren, parentId);
-    this.classes= 'deployment';
+    this.classes = 'deployment';
   }
 
-  preferredPosition(): {x: number, y: number} {
-    return{x: 750, y: 450}
-  };
+  preferredPosition(): { x: number; y: number } {
+    return { x: 750, y: 450 };
+  }
 
   preferredPortPosition(shapes: BaseShape[]): number {
-    return(this.preferredPosition().y/2);
+    return this.preferredPosition().y / 2;
   }
-
 }
 
 export class Secret extends Shape {
-  constructor(id: string, label: string, hasChildren: boolean, parentId?: string) {
+  constructor(
+    id: string,
+    label: string,
+    hasChildren: boolean,
+    parentId?: string
+  ) {
     super(id, label, 350, 200, 'roundrectangle', hasChildren, parentId);
-    this.classes= 'secret';
+    this.classes = 'secret';
   }
 
-  preferredPosition(): {x: number, y: number} {
-    return{x: 100, y: 1000}
-  };
+  preferredPosition(): { x: number; y: number } {
+    return { x: 100, y: 1000 };
+  }
 }
 
 export class ServiceAccount extends Shape {
-  constructor(id: string, label: string, hasChildren: boolean, parentId?: string) {
+  constructor(
+    id: string,
+    label: string,
+    hasChildren: boolean,
+    parentId?: string
+  ) {
     super(id, label, 350, 200, 'roundrectangle', hasChildren, parentId);
-    this.classes= 'secret';
+    this.classes = 'secret';
   }
 
-  preferredPosition(): {x: number, y: number} {
-    return{x: 600, y: 1000}
-  };
+  preferredPosition(): { x: number; y: number } {
+    return { x: 600, y: 1000 };
+  }
 }
 
 export class Service extends Shape {
-  constructor(id: string, label: string, hasChildren: boolean, parentId?: string) {
+  constructor(
+    id: string,
+    label: string,
+    hasChildren: boolean,
+    parentId?: string
+  ) {
     super(id, label, 350, 200, 'roundrectangle', hasChildren, parentId);
-    this.classes= 'secret';
+    this.classes = 'secret';
   }
 
-  preferredPosition(): {x: number, y: number} {
-    return{x: 0, y: 400}
-  };
+  preferredPosition(): { x: number; y: number } {
+    return { x: 0, y: 400 };
+  }
 }
 
 export class ReplicaSet extends Shape {
-  constructor(id: string, label: string, hasChildren: boolean, parentId?: string) {
+  constructor(
+    id: string,
+    label: string,
+    hasChildren: boolean,
+    parentId?: string
+  ) {
     super(id, label, 600, 400, 'rectangle', hasChildren, parentId);
-    this.classes= 'replicaset';
+    this.classes = 'replicaset';
   }
 
-  preferredPosition(): {x: number, y: number} {
-    return{x: 800, y: 500}
-  };
+  preferredPosition(): { x: number; y: number } {
+    return { x: 800, y: 500 };
+  }
 }
 
 export class Pod extends Shape {
-  constructor(id: string, label: string, hasChildren: boolean, parentId?: string) {
+  constructor(
+    id: string,
+    label: string,
+    hasChildren: boolean,
+    parentId?: string
+  ) {
     super(id, label, 350, 200, 'roundrectangle', hasChildren, parentId);
-    this.classes= 'pod';
+    this.classes = 'pod';
   }
 
-  preferredPosition(): {x: number, y: number} {
-    return{x: 875, y: 525}
-  };
+  preferredPosition(): { x: number; y: number } {
+    return { x: 875, y: 525 };
+  }
 }
 
 export class Port extends Shape {
-  constructor(id: string, label: string, public location: string, className: string, parentId?: string) {
+  constructor(
+    id: string,
+    label: string,
+    public location: string,
+    className: string,
+    parentId?: string
+  ) {
     super(id, label, 'label', 'label', 'rectangle', false, parentId);
-    this.classes= className;
+    this.classes = className;
   }
 
-  isMovable():boolean {
+  isMovable(): boolean {
     return false;
   }
 
-  preferredPosition(): {x: number, y: number} {
-    return{x: 750, y: 450}
-  };
+  preferredPosition(): { x: number; y: number } {
+    return { x: 750, y: 450 };
+  }
 
-  getPosition(shapes: Shape[]): {x: number, y: number} {
-    const parentNode: Shape= shapes.find( (shape: Shape) => shape.id===this.parentId);
+  getPosition(shapes: Shape[]): { x: number; y: number } {
+    const parentNode: Shape = shapes.find(
+      (shape: Shape) => shape.id === this.parentId
+    );
     const portPosition = parentNode.getPortPosition(shapes, this);
-    this.x= portPosition.x;
-    this.y= portPosition.y;
+    this.x = portPosition.x;
+    this.y = portPosition.y;
 
     return portPosition;
-  };
+  }
 }
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 a415734d..f9611c74 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,28 +1,27 @@
 <h4 *ngIf="showTitle()">{{ title }}</h4>
-<clr-datagrid>
+<clr-datagrid [clrDgLoading]="loading$ | async">
   <clr-dg-placeholder>
     <ng-container *ngIf="placeholder?.length > 0; else emptyPlaceholder">
       {{ placeholder }}
     </ng-container>
     <ng-template #emptyPlaceholder>
-      All content has been filtered out.
+      <!--      All content has been filtered out.-->
     </ng-template>
   </clr-dg-placeholder>
-  <clr-dg-column *ngFor="let columnName of columns; trackBy: identifyColumn"
-                  [clrDgSortBy]="columnName === 'Age' ? timeStampComparator : null"
-                  [(clrDgSortOrder)]="sortOrder"
+  <clr-dg-column
+    *ngFor="let columnName of columns; trackBy: identifyColumn"
+    [clrDgSortBy]="columnName === 'Age' ? timeStampComparator : null"
+    [(clrDgSortOrder)]="sortOrder"
   >
     {{ columnName }}
     <clr-dg-filter *ngIf="filters[columnName]">
-      <app-content-filter 
+      <app-content-filter
         [column]="columnName"
         [filter]="filters[columnName]"
       ></app-content-filter>
     </clr-dg-filter>
     <clr-dg-filter *ngIf="!filters[columnName]">
-      <app-content-text-filter 
-        [column]="columnName"
-      ></app-content-text-filter>
+      <app-content-text-filter [column]="columnName"></app-content-text-filter>
     </clr-dg-filter>
   </clr-dg-column>
   <clr-dg-row
@@ -30,14 +29,14 @@
     [ngClass]="row | filterDeletedDatagridRow"
   >
     <clr-dg-action-overflow *ngIf="row.actions.length > 0 && !row.isDeleted">
-      <ng-container *ngFor="let action of row.actions">
+      <ng-container *ngFor="let action of row.actions; trackBy: identifyAction">
         <button class="action-item" (click)="runAction(action)">
           {{ action.name }}
         </button>
       </ng-container>
     </clr-dg-action-overflow>
     <clr-dg-cell *ngFor="let column of columns; trackBy: identifyColumn">
-      <app-content-switcher [view]="row.data[column]"></app-content-switcher>
+      <app-view-container [view]="row.data[column]"></app-view-container>
     </clr-dg-cell>
   </clr-dg-row>
   <clr-dg-footer>
@@ -50,12 +49,6 @@
         {{ pagination.totalItems }} items
       </ng-container>
     </clr-dg-pagination>
-
-    <ng-container *ngIf="loading">
-      <span class="spinner spinner-inline" style="margin-right: 10px;">
-        Loading...
-      </span>
-    </ng-container>
   </clr-dg-footer>
 </clr-datagrid>
 
diff --git a/web/src/app/modules/shared/components/presentation/datagrid/datagrid.component.scss b/web/src/app/modules/shared/components/presentation/datagrid/datagrid.component.scss
index f3b6890f..3729f037 100644
--- a/web/src/app/modules/shared/components/presentation/datagrid/datagrid.component.scss
+++ b/web/src/app/modules/shared/components/presentation/datagrid/datagrid.component.scss
@@ -4,13 +4,12 @@
 
 .row-deleted {
   :host-context(body.dark) {
-    --barberpole-color: hsl(9, 95%, 12%)
+    --barberpole-color: hsl(9, 95%, 12%);
   }
 
   :host-context(body) {
-    --barberpole-color: hsl(9, 83%, 76%)
+    --barberpole-color: hsl(9, 83%, 76%);
   }
 
   background-color: var(--barberpole-color);
 }
-
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 3a599a34..318e058f 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
@@ -3,7 +3,11 @@
 //
 
 import { ClrDatagridSortOrder } from '@clr/angular';
-import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
+import {
+  ChangeDetectionStrategy,
+  ChangeDetectorRef,
+  Component,
+} from '@angular/core';
 import {
   Confirmation,
   GridAction,
@@ -12,32 +16,26 @@ import {
   TableRow,
   TableRowWithMetadata,
   TableView,
-  View,
 } from 'src/app/modules/shared/models/content';
 import trackByIndex from 'src/app/util/trackBy/trackByIndex';
 import trackByIdentity from 'src/app/util/trackBy/trackByIdentity';
 import { TimestampComparator } from '../../../../../util/timestamp-comparator';
 import { ViewService } from '../../../services/view/view.service';
 import { ActionService } from '../../../services/action/action.service';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
+import { BehaviorSubject, merge, Observable, timer } from 'rxjs';
+import { LoadingService } from '../../../services/loading/loading.service';
 
 @Component({
   selector: 'app-view-datagrid',
   templateUrl: './datagrid.component.html',
   styleUrls: ['./datagrid.component.scss'],
+  changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class DatagridComponent implements OnChanges {
-  private v: TableView;
+export class DatagridComponent extends AbstractViewComponent<TableView> {
   timeStampComparator = new TimestampComparator();
   sortOrder: ClrDatagridSortOrder = ClrDatagridSortOrder.UNSORTED;
 
-  @Input() set view(v: View) {
-    this.v = v as TableView;
-  }
-
-  get view() {
-    return this.v;
-  }
-
   columns: string[];
   rowsWithMetadata: TableRowWithMetadata[];
   title: string;
@@ -48,43 +46,48 @@ export class DatagridComponent implements OnChanges {
 
   actionDialogOptions: ActionDialogOptions = undefined;
 
-  private previousView: SimpleChanges;
-
   identifyRow = trackByIndex;
   identifyColumn = trackByIdentity;
+  identifyAction = trackByIdentity;
+
   loading: boolean;
+  loading$: Observable<boolean>;
 
   constructor(
     private viewService: ViewService,
-    private actionService: ActionService
-  ) {}
-
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view) {
-      if (
-        JSON.stringify(this.previousView) !==
-        JSON.stringify(changes.view.currentValue)
-      ) {
-        this.title = this.viewService.viewTitleAsText(this.view);
-
-        const current = changes.view.currentValue as TableView;
-        this.columns = current.config.columns.map(column => column.name);
-
-        if (current.config.rows) {
-          this.rowsWithMetadata = this.getRowsWithMetadata(current.config.rows);
-        }
-
-        this.placeholder = current.config.emptyContent;
-        this.lastUpdated = new Date();
-        this.loading = current.config.loading;
-        this.filters = current.config.filters;
-
-        this.previousView = changes.view.currentValue;
-      }
-    }
+    private actionService: ActionService,
+    private loadingService: LoadingService,
+    private cdr: ChangeDetectorRef
+  ) {
+    super();
+  }
+
+  update() {
+    this.title = this.viewService.viewTitleAsText(this.view);
+
+    this.loading = true;
+
+    const done = new BehaviorSubject(false);
+    this.loading$ = this.loadingService.withDelay(done, 250, 1000);
+
+    setTimeout(() => {
+      this.rowsWithMetadata = this.getRowsWithMetadata(this.v.config.rows);
+      this.placeholder = this.v.config.emptyContent;
+      this.lastUpdated = new Date();
+      this.loading = this.v.config.loading;
+      done.next(true);
+      done.complete();
+      this.cdr.markForCheck();
+    });
+    this.columns = this.v.config.columns.map(column => column.name);
+    this.filters = this.v.config.filters;
   }
 
   private getRowsWithMetadata(rows: TableRow[]): TableRowWithMetadata[] {
+    if (!rows) {
+      return [];
+    }
+
     return rows.map(row => {
       let actions: GridAction[] = [];
 
diff --git a/web/src/app/modules/shared/components/presentation/donut-chart/donut-chart.component.html b/web/src/app/modules/shared/components/presentation/donut-chart/donut-chart.component.html
index 67afd1b5..6d4d0006 100644
--- a/web/src/app/modules/shared/components/presentation/donut-chart/donut-chart.component.html
+++ b/web/src/app/modules/shared/components/presentation/donut-chart/donut-chart.component.html
@@ -1,4 +1,9 @@
-<svg [attr.viewBox]="viewBox()" class="donut" [attr.height]="scale" [attr.width]="scale">
+<svg
+  [attr.viewBox]="viewBox()"
+  class="donut"
+  [attr.height]="scale"
+  [attr.width]="scale"
+>
   <circle
     [attr.cx]="center()"
     [attr.cy]="center()"
diff --git a/web/src/app/modules/shared/components/presentation/donut-chart/donut-chart.component.ts b/web/src/app/modules/shared/components/presentation/donut-chart/donut-chart.component.ts
index 8278f065..d7d3d1a5 100644
--- a/web/src/app/modules/shared/components/presentation/donut-chart/donut-chart.component.ts
+++ b/web/src/app/modules/shared/components/presentation/donut-chart/donut-chart.component.ts
@@ -3,8 +3,9 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
-import { Component, Input, OnInit } from '@angular/core';
-import { DonutChartView, DonutSegment, View } from '../../../models/content';
+import { Component, Input } from '@angular/core';
+import { DonutChartView, DonutSegment } from '../../../models/content';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 export interface SegmentDescriptor {
   array: string;
@@ -17,23 +18,16 @@ export interface SegmentDescriptor {
   templateUrl: './donut-chart.component.html',
   styleUrls: ['./donut-chart.component.scss'],
 })
-export class DonutChartComponent implements OnInit {
-  v: DonutChartView;
-  scale: string;
-
-  @Input() set view(v: View) {
-    this.v = v as DonutChartView;
-  }
-  get view() {
-    return this.v;
-  }
-
+export class DonutChartComponent extends AbstractViewComponent<DonutChartView> {
   @Input() circumference = 100;
   height = 42;
+  scale: string;
 
-  constructor() {}
+  constructor() {
+    super();
+  }
 
-  ngOnInit() {
+  update() {
     if (this.v?.config?.size) {
       this.scale = String(this.v.config.size) + '%';
     }
diff --git a/web/src/app/modules/shared/components/presentation/error/error.component.ts b/web/src/app/modules/shared/components/presentation/error/error.component.ts
index 6cd45536..447c5943 100644
--- a/web/src/app/modules/shared/components/presentation/error/error.component.ts
+++ b/web/src/app/modules/shared/components/presentation/error/error.component.ts
@@ -2,32 +2,23 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 
-import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
-import { ErrorView, View } from 'src/app/modules/shared/models/content';
+import { Component } from '@angular/core';
+import { ErrorView } from 'src/app/modules/shared/models/content';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-error',
   templateUrl: './error.component.html',
   styleUrls: ['./error.component.scss'],
 })
-export class ErrorComponent implements OnChanges {
-  private v: ErrorView;
-
-  @Input() set view(v: View) {
-    this.v = v as ErrorView;
-  }
-  get view() {
-    return this.v;
-  }
-
+export class ErrorComponent extends AbstractViewComponent<ErrorView> {
   source: string;
 
-  constructor() {}
+  constructor() {
+    super();
+  }
 
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view.currentValue) {
-      const view = changes.view.currentValue as ErrorView;
-      this.source = view.config.data;
-    }
+  update() {
+    this.source = this.v.config.data;
   }
 }
diff --git a/web/src/app/modules/shared/components/presentation/expression-selector/expression-selector.component.ts b/web/src/app/modules/shared/components/presentation/expression-selector/expression-selector.component.ts
index 1c6a7203..de2852dd 100644
--- a/web/src/app/modules/shared/components/presentation/expression-selector/expression-selector.component.ts
+++ b/web/src/app/modules/shared/components/presentation/expression-selector/expression-selector.component.ts
@@ -2,39 +2,30 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 
-import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
-import {
-  ExpressionSelectorView,
-  View,
-} from 'src/app/modules/shared/models/content';
+import { Component } from '@angular/core';
+import { ExpressionSelectorView } from 'src/app/modules/shared/models/content';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-expression-selector',
   templateUrl: './expression-selector.component.html',
   styleUrls: ['./expression-selector.component.scss'],
 })
-export class ExpressionSelectorComponent implements OnChanges {
-  private v: ExpressionSelectorView;
-
-  @Input() set view(v: View) {
-    this.v = v as ExpressionSelectorView;
-  }
-  get view() {
-    return this.v;
-  }
-
+export class ExpressionSelectorComponent extends AbstractViewComponent<
+  ExpressionSelectorView
+> {
   key: string;
   operator: string;
   values: string;
 
-  constructor() {}
+  constructor() {
+    super();
+  }
 
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view.currentValue) {
-      const view = changes.view.currentValue as ExpressionSelectorView;
-      this.key = view.config.key;
-      this.operator = view.config.operator;
-      this.values = view.config.values?.join(',');
-    }
+  update() {
+    const view = this.v;
+    this.key = view.config.key;
+    this.operator = view.config.operator;
+    this.values = view.config.values?.join(',');
   }
 }
diff --git a/web/src/app/modules/shared/components/presentation/flexlayout/flexlayout.component.html b/web/src/app/modules/shared/components/presentation/flexlayout/flexlayout.component.html
index dfc78251..e57adca8 100644
--- a/web/src/app/modules/shared/components/presentation/flexlayout/flexlayout.component.html
+++ b/web/src/app/modules/shared/components/presentation/flexlayout/flexlayout.component.html
@@ -16,7 +16,7 @@
       *ngFor="let item of section; trackBy: identifySection"
       class="clr-col-md-{{ item.width / 2 }} content-card-parent"
     >
-      <app-content-switcher [view]="item.view"></app-content-switcher>
+      <app-view-container [view]="item.view"></app-view-container>
     </div>
   </div>
 </ng-container>
diff --git a/web/src/app/modules/shared/components/presentation/flexlayout/flexlayout.component.ts b/web/src/app/modules/shared/components/presentation/flexlayout/flexlayout.component.ts
index bbf29767..60a54941 100644
--- a/web/src/app/modules/shared/components/presentation/flexlayout/flexlayout.component.ts
+++ b/web/src/app/modules/shared/components/presentation/flexlayout/flexlayout.component.ts
@@ -2,36 +2,42 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 
-import { Component, Input } from '@angular/core';
+import { Component } from '@angular/core';
 import {
   ButtonGroupView,
   FlexLayoutItem,
-  FlexLayoutView,
-  View,
 } from 'src/app/modules/shared/models/content';
 import trackByIndex from 'src/app/util/trackBy/trackByIndex';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-flexlayout',
   templateUrl: './flexlayout.component.html',
   styleUrls: ['./flexlayout.component.scss'],
 })
-export class FlexlayoutComponent {
-  v: FlexLayoutView;
+export class FlexlayoutComponent extends AbstractViewComponent<any> {
+  buttonGroup: ButtonGroupView;
 
-  @Input() set view(v: View) {
-    this.v = v as FlexLayoutView;
-    this.buttonGroup = this.v.config.buttonGroup;
-    this.sections = this.v.config.sections;
-  }
+  sections: FlexLayoutItem[][];
+
+  identifySection = trackByIndex;
 
-  get view() {
-    return this.v;
+  constructor() {
+    super();
   }
 
-  buttonGroup: ButtonGroupView;
+  update() {
+    this.buttonGroup = this.v.config.buttonGroup;
+    this.sections = this.v.config.sections;
+  }
 
-  sections: FlexLayoutItem[][];
+  sectionStyle(item: FlexLayoutItem) {
+    return ['height', 'margin'].reduce((previousValue, currentValue) => {
+      if (!item[currentValue]) {
+        return previousValue;
+      }
 
-  identifySection = trackByIndex;
+      return { ...previousValue, [currentValue]: item[currentValue] };
+    }, {});
+  }
 }
diff --git a/web/src/app/modules/shared/components/presentation/graphviz/graphviz.component.ts b/web/src/app/modules/shared/components/presentation/graphviz/graphviz.component.ts
index db720f9c..798a8e9d 100644
--- a/web/src/app/modules/shared/components/presentation/graphviz/graphviz.component.ts
+++ b/web/src/app/modules/shared/components/presentation/graphviz/graphviz.component.ts
@@ -6,15 +6,13 @@ import {
   ChangeDetectionStrategy,
   Component,
   ElementRef,
-  Input,
-  OnChanges,
-  SimpleChanges,
   ViewChild,
   ViewEncapsulation,
 } from '@angular/core';
 
-import { GraphvizView, View } from 'src/app/modules/shared/models/content';
+import { GraphvizView } from 'src/app/modules/shared/models/content';
 import { D3GraphvizService } from '../../../services/d3/d3graphviz.service';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-graphviz',
@@ -23,27 +21,17 @@ import { D3GraphvizService } from '../../../services/d3/d3graphviz.service';
   encapsulation: ViewEncapsulation.None,
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class GraphvizComponent implements OnChanges {
+export class GraphvizComponent extends AbstractViewComponent<GraphvizView> {
   @ViewChild('viewer', { static: true }) private viewer: ElementRef;
 
-  private v: GraphvizView;
-
-  @Input() set view(v: View) {
-    this.v = v as GraphvizView;
-  }
-  get view() {
-    return this.v;
+  constructor(private d3GraphvizService: D3GraphvizService) {
+    super();
   }
 
-  constructor(private d3GraphvizService: D3GraphvizService) {}
-
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view.currentValue) {
-      const view = changes.view.currentValue as GraphvizView;
-      const current = view.config.dot;
-      if (current) {
-        this.d3GraphvizService.render(this.viewer, current);
-      }
+  update() {
+    const current = this.v.config.dot;
+    if (current) {
+      this.d3GraphvizService.render(this.viewer, current);
     }
   }
 }
diff --git a/web/src/app/modules/shared/components/presentation/iframe/iframe.component.ts b/web/src/app/modules/shared/components/presentation/iframe/iframe.component.ts
index c01d2ada..47e17758 100644
--- a/web/src/app/modules/shared/components/presentation/iframe/iframe.component.ts
+++ b/web/src/app/modules/shared/components/presentation/iframe/iframe.component.ts
@@ -1,31 +1,22 @@
-import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
-import { IFrameView, View } from 'src/app/modules/shared/models/content';
+import { Component } from '@angular/core';
+import { IFrameView } from 'src/app/modules/shared/models/content';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-iframe',
   templateUrl: './iframe.component.html',
   styleUrls: ['./iframe.component.scss'],
 })
-export class IFrameComponent implements OnChanges {
-  private v: IFrameView;
-
-  @Input() set view(v: View) {
-    this.v = v as IFrameView;
-  }
-  get view() {
-    return this.v;
-  }
-
+export class IFrameComponent extends AbstractViewComponent<IFrameView> {
   url: string;
   title: string;
 
-  constructor() {}
+  constructor() {
+    super();
+  }
 
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view.currentValue) {
-      const view = changes.view.currentValue as IFrameView;
-      this.url = view.config.url;
-      this.title = view.config.title;
-    }
+  update() {
+    this.url = this.v.config.url;
+    this.title = this.v.config.title;
   }
 }
diff --git a/web/src/app/modules/shared/components/presentation/indicator/indicator.component.html b/web/src/app/modules/shared/components/presentation/indicator/indicator.component.html
index ce4a7158..8db0d39a 100644
--- a/web/src/app/modules/shared/components/presentation/indicator/indicator.component.html
+++ b/web/src/app/modules/shared/components/presentation/indicator/indicator.component.html
@@ -3,7 +3,7 @@
     <clr-signpost>
       <div [ngClass]="[currentStatus, 'indicator']" clrSignpostTrigger></div>
       <clr-signpost-content *clrIfOpen>
-        <app-content-switcher [view]="detail"></app-content-switcher>
+        <app-view-container [view]="detail"></app-view-container>
       </clr-signpost-content>
     </clr-signpost>
   </ng-container>
diff --git a/web/src/app/modules/shared/components/presentation/label-selector/label-selector.component.ts b/web/src/app/modules/shared/components/presentation/label-selector/label-selector.component.ts
index a3129d67..e3abff68 100644
--- a/web/src/app/modules/shared/components/presentation/label-selector/label-selector.component.ts
+++ b/web/src/app/modules/shared/components/presentation/label-selector/label-selector.component.ts
@@ -2,41 +2,27 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 
-import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
-import { LabelSelectorView, View } from 'src/app/modules/shared/models/content';
+import { Component } from '@angular/core';
+import { LabelSelectorView } from 'src/app/modules/shared/models/content';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-label-selector',
   templateUrl: './label-selector.component.html',
   styleUrls: ['./label-selector.component.scss'],
 })
-export class LabelSelectorComponent implements OnChanges {
-  private v: LabelSelectorView;
-  private previousView: SimpleChanges;
-
-  @Input() set view(v: View) {
-    this.v = v as LabelSelectorView;
-  }
-  get view() {
-    return this.v;
-  }
+export class LabelSelectorComponent extends AbstractViewComponent<
+  LabelSelectorView
+> {
   key: string;
   value: string;
 
-  constructor() {}
-
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view.currentValue) {
-      if (
-        JSON.stringify(this.previousView) !==
-        JSON.stringify(changes.view.currentValue)
-      ) {
-        const view = changes.view.currentValue as LabelSelectorView;
-        this.key = view.config.key;
-        this.value = view.config.value;
+  constructor() {
+    super();
+  }
 
-        this.previousView = changes.view.currentValue;
-      }
-    }
+  update() {
+    this.key = this.v.config.key;
+    this.value = this.v.config.value;
   }
 }
diff --git a/web/src/app/modules/shared/components/presentation/labels/labels.component.ts b/web/src/app/modules/shared/components/presentation/labels/labels.component.ts
index 67253c05..1a7c6c73 100644
--- a/web/src/app/modules/shared/components/presentation/labels/labels.component.ts
+++ b/web/src/app/modules/shared/components/presentation/labels/labels.component.ts
@@ -2,47 +2,30 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 
-import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
+import { Component, Input } from '@angular/core';
 import { LabelsView, View } from 'src/app/modules/shared/models/content';
 import trackByIdentity from 'src/app/util/trackBy/trackByIdentity';
 import { ViewService } from '../../../services/view/view.service';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-labels',
   templateUrl: './labels.component.html',
   styleUrls: ['./labels.component.scss'],
 })
-export class LabelsComponent implements OnChanges {
-  private v: LabelsView;
-  private previousView: SimpleChanges;
-
-  @Input() set view(v: View) {
-    this.v = v as LabelsView;
-  }
-  get view() {
-    return this.v;
-  }
-
+export class LabelsComponent extends AbstractViewComponent<LabelsView> {
   title: string;
   labelKeys: string[];
   labels: { [key: string]: string };
   trackByIdentity = trackByIdentity;
 
-  constructor(private viewService: ViewService) {}
-
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view.currentValue) {
-      if (
-        JSON.stringify(this.previousView) !==
-        JSON.stringify(changes.view.currentValue)
-      ) {
-        const view = changes.view.currentValue as LabelsView;
-
-        this.title = this.viewService.viewTitleAsText(view);
-        this.labels = view.config.labels;
+  constructor(private viewService: ViewService) {
+    super();
+  }
 
-        this.previousView = changes.view.currentValue;
-      }
-    }
+  update() {
+    const view = this.v;
+    this.title = this.viewService.viewTitleAsText(view);
+    this.labels = view.config.labels;
   }
 }
diff --git a/web/src/app/modules/shared/components/presentation/link/link.component.ts b/web/src/app/modules/shared/components/presentation/link/link.component.ts
index bc64607f..91645894 100644
--- a/web/src/app/modules/shared/components/presentation/link/link.component.ts
+++ b/web/src/app/modules/shared/components/presentation/link/link.component.ts
@@ -2,8 +2,9 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 
-import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
-import { LinkView, View } from 'src/app/modules/shared/models/content';
+import { Component } from '@angular/core';
+import { LinkView } from 'src/app/modules/shared/models/content';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 const isUrlAbsolute = url =>
   url?.indexOf('://') > 0 || url?.indexOf('//') === 0;
@@ -13,33 +14,24 @@ const isUrlAbsolute = url =>
   templateUrl: './link.component.html',
   styleUrls: ['./link.component.scss'],
 })
-export class LinkComponent implements OnChanges {
-  private v: LinkView;
-
-  @Input() set view(v: View) {
-    this.v = v as LinkView;
-  }
-  get view() {
-    return this.v;
-  }
-
+export class LinkComponent extends AbstractViewComponent<LinkView> {
   ref: string;
   value: string;
   isAbsolute: boolean;
   hasStatus: boolean;
 
-  constructor() {}
+  constructor() {
+    super();
+  }
 
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view.currentValue) {
-      const view = changes.view.currentValue as LinkView;
-      this.ref = view.config.ref;
-      this.value = view.config.value;
-      this.isAbsolute = isUrlAbsolute(this.ref);
+  update() {
+    const view = this.v;
+    this.ref = view.config.ref;
+    this.value = view.config.value;
+    this.isAbsolute = isUrlAbsolute(this.ref);
 
-      if (view.config.status) {
-        this.hasStatus = true;
-      }
+    if (view.config.status) {
+      this.hasStatus = true;
     }
   }
 }
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 ce8f7628..2ccb31a0 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,5 +1,7 @@
-<app-breadcrumb [path]="title">
-</app-breadcrumb>
+<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>
+  <app-view-container
+    [view]="item"
+    (viewInit)="initDone()"
+  ></app-view-container>
 </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 34fdb5ed..1b054aad 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
@@ -1,7 +1,7 @@
 // Copyright (c) 2019 the Octant contributors. All Rights Reserved.
 // SPDX-License-Identifier: Apache-2.0
 //
-import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
+import { ChangeDetectionStrategy, Component } from '@angular/core';
 import {
   LinkView,
   ListView,
@@ -10,49 +10,44 @@ import {
 
 import { IconService } from '../../../services/icon/icon.service';
 import { ViewService } from '../../../services/view/view.service';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-list',
   templateUrl: './list.component.html',
   styleUrls: ['./list.component.scss'],
+  changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class ListComponent implements OnChanges {
-  v: ListView;
-
-  @Input() set view(v: View) {
-    this.v = v as ListView;
-  }
-  get view() {
-    return this.v;
-  }
-  title: string;
+export class ListComponent extends AbstractViewComponent<ListView> {
+  title: { title: string; url: string }[];
 
   iconName: string;
 
   constructor(
     private iconService: IconService,
     private viewService: ViewService
-  ) {}
+  ) {
+    super();
+  }
 
   identifyItem = (index: number, item: View): string => {
     return this.viewService.titleAsText(item.metadata.title);
   };
 
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view) {
-      const current = changes.view.currentValue;
-      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;
-        });
-      }
+  update() {
+    const current = this.v;
+    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.initialChildCount = this.v.config.items.length;
+      this.v.config.items.forEach(item => {
+        item.totalItems = this.v.config.items.length;
+      });
     }
   }
 }
diff --git a/web/src/app/modules/shared/components/presentation/loading/loading.component.ts b/web/src/app/modules/shared/components/presentation/loading/loading.component.ts
index ff4ea2b1..3e63af7d 100644
--- a/web/src/app/modules/shared/components/presentation/loading/loading.component.ts
+++ b/web/src/app/modules/shared/components/presentation/loading/loading.component.ts
@@ -2,32 +2,23 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 
-import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
+import { Component, Input } from '@angular/core';
 import { LoadingView, View } from 'src/app/modules/shared/models/content';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-loading',
   templateUrl: './loading.component.html',
   styleUrls: ['./loading.component.scss'],
 })
-export class LoadingComponent implements OnChanges {
-  private v: LoadingView;
-
-  @Input() set view(v: View) {
-    this.v = v as LoadingView;
-  }
-  get view() {
-    return this.v;
-  }
-
+export class LoadingComponent extends AbstractViewComponent<LoadingView> {
   value: string;
 
-  constructor() {}
+  constructor() {
+    super();
+  }
 
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view.currentValue) {
-      const view = changes.view.currentValue as LoadingView;
-      this.value = view.config.value;
-    }
+  update() {
+    this.value = this.v.config.value;
   }
 }
diff --git a/web/src/app/modules/shared/components/presentation/object-status/object-status.component.html b/web/src/app/modules/shared/components/presentation/object-status/object-status.component.html
index 952b1767..74e51e65 100644
--- a/web/src/app/modules/shared/components/presentation/object-status/object-status.component.html
+++ b/web/src/app/modules/shared/components/presentation/object-status/object-status.component.html
@@ -6,15 +6,15 @@
     <div class="card-block">
       <div class="card-title">
         <span *ngIf="node && !node.path">{{ node.name }}</span>
-        <app-content-switcher
+        <app-view-container
           *ngIf="node && node.path"
           [view]="node.path"
-        ></app-content-switcher>
+        ></app-view-container>
       </div>
       <div *ngIf="node" class="card-text">
         <ul class="node-status">
           <li *ngFor="let detail of node.details; trackBy: detailsTrackBy">
-            <app-content-switcher [view]="detail"></app-content-switcher>
+            <app-view-container [view]="detail"></app-view-container>
           </li>
         </ul>
       </div>
diff --git a/web/src/app/modules/shared/components/presentation/overflow-labels/overflow-labels.component.scss b/web/src/app/modules/shared/components/presentation/overflow-labels/overflow-labels.component.scss
index 50405f7e..73e3289d 100644
--- a/web/src/app/modules/shared/components/presentation/overflow-labels/overflow-labels.component.scss
+++ b/web/src/app/modules/shared/components/presentation/overflow-labels/overflow-labels.component.scss
@@ -13,7 +13,7 @@
     grid-auto-flow: column;
 
     .tooltip-wrapper {
-      margin: 0 .3rem 0 0;
+      margin: 0 0.3rem 0 0;
       overflow: hidden;
       text-overflow: ellipsis;
     }
diff --git a/web/src/app/modules/shared/components/presentation/overflow-selectors/overflow-selectors.component.scss b/web/src/app/modules/shared/components/presentation/overflow-selectors/overflow-selectors.component.scss
index 5bbf9e5f..9888535b 100644
--- a/web/src/app/modules/shared/components/presentation/overflow-selectors/overflow-selectors.component.scss
+++ b/web/src/app/modules/shared/components/presentation/overflow-selectors/overflow-selectors.component.scss
@@ -13,7 +13,7 @@
     grid-auto-flow: column;
 
     .tooltip-wrapper {
-      margin: 0 .3rem 0 0;
+      margin: 0 0.3rem 0 0;
       overflow: hidden;
       text-overflow: ellipsis;
     }
diff --git a/web/src/app/modules/shared/components/presentation/pod-status/pod-status.component.ts b/web/src/app/modules/shared/components/presentation/pod-status/pod-status.component.ts
index a75a023f..84fd7fa7 100644
--- a/web/src/app/modules/shared/components/presentation/pod-status/pod-status.component.ts
+++ b/web/src/app/modules/shared/components/presentation/pod-status/pod-status.component.ts
@@ -5,14 +5,12 @@
 import {
   Component,
   ElementRef,
-  Input,
-  OnChanges,
-  SimpleChanges,
   ViewChild,
   ViewEncapsulation,
 } from '@angular/core';
-import { PodStatusView, View } from '../../../models/content';
+import { PodStatusView } from '../../../models/content';
 import { PodStatus } from '../../../models/pod-status';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-pod-status',
@@ -20,26 +18,19 @@ import { PodStatus } from '../../../models/pod-status';
   styleUrls: ['./pod-status.component.scss'],
   encapsulation: ViewEncapsulation.None,
 })
-export class PodStatusComponent implements OnChanges {
+export class PodStatusComponent extends AbstractViewComponent<PodStatusView> {
   @ViewChild('container') private container: ElementRef;
 
-  private v: PodStatusView;
-
-  @Input() set view(v: View) {
-    this.v = v as PodStatusView;
-  }
-  get view() {
-    return this.v;
-  }
-
   edgeLength = 7;
 
   podStatuses: PodStatus[] = [];
 
-  constructor() {}
+  constructor() {
+    super();
+  }
 
-  ngOnChanges(changes: SimpleChanges): void {
-    const pods = changes.view.currentValue.config.pods;
+  update() {
+    const pods = this.v.config.pods;
 
     if (pods) {
       this.podStatuses = Object.keys(pods)
diff --git a/web/src/app/modules/shared/components/presentation/port-forward/port-forward.component.ts b/web/src/app/modules/shared/components/presentation/port-forward/port-forward.component.ts
index 4c911309..9d9bb97d 100644
--- a/web/src/app/modules/shared/components/presentation/port-forward/port-forward.component.ts
+++ b/web/src/app/modules/shared/components/presentation/port-forward/port-forward.component.ts
@@ -2,21 +2,21 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 
-import { Component, Input } from '@angular/core';
-import { PortForwardView, View } from 'src/app/modules/shared/models/content';
+import { Component } from '@angular/core';
+import { PortForwardView } from 'src/app/modules/shared/models/content';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-port-forward',
   templateUrl: './port-forward.component.html',
   styleUrls: ['./port-forward.component.scss'],
 })
-export class PortForwardComponent {
-  private v: PortForwardView;
-
-  @Input() set view(v: View) {
-    this.v = v as PortForwardView;
-  }
-  get view() {
-    return this.v;
+export class PortForwardComponent extends AbstractViewComponent<
+  PortForwardView
+> {
+  constructor() {
+    super();
   }
+
+  update() {}
 }
diff --git a/web/src/app/modules/shared/components/presentation/ports/ports.component.html b/web/src/app/modules/shared/components/presentation/ports/ports.component.html
index 0d98f1cc..b3136137 100644
--- a/web/src/app/modules/shared/components/presentation/ports/ports.component.html
+++ b/web/src/app/modules/shared/components/presentation/ports/ports.component.html
@@ -2,8 +2,12 @@
   <div class="port" *ngFor="let port of v?.config.ports; trackBy: trackByFn">
     <div class="port-text">
       <span>{{ port.config.port }}/{{ port.config.protocol }}</span>
-      <span *ngIf="port.config.targetPort > 0 && !port.config.targetPortName"> ⟹ {{port.config.targetPort}}</span>
-      <span *ngIf="port.config.targetPort > 0 && port.config.targetPortName"> ⟹ {{port.config.targetPortName}} ({{port.config.targetPort}})</span>
+      <span *ngIf="port.config.targetPort > 0 && !port.config.targetPortName">
+        ⟹ {{ port.config.targetPort }}</span
+      >
+      <span *ngIf="port.config.targetPort > 0 && port.config.targetPortName">
+        ⟹ {{ port.config.targetPortName }} ({{ port.config.targetPort }})</span
+      >
     </div>
     <div class="port-actions">
       <ng-container *ngIf="port.config.state?.isForwarded">
diff --git a/web/src/app/modules/shared/components/presentation/ports/ports.component.ts b/web/src/app/modules/shared/components/presentation/ports/ports.component.ts
index 8d9657d5..403f6389 100644
--- a/web/src/app/modules/shared/components/presentation/ports/ports.component.ts
+++ b/web/src/app/modules/shared/components/presentation/ports/ports.component.ts
@@ -5,14 +5,12 @@
 import {
   Component,
   EventEmitter,
-  Input,
-  OnChanges,
   OnDestroy,
   Output,
-  SimpleChanges,
   ViewEncapsulation,
 } from '@angular/core';
-import { PortsView, View } from 'src/app/modules/shared/models/content';
+import { PortsView } from 'src/app/modules/shared/models/content';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-ports',
@@ -20,37 +18,29 @@ import { PortsView, View } from 'src/app/modules/shared/models/content';
   styleUrls: ['./ports.component.scss'],
   encapsulation: ViewEncapsulation.None,
 })
-export class PortsComponent implements OnChanges, OnDestroy {
-  v: PortsView;
-
-  @Input() set view(v: View) {
-    this.v = v as PortsView;
-  }
-  get view() {
-    return this.v;
-  }
+export class PortsComponent extends AbstractViewComponent<PortsView>
+  implements OnDestroy {
+  private previousView: PortsView;
 
   @Output() portLoad: EventEmitter<boolean> = new EventEmitter(true);
 
-  constructor() {}
+  constructor() {
+    super();
+  }
 
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view.currentValue && !changes.view.isFirstChange()) {
-      const currentView = changes.view.currentValue as PortsView;
-      const prevView = changes.view.previousValue as PortsView;
-      if (JSON.stringify(currentView) !== JSON.stringify(prevView)) {
-        this.portLoad.emit(false);
-      }
+  update() {
+    if (JSON.stringify(this.v) !== JSON.stringify(this.previousView)) {
+      this.portLoad.emit(false);
     }
   }
 
   ngOnDestroy() {}
 
-  load(e: Event) {
+  load(_: Event) {
     this.portLoad.emit(true);
   }
 
-  trackByFn(index, item) {
+  trackByFn(index, _) {
     return index;
   }
 }
diff --git a/web/src/app/modules/shared/components/presentation/quadrant/quadrant.component.ts b/web/src/app/modules/shared/components/presentation/quadrant/quadrant.component.ts
index aa03590b..222a2435 100644
--- a/web/src/app/modules/shared/components/presentation/quadrant/quadrant.component.ts
+++ b/web/src/app/modules/shared/components/presentation/quadrant/quadrant.component.ts
@@ -2,13 +2,13 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 
-import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
+import { Component } from '@angular/core';
 import {
   QuadrantValue,
   QuadrantView,
-  View,
 } from 'src/app/modules/shared/models/content';
 import { ViewService } from '../../../services/view/view.service';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 const emptyQuadrantValue = { value: '', label: '' };
 
@@ -17,32 +17,23 @@ const emptyQuadrantValue = { value: '', label: '' };
   templateUrl: './quadrant.component.html',
   styleUrls: ['./quadrant.component.scss'],
 })
-export class QuadrantComponent implements OnChanges {
-  private v: QuadrantView;
-
-  @Input() set view(v: View) {
-    this.v = v as QuadrantView;
-  }
-  get view() {
-    return this.v;
-  }
-
+export class QuadrantComponent extends AbstractViewComponent<QuadrantView> {
   title: string;
   nw: QuadrantValue = emptyQuadrantValue;
   ne: QuadrantValue = emptyQuadrantValue;
   sw: QuadrantValue = emptyQuadrantValue;
   se: QuadrantValue = emptyQuadrantValue;
 
-  constructor(private viewService: ViewService) {}
+  constructor(private viewService: ViewService) {
+    super();
+  }
 
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view.currentValue) {
-      const view = changes.view.currentValue as QuadrantView;
-      this.title = this.viewService.viewTitleAsText(view);
-      this.nw = view.config.nw;
-      this.ne = view.config.ne;
-      this.sw = view.config.sw;
-      this.se = view.config.se;
-    }
+  update() {
+    const view = this.v;
+    this.title = this.viewService.viewTitleAsText(view);
+    this.nw = view.config.nw;
+    this.ne = view.config.ne;
+    this.sw = view.config.sw;
+    this.se = view.config.se;
   }
 }
diff --git a/web/src/app/modules/shared/components/presentation/resource-viewer/resource-viewer.component.ts b/web/src/app/modules/shared/components/presentation/resource-viewer/resource-viewer.component.ts
index be5be7c4..17b8c8eb 100644
--- a/web/src/app/modules/shared/components/presentation/resource-viewer/resource-viewer.component.ts
+++ b/web/src/app/modules/shared/components/presentation/resource-viewer/resource-viewer.component.ts
@@ -6,17 +6,15 @@ import {
   AfterViewInit,
   Component,
   Input,
-  OnChanges,
-  SimpleChanges,
   ViewEncapsulation,
 } from '@angular/core';
-import isEqual from 'lodash/isEqual';
 import {
   Node,
   ResourceViewerView,
   View,
 } from 'src/app/modules/shared/models/content';
 import { ElementsDefinition, Stylesheet } from 'cytoscape';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 const statusColorCodes = {
   ok: '#60b515',
@@ -32,17 +30,9 @@ const edgeColorCode = '#003d79';
   styleUrls: ['./resource-viewer.component.scss'],
   encapsulation: ViewEncapsulation.None,
 })
-export class ResourceViewerComponent implements OnChanges, AfterViewInit {
-  private v: ResourceViewerView;
-
-  @Input() set view(v: View) {
-    this.v = v as ResourceViewerView;
-  }
-  get view() {
-    return this.v;
-  }
-
-  currentView: ResourceViewerView;
+export class ResourceViewerComponent
+  extends AbstractViewComponent<ResourceViewerView>
+  implements AfterViewInit {
   selected: string;
   selectedNode: Node;
 
@@ -109,26 +99,21 @@ export class ResourceViewerComponent implements OnChanges, AfterViewInit {
 
   private afterFirstChange: boolean;
 
-  constructor() {}
+  constructor() {
+    super();
+  }
 
   ngAfterViewInit(): void {
     if (this.afterFirstChange) {
       this.graphData = this.generateGraphData();
     }
+    super.ngAfterViewInit();
   }
 
-  ngOnChanges(changes: SimpleChanges): void {
-    const isEquals = isEqual(
-      changes.view.currentValue,
-      changes.view.previousValue
-    );
-
-    if (changes.view.isFirstChange() || !isEquals) {
-      this.currentView = changes.view.currentValue as ResourceViewerView;
-      this.select(this.currentView.config.selected);
-      this.graphData = this.generateGraphData();
-      this.afterFirstChange = true;
-    }
+  update() {
+    this.select(this.v.config.selected);
+    this.graphData = this.generateGraphData();
+    this.afterFirstChange = true;
   }
 
   nodeChange(event) {
@@ -143,48 +128,44 @@ export class ResourceViewerComponent implements OnChanges, AfterViewInit {
   }
 
   private nodes() {
-    if (!this.currentView || !this.currentView.config.nodes) {
+    if (!this.v.config.nodes) {
       return [];
     }
 
-    const nodes = Object.entries(this.currentView.config.nodes).map(
-      ([name, details]) => {
-        const colorCode =
-          statusColorCodes[details.status] || statusColorCodes.error;
+    const nodes = Object.entries(this.v.config.nodes).map(([name, details]) => {
+      const colorCode =
+        statusColorCodes[details.status] || statusColorCodes.error;
 
-        return {
-          data: {
-            id: name,
-            name: `${details.name}\n${details.apiVersion} ${details.kind}`,
-            weight: 100,
-            colorCode,
-          },
-        };
-      }
-    );
+      return {
+        data: {
+          id: name,
+          name: `${details.name}\n${details.apiVersion} ${details.kind}`,
+          weight: 100,
+          colorCode,
+        },
+      };
+    });
 
     return Array.prototype.concat(...nodes);
   }
 
   private edges() {
-    if (!this.currentView || !this.currentView.config.edges) {
+    if (!this.v.config.edges) {
       return [];
     }
 
-    const edges = Object.entries(this.currentView.config.edges).map(
-      ([parent, maps]) => {
-        return maps.map(edge => {
-          return {
-            data: {
-              source: parent,
-              target: edge.node,
-              colorCode: edgeColorCode,
-              strength: 10,
-            },
-          };
-        });
-      }
-    );
+    const edges = Object.entries(this.v.config.edges).map(([parent, maps]) => {
+      return maps.map(edge => {
+        return {
+          data: {
+            source: parent,
+            target: edge.node,
+            colorCode: edgeColorCode,
+            strength: 10,
+          },
+        };
+      });
+    });
 
     return Array.prototype.concat(...edges);
   }
@@ -192,7 +173,7 @@ export class ResourceViewerComponent implements OnChanges, AfterViewInit {
   private select(id: string) {
     this.selected = id;
 
-    const nodes = this.currentView.config.nodes;
+    const nodes = this.v.config.nodes;
 
     if (nodes && nodes[id]) {
       this.selectedNode = nodes[id];
diff --git a/web/src/app/modules/shared/components/presentation/selectors/selectors.component.html b/web/src/app/modules/shared/components/presentation/selectors/selectors.component.html
index 11c69879..e1c1e57e 100644
--- a/web/src/app/modules/shared/components/presentation/selectors/selectors.component.html
+++ b/web/src/app/modules/shared/components/presentation/selectors/selectors.component.html
@@ -1,3 +1,6 @@
 <div class="view-selectors">
-  <app-overflow-selectors *ngIf="v?.config.selectors" [selectors]="v?.config.selectors"></app-overflow-selectors>
+  <app-overflow-selectors
+    *ngIf="v?.config.selectors"
+    [selectors]="v?.config.selectors"
+  ></app-overflow-selectors>
 </div>
diff --git a/web/src/app/modules/shared/components/presentation/selectors/selectors.component.ts b/web/src/app/modules/shared/components/presentation/selectors/selectors.component.ts
index 7a41bdea..0f4e318f 100644
--- a/web/src/app/modules/shared/components/presentation/selectors/selectors.component.ts
+++ b/web/src/app/modules/shared/components/presentation/selectors/selectors.component.ts
@@ -2,29 +2,26 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 
-import { Component, Input } from '@angular/core';
+import { Component } from '@angular/core';
 import {
   ExpressionSelectorView,
   LabelSelectorView,
   SelectorsView,
-  View,
 } from 'src/app/modules/shared/models/content';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-selectors',
   templateUrl: './selectors.component.html',
   styleUrls: ['./selectors.component.scss'],
 })
-export class SelectorsComponent {
-  v: SelectorsView;
-
-  @Input() set view(v: View) {
-    this.v = v as SelectorsView;
-  }
-  get view() {
-    return this.v;
+export class SelectorsComponent extends AbstractViewComponent<SelectorsView> {
+  constructor() {
+    super();
   }
 
+  update() {}
+
   identifyItem(
     index: number,
     item: ExpressionSelectorView | LabelSelectorView
diff --git a/web/src/app/modules/shared/components/presentation/single-stat/single-stat.component.ts b/web/src/app/modules/shared/components/presentation/single-stat/single-stat.component.ts
index 60c53129..acbe1b9d 100644
--- a/web/src/app/modules/shared/components/presentation/single-stat/single-stat.component.ts
+++ b/web/src/app/modules/shared/components/presentation/single-stat/single-stat.component.ts
@@ -1,22 +1,16 @@
-import { Component, Input, OnInit } from '@angular/core';
-import { SingleStatView, View } from '../../../models/content';
+import { Component } from '@angular/core';
+import { SingleStatView } from '../../../models/content';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-single-stat',
   templateUrl: './single-stat.component.html',
   styleUrls: ['./single-stat.component.scss'],
 })
-export class SingleStatComponent implements OnInit {
-  v: SingleStatView;
-
-  @Input() set view(v: View) {
-    this.v = v as SingleStatView;
-  }
-  get view() {
-    return this.v;
+export class SingleStatComponent extends AbstractViewComponent<SingleStatView> {
+  constructor() {
+    super();
   }
 
-  constructor() {}
-
-  ngOnInit() {}
+  update() {}
 }
diff --git a/web/src/app/modules/shared/components/presentation/stepper/stepper.component.html b/web/src/app/modules/shared/components/presentation/stepper/stepper.component.html
index a2eabff0..04149ab5 100644
--- a/web/src/app/modules/shared/components/presentation/stepper/stepper.component.html
+++ b/web/src/app/modules/shared/components/presentation/stepper/stepper.component.html
@@ -1,8 +1,4 @@
-<form
-  clrStepper
-  (ngSubmit)="onFormSubmit()"
-  [formGroup]="formGroup"
->
+<form clrStepper (ngSubmit)="onFormSubmit()" [formGroup]="formGroup">
   <div
     class="step-item"
     *ngFor="let step of steps; index as i; trackBy: trackByFn"
@@ -12,93 +8,103 @@
       <clr-step-description>{{ step.description }}</clr-step-description>
       <clr-step-content>
         <div *ngFor="let field of step.form?.fields; trackBy: trackByFn">
-            <ng-container [ngSwitch]="field.type">
-                <ng-container *ngSwitchCase="'checkbox'">
-                  <clr-checkbox-container>
-                    <label [for]="field.name">{{ field.label }}</label>
-                    <clr-checkbox-wrapper
-                      *ngFor="let opt of fieldChoices(field); trackBy: trackByFn"
-                    >
-                      <input
-                        type="checkbox"
-                        clrCheckbox
-                        [formControlName]="field.name"
-                        [value]="opt.value"
-                        [checked]="opt.checked"
-                      />
-                      <label>{{ opt.label }}</label>
-                    </clr-checkbox-wrapper>
-                    <clr-control-error>{{ field.error }}</clr-control-error>
-                  </clr-checkbox-container>
-                </ng-container>
-                <ng-container *ngSwitchCase="'radio'">
-                  <clr-radio-container>
-                    <label [for]="field.name">{{ field.label }}</label>
-                    <clr-radio-wrapper
-                      *ngFor="let opt of fieldChoices(field); trackBy: trackByFn"
-                    >
-                      <input
-                        type="radio"
-                        clrRadio
-                        [formControlName]="field.name"
-                        [value]="opt.value"
-                      />
-                      <label>{{ opt.label }}</label>
-                    </clr-radio-wrapper>
-                    <clr-control-error>{{ field.error }}</clr-control-error>
-                  </clr-radio-container>
-                </ng-container>
-                <ng-container *ngSwitchCase="'text'">
-                  <clr-input-container>
-                    <label [for]="field.name">{{ field.label }}</label>
-                    <input clrInput type="text" [name]="field.name" [formControlName]="field.name" [placeholder]="field.placeholder ? field.placeholder : ''" />
-                    <clr-control-error>{{ field.error }}</clr-control-error>
-                  </clr-input-container>
-                </ng-container>
-                <ng-container *ngSwitchCase="'number'">
-                  <clr-input-container>
-                    <label [for]="field.name">{{ field.label }}</label>
-                    <input clrInput type="number" [formControlName]="field.name" />
-                    <clr-control-error>{{ field.error }}</clr-control-error>
-                  </clr-input-container>
-                </ng-container>
-                <ng-container *ngSwitchCase="'password'">
-                  <clr-input-container>
-                    <label [for]="field.name">{{ field.label }}</label>
-                    <input clrInput type="password" [formControlName]="field.name" />
-                    <clr-control-error>{{ field.error }}</clr-control-error>
-                  </clr-input-container>
-                </ng-container>
-                <ng-container *ngSwitchCase="'select'">
-                  <clr-select-container>
-                    <label [for]="field.name">{{ field.label }}</label>
-                    <select
-                      clrSelect
-                      [formControlName]="field.name"
-                      [multiple]="field.configuration.multiple"
-                    >
-                      <option
-                        *ngFor="let opt of fieldChoices(field); trackBy: trackByFn"
-                        [value]="opt.value"
-                      >
-                        {{ opt.label }}
-                      </option>
-                    </select>
-                    <clr-control-error>{{ field.error }}</clr-control-error>
-                  </clr-select-container>
-                </ng-container>
-                <ng-container *ngSwitchCase="'textarea'">
-                  <clr-textarea-container>
-                    <label [for]="field.name">{{ field.label }}</label>
-                    <textarea clrTextarea [formControlName]="field.name"></textarea>
-                    <clr-control-error>{{ field.error }}</clr-control-error>
-                  </clr-textarea-container>
-                </ng-container>
-                <ng-container *ngSwitchCase="'hidden'"> </ng-container>
-                <ng-container *ngSwitchDefault>
-                  Unable to display form field type {{ field.type }}
-                </ng-container>
-              </ng-container>
+          <ng-container [ngSwitch]="field.type">
+            <ng-container *ngSwitchCase="'checkbox'">
+              <clr-checkbox-container>
+                <label [for]="field.name">{{ field.label }}</label>
+                <clr-checkbox-wrapper
+                  *ngFor="let opt of fieldChoices(field); trackBy: trackByFn"
+                >
+                  <input
+                    type="checkbox"
+                    clrCheckbox
+                    [formControlName]="field.name"
+                    [value]="opt.value"
+                    [checked]="opt.checked"
+                  />
+                  <label>{{ opt.label }}</label>
+                </clr-checkbox-wrapper>
+                <clr-control-error>{{ field.error }}</clr-control-error>
+              </clr-checkbox-container>
+            </ng-container>
+            <ng-container *ngSwitchCase="'radio'">
+              <clr-radio-container>
+                <label [for]="field.name">{{ field.label }}</label>
+                <clr-radio-wrapper
+                  *ngFor="let opt of fieldChoices(field); trackBy: trackByFn"
+                >
+                  <input
+                    type="radio"
+                    clrRadio
+                    [formControlName]="field.name"
+                    [value]="opt.value"
+                  />
+                  <label>{{ opt.label }}</label>
+                </clr-radio-wrapper>
+                <clr-control-error>{{ field.error }}</clr-control-error>
+              </clr-radio-container>
+            </ng-container>
+            <ng-container *ngSwitchCase="'text'">
+              <clr-input-container>
+                <label [for]="field.name">{{ field.label }}</label>
+                <input
+                  clrInput
+                  type="text"
+                  [name]="field.name"
+                  [formControlName]="field.name"
+                  [placeholder]="field.placeholder ? field.placeholder : ''"
+                />
+                <clr-control-error>{{ field.error }}</clr-control-error>
+              </clr-input-container>
+            </ng-container>
+            <ng-container *ngSwitchCase="'number'">
+              <clr-input-container>
+                <label [for]="field.name">{{ field.label }}</label>
+                <input clrInput type="number" [formControlName]="field.name" />
+                <clr-control-error>{{ field.error }}</clr-control-error>
+              </clr-input-container>
+            </ng-container>
+            <ng-container *ngSwitchCase="'password'">
+              <clr-input-container>
+                <label [for]="field.name">{{ field.label }}</label>
+                <input
+                  clrInput
+                  type="password"
+                  [formControlName]="field.name"
+                />
+                <clr-control-error>{{ field.error }}</clr-control-error>
+              </clr-input-container>
+            </ng-container>
+            <ng-container *ngSwitchCase="'select'">
+              <clr-select-container>
+                <label [for]="field.name">{{ field.label }}</label>
+                <select
+                  clrSelect
+                  [formControlName]="field.name"
+                  [multiple]="field.configuration.multiple"
+                >
+                  <option
+                    *ngFor="let opt of fieldChoices(field); trackBy: trackByFn"
+                    [value]="opt.value"
+                  >
+                    {{ opt.label }}
+                  </option>
+                </select>
+                <clr-control-error>{{ field.error }}</clr-control-error>
+              </clr-select-container>
+            </ng-container>
+            <ng-container *ngSwitchCase="'textarea'">
+              <clr-textarea-container>
+                <label [for]="field.name">{{ field.label }}</label>
+                <textarea clrTextarea [formControlName]="field.name"></textarea>
+                <clr-control-error>{{ field.error }}</clr-control-error>
+              </clr-textarea-container>
+            </ng-container>
+            <ng-container *ngSwitchCase="'hidden'"> </ng-container>
+            <ng-container *ngSwitchDefault>
+              Unable to display form field type {{ field.type }}
+            </ng-container>
+          </ng-container>
         </div>
         <button
           class="submit"
diff --git a/web/src/app/modules/shared/components/presentation/stepper/stepper.component.ts b/web/src/app/modules/shared/components/presentation/stepper/stepper.component.ts
index 5aab2d8c..b6fa83ee 100644
--- a/web/src/app/modules/shared/components/presentation/stepper/stepper.component.ts
+++ b/web/src/app/modules/shared/components/presentation/stepper/stepper.component.ts
@@ -1,27 +1,13 @@
+import { Component, EventEmitter, isDevMode, Output } from '@angular/core';
+import { ActionField, StepItem, StepperView } from '../../../models/content';
 import {
-  Component,
-  OnInit,
-  Input,
-  SimpleChanges,
-  EventEmitter,
-  Output,
-  OnChanges,
-  OnDestroy,
-  isDevMode,
-} from '@angular/core';
-import {
-  StepperView,
-  View,
-  ActionField,
-  StepItem,
-} from '../../../models/content';
-import {
-  FormGroup,
   FormBuilder,
-  Validators,
+  FormGroup,
   ValidatorFn,
+  Validators,
 } from '@angular/forms';
 import { WebsocketService } from '../../../services/websocket/websocket.service';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 interface Choice {
   label: string;
@@ -34,17 +20,7 @@ interface Choice {
   templateUrl: './stepper.component.html',
   styleUrls: ['./stepper.component.sass'],
 })
-export class StepperComponent implements OnInit {
-  private v: StepperView;
-
-  @Input() set view(v: View) {
-    this.v = v as StepperView;
-  }
-
-  get view() {
-    return this.v;
-  }
-
+export class StepperComponent extends AbstractViewComponent<StepperView> {
   @Output()
   submit: EventEmitter<FormGroup> = new EventEmitter(true);
 
@@ -53,23 +29,21 @@ export class StepperComponent implements OnInit {
 
   formGroup: FormGroup;
   action: string;
-  steps: StepItem[];
+  steps: StepItem[] = [];
 
   constructor(
     private formBuilder: FormBuilder,
     private websocketService: WebsocketService
   ) {
-    this.steps = [] as StepItem[];
+    super();
   }
 
-  ngOnInit() {
-    if (this.v) {
-      this.action = this.v.config.action;
-      this.steps = this.v.config.steps;
+  update() {
+    this.action = this.v.config.action;
+    this.steps = this.v.config.steps;
 
-      const stepGroups = this.createStepGroups(this.steps);
-      this.formGroup = this.formBuilder.group(stepGroups);
-    }
+    const stepGroups = this.createStepGroups(this.steps);
+    this.formGroup = this.formBuilder.group(stepGroups);
   }
 
   createStepGroups(steps: StepItem[]): { [name: string]: any } {
@@ -110,7 +84,7 @@ export class StepperComponent implements OnInit {
     this.cancel.emit(true);
   }
 
-  trackByFn(index, item) {
+  trackByFn(index, _) {
     return index;
   }
 
diff --git a/web/src/app/modules/shared/components/presentation/summary/summary.component.html b/web/src/app/modules/shared/components/presentation/summary/summary.component.html
index c077c4b1..a468add0 100644
--- a/web/src/app/modules/shared/components/presentation/summary/summary.component.html
+++ b/web/src/app/modules/shared/components/presentation/summary/summary.component.html
@@ -25,59 +25,8 @@
         <tbody>
           <tr *ngFor="let item of v?.config.sections; trackBy: identifyItem">
             <td class="left">{{ item.header }}</td>
-            <td class="left" [ngSwitch]="item?.content?.metadata?.type">
-              <ng-container *ngSwitchCase="'annotations'">
-                <app-view-annotations
-                  [view]="item.content"
-                ></app-view-annotations>
-              </ng-container>
-              <ng-container *ngSwitchCase="'labels'">
-                <app-view-labels [view]="item.content"></app-view-labels>
-              </ng-container>
-              <ng-container *ngSwitchCase="'link'">
-                <app-view-link [view]="item.content"></app-view-link>
-              </ng-container>
-              <ng-container *ngSwitchCase="'selectors'">
-                <app-view-selectors [view]="item.content"></app-view-selectors>
-              </ng-container>
-              <ng-container *ngSwitchCase="'text'">
-                <app-view-text [view]="item.content"></app-view-text>
-              </ng-container>
-              <ng-container *ngSwitchCase="'timestamp'">
-                <app-view-timestamp [view]="item.content"></app-view-timestamp>
-              </ng-container>
-              <ng-container *ngSwitchCase="'table'">
-                <app-view-table [view]="item.content"></app-view-table>
-              </ng-container>
-              <ng-container *ngSwitchCase="'ports'">
-                <app-view-ports
-                  [view]="item.content"
-                  (portLoad)="onPortLoad($event)"
-                ></app-view-ports>
-              </ng-container>
-              <ng-container *ngSwitchCase="'list'">
-                <app-view-list [view]="item.content"></app-view-list>
-              </ng-container>
-              <ng-container *ngSwitchCase="'loading'">
-                <app-view-loading [view]="item.content"></app-view-loading>
-              </ng-container>
-              <ng-container *ngSwitchCase="'error'">
-                <app-view-error [view]="item.content"></app-view-error>
-              </ng-container>
-              <ng-container *ngSwitchCase="'graphviz'">
-                <app-view-graphviz [view]="item.content"></app-view-graphviz>
-              </ng-container>
-              <div class="alert alert-danger static" *ngSwitchDefault>
-                <span class="alert-icon-wrapper">
-                  <clr-icon
-                    class="alert-icon"
-                    shape="exclamation-circle"
-                  ></clr-icon>
-                </span>
-                <span class="alert-text">
-                  {{ item?.content?.metadata?.type }} not implemented
-                </span>
-              </div>
+            <td class="left">
+              <app-view-container [view]="item.content"></app-view-container>
             </td>
           </tr>
         </tbody>
@@ -85,7 +34,7 @@
     </div>
     <div class="card-footer" *ngIf="shouldShowFooter()">
       <ng-container
-        *ngFor="let action of v?.config.actions; trackBy: identifyItem"
+        *ngFor="let action of v.config.actions; trackBy: identifyItem"
       >
         <button class="btn btn-sm btn-link" (click)="setAction(action)">
           {{ action.name }}
diff --git a/web/src/app/modules/shared/components/presentation/summary/summary.component.ts b/web/src/app/modules/shared/components/presentation/summary/summary.component.ts
index bf41c198..7900bc58 100644
--- a/web/src/app/modules/shared/components/presentation/summary/summary.component.ts
+++ b/web/src/app/modules/shared/components/presentation/summary/summary.component.ts
@@ -2,32 +2,23 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 
-import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
+import { Component } from '@angular/core';
 import {
   Action,
   SummaryItem,
   SummaryView,
-  View,
 } from 'src/app/modules/shared/models/content';
 import { FormGroup } from '@angular/forms';
 import { ActionService } from '../../../services/action/action.service';
 import { ViewService } from '../../../services/view/view.service';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-summary',
   templateUrl: './summary.component.html',
   styleUrls: ['./summary.component.scss'],
 })
-export class SummaryComponent implements OnChanges {
-  private v: SummaryView;
-
-  @Input() set view(v: View) {
-    this.v = v as SummaryView;
-  }
-  get view() {
-    return this.v;
-  }
-
+export class SummaryComponent extends AbstractViewComponent<SummaryView> {
   title: string;
   isLoading = false;
 
@@ -36,13 +27,13 @@ export class SummaryComponent implements OnChanges {
   constructor(
     private actionService: ActionService,
     private viewService: ViewService
-  ) {}
+  ) {
+    super();
+  }
 
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view.currentValue) {
-      const view: SummaryView = changes.view.currentValue;
-      this.title = this.viewService.viewTitleAsText(view);
-    }
+  update() {
+    const view = this.v;
+    this.title = this.viewService.viewTitleAsText(view);
   }
 
   identifyItem(index: number, item: SummaryItem): string {
diff --git a/web/src/app/modules/shared/components/presentation/table/table.component.html b/web/src/app/modules/shared/components/presentation/table/table.component.html
index 6a843ed9..93177cef 100644
--- a/web/src/app/modules/shared/components/presentation/table/table.component.html
+++ b/web/src/app/modules/shared/components/presentation/table/table.component.html
@@ -9,7 +9,7 @@
   <tbody>
     <tr *ngFor="let row of rows; trackBy: trackByIndex">
       <td class="left" *ngFor="let column of columns; trackBy: trackByIdentity">
-        <app-content-switcher [view]="row[column]"></app-content-switcher>
+        <app-view-container [view]="row[column]"></app-view-container>
       </td>
     </tr>
   </tbody>
diff --git a/web/src/app/modules/shared/components/presentation/table/table.component.ts b/web/src/app/modules/shared/components/presentation/table/table.component.ts
index 07bfa122..bdce221f 100644
--- a/web/src/app/modules/shared/components/presentation/table/table.component.ts
+++ b/web/src/app/modules/shared/components/presentation/table/table.component.ts
@@ -2,19 +2,19 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 
-import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
+import { Component } from '@angular/core';
 import { TableRow, TableView } from 'src/app/modules/shared/models/content';
 import trackByIdentity from 'src/app/util/trackBy/trackByIdentity';
 import trackByIndex from 'src/app/util/trackBy/trackByIndex';
 import { ViewService } from '../../../services/view/view.service';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-table',
   templateUrl: './table.component.html',
   styleUrls: ['./table.component.scss'],
 })
-export class TableComponent implements OnChanges {
-  @Input() view: TableView;
+export class TableComponent extends AbstractViewComponent<TableView> {
   columns: string[];
   rows: TableRow[];
   title: string;
@@ -22,15 +22,15 @@ export class TableComponent implements OnChanges {
   trackByIdentity = trackByIdentity;
   trackByIndex = trackByIndex;
 
-  constructor(private viewService: ViewService) {}
+  constructor(private viewService: ViewService) {
+    super();
+  }
 
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view) {
-      const current = changes.view.currentValue;
-      this.title = this.viewService.viewTitleAsText(current);
-      this.columns = current.config.columns.map(column => column.name);
-      this.rows = current.config.rows;
-      this.placeholder = current.config.emptyContent;
-    }
+  update() {
+    const current = this.v;
+    this.title = this.viewService.viewTitleAsText(current);
+    this.columns = current.config.columns.map(column => column.name);
+    this.rows = current.config.rows;
+    this.placeholder = current.config.emptyContent;
   }
 }
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 2598fa50..d77b092e 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,10 +1,9 @@
 <div class="clr-row clr-align-items-center clr-justify-content-between">
   <div class="clr-col-10">
-    <app-breadcrumb [path]="title">
-    </app-breadcrumb>
+    <app-breadcrumb [path]="title"> </app-breadcrumb>
   </div>
   <div class="clr-col-2">
-    <div class="action-buttons">
+    <div *ngIf="buttonGroup" class="action-buttons">
       <app-button-group [view]="buttonGroup"></app-button-group>
     </div>
   </div>
@@ -23,7 +22,7 @@
     </div>
     <ng-template [clrIfActive]="activeTab === tab.accessor">
       <clr-tab-content>
-        <app-content-switcher [view]="tab.view"></app-content-switcher>
+        <app-view-container [view]="tab.view"></app-view-container>
       </clr-tab-content>
     </ng-template>
   </clr-tab>
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 52c92dd5..4dd17ae4 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
@@ -10,9 +10,9 @@ import {
 } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
 import {
-  View,
   ButtonGroupView,
   PathItem,
+  View,
 } from 'src/app/modules/shared/models/content';
 import { SliderService } from 'src/app/modules/shared/slider/slider.service';
 import { ViewService } from '../../../services/view/view.service';
diff --git a/web/src/app/modules/shared/components/presentation/text/text.component.spec.ts b/web/src/app/modules/shared/components/presentation/text/text.component.spec.ts
index 1e8ac341..831d386a 100644
--- a/web/src/app/modules/shared/components/presentation/text/text.component.spec.ts
+++ b/web/src/app/modules/shared/components/presentation/text/text.component.spec.ts
@@ -7,6 +7,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
 import { TextView } from '../../../models/content';
 import { TextComponent } from './text.component';
 import { Status } from '../indicator/indicator.component';
+import { ClarityModule } from '@clr/angular';
 
 @Component({
   template: '<app-view-text [view]="view"></app-view-text>',
@@ -23,6 +24,7 @@ describe('TextComponent', () => {
     beforeEach(async(() => {
       TestBed.configureTestingModule({
         declarations: [TestWrapperComponent, TextComponent],
+        imports: [ClarityModule],
       }).compileComponents();
     }));
 
diff --git a/web/src/app/modules/shared/components/presentation/text/text.component.ts b/web/src/app/modules/shared/components/presentation/text/text.component.ts
index 4e5aaf19..ca21ec60 100644
--- a/web/src/app/modules/shared/components/presentation/text/text.component.ts
+++ b/web/src/app/modules/shared/components/presentation/text/text.component.ts
@@ -1,42 +1,33 @@
 // Copyright (c) 2019 the Octant contributors. All Rights Reserved.
 // SPDX-License-Identifier: Apache-2.0
 //
-import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
+import { Component } from '@angular/core';
 import { TextView } from 'src/app/modules/shared/models/content';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-text',
   templateUrl: './text.component.html',
   styleUrls: ['./text.component.scss'],
 })
-export class TextComponent implements OnChanges {
-  v: TextView;
-
-  @Input() set view(v: TextView) {
-    this.v = v as TextView;
-  }
-
-  get view(): TextView {
-    return this.v;
-  }
-
+export class TextComponent extends AbstractViewComponent<TextView> {
   value: string;
 
   isMarkdown: boolean;
 
   hasStatus = false;
 
-  constructor() {}
+  constructor() {
+    super();
+  }
 
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view && changes.view.currentValue) {
-      const view = changes.view.currentValue as TextView;
-      this.value = view.config.value;
-      this.isMarkdown = view.config.isMarkdown;
+  update() {
+    const view = this.v;
+    this.value = view.config.value;
+    this.isMarkdown = view.config.isMarkdown;
 
-      if (view.config.status) {
-        this.hasStatus = true;
-      }
+    if (view.config.status) {
+      this.hasStatus = true;
     }
   }
 }
diff --git a/web/src/app/modules/shared/components/presentation/timestamp/timestamp.component.scss b/web/src/app/modules/shared/components/presentation/timestamp/timestamp.component.scss
index 4f6d645d..f834d136 100644
--- a/web/src/app/modules/shared/components/presentation/timestamp/timestamp.component.scss
+++ b/web/src/app/modules/shared/components/presentation/timestamp/timestamp.component.scss
@@ -4,5 +4,5 @@
 @import '../../../../../../styles';
 
 .tooltip-wrapper {
-  margin: 0 .3rem 0 0;
+  margin: 0 0.3rem 0 0;
 }
diff --git a/web/src/app/modules/shared/components/presentation/timestamp/timestamp.component.ts b/web/src/app/modules/shared/components/presentation/timestamp/timestamp.component.ts
index 17ead553..04725051 100644
--- a/web/src/app/modules/shared/components/presentation/timestamp/timestamp.component.ts
+++ b/web/src/app/modules/shared/components/presentation/timestamp/timestamp.component.ts
@@ -2,39 +2,22 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 
-import {
-  Component,
-  Input,
-  OnChanges,
-  OnDestroy,
-  SimpleChanges,
-  ChangeDetectionStrategy,
-  OnInit,
-  ChangeDetectorRef,
-} from '@angular/core';
+import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
 import dayjs from 'dayjs';
 import utc from 'dayjs/plugin/utc';
 import LocalizedFormat from 'dayjs/plugin/localizedFormat';
-import { TimestampView, View } from 'src/app/modules/shared/models/content';
+import { TimestampView } from 'src/app/modules/shared/models/content';
 import { Subscription } from 'rxjs';
 import { ContentService } from '../../../services/content/content.service';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-timestamp',
   templateUrl: './timestamp.component.html',
   styleUrls: ['./timestamp.component.scss'],
-  changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class TimestampComponent implements OnInit, OnChanges, OnDestroy {
-  private v: TimestampView;
-
-  @Input() set view(v: View) {
-    this.v = v as TimestampView;
-  }
-  get view() {
-    return this.v;
-  }
-
+export class TimestampComponent extends AbstractViewComponent<TimestampView>
+  implements OnInit, OnDestroy {
   timestamp: number;
   humanReadable: string;
   age: string;
@@ -45,6 +28,7 @@ export class TimestampComponent implements OnInit, OnChanges, OnDestroy {
     private contentService: ContentService,
     private cd: ChangeDetectorRef
   ) {
+    super();
     dayjs.extend(utc);
     dayjs.extend(LocalizedFormat);
   }
@@ -62,16 +46,12 @@ export class TimestampComponent implements OnInit, OnChanges, OnDestroy {
     return `${-this.scrollPosition - 64}px`;
   }
 
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view && changes.view.currentValue) {
-      const view = changes.view.currentValue as TimestampView;
-
-      this.timestamp = view.config.timestamp;
-      this.humanReadable =
-        dayjs(this.timestamp * 1000)
-          .utc()
-          .format('llll') + ' UTC';
-    }
+  update() {
+    this.timestamp = this.v.config.timestamp;
+    this.humanReadable =
+      dayjs(this.timestamp * 1000)
+        .utc()
+        .format('llll') + ' UTC';
   }
 
   ngOnDestroy(): void {
diff --git a/web/src/app/modules/shared/components/presentation/yaml/yaml.component.ts b/web/src/app/modules/shared/components/presentation/yaml/yaml.component.ts
index 3fcd86ed..6dc84f9a 100644
--- a/web/src/app/modules/shared/components/presentation/yaml/yaml.component.ts
+++ b/web/src/app/modules/shared/components/presentation/yaml/yaml.component.ts
@@ -2,32 +2,23 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 
-import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
-import { View, YAMLView } from 'src/app/modules/shared/models/content';
+import { Component } from '@angular/core';
+import { YAMLView } from 'src/app/modules/shared/models/content';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-view-yaml',
   templateUrl: './yaml.component.html',
   styleUrls: ['./yaml.component.scss'],
 })
-export class YamlComponent implements OnChanges {
-  private v: YAMLView;
-
-  @Input() set view(v: View) {
-    this.v = v as YAMLView;
-  }
-  get view() {
-    return this.v;
-  }
-
+export class YamlComponent extends AbstractViewComponent<YAMLView> {
   source: string;
 
-  constructor() {}
+  constructor() {
+    super();
+  }
 
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes.view.currentValue) {
-      const view = changes.view.currentValue as YAMLView;
-      this.source = view.config.data;
-    }
+  update() {
+    this.source = this.v.config.data;
   }
 }
diff --git a/web/src/app/modules/shared/components/smart/context-selector/context-selector.component.html b/web/src/app/modules/shared/components/smart/context-selector/context-selector.component.html
index 3405c5e2..fd3ebecf 100644
--- a/web/src/app/modules/shared/components/smart/context-selector/context-selector.component.html
+++ b/web/src/app/modules/shared/components/smart/context-selector/context-selector.component.html
@@ -2,13 +2,17 @@
 </ng-container>
 
 <ng-template #contextMenu>
-  <clr-dropdown class= "dropdown-top" [clrCloseMenuOnItemClick]="true">
+  <clr-dropdown class="dropdown-top" [clrCloseMenuOnItemClick]="true">
     <button class="dropdown-button" type="button" clrDropdownTrigger>
       <clr-icon shape="cluster"></clr-icon>
       {{ selected | truncate }}
       <clr-icon shape="caret down"></clr-icon>
     </button>
-    <clr-dropdown-menu class="dropdown-top" *clrIfOpen [clrPosition]="'bottom-right'">
+    <clr-dropdown-menu
+      class="dropdown-top"
+      *clrIfOpen
+      [clrPosition]="'bottom-right'"
+    >
       <label class="dropdown-header">Contexts</label>
 
       <ng-container *ngFor="let context of contexts; trackBy: trackByFn">
diff --git a/web/src/app/modules/shared/components/smart/context-selector/context-selector.component.scss b/web/src/app/modules/shared/components/smart/context-selector/context-selector.component.scss
index 246aa3cb..26772894 100644
--- a/web/src/app/modules/shared/components/smart/context-selector/context-selector.component.scss
+++ b/web/src/app/modules/shared/components/smart/context-selector/context-selector.component.scss
@@ -27,5 +27,5 @@
 }
 
 .dropdown .dropdown-toggle:not(.btn) {
-  padding: 0 1.0rem 0 0;
+  padding: 0 1rem 0 0;
 }
diff --git a/web/src/app/modules/shared/components/smart/editor/editor.component.html b/web/src/app/modules/shared/components/smart/editor/editor.component.html
index 90a76606..ea884f02 100644
--- a/web/src/app/modules/shared/components/smart/editor/editor.component.html
+++ b/web/src/app/modules/shared/components/smart/editor/editor.component.html
@@ -1,15 +1,22 @@
 <div class="editor-container">
-    <div class="editor">
-        <ng-monaco-editor
-                [(ngModel)]="value"
-                [options]="options"
-                modelUri="file:text.yaml"
-        ></ng-monaco-editor>
-    </div>
-
-    <div class="controls">
-        <button (click)="reset()" [disabled]="isUpdateEnabled()" class="btn">Reset</button>
-        <button (click)="submit()" [disabled]="isUpdateEnabled()" class="btn btn-primary">{{ submitLabel }}</button>
-    </div>
+  <div class="editor">
+    <ng-monaco-editor
+      [(ngModel)]="value"
+      [options]="options"
+      modelUri="file:text.yaml"
+    ></ng-monaco-editor>
+  </div>
 
+  <div class="controls">
+    <button (click)="reset()" [disabled]="isUpdateEnabled()" class="btn">
+      Reset
+    </button>
+    <button
+      (click)="submit()"
+      [disabled]="isUpdateEnabled()"
+      class="btn btn-primary"
+    >
+      {{ submitLabel }}
+    </button>
+  </div>
 </div>
diff --git a/web/src/app/modules/shared/components/smart/editor/editor.component.ts b/web/src/app/modules/shared/components/smart/editor/editor.component.ts
index 52a79a45..70c174fa 100644
--- a/web/src/app/modules/shared/components/smart/editor/editor.component.ts
+++ b/web/src/app/modules/shared/components/smart/editor/editor.component.ts
@@ -3,10 +3,11 @@ Copyright (c) 2020 the Octant contributors. All Rights Reserved.
 SPDX-License-Identifier: Apache-2.0
 */
 
-import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
-import { EditorView, View } from '../../../models/content';
+import { Component } from '@angular/core';
+import { EditorView } from '../../../models/content';
 import { NamespaceService } from '../../../services/namespace/namespace.service';
 import { ActionService } from '../../../services/action/action.service';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 interface Options {
   readOnly: boolean;
@@ -18,17 +19,7 @@ interface Options {
   templateUrl: './editor.component.html',
   styleUrls: ['./editor.component.scss'],
 })
-export class EditorComponent implements OnChanges {
-  private v: EditorView;
-
-  @Input() set view(v: View) {
-    this.v = v as EditorView;
-  }
-
-  get view() {
-    return this.v;
-  }
-
+export class EditorComponent extends AbstractViewComponent<EditorView> {
   set value(v: string) {
     if (v !== this.editorValue) {
       this.isModified = true;
@@ -46,7 +37,7 @@ export class EditorComponent implements OnChanges {
 
   isModified = false;
 
-  options: Options;
+  options: Options = { language: 'yaml', readOnly: false };
 
   submitAction = 'action.octant.dev/update';
   submitLabel = 'Update';
@@ -55,24 +46,22 @@ export class EditorComponent implements OnChanges {
     private namespaceService: NamespaceService,
     private actionService: ActionService
   ) {
-    this.options = {} as Options;
+    super();
   }
 
-  ngOnChanges(changes: SimpleChanges) {
-    if (changes.view.currentValue) {
-      const view = changes.view.currentValue as EditorView;
-
-      if (!this.isModified) {
-        this.editorValue = view.config.value;
-        this.metadata = view.config.metadata;
-        this.pristineValue = view.config.value;
-        this.options.readOnly = view.config.readOnly;
-        this.options.language = view.config.language;
-      }
+  update() {
+    const view = this.v;
 
-      this.submitAction = view.config.submitAction || this.submitAction;
-      this.submitLabel = view.config.submitLabel || this.submitLabel;
+    if (!this.isModified) {
+      this.editorValue = view.config.value;
+      this.metadata = view.config.metadata;
+      this.pristineValue = view.config.value;
+      this.options.readOnly = view.config.readOnly;
+      this.options.language = view.config.language;
     }
+
+    this.submitAction = view.config.submitAction || this.submitAction;
+    this.submitLabel = view.config.submitLabel || this.submitLabel;
   }
 
   submit() {
diff --git a/web/src/app/modules/shared/components/smart/helper/helper.component.html b/web/src/app/modules/shared/components/smart/helper/helper.component.html
index c14083c9..ae03c8c2 100644
--- a/web/src/app/modules/shared/components/smart/helper/helper.component.html
+++ b/web/src/app/modules/shared/components/smart/helper/helper.component.html
@@ -4,7 +4,13 @@
       <clr-icon shape="help"></clr-icon>
     </button>
     <clr-dropdown-menu clrPosition="bottom-right" *clrIfOpen>
-      <div aria-label="Dropdown header About Octant" clrDropdownItem (click)="isModalOpen = true">About Octant</div>
+      <div
+        aria-label="Dropdown header About Octant"
+        clrDropdownItem
+        (click)="isModalOpen = true"
+      >
+        About Octant
+      </div>
     </clr-dropdown-menu>
   </clr-dropdown>
 </div>
@@ -15,21 +21,23 @@
     <table class="table table-vertical table-noborder">
       <tbody>
         <tr>
-            <th>Version</th>
-            <td>{{ version }}</td>
+          <th>Version</th>
+          <td>{{ version }}</td>
         </tr>
         <tr>
-            <th>Commit</th>
-            <td>{{ commit }}</td>
+          <th>Commit</th>
+          <td>{{ commit }}</td>
         </tr>
         <tr>
-            <th>Built</th>
-            <td>{{ time }}</td>
+          <th>Built</th>
+          <td>{{ time }}</td>
         </tr>
       </tbody>
     </table>
   </div>
   <div class="modal-footer">
-    <button type="button" class="btn btn-primary" (click)="isModalOpen = false">Close</button>
+    <button type="button" class="btn btn-primary" (click)="isModalOpen = false">
+      Close
+    </button>
   </div>
-</clr-modal>
\ No newline at end of file
+</clr-modal>
diff --git a/web/src/app/modules/shared/components/smart/logs/logs.component.html b/web/src/app/modules/shared/components/smart/logs/logs.component.html
index 85ce93cf..2f68e119 100644
--- a/web/src/app/modules/shared/components/smart/logs/logs.component.html
+++ b/web/src/app/modules/shared/components/smart/logs/logs.component.html
@@ -11,7 +11,8 @@
         <option
           *ngFor="let container of v?.config.containers"
           value="{{ container }}"
-        > {{ container === '' ? '[all containers]' : container }}</option
+        >
+          {{ container === '' ? '[all containers]' : container }}</option
         >
       </select>
     </clr-select-container>
diff --git a/web/src/app/modules/shared/components/smart/logs/logs.component.integration.spec.ts b/web/src/app/modules/shared/components/smart/logs/logs.component.integration.spec.ts
index eb7becd6..18f7158d 100644
--- a/web/src/app/modules/shared/components/smart/logs/logs.component.integration.spec.ts
+++ b/web/src/app/modules/shared/components/smart/logs/logs.component.integration.spec.ts
@@ -74,35 +74,15 @@ describe('LogsComponent <-> PodsLogsService', () => {
   beforeEach(() => {
     fixture = TestBed.createComponent(LogsComponent);
     component = fixture.componentInstance;
-    fixture.detectChanges();
     service = TestBed.inject(PodLogsService);
     httpClient = TestBed.inject(HttpClient);
     httpTestingController = TestBed.inject(HttpTestingController);
-  });
-
-  it('should render list of containers to choose from', () => {
-    expect(component.selectedContainer).toBe('');
 
     component.view = createTestLogsView([
       'containerA',
       'containerB',
       'containerC',
     ]);
-    fixture.detectChanges();
-
-    const selectOptionsDebugElements: DebugElement[] = fixture.debugElement.queryAll(
-      By.css('.container-select select > option')
-    );
-    expect(selectOptionsDebugElements.length).toBe(3);
-    expect(selectOptionsDebugElements[0].nativeElement.value).toBe(
-      'containerA'
-    );
-    expect(selectOptionsDebugElements[1].nativeElement.value).toBe(
-      'containerB'
-    );
-    expect(selectOptionsDebugElements[2].nativeElement.value).toBe(
-      'containerC'
-    );
   });
 
   it('should allow user to toggle displaying timestamps', () => {
@@ -142,7 +122,7 @@ describe('LogsComponent <-> PodsLogsService', () => {
     );
   });
 
-  it('should continously scroll to new logs if user has already scrolled to the bottom', () => {
+  it('should continuously scroll to new logs if user has already scrolled to the bottom', () => {
     const numberOfEntriesRequiredToScroll = 200;
     component.containerLogs = map(
       range(numberOfEntriesRequiredToScroll),
@@ -223,7 +203,13 @@ describe('LogsComponent <-> PodsLogsService', () => {
     expect(logWrapperNativeElement.scrollTop).toBe(halfwayScrollMark);
   });
 
-  it('should filer messages based on search string', () => {
+  it('should filter messages based on search string', () => {
+    component.view = createTestLogsView([
+      'containerA',
+      'containerB',
+      'containerC',
+    ]);
+
     component.shouldDisplayTimestamp = true;
     component.containerLogs = defaultTestLogs;
     component.filterText = 'message';
diff --git a/web/src/app/modules/shared/components/smart/logs/logs.component.spec.ts b/web/src/app/modules/shared/components/smart/logs/logs.component.spec.ts
index 790b6983..462d598d 100644
--- a/web/src/app/modules/shared/components/smart/logs/logs.component.spec.ts
+++ b/web/src/app/modules/shared/components/smart/logs/logs.component.spec.ts
@@ -4,7 +4,7 @@
 
 import { async, ComponentFixture, TestBed } from '@angular/core/testing';
 import { LogsComponent } from './logs.component';
-import { LogEntry } from 'src/app/modules/shared/models/content';
+import { LogEntry, LogsView } from 'src/app/modules/shared/models/content';
 import { By } from '@angular/platform-browser';
 import { DebugElement } from '@angular/core';
 import { AnsiPipe } from '../../../pipes/ansiPipe/ansi.pipe';
@@ -40,6 +40,19 @@ describe('LogsComponent', () => {
   beforeEach(() => {
     fixture = TestBed.createComponent(LogsComponent);
     component = fixture.componentInstance;
+    component.view = {
+      metadata: {
+        type: 'logs',
+        title: [],
+        accessor: 'logs',
+      },
+      config: {
+        namespace: 'default',
+        name: 'cartpod',
+        containers: [],
+      },
+    } as LogsView;
+
     fixture.detectChanges();
   });
 
diff --git a/web/src/app/modules/shared/components/smart/logs/logs.component.ts b/web/src/app/modules/shared/components/smart/logs/logs.component.ts
index 0db2f7dd..406f49f9 100644
--- a/web/src/app/modules/shared/components/smart/logs/logs.component.ts
+++ b/web/src/app/modules/shared/components/smart/logs/logs.component.ts
@@ -5,9 +5,10 @@
 import {
   AfterContentChecked,
   AfterViewChecked,
+  ChangeDetectionStrategy,
+  ChangeDetectorRef,
   Component,
   ElementRef,
-  Input,
   IterableDiffer,
   IterableDiffers,
   OnDestroy,
@@ -15,35 +16,24 @@ import {
   ViewChild,
   ViewEncapsulation,
 } from '@angular/core';
-import {
-  LogEntry,
-  LogsView,
-  View,
-} from 'src/app/modules/shared/models/content';
+import { LogEntry, LogsView } from 'src/app/modules/shared/models/content';
 import {
   PodLogsService,
   PodLogsStreamer,
 } from 'src/app/modules/shared/pod-logs/pod-logs.service';
 import { formatDate } from '@angular/common';
 import { Subscription } from 'rxjs';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-logs',
   templateUrl: './logs.component.html',
   styleUrls: ['./logs.component.scss'],
   encapsulation: ViewEncapsulation.None,
+  changeDetection: ChangeDetectionStrategy.Default,
 })
-export class LogsComponent
+export class LogsComponent extends AbstractViewComponent<LogsView>
   implements OnInit, OnDestroy, AfterContentChecked, AfterViewChecked {
-  v: LogsView;
-
-  @Input() set view(v: View) {
-    this.v = v as LogsView;
-  }
-  get view() {
-    return this.v;
-  }
-
   private logStream: PodLogsStreamer;
   scrollToBottom = true;
 
@@ -66,18 +56,22 @@ export class LogsComponent
 
   constructor(
     private podLogsService: PodLogsService,
-    private iterableDiffers: IterableDiffers
-  ) {}
+    private iterableDiffers: IterableDiffers,
+    private cdr: ChangeDetectorRef
+  ) {
+    super();
+  }
 
   ngOnInit() {
     this.containerLogsDiffer = this.iterableDiffers
       .find(this.containerLogs)
       .create();
-    if (this.v) {
-      if (this.v.config.containers && this.v.config.containers.length > 0) {
-        this.selectedContainer = this.v.config.containers[0];
-      }
-      this.startStream();
+    this.startStream();
+  }
+
+  protected update() {
+    if (this.v.config.containers && this.v.config.containers.length > 0) {
+      this.selectedContainer = this.v.config.containers[0];
     }
   }
 
@@ -125,6 +119,7 @@ export class LogsComponent
           }
           this.containerLogs.push(entry);
           this.updateSelectedCount();
+          this.cdr.markForCheck();
         }
       );
     }
diff --git a/web/src/app/modules/shared/components/smart/slider-view/slider-view.component.html b/web/src/app/modules/shared/components/smart/slider-view/slider-view.component.html
index 7828811e..8881a0cb 100644
--- a/web/src/app/modules/shared/components/smart/slider-view/slider-view.component.html
+++ b/web/src/app/modules/shared/components/smart/slider-view/slider-view.component.html
@@ -16,13 +16,11 @@ SPDX-License-Identifier: Apache-2.0 -->
     <clr-icon shape="angle" [class]="animationState"></clr-icon>
   </a>
   <div class="status-view--content" [ngStyle]="contentStyle">
-    <ng-container *ngIf="view?.config.tabs">
-      <app-object-tabs
-        [views]="tabs"
-        [payloads]="payloads"
-        [closable]="true"
-        [extView]="true"
-      ></app-object-tabs>
-    </ng-container>
+    <app-object-tabs
+      [views]="tabs"
+      [payloads]="payloads"
+      [closable]="true"
+      [extView]="true"
+    ></app-object-tabs>
   </div>
 </div>
diff --git a/web/src/app/modules/shared/components/smart/slider-view/slider-view.component.ts b/web/src/app/modules/shared/components/smart/slider-view/slider-view.component.ts
index 87106552..87a9d8a2 100644
--- a/web/src/app/modules/shared/components/smart/slider-view/slider-view.component.ts
+++ b/web/src/app/modules/shared/components/smart/slider-view/slider-view.component.ts
@@ -3,11 +3,12 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
-import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
+import { Component, OnChanges, SimpleChanges } from '@angular/core';
 import { ExtensionView, View } from 'src/app/modules/shared/models/content';
 import { SlideInOutAnimation } from './slide-in-out.animation';
 import { ResizeEvent } from 'angular-resizable-element';
 import { SliderService } from 'src/app/modules/shared/slider/slider.service';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   selector: 'app-slider-view',
@@ -15,9 +16,8 @@ import { SliderService } from 'src/app/modules/shared/slider/slider.service';
   styleUrls: ['./slider-view.component.scss'],
   animations: [SlideInOutAnimation],
 })
-export class SliderViewComponent implements OnChanges {
-  @Input() view: ExtensionView;
-
+export class SliderViewComponent extends AbstractViewComponent<ExtensionView>
+  implements OnChanges {
   style: object = {};
   contentStyle: object = {};
   animationState = 'out';
@@ -26,33 +26,43 @@ export class SliderViewComponent implements OnChanges {
   tabs: View[] = [];
   payloads: { [key: string]: string }[] = [];
 
-  constructor(private sliderService: SliderService) {}
+  constructor(private sliderService: SliderService) {
+    super();
+  }
+
+  update() {
+    const extView = this.v;
+    if (extView && extView.config.tabs) {
+      this.tabs = [];
+      this.payloads = [];
+      extView.config.tabs.forEach(tab => {
+        this.tabs.push(tab.tab);
+        this.payloads.push(tab.payload);
+      });
+    } else {
+      this.animationState = 'out';
+      this.sliderService.resetDefault();
+    }
+  }
 
   ngOnChanges(changes: SimpleChanges): void {
     if (changes.view.currentValue) {
-      const extView = changes.view.currentValue as ExtensionView;
-      if (extView.config.tabs) {
-        this.tabs = [];
-        this.payloads = [];
-        extView.config.tabs.forEach(tab => {
-          this.tabs.push(tab.tab);
-          this.payloads.push(tab.payload);
-        });
-
-        if (!changes.view.isFirstChange() && this.animationState === 'out') {
-          const prevExtView = changes.view.previousValue as ExtensionView;
-          if (prevExtView.config.tabs === null && extView.config.tabs.length) {
-            this.slide();
-            return;
-          }
-          if (extView.config.tabs.length > prevExtView.config.tabs.length) {
-            this.slide();
-            return;
-          }
+      if (!changes.view.isFirstChange() && this.animationState === 'out') {
+        const prevExtView = changes.view.previousValue as ExtensionView;
+        if (
+          prevExtView.config.tabs === null &&
+          changes.view.currentValue.config.tabs.length
+        ) {
+          this.slide();
+          return;
+        }
+        if (
+          changes.view.currentValue.config.tabs.length >
+          prevExtView.config.tabs.length
+        ) {
+          this.slide();
+          return;
         }
-      } else {
-        this.animationState = 'out';
-        this.sliderService.resetDefault();
       }
     }
   }
diff --git a/web/src/app/modules/shared/components/smart/terminal/terminal.component.html b/web/src/app/modules/shared/components/smart/terminal/terminal.component.html
index eea0b9aa..2da6ad13 100644
--- a/web/src/app/modules/shared/components/smart/terminal/terminal.component.html
+++ b/web/src/app/modules/shared/components/smart/terminal/terminal.component.html
@@ -8,10 +8,7 @@
       (change)="onContainerChange($event.target.value)"
     >
       <option
-        *ngFor="
-          let container of view?.config.containers;
-          trackBy: trackByIdentity
-        "
+        *ngFor="let container of containers; trackBy: trackByIdentity"
         value="{{ container }}"
         >{{ container }}</option
       >
diff --git a/web/src/app/modules/shared/components/smart/terminal/terminal.component.spec.ts b/web/src/app/modules/shared/components/smart/terminal/terminal.component.spec.ts
index 7e1ed4de..658050da 100644
--- a/web/src/app/modules/shared/components/smart/terminal/terminal.component.spec.ts
+++ b/web/src/app/modules/shared/components/smart/terminal/terminal.component.spec.ts
@@ -3,6 +3,7 @@
 //
 import { async, ComponentFixture, TestBed } from '@angular/core/testing';
 import { TerminalComponent } from './terminal.component';
+import { TerminalView } from '../../../models/content';
 
 describe('TerminalComponent', () => {
   let component: TerminalComponent;
@@ -17,6 +18,20 @@ describe('TerminalComponent', () => {
   beforeEach(() => {
     fixture = TestBed.createComponent(TerminalComponent);
     component = fixture.componentInstance;
+    component.view = {
+      metadata: {
+        type: '',
+      },
+      config: {
+        containers: [],
+        name: 'name',
+        namespace: 'namespace',
+        podName: 'pod-name',
+        terminal: {
+          active: false,
+        },
+      },
+    } as TerminalView;
     fixture.detectChanges();
   });
 
diff --git a/web/src/app/modules/shared/components/smart/terminal/terminal.component.ts b/web/src/app/modules/shared/components/smart/terminal/terminal.component.ts
index 934bf4df..f9032b53 100644
--- a/web/src/app/modules/shared/components/smart/terminal/terminal.component.ts
+++ b/web/src/app/modules/shared/components/smart/terminal/terminal.component.ts
@@ -3,10 +3,9 @@
 //
 import {
   AfterViewInit,
+  ChangeDetectionStrategy,
   Component,
   ElementRef,
-  HostListener,
-  Input,
   OnDestroy,
   ViewChild,
   ViewEncapsulation,
@@ -20,26 +19,40 @@ import {
 import trackByIdentity from 'src/app/util/trackBy/trackByIdentity';
 import { TerminalView } from 'src/app/modules/shared/models/content';
 import { WebsocketService } from '../../../services/websocket/websocket.service';
+import { AbstractViewComponent } from '../../abstract-view/abstract-view.component';
 
 @Component({
   encapsulation: ViewEncapsulation.None,
   selector: 'app-terminal',
   styleUrls: ['./terminal.component.scss'],
   templateUrl: './terminal.component.html',
+  changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class TerminalComponent implements OnDestroy, AfterViewInit {
-  constructor(
-    private terminalService: TerminalOutputService,
-    private wss: WebsocketService
-  ) {}
+export class TerminalComponent extends AbstractViewComponent<TerminalView>
+  implements OnDestroy, AfterViewInit {
+  @ViewChild('terminal', { static: true }) terminalDiv: ElementRef;
 
   selectedContainer = '';
+  containers: string[] = [];
+
   private terminalStream: TerminalOutputStreamer;
   private term: Terminal;
   private fitAddon: FitAddon;
+
   trackByIdentity = trackByIdentity;
-  @Input() view: TerminalView;
-  @ViewChild('terminal', { static: true }) terminalDiv: ElementRef;
+
+  constructor(
+    private terminalService: TerminalOutputService,
+    private wss: WebsocketService
+  ) {
+    super();
+  }
+
+  update() {
+    if (this.v) {
+      this.containers = this.v.config.containers;
+    }
+  }
 
   ngOnDestroy(): void {
     if (this.terminalStream) {
@@ -51,32 +64,32 @@ export class TerminalComponent implements OnDestroy, AfterViewInit {
   }
 
   ngAfterViewInit() {
-    if (this.view) {
-      const logLevel = 'info';
-      const { terminal } = this.view.config;
-      const { active } = terminal;
-      const disableStdin = !active;
-      this.term = new Terminal({
-        logLevel,
-        disableStdin,
-      });
-      setTimeout(() => {
-        this.initStream();
-      });
-      this.enableResize();
-      this.term.onData(data => {
-        if (active) {
-          this.wss.sendMessage('action.octant.dev/sendTerminalCommand', {
-            key: data,
-          });
-        }
-      });
-      this.fitAddon = new FitAddon();
-      this.term.loadAddon(this.fitAddon);
-      this.term.open(this.terminalDiv.nativeElement);
-      this.term.focus();
-      this.fitAddon.fit();
-    }
+    const logLevel = 'info';
+    const { terminal } = this.v.config;
+    const { active } = terminal;
+    const disableStdin = !active;
+    this.term = new Terminal({
+      logLevel,
+      disableStdin,
+    });
+    setTimeout(() => {
+      this.initStream();
+    });
+    this.enableResize();
+    this.term.onData(data => {
+      if (active) {
+        this.wss.sendMessage('action.octant.dev/sendTerminalCommand', {
+          key: data,
+        });
+      }
+    });
+    this.fitAddon = new FitAddon();
+    this.term.loadAddon(this.fitAddon);
+    this.term.open(this.terminalDiv.nativeElement);
+    this.term.focus();
+    this.fitAddon.fit();
+
+    super.ngAfterViewInit();
   }
 
   enableResize() {
@@ -108,7 +121,7 @@ export class TerminalComponent implements OnDestroy, AfterViewInit {
   }
 
   initStream() {
-    const { namespace, podName, terminal } = this.view.config;
+    const { namespace, podName, terminal } = this.v.config;
     const { container } = terminal;
     if (this.terminalService.selectedContainer) {
       this.selectedContainer = this.terminalService.selectedContainer;
diff --git a/web/src/app/modules/shared/components/view/view-container.component.spec.ts b/web/src/app/modules/shared/components/view/view-container.component.spec.ts
new file mode 100644
index 00000000..60e87308
--- /dev/null
+++ b/web/src/app/modules/shared/components/view/view-container.component.spec.ts
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ViewContainerComponent } from './view-container.component';
+import { DYNAMIC_COMPONENTS_MAPPING } from '../../dynamic-components';
+import { TextComponent } from '../presentation/text/text.component';
+
+describe('ViewContainerComponent', () => {
+  let component: ViewContainerComponent;
+  let fixture: ComponentFixture<ViewContainerComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ViewContainerComponent],
+      providers: [
+        {
+          provide: DYNAMIC_COMPONENTS_MAPPING,
+          useValue: {
+            text: TextComponent,
+          },
+        },
+      ],
+    }).compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(ViewContainerComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/web/src/app/modules/shared/components/view/view-container.component.ts b/web/src/app/modules/shared/components/view/view-container.component.ts
new file mode 100644
index 00000000..fcccb20c
--- /dev/null
+++ b/web/src/app/modules/shared/components/view/view-container.component.ts
@@ -0,0 +1,110 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+  AfterViewInit,
+  ChangeDetectionStrategy,
+  Component,
+  ComponentFactoryResolver,
+  EventEmitter,
+  Inject,
+  Input,
+  OnChanges,
+  OnInit,
+  Output,
+  SimpleChanges,
+  Type,
+  ViewChild,
+} from '@angular/core';
+import { View } from '../../models/content';
+import { ViewHostDirective } from '../../directives/view-host/view-host.directive';
+import {
+  ComponentMapping,
+  DYNAMIC_COMPONENTS_MAPPING,
+} from '../../dynamic-components';
+import { MissingComponentComponent } from '../missing-component/missing-component.component';
+
+interface Viewer {
+  view: View;
+  viewInit: EventEmitter<void>;
+  ping: () => void;
+}
+
+@Component({
+  selector: 'app-view-container',
+  template: `<ng-container appView></ng-container>`,
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ViewContainerComponent
+  implements OnInit, OnChanges, AfterViewInit {
+  @ViewChild(ViewHostDirective, { static: true }) appView: ViewHostDirective;
+  @Input() view: View;
+  @Input() enableDebug = false;
+  @Output() viewInit: EventEmitter<void> = new EventEmitter<void>();
+
+  private start: number;
+
+  constructor(
+    private componentFactoryResolver: ComponentFactoryResolver,
+    @Inject(DYNAMIC_COMPONENTS_MAPPING)
+    private componentMappings: ComponentMapping
+  ) {}
+
+  ngOnInit(): void {
+    this.loadView();
+
+    if (this.enableDebug) {
+      this.start = new Date().getTime();
+    }
+  }
+
+  ngAfterViewInit() {
+    if (this.view === null) {
+      return;
+    }
+
+    if (this.enableDebug) {
+      console.log(
+        `${this.view.metadata.type}: ${new Date().getTime() - this.start}`
+      );
+    }
+  }
+
+  ngOnChanges(changes: SimpleChanges) {
+    if (changes.view.currentValue) {
+      // TODO: send a checksum so this doesn't need to be calculated here.
+      const prev = JSON.stringify(changes.view.previousValue);
+      const cur = JSON.stringify(changes.view.currentValue);
+
+      if (cur !== prev) {
+        this.loadView();
+      }
+    }
+  }
+
+  loadView() {
+    if (!this.view || !this.view.metadata) {
+      return;
+    }
+
+    const viewType = this.view.metadata.type;
+    let component: Type<any> = this.componentMappings[viewType];
+    if (!component) {
+      component = MissingComponentComponent;
+    }
+
+    const componentFactory = this.componentFactoryResolver.resolveComponentFactory(
+      component
+    );
+    const viewContainerRef = this.appView.viewContainerRef;
+    viewContainerRef.clear();
+
+    const componentRef = viewContainerRef.createComponent<Viewer>(
+      componentFactory
+    );
+    componentRef.instance.view = this.view;
+    componentRef.instance.viewInit.subscribe(_ => this.viewInit.emit());
+  }
+}
diff --git a/web/src/app/modules/shared/directives/view-host/view-host.directive.spec.ts b/web/src/app/modules/shared/directives/view-host/view-host.directive.spec.ts
new file mode 100644
index 00000000..9b58ca55
--- /dev/null
+++ b/web/src/app/modules/shared/directives/view-host/view-host.directive.spec.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { ViewHostDirective } from './view-host.directive';
+
+describe('ViewHostDirective', () => {
+  it('should create an instance', () => {
+    const directive = new ViewHostDirective(undefined);
+    expect(directive).toBeTruthy();
+  });
+});
diff --git a/web/src/app/modules/shared/directives/view-host/view-host.directive.ts b/web/src/app/modules/shared/directives/view-host/view-host.directive.ts
new file mode 100644
index 00000000..06ab3b00
--- /dev/null
+++ b/web/src/app/modules/shared/directives/view-host/view-host.directive.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Directive, ViewContainerRef } from '@angular/core';
+
+@Directive({
+  selector: '[appView]',
+})
+export class ViewHostDirective {
+  constructor(public viewContainerRef: ViewContainerRef) {}
+}
diff --git a/web/src/app/modules/shared/dynamic-components.ts b/web/src/app/modules/shared/dynamic-components.ts
new file mode 100644
index 00000000..b1af6c26
--- /dev/null
+++ b/web/src/app/modules/shared/dynamic-components.ts
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { InjectionToken, Type } from '@angular/core';
+import { AnnotationsComponent } from './components/presentation/annotations/annotations.component';
+import { IFrameComponent } from './components/presentation/iframe/iframe.component';
+import { ResourceViewerComponent } from './components/presentation/resource-viewer/resource-viewer.component';
+import { LogsComponent } from './components/smart/logs/logs.component';
+import { PodStatusComponent } from './components/presentation/pod-status/pod-status.component';
+import { ButtonGroupComponent } from './components/presentation/button-group/button-group.component';
+import { CardComponent } from './components/presentation/card/card.component';
+import { YamlComponent } from './components/presentation/yaml/yaml.component';
+import { CodeComponent } from './components/presentation/code/code.component';
+import { EditorComponent } from './components/smart/editor/editor.component';
+import { PortForwardComponent } from './components/presentation/port-forward/port-forward.component';
+import { ExpressionSelectorComponent } from './components/presentation/expression-selector/expression-selector.component';
+import { ListComponent } from './components/presentation/list/list.component';
+import { FlexlayoutComponent } from './components/presentation/flexlayout/flexlayout.component';
+import { LoadingComponent } from './components/presentation/loading/loading.component';
+import { TextComponent } from './components/presentation/text/text.component';
+import { QuadrantComponent } from './components/presentation/quadrant/quadrant.component';
+import { SelectorsComponent } from './components/presentation/selectors/selectors.component';
+import { TerminalComponent } from './components/smart/terminal/terminal.component';
+import { DatagridComponent } from './components/presentation/datagrid/datagrid.component';
+import { DonutChartComponent } from './components/presentation/donut-chart/donut-chart.component';
+import { GraphvizComponent } from './components/presentation/graphviz/graphviz.component';
+import { ErrorComponent } from './components/presentation/error/error.component';
+import { LabelSelectorComponent } from './components/presentation/label-selector/label-selector.component';
+import { PortsComponent } from './components/presentation/ports/ports.component';
+import { CardListComponent } from './components/presentation/card-list/card-list.component';
+import { LabelsComponent } from './components/presentation/labels/labels.component';
+import { LinkComponent } from './components/presentation/link/link.component';
+import { SingleStatComponent } from './components/presentation/single-stat/single-stat.component';
+import { SummaryComponent } from './components/presentation/summary/summary.component';
+import { StepperComponent } from './components/presentation/stepper/stepper.component';
+import { TimestampComponent } from './components/presentation/timestamp/timestamp.component';
+import { ContainersComponent } from './components/presentation/containers/containers.component';
+
+export interface ComponentMapping {
+  [key: string]: Type<any>;
+}
+
+const DynamicComponentMapping: ComponentMapping = {
+  annotations: AnnotationsComponent,
+  buttonGroup: ButtonGroupComponent,
+  card: CardComponent,
+  cardList: CardListComponent,
+  codeBlock: CodeComponent,
+  containers: ContainersComponent,
+  donutChart: DonutChartComponent,
+  editor: EditorComponent,
+  expressionSelector: ExpressionSelectorComponent,
+  graphviz: GraphvizComponent,
+  flexlayout: FlexlayoutComponent,
+  labels: LabelsComponent,
+  labelSelector: LabelSelectorComponent,
+  loading: LoadingComponent,
+  error: ErrorComponent,
+  iframe: IFrameComponent,
+  link: LinkComponent,
+  list: ListComponent,
+  logs: LogsComponent,
+  podStatus: PodStatusComponent,
+  portforward: PortForwardComponent,
+  ports: PortsComponent,
+  quadrant: QuadrantComponent,
+  resourceViewer: ResourceViewerComponent,
+  selectors: SelectorsComponent,
+  singleStat: SingleStatComponent,
+  stepper: StepperComponent,
+  summary: SummaryComponent,
+  table: DatagridComponent,
+  terminal: TerminalComponent,
+  text: TextComponent,
+  timestamp: TimestampComponent,
+  yaml: YamlComponent,
+};
+
+export const DYNAMIC_COMPONENTS_MAPPING = new InjectionToken<ComponentMapping>(
+  'dynamicComponentsMapping'
+);
+
+export const dynamicComponents = () => {
+  return {
+    provide: DYNAMIC_COMPONENTS_MAPPING,
+    useValue: DynamicComponentMapping,
+  };
+};
diff --git a/web/src/app/modules/shared/highlight.ts b/web/src/app/modules/shared/highlight.ts
index 9e9e6959..66b6ee3f 100644
--- a/web/src/app/modules/shared/highlight.ts
+++ b/web/src/app/modules/shared/highlight.ts
@@ -6,18 +6,16 @@
 
 import { HIGHLIGHT_OPTIONS } from 'ngx-highlightjs';
 
-export function hljsLanguages() {
+const languages = () => {
   return {
     json: () => import('highlight.js/lib/languages/json'),
     yaml: () => import('highlight.js/lib/languages/yaml'),
   };
-}
+};
 
-export function highlightProvider() {
-  return {
-    provide: HIGHLIGHT_OPTIONS,
-    useValue: {
-      languages: hljsLanguages(),
-    },
-  };
-}
+export const highlightProvider = () => ({
+  provide: HIGHLIGHT_OPTIONS,
+  useValue: {
+    languages: languages(),
+  },
+});
diff --git a/web/src/app/modules/shared/models/content.ts b/web/src/app/modules/shared/models/content.ts
index 5b1853e3..88d542cf 100644
--- a/web/src/app/modules/shared/models/content.ts
+++ b/web/src/app/modules/shared/models/content.ts
@@ -243,7 +243,7 @@ export interface Node {
   apiVersion: string;
   kind: string;
   status: string;
-  details: View;
+  details: View[];
   path: LinkView;
 }
 
diff --git a/web/src/app/modules/shared/services/loading/loading.service.ts b/web/src/app/modules/shared/services/loading/loading.service.ts
index 8b9e5066..3c4e1ab0 100644
--- a/web/src/app/modules/shared/services/loading/loading.service.ts
+++ b/web/src/app/modules/shared/services/loading/loading.service.ts
@@ -9,7 +9,7 @@ import {
   distinctUntilChanged,
   mapTo,
   startWith,
-  takeWhile,
+  takeUntil,
 } from 'rxjs/operators';
 
 @Injectable({
@@ -20,12 +20,17 @@ export class LoadingService {
 
   constructor() {}
 
-  public showSpinner: Observable<boolean> = merge(
-    timer(650).pipe(
-      // show only if operation > 650ms
-      mapTo(true),
-      takeWhile(() => !this.requestComplete.value)
-    ), // if shown, stay at least 1sec to prevent flicker
-    combineLatest([this.requestComplete, timer(1650)]).pipe(mapTo(false))
-  ).pipe(startWith(false), distinctUntilChanged());
+  withDelay(
+    watch: Observable<boolean>,
+    after: number,
+    atLeast: number
+  ): Observable<boolean> {
+    const loadingTimer = timer(after).pipe(takeUntil(watch));
+    const holdTimer = timer(after + atLeast);
+
+    return merge<boolean>(
+      loadingTimer.pipe(mapTo(true)),
+      combineLatest([watch, holdTimer]).pipe(mapTo(false))
+    ).pipe(startWith(false), distinctUntilChanged());
+  }
 }
diff --git a/web/src/app/modules/shared/services/websocket/websocket.service.ts b/web/src/app/modules/shared/services/websocket/websocket.service.ts
index 88bdb308..c8c2f5a9 100644
--- a/web/src/app/modules/shared/services/websocket/websocket.service.ts
+++ b/web/src/app/modules/shared/services/websocket/websocket.service.ts
@@ -4,7 +4,12 @@
  */
 
 import { Injectable } from '@angular/core';
-import { BehaviorSubject, Observable, Subject } from 'rxjs';
+import {
+  BehaviorSubject,
+  ObjectUnsubscribedError,
+  Observable,
+  Subject,
+} from 'rxjs';
 import {
   NotifierService,
   NotifierSession,
@@ -183,7 +188,9 @@ export class WebsocketService implements BackendService {
         );
       }
     } catch (err) {
-      console.error('parse websocket', err, data);
+      if (!(err instanceof ObjectUnsubscribedError)) {
+        console.error('parse websocket', err, data);
+      }
     }
   }
 }
diff --git a/web/src/app/modules/shared/shared.module.ts b/web/src/app/modules/shared/shared.module.ts
index 4ccf7d75..9a22f0f5 100644
--- a/web/src/app/modules/shared/shared.module.ts
+++ b/web/src/app/modules/shared/shared.module.ts
@@ -43,7 +43,6 @@ import { ContentFilterComponent } from './components/presentation/content-filter
 import { HeptagonGridComponent } from './components/presentation/heptagon-grid/heptagon-grid.component';
 import { HeptagonGridRowComponent } from './components/presentation/heptagon-grid-row/heptagon-grid-row.component';
 import { HeptagonLabelComponent } from './components/presentation/heptagon-label/heptagon-label.component';
-import { ContentSwitcherComponent } from './components/presentation/content-switcher/content-switcher.component';
 import { ObjectStatusComponent } from './components/presentation/object-status/object-status.component';
 import { PodStatusComponent } from './components/presentation/pod-status/pod-status.component';
 import { FormsModule, ReactiveFormsModule } from '@angular/forms';
@@ -72,6 +71,10 @@ import { FilterDeletedDatagridRowPipe } from './pipes/filterDeletedDatagridRow/f
 import { ContentTextFilterComponent } from './components/presentation/content-text-filter/content-text-filter.component';
 import { TruncatePipe } from './pipes/truncate/truncate.pipe';
 import { StepperComponent } from './components/presentation/stepper/stepper.component';
+import { ViewHostDirective } from './directives/view-host/view-host.directive';
+import { dynamicComponents } from './dynamic-components';
+import { ViewContainerComponent } from './components/view/view-container.component';
+import { MissingComponentComponent } from './components/missing-component/missing-component.component';
 
 @NgModule({
   declarations: [
@@ -84,7 +87,6 @@ import { StepperComponent } from './components/presentation/stepper/stepper.comp
     CodeComponent,
     ContainersComponent,
     ContentFilterComponent,
-    ContentSwitcherComponent,
     ContextSelectorComponent,
     CytoscapeComponent,
     Cytoscape2Component,
@@ -139,6 +141,9 @@ import { StepperComponent } from './components/presentation/stepper/stepper.comp
     HelperComponent,
     ContentTextFilterComponent,
     StepperComponent,
+    ViewHostDirective,
+    ViewContainerComponent,
+    MissingComponentComponent,
   ],
   imports: [
     ClarityModule,
@@ -154,7 +159,7 @@ import { StepperComponent } from './components/presentation/stepper/stepper.comp
     ResizableModule,
     RouterModule,
   ],
-  providers: [highlightProvider()],
+  providers: [highlightProvider(), dynamicComponents()],
   exports: [
     FormatPathPipe,
     AlertComponent,
@@ -166,7 +171,6 @@ import { StepperComponent } from './components/presentation/stepper/stepper.comp
     CodeComponent,
     ContainersComponent,
     ContentFilterComponent,
-    ContentSwitcherComponent,
     ContextSelectorComponent,
     CytoscapeComponent,
     Cytoscape2Component,
@@ -213,6 +217,8 @@ import { StepperComponent } from './components/presentation/stepper/stepper.comp
     PreferencesComponent,
     HelperComponent,
     StepperComponent,
+    ViewHostDirective,
+    ViewContainerComponent,
   ],
 })
 export class SharedModule {}
diff --git a/web/src/app/modules/sugarloaf/components/smart/container/container.component.scss b/web/src/app/modules/sugarloaf/components/smart/container/container.component.scss
index 8efd1317..16b9b204 100644
--- a/web/src/app/modules/sugarloaf/components/smart/container/container.component.scss
+++ b/web/src/app/modules/sugarloaf/components/smart/container/container.component.scss
@@ -12,7 +12,7 @@
 
   :host-context(body) {
     --navigation-border-color: rgb(215, 215, 215);
-    --navigation-background-color: #0079AD;
+    --navigation-background-color: #0079ad;
   }
 
   // Navigation's color variables for Dark Theme.
@@ -37,7 +37,6 @@
         display: none;
       }
     }
-
   }
 
   .header-centered {
diff --git a/web/src/app/modules/sugarloaf/components/smart/content/content.component.html b/web/src/app/modules/sugarloaf/components/smart/content/content.component.html
index 1c7cb8a4..69df534c 100644
--- a/web/src/app/modules/sugarloaf/components/smart/content/content.component.html
+++ b/web/src/app/modules/sugarloaf/components/smart/content/content.component.html
@@ -1,4 +1,9 @@
-<div clrFocusOnViewInit class="content-component" (scroll)="onScroll($event)" [class.loading-content]="showSpinner || !hasReceivedContent">
+<div
+  clrFocusOnViewInit
+  class="content-component"
+  (scroll)="onScroll($event)"
+  [class.loading-content]="!hasReceivedContent"
+>
   <ng-container *ngIf="hasReceivedContent && !showSpinner; else loading">
     <ng-container
       *ngIf="hasTabs; then withTabs; else withoutTabs"
@@ -6,13 +11,12 @@
     <ng-template #withTabs>
       <app-object-tabs
         [buttonGroup]="buttonGroup"
-        [iconName]="iconName"
         [title]="title"
         [views]="views"
       ></app-object-tabs>
     </ng-template>
     <ng-template #withoutTabs>
-      <app-content-switcher [view]="singleView"></app-content-switcher>
+      <app-view-container [view]="singleView"></app-view-container>
     </ng-template>
   </ng-container>
 
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 7ab35c8a..0e5209df 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
@@ -50,21 +50,9 @@ export class ContentComponent implements OnInit, OnDestroy {
     private router: Router,
     private iconService: IconService,
     private contentService: ContentService,
-    public loadingService: LoadingService
+    private loadingService: LoadingService
   ) {}
 
-  updatePath(url: string) {
-    const tree = this.router.parseUrl(url);
-
-    const primary = tree.root.children.primary;
-    let segments = [];
-    if (primary) {
-      segments = primary.segments;
-    }
-
-    this.handlePathChange(segments, tree.queryParams, false);
-  }
-
   ngOnInit() {
     this.updatePath(this.router.routerState.snapshot.url);
 
@@ -74,17 +62,33 @@ export class ContentComponent implements OnInit, OnDestroy {
       }
     );
 
-    this.loadingSubscription = this.loadingService.showSpinner.subscribe(
-      value => {
-        this.showSpinner = value;
-      }
-    );
+    this.loadingService
+      .withDelay(this.loadingService.requestComplete, 650, 1000)
+      .subscribe(v => {
+        this.showSpinner = v;
+      });
   }
 
   ngOnDestroy() {
     this.resetView();
-    this.contentSubscription.unsubscribe();
-    this.loadingSubscription.unsubscribe();
+    if (this.contentSubscription) {
+      this.contentSubscription.unsubscribe();
+    }
+    if (this.loadingSubscription) {
+      this.loadingSubscription.unsubscribe();
+    }
+  }
+
+  updatePath(url: string) {
+    const tree = this.router.parseUrl(url);
+
+    const primary = tree.root.children.primary;
+    let segments = [];
+    if (primary) {
+      segments = primary.segments;
+    }
+
+    this.handlePathChange(segments, tree.queryParams, false);
   }
 
   private handlePathChange(
@@ -129,6 +133,7 @@ export class ContentComponent implements OnInit, OnDestroy {
       this.hasReceivedContent = false;
       return;
     }
+
     this.buttonGroup = contentResponse.content.buttonGroup;
 
     this.extView = contentResponse.content.extensionComponent;
diff --git a/web/src/app/modules/sugarloaf/components/smart/namespace/namespace.component.html b/web/src/app/modules/sugarloaf/components/smart/namespace/namespace.component.html
index 3ebc23fa..25187b2a 100644
--- a/web/src/app/modules/sugarloaf/components/smart/namespace/namespace.component.html
+++ b/web/src/app/modules/sugarloaf/components/smart/namespace/namespace.component.html
@@ -3,19 +3,26 @@
     <clr-dropdown class="dropdown-top" [clrCloseMenuOnItemClick]="true">
       <button type="button" class="dropdown-button" clrDropdownTrigger>
         <clr-icon shape="namespace"></clr-icon>
-      {{ currentNamespace }}
+        {{ currentNamespace }}
         <clr-icon shape="caret down"></clr-icon>
       </button>
-      <clr-dropdown-menu class="dropdown-top" *clrIfOpen [clrPosition]="'bottom-right'">
+      <clr-dropdown-menu
+        class="dropdown-top"
+        *clrIfOpen
+        [clrPosition]="'bottom-right'"
+      >
         <label class="dropdown-header">Namespaces</label>
-        <ng-container *ngFor="let namespace of namespaces;trackBy: trackByIdentity">
+        <ng-container
+          *ngFor="let namespace of namespaces; trackBy: trackByIdentity"
+        >
           <button
             type="button"
             class="dropdown-button"
             [ngClass]="namespaceClass(namespace)"
             clrDropdownItem
-            (click)="selectNamespace(namespace)">
-          {{ namespace }}
+            (click)="selectNamespace(namespace)"
+          >
+            {{ namespace }}
           </button>
         </ng-container>
       </clr-dropdown-menu>
diff --git a/web/src/app/modules/sugarloaf/components/smart/namespace/namespace.component.scss b/web/src/app/modules/sugarloaf/components/smart/namespace/namespace.component.scss
index 7be272af..7310874e 100644
--- a/web/src/app/modules/sugarloaf/components/smart/namespace/namespace.component.scss
+++ b/web/src/app/modules/sugarloaf/components/smart/namespace/namespace.component.scss
@@ -28,5 +28,5 @@
 }
 
 .dropdown .dropdown-toggle:not(.btn) {
-  padding: 0 1.0rem 0 0;
+  padding: 0 1rem 0 0;
 }
diff --git a/web/src/app/modules/sugarloaf/components/smart/navigation/navigation.component.html b/web/src/app/modules/sugarloaf/components/smart/navigation/navigation.component.html
index 6df7d749..d71153c2 100644
--- a/web/src/app/modules/sugarloaf/components/smart/navigation/navigation.component.html
+++ b/web/src/app/modules/sugarloaf/components/smart/navigation/navigation.component.html
@@ -1,44 +1,71 @@
-<clr-vertical-nav [clrVerticalNavCollapsible]="true" [(clrVerticalNavCollapsed)]="collapsed" (clrVerticalNavCollapsedChange)="updateNavCollapsed($event)">
+<clr-vertical-nav
+  [clrVerticalNavCollapsible]="true"
+  [(clrVerticalNavCollapsed)]="collapsed"
+  (clrVerticalNavCollapsedChange)="updateNavCollapsed($event)"
+>
   <!-- Navigation items -->
-  <div [ngClass]= "{'navigation-items-collapsed' : collapsed, 'navigation-items' : !collapsed}">
-    <ng-container *ngFor="let section of navigation.sections; let i = index; trackBy: identifyNavigationItem">
+  <div
+    [ngClass]="{
+      'navigation-items-collapsed': collapsed,
+      'navigation-items': !collapsed
+    }"
+  >
+    <ng-container
+      *ngFor="
+        let section of navigation.sections;
+        let i = index;
+        trackBy: identifyNavigationItem
+      "
+    >
       <!-- Collapsed Navigation -->
       <ng-container *ngIf="collapsed; else expanded">
         <ng-template [ngIf]="section.module">
           <div class="nav-divider"></div>
         </ng-template>
 
-        <a clrVerticalNavLink
-           (click)="openPopup(i)"
-           [ngClass]= "{'item-selected' : i == this.lastSelection}"
-           [routerLinkActiveOptions]="{exact: !section.children?.length}"
-           [routerLink]="formatPath(section.path)"
-           routerLinkActive="active"
-           (mouseover)="flyoutIndex= i; lastSelection=i"
+        <a
+          clrVerticalNavLink
+          (click)="openPopup(i)"
+          [ngClass]="{ 'item-selected': i == this.lastSelection }"
+          [routerLinkActiveOptions]="{ exact: !section.children?.length }"
+          [routerLink]="formatPath(section.path)"
+          routerLinkActive="active"
+          (mouseover)="flyoutIndex = i; lastSelection = i"
         >
-          <clr-icon [attr.shape]="section.iconName | default:'home'" clrVerticalNavIcon></clr-icon>
+          <clr-icon
+            [attr.shape]="section.iconName | default: 'home'"
+            clrVerticalNavIcon
+          ></clr-icon>
         </a>
 
-        <div class="flyout nav-group-children"
-              [ngClass]= "{'flyout-empty' : !section.children?.length}"
-              (mouseleave)="flyoutIndex= -1; lastSelection= -1"
-              (document:mouseleave)="flyoutIndex= -1; lastSelection= -1"
+        <div
+          class="flyout nav-group-children"
+          [ngClass]="{ 'flyout-empty': !section.children?.length }"
+          (mouseleave)="flyoutIndex = -1; lastSelection = -1"
+          (document:mouseleave)="flyoutIndex = -1; lastSelection = -1"
         >
-          <a class="flyout-header"
-              (click)="openPopup(i)"
-              [routerLinkActiveOptions]="{exact: !section.children?.length}"
-              [routerLink]="formatPath(section.path)"
-              routerLinkActive="active"
+          <a
+            class="flyout-header"
+            (click)="openPopup(i)"
+            [routerLinkActiveOptions]="{ exact: !section.children?.length }"
+            [routerLink]="formatPath(section.path)"
+            routerLinkActive="active"
           >
             {{ section.title }}
           </a>
           <clr-vertical-nav-group-children class="flyout-group">
-            <ng-container *ngFor="let category of section.children; trackBy: identifyNavigationItem">
-              <a [routerLink]="formatPath(category.path)"
-                  [routerLinkActiveOptions]="{exact: true}"
-                  routerLinkActive="active"
-                  class="flyout-item nav-link"
-                  (click)="setNavState(false, i)"
+            <ng-container
+              *ngFor="
+                let category of section.children;
+                trackBy: identifyNavigationItem
+              "
+            >
+              <a
+                [routerLink]="formatPath(category.path)"
+                [routerLinkActiveOptions]="{ exact: true }"
+                routerLinkActive="active"
+                class="flyout-item nav-link"
+                (click)="setNavState(false, i)"
               >
                 {{ category.title }}
               </a>
@@ -52,30 +79,45 @@
           <div class="nav-divider"></div>
           <span class="title">{{ section.module }}</span>
         </ng-template>
-        <ng-container *ngIf="section.children?.length > 0; else noChildExpanded">
+        <ng-container
+          *ngIf="section.children?.length > 0; else noChildExpanded"
+        >
           <clr-vertical-nav-group
             routerLinkActive="active"
             [clrVerticalNavGroupExpanded]="shouldExpand(i)"
             (clrVerticalNavGroupExpandedChange)="setNavState($event, i)"
           >
-            <clr-icon [attr.shape]="section.iconName | default:'home'" clrVerticalNavIcon></clr-icon>
+            <clr-icon
+              [attr.shape]="section.iconName | default: 'home'"
+              clrVerticalNavIcon
+            ></clr-icon>
             {{ section.title }}
             <clr-vertical-nav-group-children>
-              <a clrVerticalNavLink
-                 [routerLink]="formatPath(section.path)"
-                 [routerLinkActiveOptions]="{exact: true}"
-                 routerLinkActive="active"
+              <a
+                clrVerticalNavLink
+                [routerLink]="formatPath(section.path)"
+                [routerLinkActiveOptions]="{ exact: true }"
+                routerLinkActive="active"
               >
                 Overview
               </a>
-              <ng-container *ngFor="let category of section.children; trackBy: identifyNavigationItem">
+              <ng-container
+                *ngFor="
+                  let category of section.children;
+                  trackBy: identifyNavigationItem
+                "
+              >
                 <a
                   clrVerticalNavLink
                   [routerLink]="formatPath(category.path)"
-                  [routerLinkActiveOptions]="{exact: true}"
+                  [routerLinkActiveOptions]="{ exact: true }"
                   routerLinkActive="active"
                 >
-                  <clr-icon *ngIf="itemIcon(category) as categoryIcon" [attr.shape]="categoryIcon" clrVerticalNavIcon></clr-icon>
+                  <clr-icon
+                    *ngIf="itemIcon(category) as categoryIcon"
+                    [attr.shape]="categoryIcon"
+                    clrVerticalNavIcon
+                  ></clr-icon>
                   {{ category.title }}
                 </a>
               </ng-container>
@@ -83,13 +125,17 @@
           </clr-vertical-nav-group>
         </ng-container>
         <ng-template #noChildExpanded>
-          <a clrVerticalNavLink
-             (click)="closePopups(i)"
-             [routerLink]="formatPath(section.path)"
-             [routerLinkActiveOptions]="{exact: true}"
-             routerLinkActive="active"
+          <a
+            clrVerticalNavLink
+            (click)="closePopups(i)"
+            [routerLink]="formatPath(section.path)"
+            [routerLinkActiveOptions]="{ exact: true }"
+            routerLinkActive="active"
           >
-            <clr-icon [attr.shape]="section.iconName | default:'home'" clrVerticalNavIcon></clr-icon>
+            <clr-icon
+              [attr.shape]="section.iconName | default: 'home'"
+              clrVerticalNavIcon
+            ></clr-icon>
             <div class="nav-item-text">
               {{ section.title }}
             </div>
diff --git a/web/src/app/modules/sugarloaf/components/smart/navigation/navigation.component.scss b/web/src/app/modules/sugarloaf/components/smart/navigation/navigation.component.scss
index 2ea47833..233fa853 100644
--- a/web/src/app/modules/sugarloaf/components/smart/navigation/navigation.component.scss
+++ b/web/src/app/modules/sugarloaf/components/smart/navigation/navigation.component.scss
@@ -8,7 +8,10 @@
   --flyout-background-color: var(--clr-color-neutral-50);
   --flyout-color: var(--clr-global-font-color);
   --flyout-header-color: var(--clr-vertical-nav-item-color, #666666);
-  --flyout-header-active-color: var(--clr-vertical-nav-item-active-color, #666666);
+  --flyout-header-active-color: var(
+    --clr-vertical-nav-item-active-color,
+    #666666
+  );
 }
 
 :host-context(body.dark) {
@@ -156,5 +159,4 @@
   .flyout:hover {
     display: flex;
   }
-
 }
diff --git a/web/src/app/modules/sugarloaf/components/smart/notifier/notifier.component.html b/web/src/app/modules/sugarloaf/components/smart/notifier/notifier.component.html
index 2f910909..63d983f7 100644
--- a/web/src/app/modules/sugarloaf/components/smart/notifier/notifier.component.html
+++ b/web/src/app/modules/sugarloaf/components/smart/notifier/notifier.component.html
@@ -48,9 +48,9 @@
 
 <ng-container *ngIf="!loading && success">
   <clr-alert
-          [clrAlertType]="'success'"
-          [clrAlertIcon]="'check-circle'"
-          [clrAlertAppLevel]="true"
+    [clrAlertType]="'success'"
+    [clrAlertIcon]="'check-circle'"
+    [clrAlertAppLevel]="true"
   >
     <clr-alert-item>
       <span class="alert-text">
@@ -59,4 +59,3 @@
     </clr-alert-item>
   </clr-alert>
 </ng-container>
-
diff --git a/web/src/app/modules/sugarloaf/components/smart/theme-switch/theme-switch-button.component.html b/web/src/app/modules/sugarloaf/components/smart/theme-switch/theme-switch-button.component.html
index 0ee193c5..99fc938c 100644
--- a/web/src/app/modules/sugarloaf/components/smart/theme-switch/theme-switch-button.component.html
+++ b/web/src/app/modules/sugarloaf/components/smart/theme-switch/theme-switch-button.component.html
@@ -1,13 +1,21 @@
 <div class="switch-button-container">
   <ng-container *ngIf="collapsed; else expanded">
-    <button id="switchButton" class="btn btn-link btn-inverse btn-collapsed" (click)="switchTheme()">
+    <button
+      id="switchButton"
+      class="btn btn-link btn-inverse btn-collapsed"
+      (click)="switchTheme()"
+    >
       <clr-icon [attr.shape]="lightThemeEnabled ? 'moon' : 'sun'"></clr-icon>
     </button>
   </ng-container>
-<ng-template #expanded>
-<button id="switchButton" class="btn btn-link btn-inverse" (click)="switchTheme()">
-  <clr-icon [attr.shape]="lightThemeEnabled ? 'moon' : 'sun'"></clr-icon>
-{{ lightThemeEnabled ? 'dark' : 'light' }}
-</button>
-</ng-template>
+  <ng-template #expanded>
+    <button
+      id="switchButton"
+      class="btn btn-link btn-inverse"
+      (click)="switchTheme()"
+    >
+      <clr-icon [attr.shape]="lightThemeEnabled ? 'moon' : 'sun'"></clr-icon>
+      {{ lightThemeEnabled ? 'dark' : 'light' }}
+    </button>
+  </ng-template>
 </div>
diff --git a/web/src/app/modules/sugarloaf/components/smart/uploader/uploader.component.html b/web/src/app/modules/sugarloaf/components/smart/uploader/uploader.component.html
index 437c5e13..b701691d 100644
--- a/web/src/app/modules/sugarloaf/components/smart/uploader/uploader.component.html
+++ b/web/src/app/modules/sugarloaf/components/smart/uploader/uploader.component.html
@@ -1,25 +1,49 @@
 <div *ngIf="showModal" class="modal">
-    <div class="modal-dialog modal-xl" role="dialog" aria-hidden="true">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h3 class="modal-title">Add a new kubeconfig</h3>
+  <div class="modal-dialog modal-xl" role="dialog" aria-hidden="true">
+    <div class="modal-content">
+      <div class="modal-header">
+        <h3 class="modal-title">Add a new kubeconfig</h3>
+      </div>
+      <div class="modal-body">
+        <form class="clr-form">
+          <label for="config" class="clr-control-label"
+            >Upload a kubeconfig</label
+          >
+          <div
+            class="clr-control-container"
+            [ngClass]="{ 'clr-error': inputValue === '' }"
+          >
+            <div class="clr-textarea-wrapper">
+              <textarea
+                id="config"
+                name="config"
+                [(ngModel)]="inputValue"
+                (ngModelChange)="updateInput($event)"
+                placeholder="Enter value here"
+                class="clr-textarea"
+              ></textarea>
+              <clr-icon
+                class="clr-validate-icon"
+                shape="exclamation-circle"
+              ></clr-icon>
             </div>
-            <div class="modal-body">
-                <form class="clr-form">
-                    <label for="config" class="clr-control-label">Upload a kubeconfig</label>
-                    <div class="clr-control-container" [ngClass]="{'clr-error': inputValue === ''}">
-                        <div class="clr-textarea-wrapper">
-                            <textarea id="config" name="config" [(ngModel)]="inputValue" (ngModelChange)="updateInput($event)" placeholder="Enter value here" class="clr-textarea"></textarea>
-                            <clr-icon class="clr-validate-icon" shape="exclamation-circle"></clr-icon>
-                        </div>
-                        <span *ngIf="inputValue === ''" class="clr-subtext">This can't be empty</span>
-                    </div>
-                  </form>
-            </div>
-            <div class="modal-footer">
-                <button (click)="upload()" class="btn btn-primary" type="button" [disabled]="hasInput()">Upload</button>
-            </div>
-        </div>
+            <span *ngIf="inputValue === ''" class="clr-subtext"
+              >This can't be empty</span
+            >
+          </div>
+        </form>
+      </div>
+      <div class="modal-footer">
+        <button
+          (click)="upload()"
+          class="btn btn-primary"
+          type="button"
+          [disabled]="hasInput()"
+        >
+          Upload
+        </button>
+      </div>
     </div>
+  </div>
 </div>
-<div *ngIf="showModal" class="modal-backdrop" aria-hidden="true"></div>
\ No newline at end of file
+<div *ngIf="showModal" class="modal-backdrop" aria-hidden="true"></div>
diff --git a/web/src/app/modules/sugarloaf/components/smart/uploader/uploader.component.scss b/web/src/app/modules/sugarloaf/components/smart/uploader/uploader.component.scss
index 392b0a3d..193ea7ab 100644
--- a/web/src/app/modules/sugarloaf/components/smart/uploader/uploader.component.scss
+++ b/web/src/app/modules/sugarloaf/components/smart/uploader/uploader.component.scss
@@ -1,13 +1,13 @@
 .clr-control-container {
-    width: 100%;
-    min-height: 400px;
+  width: 100%;
+  min-height: 400px;
 }
 
 .clr-textarea-wrapper {
-    min-height: inherit;
+  min-height: inherit;
 }
 
 .clr-textarea {
-    width: 100%;
-    min-height: inherit;
-}
\ No newline at end of file
+  width: 100%;
+  min-height: inherit;
+}
diff --git a/web/src/main.ts b/web/src/main.ts
index 85c4ab04..a38494c4 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -2,11 +2,6 @@
 // SPDX-License-Identifier: Apache-2.0
 //
 
-// Note(marlon): zone.js seems to add a thread blocking event listener
-// to mouse scroll events: https://github.com/angular/zone.js/issues/1097
-// This makes all event listeners passive by default. To note when attaching
-// custom event listeners in our home.
-import 'default-passive-events';
 import { enableProdMode } from '@angular/core';
 import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
 import { AppModule } from './app/app.module';
diff --git a/web/src/stories/sandbox.stories.ts b/web/src/stories/sandbox.stories.ts
index e4be277a..6ce3acb6 100644
--- a/web/src/stories/sandbox.stories.ts
+++ b/web/src/stories/sandbox.stories.ts
@@ -1,23 +1,23 @@
-import {storiesOf} from '@storybook/angular';
-import {object} from "@storybook/addon-knobs";
-import {big_data} from "./overview.data";
+import { storiesOf } from '@storybook/angular';
+import { object } from '@storybook/addon-knobs';
+import { big_data } from './overview.data';
 
 storiesOf('Other/Sandbox', module).add('Component Sandbox', () => {
   const view = object('JSON', big_data);
 
   return {
     props: {
-      view: view,
+      view,
     },
     template: `
       <div class="main-container">
           <div class="content-container">
               <div class="content-area">
-                <app-content-switcher [view]="view">
-                </app-content-switcher>
+                <app-view-container [view]="view">
+                </app-view-container>
               </div>
           </div>
       </div>
       `,
-  }
+  };
 });
diff --git a/web/tsconfig.compodoc.json b/web/tsconfig.compodoc.json
index a3751745..0f15325e 100644
--- a/web/tsconfig.compodoc.json
+++ b/web/tsconfig.compodoc.json
@@ -156,8 +156,6 @@
     "src//app/modules/shared/components/presentation/quadrant/quadrant.component.spec.ts",
     "src//app/modules/shared/components/presentation/yaml/yaml.component.ts",
     "src//app/modules/shared/components/presentation/yaml/yaml.component.spec.ts",
-    "src//app/modules/shared/components/presentation/content-switcher/content-switcher.component.ts",
-    "src//app/modules/shared/components/presentation/content-switcher/content-switcher.component.spec.ts",
     "src//app/modules/shared/components/presentation/containers/containers.component.spec.ts",
     "src//app/modules/shared/components/presentation/containers/containers.component.ts",
     "src//app/modules/shared/components/presentation/text/text.component.spec.ts",
@@ -222,6 +220,6 @@
     "src//stories/graph.stories.ts",
     "src//stories/graph.data.ts",
     "src//stories/preferences.stories.ts",
-    "src//polyfills.ts",
-  ],
+    "src//polyfills.ts"
+  ]
 }


Test patch:
diff --git a/internal/api/container_logs_test.go b/internal/api/container_logs_test.go
index faf56bbd..0882a057 100644
--- a/internal/api/container_logs_test.go
+++ b/internal/api/container_logs_test.go
@@ -38,12 +38,7 @@ func TestContainerLogs_SendLogEventsStops(t *testing.T) {
 
 	dashConfig := configFake.NewMockDash(controller)
 
-	key := store.Key{
-		Namespace: "test-ns",
-		Name:      "test-pod",
-	}
-
-	eventType := event.NewLoggingEventType(key.Namespace, key.Name)
+	eventType := event.NewLoggingEventType("test-ns", "test-pod")
 	logCh := make(chan container.LogEntry)
 
 	s := NewPodLogsStateManager(dashConfig)
@@ -69,6 +64,7 @@ func TestContainerLogs_SendLogEventsClientSend(t *testing.T) {
 
 	dashConfig := configFake.NewMockDash(controller)
 	client := newOctantClient()
+	defer client.Close()
 
 	key := store.Key{
 		Namespace: "test-ns",
@@ -107,11 +103,15 @@ func TestContainerLogs_SendLogEventsClientSend(t *testing.T) {
 type octantClient struct {
 	sendCalledWith event.Event
 	ch             chan bool
+	stopCh         chan struct{}
 }
 
+var _ OctantClient = &octantClient{}
+
 func newOctantClient() *octantClient {
 	return &octantClient{
-		ch: make(chan bool, 1),
+		ch:     make(chan bool, 1),
+		stopCh: make(chan struct{}, 1),
 	}
 }
 
@@ -120,3 +120,12 @@ func (oc *octantClient) Send(event event.Event) {
 	oc.ch <- true
 }
 func (oc *octantClient) ID() string { return "" }
+
+func (oc *octantClient) StopCh() <-chan struct{} {
+	return oc.stopCh
+}
+
+func (oc *octantClient) Close() {
+	close(oc.ch)
+	close(oc.stopCh)
+}
diff --git a/internal/api/content_manager_test.go b/internal/api/content_manager_test.go
index 6eb3354e..c1e5235e 100644
--- a/internal/api/content_manager_test.go
+++ b/internal/api/content_manager_test.go
@@ -54,9 +54,12 @@ func TestContentManager_GenerateContent(t *testing.T) {
 	})
 	octantClient := fake.NewMockOctantClient(controller)
 
+	stopCh := make(chan struct{}, 1)
+
 	contentResponse := component.ContentResponse{}
 	contentEvent := api.CreateContentEvent(contentResponse, "default", "/path", params)
 	octantClient.EXPECT().Send(contentEvent).AnyTimes()
+	octantClient.EXPECT().StopCh().Return(stopCh).AnyTimes()
 
 	logger := log.NopLogger()