METRON-2102 [UI] Adding click-through navigation to Alerts table (tiborm via mmiklavc) closes apache/metron#1431
diff --git a/metron-deployment/packaging/ambari/metron-mpack/src/main/resources/common-services/METRON/CURRENT/package/scripts/params/params_linux.py b/metron-deployment/packaging/ambari/metron-mpack/src/main/resources/common-services/METRON/CURRENT/package/scripts/params/params_linux.py
index 64105e3..de6b8bc 100755
--- a/metron-deployment/packaging/ambari/metron-mpack/src/main/resources/common-services/METRON/CURRENT/package/scripts/params/params_linux.py
+++ b/metron-deployment/packaging/ambari/metron-mpack/src/main/resources/common-services/METRON/CURRENT/package/scripts/params/params_linux.py
@@ -465,6 +465,7 @@
 metron_knox_root_path = '/gateway/metron'
 metron_rest_path = '/api/v1'
 metron_alerts_ui_login_path = '/login'
+metron_alerts_ui_context_menu_config_url = '/assets/context-menu.conf.json'
 metron_management_ui_login_path = '/login'
 metron_knox_enabled = config['configurations']['metron-security-env']['metron.knox.enabled']
 metron_knox_sso_pubkey = config['configurations']['metron-security-env']['metron.knox.sso.pubkey']
diff --git a/metron-deployment/packaging/ambari/metron-mpack/src/main/resources/common-services/METRON/CURRENT/package/templates/alerts-ui-app-config.json.j2 b/metron-deployment/packaging/ambari/metron-mpack/src/main/resources/common-services/METRON/CURRENT/package/templates/alerts-ui-app-config.json.j2
index edbc1b6..cdc064e 100644
--- a/metron-deployment/packaging/ambari/metron-mpack/src/main/resources/common-services/METRON/CURRENT/package/templates/alerts-ui-app-config.json.j2
+++ b/metron-deployment/packaging/ambari/metron-mpack/src/main/resources/common-services/METRON/CURRENT/package/templates/alerts-ui-app-config.json.j2
@@ -1,4 +1,5 @@
 {
   "apiRoot": "{{metron_rest_path}}",
-  "loginPath": "{{metron_alerts_ui_login_path}}"
+  "loginPath": "{{metron_alerts_ui_login_path}}",
+  "contextMenuConfigURL": "{{metron_alerts_ui_context_menu_config_url}}"
 }
\ No newline at end of file
diff --git a/metron-deployment/packaging/docker/rpm-docker/SPECS/metron.spec b/metron-deployment/packaging/docker/rpm-docker/SPECS/metron.spec
index 2047d10..8cd9bef 100644
--- a/metron-deployment/packaging/docker/rpm-docker/SPECS/metron.spec
+++ b/metron-deployment/packaging/docker/rpm-docker/SPECS/metron.spec
@@ -657,6 +657,7 @@
 %attr(0644,root,root) %{metron_home}/web/alerts-ui/assets/fonts/Roboto/*.ttf
 %attr(0644,root,root) %{metron_home}/web/alerts-ui/assets/images/*
 %attr(0644,root,root) %{metron_home}/web/alerts-ui/assets/app-config.json
+%attr(0644,root,root) %{metron_home}/web/alerts-ui/assets/context-menu.conf.json
 
 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
diff --git a/metron-interface/metron-alerts/README.md b/metron-interface/metron-alerts/README.md
index bcf29c0..1eb5709 100644
--- a/metron-interface/metron-alerts/README.md
+++ b/metron-interface/metron-alerts/README.md
@@ -22,6 +22,7 @@
 - [Cypress Tests](#cypress-tests)
 - [Mpack Integration](#mpack-integration)
 - [Installing on an existing Cluster](#installing-on-an-existing-cluster)
+- [Click Through Navigation feature](#click-through-navigation-feature)
 
 ## Caveats
 ### Local Storage
@@ -210,3 +211,6 @@
 
 If you like to learn more about Cypress based tests please visit [Cypress.io](http://cypress.io).
 You can find more information about debuggin in this [section of the official documentation](https://docs.cypress.io/guides/guides/debugging.html#Using-debugger).
+
+## Click Through Navigation feature
+Click Through Navigation is a feature helps users to integrate Metron Alerts UI with other services. You can find more on this on the following [page](./src/app/shared/context-menu/README.md).
\ No newline at end of file
diff --git a/metron-interface/metron-alerts/cypress/fixtures/context-menu.conf.json b/metron-interface/metron-alerts/cypress/fixtures/context-menu.conf.json
new file mode 100644
index 0000000..d28507b
--- /dev/null
+++ b/metron-interface/metron-alerts/cypress/fixtures/context-menu.conf.json
@@ -0,0 +1,49 @@
+{
+  "isEnabled": true,
+  "config": {
+    "alertEntry": [
+      {
+        "label": "Internal ticketing system",
+        "urlPattern": "http://mytickets.org/tickets/{id}"
+      }
+    ],
+    "metaAlertEntry": [
+      {
+        "label": "MetaAlert specific item",
+        "urlPattern": "http://mytickets.org/tickets/{id}"
+      }
+    ],
+    "id": [
+      {
+        "label": "Dynamic menu item 01",
+        "urlPattern": "http://mytickets.org/tickets/{}"
+      }
+    ],
+    "ip_src_addr": [
+      {
+        "label": "IP Investigation Notebook",
+        "urlPattern": "http://zepellin.example.com:9000/notebook/BLAHBAH?ip={ip_src_addr}"
+      },
+      {
+        "label": "IP Conversation Investigation",
+        "urlPattern": "http://zepellin.example.com:9000/notebook/BLAHBAH?ip_src_addr={ip_src_addr}&ip_dst_addr={ip_dst_addr}"
+      }
+    ],
+    "ip_dst_addr": [
+      {
+        "label": "IP Investigation Notebook",
+        "urlPattern": "http://zepellin.example.com:9000/notebook/BLAHBAH?ip={ip_dst_addr}"
+      },
+      {
+        "label": "IP Conversation Investigation",
+        "urlPattern": "http://zepellin.example.com:9000/notebook/BLAHBAH?ip_src_addr={ip_src_addr}&ip_dst_addr={ip_dst_addr}"
+      }
+    ],
+    "host": [
+      {
+        "label": "Whois Reputation Service",
+        "urlPattern": "https://www.whois.com/whois/{}"
+      }
+    ]
+  }
+}
diff --git a/metron-interface/metron-alerts/cypress/integration/alert-list/context-menu.spec.js b/metron-interface/metron-alerts/cypress/integration/alert-list/context-menu.spec.js
new file mode 100644
index 0000000..951f57e
--- /dev/null
+++ b/metron-interface/metron-alerts/cypress/integration/alert-list/context-menu.spec.js
@@ -0,0 +1,89 @@
+/// <reference types="Cypress" />
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import * as appConfigJSON from '../../../src/assets/app-config.json';
+
+context('Context Menu on Alerts', () => {
+
+  beforeEach(() => {
+    cy.server();
+    cy.route({
+      method: 'GET',
+      url: '/api/v1/user',
+      response: 'user'
+    });
+    cy.route({
+        method: 'POST',
+        url: '/api/v1/logout',
+        response: []
+    });
+
+    cy.route('GET', '/api/v1/global/config', 'fixture:config.json');
+    cy.route('POST', 'search', 'fixture:search.json');
+
+    cy.route('GET', appConfigJSON.contextMenuConfigURL, 'fixture:context-menu.conf.json');
+
+    cy.visit('login');
+    cy.get('[name="user"]').type('user');
+    cy.get('[name="password"]').type('password');
+    cy.contains('LOG IN').click();
+
+    cy.get('[data-qe-id="alert-search-btn"]').click();
+  });
+
+  it('clicking on a table cell should show context menu', () => {
+    cy.get('[data-qe-id="row-5"] > :nth-child(6) > a').click();
+    cy.get('[data-qe-id="cm-dropdown"]').should('be.visible');
+  });
+
+  it('clicking on "Add to search bar" should apply value to filter bar', () => {
+    cy.get('[data-qe-id="row-5"] > :nth-child(6) > a').click();
+    cy.get('[data-qe-id="cm-dropdown"]').should('be.visible');
+    cy.contains('Add to search bar').click();
+    cy.get('.ace_keyword').should('contain', 'ip_src_addr:');
+    cy.get('.ace_value').should('contain', '192.168.66.121');
+  });
+
+  it('clicking on "Add to search bar" should close the dropdown of context menu', () => {
+    cy.get('[data-qe-id="row-5"] > :nth-child(6) > a').click();
+    cy.get('[data-qe-id="cm-dropdown"]').should('be.visible');
+    cy.contains('Add to search bar').click();
+    cy.get('[data-qe-id="cm-dropdown"]').should('not.be.visible');
+  });
+
+  it('dynamic items should be rendered', () => {
+    cy.get('[data-qe-id="row-5"] > :nth-child(6) > a').click();
+    cy.get('[data-qe-id="cm-dropdown"]').should('be.visible');
+    cy.get('[data-qe-id="cm-dropdown"]').contains('IP Investigation Notebook').should('be.visible');
+  });
+
+  // this use case was a former bug caused by the behaviour of the rxjs Subject class
+  // here we pinning down the fix with a test
+  it('dynamic items should be rendered after a clicking a predefined item', () => {
+    cy.get('[data-qe-id="row-5"] > :nth-child(6) > a').click();
+    cy.get('[data-qe-id="cm-dropdown"]').should('be.visible');
+    cy.contains('Add to search bar').click();
+
+    cy.wait(300);
+
+    cy.get('[data-qe-id="row-5"] > :nth-child(6) > a').click();
+    cy.get('[data-qe-id="cm-dropdown"]').should('be.visible');
+    cy.get('[data-qe-id="cm-dropdown"]').contains('IP Investigation Notebook').should('be.visible');
+  });
+
+})
diff --git a/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.component.html b/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.component.html
index 9f12cbd..79f0962 100644
--- a/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.component.html
+++ b/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.component.html
@@ -27,7 +27,7 @@
                         <app-time-range class="d-flex position-relative" (timeRangeChange)="onTimeRangeChange($event)" [disabled]="timeStampfilterPresent" [selectedTimeRange]="selectedTimeRange"> </app-time-range>
                     </span>
                     <span class="input-group-append">
-                        <button class="btn btn-secondary btn-search rounded-right" type="button" data-name="search" (click)="onSearch(alertSearchDirective.getSeacrhText())"></button>
+                        <button data-qe-id="alert-search-btn" class="btn btn-secondary btn-search rounded-right" type="button" data-name="search" (click)="onSearch(alertSearchDirective.getSeacrhText())"></button>
                     </span>
                     <div class="input-group-append">
                         <span class="save-button" (click)="showSaveSearch()">
diff --git a/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.component.ts b/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.component.ts
index 26b472d..b12cb60 100644
--- a/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.component.ts
+++ b/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.component.ts
@@ -1,5 +1,3 @@
-
-import {forkJoin as observableForkJoin} from 'rxjs';
 /**
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
@@ -17,6 +15,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {forkJoin as observableForkJoin} from 'rxjs';
 import {Component, OnInit, ViewChild, ElementRef, OnDestroy} from '@angular/core';
 import {Router, NavigationStart} from '@angular/router';
 import {Subscription} from 'rxjs';
diff --git a/metron-interface/metron-alerts/src/app/alerts/alerts-list/table-view/table-view.component.html b/metron-interface/metron-alerts/src/app/alerts/alerts-list/table-view/table-view.component.html
index 597cd45..96898c3 100644
--- a/metron-interface/metron-alerts/src/app/alerts/alerts-list/table-view/table-view.component.html
+++ b/metron-interface/metron-alerts/src/app/alerts/alerts-list/table-view/table-view.component.html
@@ -26,16 +26,32 @@
     <tbody>
     <ng-container *ngFor="let alert of alerts; let alertIndex = index;">
 
+      <!-- standalone alerts (not wrapped by meta alert) -->
       <ng-container *ngIf="!alert.source.metron_alert || alert.source.metron_alert.length === 0">
-        <tr attr.data-qe-id="{{'row-' + alertIndex}}" (click)="showDetails($event, alert)" [ngClass]="{'selected' : selectedAlerts.indexOf(alert) != -1}">
+        <tr attr.data-qe-id="{{'row-' + alertIndex}}"
+          [ngClass]="{'selected' : selectedAlerts.indexOf(alert) != -1}"
+          ctxMenu ctxMenuId="alertEntry" ctxMenuTitle="Alert entry: {{ alert.id }}"
+          [ctxMenuData]="merge(alert.source, { id: alert.id })"
+          [ctxMenuItems]="[{ label: 'Show details', event: 'ctxMenuEventShowDetails'}]"
+          (ctxMenuEventShowDetails)="showDetails($event, alert)">
           <td width="15" class="icon-cell"></td>
-          <td (click)="addFilter(threatScoreFieldName(), getScore(alert.source))">
+          <td>
             <div appAlertSeverity [severity]="getScore(alert.source)">
-              <a attr.data-qe-id="{{'score'}}"> {{ hasScore(alert.source) ? getScore(alert.source) : '-' }} </a>
+              <a attr.data-qe-id="{{'score'}}"
+              ctxMenu ctxMenuId="score"
+              [ctxMenuData]="alert.source"
+              [ctxMenuItems]="[{ label: 'Add to search bar', event: 'ctxMenuEventAddToFilters'}]"
+              (ctxMenuEventAddToFilters)="addFilter(threatScoreFieldName(), getScore(alert.source))">
+              {{ hasScore(alert.source) ? getScore(alert.source) : '-' }}
+            </a>
             </div>
           </td>
           <td *ngFor="let column of alertsColumnsToDisplay; let columnIndex = index;" #cell>
-            <a  attr.data-qe-id="{{'cell-' + columnIndex}}" (click)="addFilter(column.name, getValue(alert, column, false))" title="{{getValue(alert, column, true)}}"  style="color:#689AA9">
+            <a attr.data-qe-id="{{'cell-' + columnIndex}}" title="{{getValue(alert, column, true)}}"  style="color:#689AA9"
+              ctxMenu [ctxMenuId]="column.name"
+              [ctxMenuData]="alert.source"
+              [ctxMenuItems]="[{ label: 'Add to search bar', event: 'ctxMenuEventAddToFilters'}]"
+              (ctxMenuEventAddToFilters)="addFilter(column.name, getValue(alert, column, false))">
               {{ getValue(alert,column, true) | centerEllipses:20:cell }}
             </a>
           </td>
@@ -50,22 +66,45 @@
         </tr>
       </ng-container>
 
+      <!-- alerts wrapped into a meta alert -->
       <ng-container *ngIf="alert.source.metron_alert && alert.source.metron_alert.length > 0">
-        <tr (click)="showDetails($event, alert)" [ngClass]="{'selected' : selectedAlerts.indexOf(alert) != -1}">
+        <!-- meta alert entries -->
+        <tr [ngClass]="{'selected' : selectedAlerts.indexOf(alert) != -1}"
+          ctxMenu ctxMenuId="metaAlertEntry" ctxMenuTitle="Alert entry: {{ alert.id }}"
+          [ctxMenuData]="merge(alert.source, { id: alert.id })"
+          [ctxMenuItems]="[{ label: 'Show details', event: 'ctxMenuEventShowDetails'}]"
+          (ctxMenuEventShowDetails)="showDetails($event, alert)">
           <td width="15" class="icon-cell dropdown-cell" (click)="toggleExpandCollapse($event, alert)">
             <i class="fa" aria-hidden="true"
                [ngClass]="{'fa-caret-right': metaAlertsDisplayState[alert.id] === metronAlertDisplayState.COLLAPSE, 'fa-caret-down': metaAlertsDisplayState[alert.id] === metronAlertDisplayState.EXPAND}">
             </i>
           </td>
-          <td (click)="addFilter(threatScoreFieldName(), getScore(alert.source))">
-            <span appAlertSeverity [severity]="getScore(alert.source)"> <a> {{ hasScore(alert.source) ? getScore(alert.source) : '-' }} </a> </span>
+          <td>
+            <div appAlertSeverity [severity]="getScore(alert.source)">
+              <a ctxMenu ctxMenuId="metaAlert-{{ alert.id }}"
+              [ctxMenuData]="alert.source"
+              [ctxMenuItems]="[{ label: 'Add to search bar', event: 'ctxMenuEventAddToFilters'}]"
+              (ctxMenuEventAddToFilters)="addFilter(threatScoreFieldName(), getScore(alert.source))">
+                {{ hasScore(alert.source) ? getScore(alert.source) : '-' }}
+              </a>
+            </div>
           </td>
           <td [attr.colspan]="alertsColumnsToDisplay.length - 1">
-            <a (click)="addFilter('guid', alert.source['guid'])" [attr.title]="alert.source['guid']" style="color:#689AA9"> {{ alert.source['name'] ? alert.source['name'] : alert.source['guid'] | centerEllipses:20:cell }}</a>
-              <span> ({{ alert.source.metron_alert.length }})</span>
+            <a [attr.title]="alert.source['guid']" style="color:#689AA9"
+              ctxMenu ctxMenuId="metaAlert-{{ alert.id }}"
+              [ctxMenuData]="alert.source"
+              [ctxMenuItems]="[{ label: 'Add to search bar', event: 'ctxMenuEventAddToFilters'}]"
+              (ctxMenuEventAddToFilters)="addFilter('guid', alert.source['guid'])">
+                {{ alert.source['name'] ? alert.source['name'] : alert.id | centerEllipses:20:cell }}
+            </a>
+            <span> ({{ alert.source.metron_alert.length }})</span>
           </td>
           <td>
-            <a *ngIf="isStatusFieldPresent" (click)="addFilter('alert_status', alert.source['alert_status'])" style="color:#689AA9">
+            <a *ngIf="isStatusFieldPresent" style="color:#689AA9"
+              ctxMenu ctxMenuId="metaAlert-{{ alert.id }}"
+              [ctxMenuData]="alert.source"
+              [ctxMenuItems]="[{ label: 'Add to search bar', event: 'ctxMenuEventAddToFilters'}]"
+              (ctxMenuEventAddToFilters)="addFilter('alert_status', alert.source['alert_status'])">
               {{ alert.source['alert_status'] ?alert.source['alert_status'] : 'New' | centerEllipses:20:cell }}
             </a>
           </td>
@@ -80,19 +119,37 @@
             <label attr.for="{{ alert.id }}"></label>
           </td>
         </tr>
-        <tr *ngFor="let metaAlerts of alert.source.metron_alert; let metaAlertIndex = index;" (click)="showMetaAlertDetails($event, metaAlerts)"
-            [ngClass]="{'selected' : selectedAlerts.indexOf(metaAlerts) != -1 , 'd-none': metaAlertsDisplayState[alert.id] === metronAlertDisplayState.COLLAPSE}">
+        <!-- nested alert entries -->
+        <tr *ngFor="let metaAlerts of alert.source.metron_alert; let metaAlertIndex = index;"
+          [ngClass]="{'selected' : selectedAlerts.indexOf(metaAlerts) != -1 , 'd-none': metaAlertsDisplayState[alert.id] === metronAlertDisplayState.COLLAPSE}"
+          ctxMenu ctxMenuId="metaAlertEntry" ctxMenuTitle="Alert entry: {{ alert.id }}"
+          [ctxMenuData]="merge(metaAlerts, { id: alert.id })"
+          [ctxMenuItems]="[{ label: 'Show details', event: 'ctxMenuEventShowDetails'}]"
+          (ctxMenuEventShowDetails)="showMetaAlertDetails($event, metaAlerts)">
           <td width="15" class="icon-cell" class="dropdown-cell"></td>
-          <td (click)="addFilter(threatScoreFieldName(), getScore(alert.source))" style="padding-left: 15px">
+          <td style="padding-left: 15px">
             <div appAlertSeverity [severity]="getScore(metaAlerts)">
-              <a> {{ hasScore(metaAlerts) ? getScore(metaAlerts) : '-' }} </a>
+              <a ctxMenu [ctxMenuId]="threatScoreFieldName()"
+              [ctxMenuData]="metaAlerts"
+              [ctxMenuItems]="[{ label: 'Add to search bar', event: 'ctxMenuEventAddToFilters'}]"
+              (ctxMenuEventAddToFilters)="addFilter(threatScoreFieldName(), getScore(alert.source))">
+                {{ hasScore(metaAlerts) ? getScore(metaAlerts) : '-' }}
+              </a>
             </div>
           </td>
           <td *ngFor="let column of alertsColumnsToDisplay">
-            <a *ngIf="column.name !== 'alert_status'" (click)="addFilter(column.name, getValueFromSource(metaAlerts, column, false))" title="{{ getValueFromSource(metaAlerts, column, true) }}" style="color:#689AA9">
+            <a *ngIf="column.name !== 'alert_status'" title="{{ getValueFromSource(metaAlerts, column, true) }}" style="color:#689AA9"
+              ctxMenu [ctxMenuId]="column.name"
+              [ctxMenuData]="metaAlerts"
+              [ctxMenuItems]="[{ label: 'Add to search bar', event: 'ctxMenuEventAddToFilters'}]"
+              (ctxMenuEventAddToFilters)="addFilter(column.name, getValueFromSource(metaAlerts, column, false))">
               {{ getValueFromSource(metaAlerts, column, true) | centerEllipses:20:cell }}
             </a>
-            <a *ngIf="column.name === 'alert_status'" (click)="addFilter(column.name, getValue(alert, column, false))" title="{{getValue(alert, column, true)}}" style="color:#689AA9">
+            <a *ngIf="column.name === 'alert_status'" title="{{getValue(alert, column, true)}}" style="color:#689AA9"
+              ctxMenu [ctxMenuId]="column.name"
+              [ctxMenuData]="metaAlerts"
+              [ctxMenuItems]="[{ label: 'Add to search bar', event: 'ctxMenuEventAddToFilters'}]"
+              (ctxMenuEventAddToFilters)="addFilter(column.name, getValue(alert, column, false))">
               {{ getValue(alert,column, true) | centerEllipses:20:cell }}
             </a>
           </td>
diff --git a/metron-interface/metron-alerts/src/app/alerts/alerts-list/table-view/table-view.component.spec.ts b/metron-interface/metron-alerts/src/app/alerts/alerts-list/table-view/table-view.component.spec.ts
index 8f2b4c4..29c0ffe 100644
--- a/metron-interface/metron-alerts/src/app/alerts/alerts-list/table-view/table-view.component.spec.ts
+++ b/metron-interface/metron-alerts/src/app/alerts/alerts-list/table-view/table-view.component.spec.ts
@@ -32,6 +32,7 @@
 import { MetaAlertService } from '../../../service/meta-alert.service';
 import { DialogService } from 'app/service/dialog.service';
 import { AppConfigService } from '../../../service/app-config.service';
+import { ContextMenuComponent } from 'app/shared/context-menu/context-menu.component';
 
 @Component({selector: 'metron-table-pagination', template: ''})
 class MetronTablePaginationComponent {
@@ -67,6 +68,7 @@
         CenterEllipsesPipe,
         ColumnNameTranslatePipe,
         AlertSeverityDirective,
+        ContextMenuComponent,
         MetronTablePaginationComponent,
         TableViewComponent,
       ]
diff --git a/metron-interface/metron-alerts/src/app/alerts/alerts-list/table-view/table-view.component.ts b/metron-interface/metron-alerts/src/app/alerts/alerts-list/table-view/table-view.component.ts
index e9d19a1..6f4bc9f 100644
--- a/metron-interface/metron-alerts/src/app/alerts/alerts-list/table-view/table-view.component.ts
+++ b/metron-interface/metron-alerts/src/app/alerts/alerts-list/table-view/table-view.component.ts
@@ -37,6 +37,8 @@
 import { ConfirmationType } from 'app/model/confirmation-type';
 import {HttpErrorResponse} from "@angular/common/http";
 
+import { merge } from '../../../shared/context-menu/context-menu.util'
+
 export enum MetronAlertDisplayState {
   COLLAPSE, EXPAND
 }
@@ -55,6 +57,8 @@
   globalConfig: {} = {};
   configSubscription: Subscription;
 
+  merge: Function = merge;
+
   @Input() alerts: Alert[] = [];
   @Input() queryBuilder: QueryBuilder;
   @Input() pagination: Pagination;
@@ -108,10 +112,9 @@
   }
 
   hasScore(alertSource) {
-    if(alertSource[this.threatScoreFieldName()]) {
+    if (alertSource[this.threatScoreFieldName()]) {
       return true;
-    }
-    else {
+    } else {
       return false;
     }
   }
diff --git a/metron-interface/metron-alerts/src/app/app.module.spec.ts b/metron-interface/metron-alerts/src/app/app.module.spec.ts
new file mode 100644
index 0000000..88dcd92
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/app.module.spec.ts
@@ -0,0 +1,18 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import './app.module';
diff --git a/metron-interface/metron-alerts/src/app/service/app-config.service.spec.ts b/metron-interface/metron-alerts/src/app/service/app-config.service.spec.ts
new file mode 100644
index 0000000..95beb9a
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/service/app-config.service.spec.ts
@@ -0,0 +1,154 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { TestBed, getTestBed, inject } from '@angular/core/testing';
+
+import { AppConfigService } from './app-config.service';
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+
+describe('AppConfigService', () => {
+
+  let mockBackend: HttpTestingController;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      imports: [ HttpClientTestingModule ],
+      providers: [ AppConfigService ],
+    });
+
+    mockBackend = getTestBed().get(HttpTestingController);
+  });
+
+  it('should be created', inject([AppConfigService], (service: AppConfigService) => {
+    expect(service).toBeTruthy();
+  }));
+
+  it('should expose apiRoot', inject([AppConfigService], (service: AppConfigService) => {
+    expect(typeof service.getApiRoot).toBe('function');
+  }));
+
+  it('should expose loginPath', inject([AppConfigService], (service: AppConfigService) => {
+    expect(typeof service.getLoginPath).toBe('function');
+  }));
+
+  it('should expose contextMenuConfigURL', inject([AppConfigService], (service: AppConfigService) => {
+    expect(typeof service.getContextMenuConfigURL).toBe('function');
+  }));
+
+  it('should load app-config.json', inject([AppConfigService], (service: AppConfigService) => {
+    service.loadAppConfig();
+
+    const req = mockBackend.expectOne('assets/app-config.json');
+    expect(req.request.method).toEqual('GET');
+    req.flush({});
+
+    mockBackend.verify();
+  }));
+
+  it('getApiRoot() should return with apiRoot value', function(done) {
+    inject([AppConfigService], (service: AppConfigService) => {
+      service.loadAppConfig().then(() => {
+        expect(service.getApiRoot()).toBe('/api/v1');
+        done();
+      }, (error) => {
+        throw error;
+      });
+
+      const req = mockBackend.expectOne('assets/app-config.json');
+      req.flush({ apiRoot: '/api/v1' });
+    })();
+  });
+
+  it('getApiRoot() should log error on the console if apiRoot is undefined', function(done) {
+    inject([AppConfigService], (service: AppConfigService) => {
+      spyOn(console, 'error');
+
+      service.loadAppConfig().then(() => {
+        service.getApiRoot();
+        expect(console.error).toHaveBeenCalledWith('[AppConfigService] apiRoot entry is missing from /assets/app-config.json');
+        done();
+      }, (error) => {
+        throw error;
+      });
+
+      const req = mockBackend.expectOne('assets/app-config.json');
+      req.flush({});
+    })();
+  });
+
+  it('getLoginPath() should return with loginPath value', function(done) {
+    inject([AppConfigService], (service: AppConfigService) => {
+      service.loadAppConfig().then(() => {
+        expect(service.getLoginPath()).toBe('/login');
+        done();
+      }, (error) => {
+        throw error;
+      });
+
+      const req = mockBackend.expectOne('assets/app-config.json');
+      req.flush({ loginPath: '/login' });
+    })();
+  });
+
+  it('getLoginPath() should log error on the console if loginPath is undefined', function(done) {
+    inject([AppConfigService], (service: AppConfigService) => {
+      spyOn(console, 'error');
+
+      service.loadAppConfig().then(() => {
+        service.getLoginPath();
+        expect(console.error).toHaveBeenCalledWith('[AppConfigService] loginPath entry is missing from /assets/app-config.json');
+        done();
+      }, (error) => {
+        throw error;
+      });
+
+      const req = mockBackend.expectOne('assets/app-config.json');
+      req.flush({});
+    })();
+  });
+
+  it('getContextMenuConfigURL() should return with contextMenuConfigURL value', function(done) {
+    inject([AppConfigService], (service: AppConfigService) => {
+      service.loadAppConfig().then(() => {
+        expect(service.getContextMenuConfigURL()).toBe('/contextMenuConfigURL');
+        done();
+      }, (error) => {
+        throw error;
+      });
+
+      const req = mockBackend.expectOne('assets/app-config.json');
+      req.flush({ contextMenuConfigURL: '/contextMenuConfigURL' });
+    })();
+  });
+
+  it('getContextMenuConfigURL() should log error on the console if contextMenuConfigURL is undefined', function(done) {
+    inject([AppConfigService], (service: AppConfigService) => {
+      spyOn(console, 'error');
+
+      service.loadAppConfig().then(() => {
+        service.getContextMenuConfigURL();
+        expect(console.error).toHaveBeenCalledWith('[AppConfigService] contextMenuConfigURL entry is missing from /assets/app-config.json');
+        done();
+      }, (error) => {
+        throw error;
+      });
+
+      const req = mockBackend.expectOne('assets/app-config.json');
+      req.flush({});
+    })();
+  });
+});
diff --git a/metron-interface/metron-alerts/src/app/service/app-config.service.ts b/metron-interface/metron-alerts/src/app/service/app-config.service.ts
index a3b7414..97a18f5 100644
--- a/metron-interface/metron-alerts/src/app/service/app-config.service.ts
+++ b/metron-interface/metron-alerts/src/app/service/app-config.service.ts
@@ -24,6 +24,10 @@
 
   private static appConfigStatic;
 
+  static getAppConfigStatic() {
+    return AppConfigService.appConfigStatic;
+  }
+
   constructor(private http: HttpClient) { }
 
   loadAppConfig() {
@@ -36,14 +40,23 @@
   }
 
   getApiRoot() {
-    return AppConfigService.appConfigStatic['apiRoot'];
+    if (AppConfigService.appConfigStatic['apiRoot'] === undefined) {
+      console.error('[AppConfigService] apiRoot entry is missing from /assets/app-config.json');
+    }
+    return AppConfigService.appConfigStatic['apiRoot']
   }
 
   getLoginPath() {
+    if (AppConfigService.appConfigStatic['loginPath'] === undefined) {
+      console.error('[AppConfigService] loginPath entry is missing from /assets/app-config.json');
+    }
     return AppConfigService.appConfigStatic['loginPath'];
   }
 
-  static getAppConfigStatic() {
-    return AppConfigService.appConfigStatic;
+  getContextMenuConfigURL() {
+    if (AppConfigService.appConfigStatic['contextMenuConfigURL'] === undefined) {
+      console.error('[AppConfigService] contextMenuConfigURL entry is missing from /assets/app-config.json');
+    }
+    return AppConfigService.appConfigStatic['contextMenuConfigURL'];
   }
-}
\ No newline at end of file
+}
diff --git a/metron-interface/metron-alerts/src/app/shared/context-menu/README.md b/metron-interface/metron-alerts/src/app/shared/context-menu/README.md
new file mode 100644
index 0000000..c669e86
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/shared/context-menu/README.md
@@ -0,0 +1,203 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+# About the feature
+Click Through Navigation is a feature makes Metron Users able to reach other web services via dynamically created URLs by clicking link item in a context menu.
+This context menu (aka. click-through menu) is attached to the alerts table and the links are populated with alert data from the specific row of the table.
+
+# Configuration
+
+In it's current state the click through navigation is configurable via a JSON file bundled with Alerts UI.
+
+> We're planning to provide a administration interface for click through navigation not too far in the future. A UI would make configuration process easier and more interactive. However, the feature is fully functional in it's current form. You can follow the progress via the following [Jira ticket](https://issues.apache.org/jira/browse/METRON-2102).
+
+## Location of the config JSON file
+### In Metron source code
+If you are a developer and like to experimental with the feature on your localhost you can find the config file here:
+```
+metron-interface/metron-alerts/src/assets/context-menu.conf.json
+```
+### In a deployed environment
+If you are an operations person or you like to configure a Metron instance already deployed you can find the config file here:
+```
+/usr/metron/{version}/web/alerts-ui/assets/context-menu.conf.json
+```
+
+## Applying changes in the config JSON
+If you made any changes in the config JSON file you need to restart Metron Alerts UI in Ambari to apply them.
+
+## Config validation and troubleshooting
+Click through feature in Alerts UI try to help debugging any possible issues (misspelling, invalid values etc.) by validating context-menu.conf.json. Alert UI provides you error messages in your browser console if config JSON is corrupt in any possible ways.
+
+## Enabling feature
+The feature is by default turned off. A sample configuration is added as an example and for testing purposes.
+If you like to enable click through navigation you should set isEnabled to true in the config JSON file:
+```
+{
+  isEnabled: true,
+  config: {
+  ...
+```
+
+## Attaching and configuring click-through menu to a column
+Items and URLs in the context menu based on a configuration (this is currently a JSON file). A configuration could be attached to a cell or a row.
+If you like to attach a menu configuration to a cell of a column you should use the field id (what field of the alert populates the column) to target the particular column.
+
+For example, the following configuration adding the "Whois Reputation Service" item to the context menu which appears if the user left click on a value in the "host" column:
+```
+{
+  isEnabled: true,
+  config: {
+    "host": [
+      {
+        "label": "Whois Reputation Service",
+        "urlPattern": "https://www.whois.com/whois/{}"
+      }
+    ],
+  ...
+```
+Clicking on the item opens another browser tab and call the URL in the urlPattern config field. "{}" at the end of the pattern stands for being a default placeholder and it will be replaced by the value of the host field in the particular row which was clicked.
+But in the configuration, any available alert property field could be referenced like the following:
+```
+{
+  isEnabled: true,
+  config: {
+    "host": [
+      {
+        "label": "Whois Reputation Service",
+        "urlPattern": "https://www.whois.com/whois/{ip_src_addr}"
+      }
+    ],
+  ...
+```
+In this case however the menu attached to the host column the place holder will be resolved with the value of the ip_src_addr field of the particular alert item.
+You can reference multiple fields and can combine default and specific placeholders:
+```
+{
+  isEnabled: true,
+  config: {
+    "host": [
+      {
+        "label": "Whois Reputation Service",
+        "urlPattern": "https://www.whois.com/whois/{}?srcip={ip_src_addr}&destip={ip_dest_addr}"
+      }
+    ],
+  ...
+```
+Configuration to a particular column could contain multiple menu items like in the following example:
+```
+{
+  isEnabled: true,
+  config: {
+    "ip_src_addr": [
+      {
+        "label": "IP Investigation Notebook",
+        "urlPattern": "http://zepellin.example.com:9000/notebook/someid?ip={ip_src_addr}"
+      },
+      {
+        "label": "IP Conversation Investigation",
+        "urlPattern": "http://zepellin.example.com:9000/notebook/someid?ip_src_addr={ip_src_addr}&ip_dst_addr={ip_dst_addr}"
+      }
+    ],
+    "host": [
+      {
+        "label": "Whois Reputation Service",
+        "urlPattern": "https://www.whois.com/whois/{}?srcip={ip_src_addr}&destip={ip_dest_addr}"
+      }
+    ],
+  ...
+```
+
+## Attaching and configuring click-through menu to rows
+
+In the case of rows, we distinguish simple alerts and meta alerts. So these two types are configurable separately.
+```
+{
+  isEnabled: true,
+  config: {
+    "alertEntry": [
+      {
+        "label": "Internal ticketing system",
+        "urlPattern": "http://mytickets.org/tickets/{id}"
+      }
+    ],
+    "metaAlertEntry": [
+      {
+        "label": "MetaAlert specific item",
+        "urlPattern": "http://mytickets.org/tickets/{id}"
+      }
+    ],
+  ...
+```
+These two keywords: **"alertEntry"** and **"metaAlertEntry"** stand for configuring menu attached to alert and meta alert rows.
+When the user clicking on a value it is recognized as a cell/column specific click and the menu configured to the particular field/column will appear.
+If the user clicks outside of value (to the blank space between values) it will be recognized as a row click and an alert or meta alert specific click-through menu will show up depending on the type of the row.
+
+# Sample configuration provided by default
+
+The default configuration at the time of writing looks like the following:
+```
+{
+  isEnabled: false,
+  config: {
+    "alertEntry": [
+      {
+        "label": "Internal ticketing system",
+        "urlPattern": "http://mytickets.org/tickets/{id}"
+      }
+    ],
+    "metaAlertEntry": [
+      {
+        "label": "MetaAlert specific item",
+        "urlPattern": "http://mytickets.org/tickets/{id}"
+      }
+    ],
+    "id": [
+      {
+        "label": "Dynamic menu item 01",
+        "urlPattern": "http://mytickets.org/tickets/{}"
+      }
+    ],
+    "ip_src_addr": [
+      {
+        "label": "IP Investigation Notebook",
+        "urlPattern": "http://zepellin.example.com:9000/notebook/someid?ip={ip_src_addr}"
+      },
+      {
+        "label": "IP Conversation Investigation",
+        "urlPattern": "http://zepellin.example.com:9000/notebook/someid?ip_src_addr={ip_src_addr}&ip_dst_addr={ip_dst_addr}"
+      }
+    ],
+    "ip_dst_addr": [
+      {
+        "label": "IP Investigation Notebook",
+        "urlPattern": "http://zepellin.example.com:9000/notebook/someid?ip={ip_dst_addr}"
+      },
+      {
+        "label": "IP Conversation Investigation",
+        "urlPattern": "http://zepellin.example.com:9000/notebook/someid?ip_src_addr={ip_src_addr}&ip_dst_addr={ip_dst_addr}"
+      }
+    ],
+    "host": [
+      {
+      "label": "Whois Reputation Service",
+      "urlPattern": "https://www.whois.com/whois/{}"
+      }
+    ]
+  }
+}
+```
\ No newline at end of file
diff --git a/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.component.html b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.component.html
new file mode 100644
index 0000000..290ec78
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.component.html
@@ -0,0 +1,25 @@
+<!--
+  Licensed to the Apache Software
+	Foundation (ASF) under one or more contributor license agreements. See the
+	NOTICE file distributed with this work for additional information regarding
+	copyright ownership. The ASF licenses this file to You under the Apache License,
+	Version 2.0 (the "License"); you may not use this file except in compliance
+	with the License. You may obtain a copy of the License at
+  http://www.apache.org/licenses/LICENSE-2.0
+  Unless required by applicable law or agreed to in writing, software distributed
+	under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
+	OR CONDITIONS OF ANY KIND, either express or implied. See the License for
+  the specific language governing permissions and limitations under the License.
+  -->
+<ng-content></ng-content>
+<div *ngIf="isOpen" #contextMenuDropDown class="dropdown-menu" data-qe-id="cm-dropdown">
+  <div class="card-header">{{ ctxMenuTitle || ctxMenuId || 'Menu' }}</div>
+  <a class="dropdown-item" *ngFor="let predefinedItem of ctxMenuItems"
+    data-qe-id="cm-predefined-item"
+    (click)="onPredefinedItemClicked($event, predefinedItem.event)">{{ predefinedItem.label }}</a>
+  <div *ngIf="dynamicMenuItems.length" class="dropdown-divider"></div>
+  <a *ngFor="let dynamicItem of dynamicMenuItems" class="dropdown-item"
+    data-qe-id="cm-dynamic-item"
+    (click)="onDynamicItemClicked($event, dynamicItem.urlPattern)" >{{ dynamicItem.label }}</a>
+</div>
+<div *ngIf="isOpen" #clickOutsideCanvas class="transparent viewport-sized" data-qe-id="cm-outside"></div>
\ No newline at end of file
diff --git a/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.component.scss b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.component.scss
new file mode 100644
index 0000000..be7e28a
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.component.scss
@@ -0,0 +1,64 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@import "_variables.scss";
+
+$menu-z-index: 98888;
+
+.transparent {
+  opacity: 0;
+}
+
+.viewport-sized {
+  display: block;
+  position: fixed;
+  z-index: $menu-z-index;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background-color: aqua;
+}
+
+.dropdown-menu {
+  display: block;
+  z-index: $menu-z-index + 1;
+  font-size: .9em;
+  padding: 0 0 .5rem 0;
+
+  .card-header {
+    padding: .3rem .6rem .2rem .6rem;
+    margin-bottom: .5rem;
+    background-color: $gray;
+    color: $black;
+    font-size: .8rem;
+  }
+
+  .dropdown-item {
+    border: none !important;
+    cursor: pointer;
+  }
+
+  .dropdown-item:hover {
+    background-color: $abbey !important;
+  }
+
+  .dropdown-divider {
+    border-top: 1px solid #4d4d4d;
+  }
+}
+
diff --git a/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.component.spec.ts b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.component.spec.ts
new file mode 100644
index 0000000..253d897
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.component.spec.ts
@@ -0,0 +1,281 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { ComponentFixture, TestBed, getTestBed } from '@angular/core/testing';
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { ContextMenuComponent } from './context-menu.component';
+import { ContextMenuService } from './context-menu.service';
+import { Component, Injectable } from '@angular/core';
+import { By } from '@angular/platform-browser';
+import { of } from 'rxjs';
+
+const FAKE_CONFIG_SVC_URL = '/test/config/menu/url';
+
+@Injectable()
+class FakeContextMenuService {
+
+  fakeConfig = {}
+
+  getConfig() {
+    return of(this.fakeConfig);
+  }
+}
+
+@Component({
+  template: `
+    <div ctxMenu
+      ctxMenuId="testMenuConfigId"
+      ctxMenuTitle="This is a test"
+      [ctxMenuItems]="[
+        { label: 'Test Label 01', event: 'customEventOne'},
+        { label: 'Test Label 02', event: 'customEventTwo'}
+      ]"
+      [ctxMenuData]="{
+        testMenuConfigId: 'testValue',
+        customKey: 'customValue'
+      }">
+      Context Menu Test In Progress...
+    </div>
+  `
+})
+class TestComponent {}
+
+describe('ContextMenuComponent', () => {
+  let fixture: ComponentFixture<TestComponent>;
+  let directiveHostEl: any;
+
+  let fakeContextMenuSvc: FakeContextMenuService;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      imports: [ HttpClientTestingModule ],
+      declarations: [ ContextMenuComponent, TestComponent ],
+      providers: [
+        { provide: ContextMenuService, useClass: FakeContextMenuService }
+      ]
+    })
+    .compileComponents();
+
+    fakeContextMenuSvc = getTestBed().get(ContextMenuService);
+    fixture = TestBed.createComponent(TestComponent);
+    directiveHostEl = fixture.debugElement.query(By.directive(ContextMenuComponent)).nativeElement;
+  });
+
+  afterEach(() => {
+    fixture.destroy();
+  })
+
+  it('should create', () => {
+    expect(fixture).toBeTruthy();
+  });
+
+  it('should show context menu on left click when feature enabled', () => {
+    fakeContextMenuSvc.fakeConfig = {
+        isEnabled: true,
+        config: {
+          menuKey: []
+        }
+      };
+    fixture.detectChanges();
+
+    directiveHostEl.click();
+    fixture.detectChanges();
+
+    expect(document.body.querySelector('[data-qe-id="cm-dropdown"]')).toBeTruthy();
+  });
+
+  it('should NOT show context menu on left click when feature IS NOT enabled', () => {
+    fakeContextMenuSvc.fakeConfig = {
+      isEnabled: false,
+      config: {
+        menuKey: []
+      }
+    };
+    fixture.detectChanges();
+
+    directiveHostEl.click();
+    fixture.detectChanges();
+
+    expect(document.body.querySelector('[data-qe-id="cm-dropdown"]')).toBeFalsy();
+  });
+
+  it('should close context menu if user clicks outside of it', () => {
+    fakeContextMenuSvc.fakeConfig = {
+      isEnabled: true,
+      config: {
+        menuKey: []
+      }
+    };
+    fixture.detectChanges();
+
+    directiveHostEl.click();
+    fixture.detectChanges();
+
+    expect(document.body.querySelector('[data-qe-id="cm-dropdown"]')).toBeTruthy();
+
+    (document.body.querySelector('[data-qe-id="cm-outside"]') as HTMLElement).click();
+    fixture.detectChanges();
+
+    expect(document.body.querySelector('.dropdown-menu')).toBeFalsy();
+  });
+
+  it('should render predefined menu items', () => {
+    fakeContextMenuSvc.fakeConfig = {
+      isEnabled: true,
+      config: {
+        menuKey: []
+      }
+    };
+    fixture.detectChanges();
+
+    directiveHostEl.click();
+    fixture.detectChanges();
+
+    expect(document.body.querySelector('[data-qe-id="cm-predefined-item"]')).toBeTruthy();
+  });
+
+  it('should render multiple predefined menu items', () => {
+    fakeContextMenuSvc.fakeConfig = {
+      isEnabled: true,
+      config: {
+        menuKey: []
+      }
+    };
+    fixture.detectChanges();
+
+    directiveHostEl.click();
+    fixture.detectChanges();
+
+    expect(document.body.querySelectorAll('[data-qe-id="cm-predefined-item"]').length).toBe(2);
+  });
+
+  it('predefined menu item should render label', () => {
+    fakeContextMenuSvc.fakeConfig = {
+      isEnabled: true,
+      config: {
+        menuKey: []
+      }
+    };
+    fixture.detectChanges();
+
+    directiveHostEl.click();
+    fixture.detectChanges();
+
+    expect(document.body
+      .querySelector('[data-qe-id="cm-predefined-item"]')
+      .firstChild.textContent
+    ).toBe('Test Label 01');
+  });
+
+  it('should fetch dymamic menu items', () => {
+    spyOn(fakeContextMenuSvc, 'getConfig').and.callThrough();
+    fixture.detectChanges();
+
+    expect(fakeContextMenuSvc.getConfig).toHaveBeenCalled();
+  });
+
+  it('should render dymamic menu items', () => {
+    fakeContextMenuSvc.fakeConfig = {
+      isEnabled: true,
+      config: { testMenuConfigId: [
+        { label: 'dynamic test item #4532', urlPattern: '/myTestUri/{}' },
+        { label: 'dynamic test item #756', urlPattern: '/myTestUri/{}' },
+      ] }
+    };
+    fixture.detectChanges();
+
+    directiveHostEl.click();
+    fixture.detectChanges();
+
+    expect(document.body
+      .querySelectorAll('[data-qe-id="cm-dynamic-item"]')[0]
+      .firstChild.textContent
+    ).toBe('dynamic test item #4532');
+
+    expect(document.body
+      .querySelectorAll('[data-qe-id="cm-dynamic-item"]')[1]
+      .firstChild.textContent
+    ).toBe('dynamic test item #756');
+  });
+
+  it('should emit the configured event if user clicks on predefined menu item', () => {
+    fakeContextMenuSvc.fakeConfig = {
+        isEnabled: true,
+        config: {}
+      };
+    fixture.detectChanges();
+
+    directiveHostEl.addEventListener('customEventOne', (event) => {
+      expect(event.type).toBe('customEventOne');
+    });
+
+    directiveHostEl.click();
+    fixture.detectChanges();
+
+    fixture.nativeElement.querySelector('[data-qe-id="cm-predefined-item"]').click()
+    fixture.detectChanges();
+  });
+
+  it('should call window.open if user clicks on dynamic menu item', () => {
+    const RAW_URL = '/myTestUri/{}';
+    const EXPECTED_URL = '/myTestUri/testValue';
+    const DYNAMIC_ITEM = '[data-qe-id="cm-dynamic-item"]';
+
+    spyOn(window, 'open');
+
+    fakeContextMenuSvc.fakeConfig = {
+      isEnabled: true,
+      config: {
+        testMenuConfigId: [{ label: 'dynamic test item #98', urlPattern: RAW_URL }]
+      }
+    };
+    fixture.detectChanges();
+
+    directiveHostEl.click();
+    fixture.detectChanges();
+
+    fixture.nativeElement.querySelector(DYNAMIC_ITEM).click()
+    fixture.detectChanges();
+
+    expect(window.open).toHaveBeenCalledWith(EXPECTED_URL);
+  });
+
+  it('urlPatter should be parsed and resolved when calling window.open', () => {
+    const RAW_URL = '/myTestUri/{}/customkeyshouldresolveto/{customKey}';
+    const EXPECTED_URL = '/myTestUri/testValue/customkeyshouldresolveto/customValue';
+    const DYNAMIC_ITEM = '[data-qe-id="cm-dynamic-item"]';
+
+    spyOn(window, 'open');
+
+    fakeContextMenuSvc.fakeConfig = {
+      isEnabled: true,
+      config: {
+        testMenuConfigId: [{ label: 'dynamic test item #98', urlPattern: RAW_URL }]
+      }
+    };
+    fixture.detectChanges();
+
+    directiveHostEl.click();
+    fixture.detectChanges();
+
+    fixture.nativeElement.querySelector(DYNAMIC_ITEM).click()
+    fixture.detectChanges();
+
+    expect(window.open).toHaveBeenCalledWith(EXPECTED_URL);
+  });
+
+});
diff --git a/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.component.ts b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.component.ts
new file mode 100644
index 0000000..4ae0ad6
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.component.ts
@@ -0,0 +1,164 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+  Component,
+  AfterContentInit,
+  OnDestroy,
+  ViewChild,
+  ElementRef,
+  Input,
+  OnInit
+} from '@angular/core';
+import { ContextMenuService, ContextMenuConfigModel } from './context-menu.service';
+import { fromEvent, Subject, merge } from 'rxjs';
+import Popper from 'popper.js';
+import { takeUntil, filter } from 'rxjs/operators';
+import { DynamicMenuItem } from './dynamic-item.model';
+
+@Component({
+  selector: '[ctxMenu]',
+  templateUrl: './context-menu.component.html',
+  styleUrls: ['./context-menu.component.scss']
+})
+export class ContextMenuComponent implements OnInit, AfterContentInit, OnDestroy {
+
+  @ViewChild('contextMenuDropDown') dropDown: ElementRef;
+  @ViewChild('clickOutsideCanvas') outside: ElementRef;
+
+  @Input() ctxMenuItems: { label: string, event: string }[];
+  @Input() ctxMenuTitle: string;
+  @Input() ctxMenuId: string;
+  @Input() ctxMenuData: any;
+
+  dynamicMenuItems: DynamicMenuItem[] = [];
+
+  isEnabled = false;
+  isOpen = false;
+
+  private destroyed$: Subject<boolean> = new Subject<boolean>();
+
+  private popper: Popper;
+
+  constructor(
+    private contextMenuSvc: ContextMenuService,
+    private host: ElementRef
+    ) {}
+
+  ngOnInit() {
+    this.fetchContextMenuConfig();
+  }
+
+  ngAfterContentInit() {
+    this.subscribeTo();
+  }
+
+  private fetchContextMenuConfig() {
+    this.contextMenuSvc.getConfig()
+      .pipe(filter(value => !!value))
+      .subscribe((contextMenuConfigJSON: ContextMenuConfigModel) => {
+        this.isEnabled = contextMenuConfigJSON.isEnabled;
+        const currentConfig = contextMenuConfigJSON.config[this.ctxMenuId];
+
+        if (!this.isEnabled || !currentConfig) {
+          return;
+        }
+
+        this.dynamicMenuItems = currentConfig;
+      });
+  }
+
+  private subscribeTo() {
+    fromEvent(this.host.nativeElement, 'click')
+      .pipe(takeUntil(this.destroyed$))
+      .subscribe(this.toggle.bind(this));
+
+    merge(
+      fromEvent(this.host.nativeElement, 'mouseover'),
+      fromEvent(this.host.nativeElement, 'mouseout'),
+    )
+      .pipe(takeUntil(this.destroyed$))
+      .subscribe((event: MouseEvent) => {
+        if (this.isOpen) {
+          event.stopPropagation();
+        }
+      });
+  }
+
+  private toggle($event: MouseEvent) {
+    $event.stopPropagation();
+
+    if (!this.isEnabled) {
+      this.host.nativeElement.dispatchEvent(new Event(this.ctxMenuItems[0].event));
+      return;
+    }
+
+    if (this.isOpen) {
+      if (this.popper) {
+        this.popper.destroy();
+      }
+      this.isOpen = false;
+      return;
+    }
+
+    const origin = this.getContextMenuOrigin($event);
+    this.isOpen = true;
+
+    let mutationObserver = new MutationObserver((mutations) => {
+      if (document.body.contains(this.dropDown.nativeElement)) {
+        mutationObserver.disconnect();
+        mutationObserver = null;
+
+        this.popper = new Popper(origin, this.dropDown.nativeElement, { placement: 'bottom-start' });
+      }
+    });
+    mutationObserver.observe(document.body, {
+      attributes: false,
+      childList: true,
+      characterData: false,
+      subtree: true}
+    );
+  }
+
+  private getContextMenuOrigin($event: MouseEvent): HTMLElement {
+    if (($event.currentTarget as HTMLElement).contains($event.target as Node)) {
+      return $event.target as HTMLElement;
+    } else {
+      return $event.currentTarget as HTMLElement;
+    }
+  }
+
+  onPredefinedItemClicked($event: MouseEvent, eventName: string) {
+    this.host.nativeElement.dispatchEvent(new Event(eventName));
+  }
+
+  onDynamicItemClicked($event: MouseEvent, url: string) {
+    window.open(this.parseUrlPattern(url, this.ctxMenuData));
+  }
+
+  private parseUrlPattern(url = '', data = {}, delimeter: RegExp = /{|}/): string {
+    return url.replace('{}', `{${this.ctxMenuId}}`)
+      .split(delimeter).map((urlSegment) => {
+        return data[urlSegment] || urlSegment;
+    }).join('');
+  }
+
+  ngOnDestroy() {
+    this.destroyed$.next(true);
+    this.destroyed$.complete();
+  }
+}
diff --git a/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.module.spec.ts b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.module.spec.ts
new file mode 100644
index 0000000..726e3ca
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.module.spec.ts
@@ -0,0 +1,30 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { ContextMenuModule } from './context-menu.module';
+
+describe('ContextMenuModule', () => {
+  let contextMenuModule: ContextMenuModule;
+
+  beforeEach(() => {
+    contextMenuModule = new ContextMenuModule();
+  });
+
+  it('should create an instance', () => {
+    expect(contextMenuModule).toBeTruthy();
+  });
+});
diff --git a/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.module.ts b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.module.ts
new file mode 100644
index 0000000..bc952ec
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.module.ts
@@ -0,0 +1,37 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { NgModule } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { ContextMenuComponent } from './context-menu.component';
+import { ContextMenuService } from './context-menu.service';
+
+@NgModule({
+  imports: [
+    CommonModule,
+  ],
+  declarations: [
+    ContextMenuComponent,
+  ],
+  exports: [
+    ContextMenuComponent,
+  ],
+  providers: [
+    ContextMenuService
+  ]
+})
+export class ContextMenuModule { }
diff --git a/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.service.spec.ts b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.service.spec.ts
new file mode 100644
index 0000000..68a0814
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.service.spec.ts
@@ -0,0 +1,229 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { TestBed } from '@angular/core/testing';
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+  TestRequest
+} from '@angular/common/http/testing';
+import { ContextMenuService } from './context-menu.service';
+import { AppConfigService } from 'app/service/app-config.service';
+import { Injectable } from '@angular/core';
+import { filter } from 'rxjs/operators';
+import { Spy } from 'jasmine-core';
+
+const FAKE_CONFIG_SVC_URL = '/test/config/menu/url';
+
+@Injectable()
+class FakeAppConfigService {
+  constructor() {}
+
+  getContextMenuConfigURL() {
+    return FAKE_CONFIG_SVC_URL;
+  }
+}
+
+describe('ContextMenuService', () => {
+
+  let contextMenuSvc: ContextMenuService;
+  let mockBackend: HttpTestingController;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      imports: [ HttpClientTestingModule ],
+      providers: [
+        ContextMenuService,
+        { provide: AppConfigService, useClass: FakeAppConfigService }
+      ]
+    }).compileComponents();
+
+    contextMenuSvc = TestBed.get(ContextMenuService);
+    mockBackend = TestBed.get(HttpTestingController);
+  });
+
+  it('should be created', () => {
+    expect(contextMenuSvc).toBeTruthy();
+  });
+
+  it('should invoke context menu endpoint only once', () => {
+    contextMenuSvc.getConfig().subscribe();
+
+    const req: TestRequest = mockBackend.expectOne(FAKE_CONFIG_SVC_URL);
+    req.flush({ isEnabled: true, config: { menuKey: [] }});
+
+    expect(req.request.method).toEqual('GET');
+    mockBackend.verify();
+  });
+
+  it('getConfig() should return with the result of config svc', () => {
+    contextMenuSvc.getConfig()
+      .pipe(filter(value => !!value)) // first emitted default value is undefined
+      .subscribe((result) => {
+        expect(result).toEqual({ isEnabled: true, config: { menuKey: [] }});
+      });
+
+    const req = mockBackend.expectOne(FAKE_CONFIG_SVC_URL);
+    req.flush({ isEnabled: true, config: { menuKey: [] }});
+  })
+
+  it('should cache the first response', () => {
+    contextMenuSvc.getConfig().subscribe(); // returns the default value first
+
+    const req = mockBackend.expectOne(FAKE_CONFIG_SVC_URL);
+    req.flush({ isEnabled: true, config: { menuKey: [] }});
+
+    contextMenuSvc.getConfig().subscribe((first) => {
+      contextMenuSvc.getConfig().subscribe((second) => {
+        expect(first).toBe(second);
+      });
+    });
+  });
+
+  it('should show console error if isEnabled flag is missing', () => {
+    spyOn(console, 'error');
+    contextMenuSvc.getConfig().subscribe(); // returns the default value first
+
+    const req = mockBackend.expectOne(FAKE_CONFIG_SVC_URL);
+    req.flush({ config: { menuKey: [] }});
+
+    expect(console.error).toHaveBeenCalledWith('[Context Menu] CONFIG: isEnabled and/or config entries are missing.');
+  });
+
+  it('should show console error if isEnabled value is invalid', () => {
+    spyOn(console, 'error');
+    contextMenuSvc.getConfig().subscribe(); // returns the default value first
+
+    const req = mockBackend.expectOne(FAKE_CONFIG_SVC_URL);
+    req.flush({ isEnabled: 'false', config: { menuKey: [] }});
+
+    expect(console.error).toHaveBeenCalledWith('[Context Menu] CONFIG: isEnabled has to be a boolean. Defaulting to false.');
+  });
+
+  it('should default to false if isEnabled value is invalid', () => {
+    contextMenuSvc.getConfig()
+      .pipe(filter(value => !!value)) // first emitted default value is undefined
+      .subscribe((result) => {
+        expect(result).toEqual({ isEnabled: false, config: {}});
+      });
+
+    const req = mockBackend.expectOne(FAKE_CONFIG_SVC_URL);
+    req.flush({ isEnabledMisspelled: true, config: { menuKey: [] }});
+  });
+
+  it('should show console error if config is missing', () => {
+    spyOn(console, 'error');
+    contextMenuSvc.getConfig().subscribe(); // returns the default value first
+
+    const req = mockBackend.expectOne(FAKE_CONFIG_SVC_URL);
+    req.flush({ isEnabled: true });
+
+    expect(console.error).toHaveBeenCalledWith('[Context Menu] CONFIG: isEnabled and/or config entries are missing.');
+  });
+
+  it('should show console error if config is not an object', () => {
+    spyOn(console, 'error');
+    contextMenuSvc.getConfig().subscribe(); // returns the default value first
+
+    const req = mockBackend.expectOne(FAKE_CONFIG_SVC_URL);
+    req.flush({ isEnabled: true, config: '' });
+
+    expect(console.error).toHaveBeenCalledWith('[Context Menu] CONFIG: Config entry has to be an object. Defaulting to {}.');
+  });
+
+  it('should show console error if config is an array', () => {
+    spyOn(console, 'error');
+    contextMenuSvc.getConfig().subscribe(); // returns the default value first
+
+    const req = mockBackend.expectOne(FAKE_CONFIG_SVC_URL);
+    req.flush({ isEnabled: true, config: [] });
+
+    expect(console.error).toHaveBeenCalledWith('[Context Menu] CONFIG: Config entry has to be an object. Defaulting to {}.');
+  });
+
+  it('should show console error if a config entry is not an array', () => {
+    spyOn(console, 'error');
+    contextMenuSvc.getConfig().subscribe(); // returns the default value first
+
+    const req = mockBackend.expectOne(FAKE_CONFIG_SVC_URL);
+    req.flush({ isEnabled: true, config: { menuKey: '' } });
+
+    expect(console.error).toHaveBeenCalledWith('[Context Menu] CONFIG: Each item in config object has to be an array.');
+  });
+
+  it('should show console error if a config entry is corrupt', () => {
+    spyOn(console, 'error');
+    contextMenuSvc.getConfig().subscribe(); // returns the default value first
+
+    const req = mockBackend.expectOne(FAKE_CONFIG_SVC_URL);
+    req.flush({ isEnabled: true, config: { menuKey: [ { labelMisspelled: 'menu item label', urlPattern: '/some/url/pattern/{}' } ] } });
+
+    expect(console.error).toHaveBeenCalledTimes(2);
+    expect((console.error as Spy).calls.argsFor(0)[0]).toBe(
+      '[Context Menu] CONFIG: Entry is invalid. Missing field: label'
+    );
+    expect((console.error as Spy).calls.argsFor(1)[0]).toBe(
+      '[Context Menu] CONFIG: Entry is invalid: ' +
+      '{"labelMisspelled":"menu item label","urlPattern":"/some/url/pattern/{}"}'
+    );
+  });
+
+  it('should default to { isEnabled: false, config: {}} if a config entry is corrupt', () => {
+    contextMenuSvc.getConfig()
+      .pipe(filter(value => !!value)) // first emitted default value is undefined
+      .subscribe((result) => {
+        expect(result).toEqual({ isEnabled: false, config: {}});
+      });
+
+    const req = mockBackend.expectOne(FAKE_CONFIG_SVC_URL);
+    req.flush({ isEnabled: true, configMisspelled: { menuKey: [] }});
+  });
+
+  it('should show no error if config is valid', () => {
+    spyOn(console, 'error');
+    contextMenuSvc.getConfig().subscribe(); // returns the default value first
+
+    const req = mockBackend.expectOne(FAKE_CONFIG_SVC_URL);
+    req.flush({
+      isEnabled: true,
+      config: {
+        menuKey: [
+          {
+            label: 'menu item label',
+            urlPattern: '/some/url/pattern/{}'
+          },
+          {
+            label: 'menu item label 2',
+            urlPattern: '/some/url/pattern/2/{}'
+          }
+        ],
+        menuKey2: [
+          {
+            label: 'menu item label',
+            urlPattern: '/some/url/pattern/{}'
+          },
+          {
+            label: 'menu item label 2',
+            urlPattern: '/some/url/pattern/2/{}'
+          }
+        ]
+      }
+    });
+
+    expect(console.error).toHaveBeenCalledTimes(0);
+  });
+});
diff --git a/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.service.ts b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.service.ts
new file mode 100644
index 0000000..16f1b81
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.service.ts
@@ -0,0 +1,94 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { Observable, BehaviorSubject } from 'rxjs';
+import { catchError, map } from 'rxjs/operators';
+import { HttpUtil } from 'app/utils/httpUtil';
+import { AppConfigService } from 'app/service/app-config.service';
+import { DynamicMenuItem } from './dynamic-item.model';
+
+export interface ContextMenuConfigModel {
+  isEnabled: boolean,
+  config: {}
+}
+
+@Injectable()
+export class ContextMenuService {
+  private cachedConfig$: BehaviorSubject<ContextMenuConfigModel>;
+
+  constructor(
+    private http: HttpClient,
+    private appConfig: AppConfigService
+    ) {}
+
+  getConfig(): Observable<ContextMenuConfigModel> {
+    if (!this.cachedConfig$) {
+      const defaultConfig = { isEnabled: false, config: {} };
+
+      this.cachedConfig$ = new BehaviorSubject(undefined);
+
+      this.http.get(this.appConfig.getContextMenuConfigURL())
+      .pipe(
+        map(HttpUtil.extractData),
+        catchError(HttpUtil.handleError)
+      ).subscribe((result) => {
+        if (this.validate(result)) {
+          this.cachedConfig$.next(result);
+        } else {
+          this.cachedConfig$.next(defaultConfig);
+        }
+      });
+    }
+
+    return this.cachedConfig$;
+  }
+
+  private validate(configJson: ContextMenuConfigModel) {
+
+    if (!configJson.hasOwnProperty('isEnabled') || !configJson.hasOwnProperty('config')) {
+      console.error('[Context Menu] CONFIG: isEnabled and/or config entries are missing.')
+      return false;
+    }
+
+    if (configJson.isEnabled !== true && configJson.isEnabled !== false) {
+      console.error('[Context Menu] CONFIG: isEnabled has to be a boolean. Defaulting to false.');
+      return false;
+    }
+
+    if (typeof configJson.config !== 'object' || Array.isArray(configJson.config)) {
+      console.error('[Context Menu] CONFIG: Config entry has to be an object. Defaulting to {}.');
+      return false;
+    }
+
+    return Object.keys(configJson.config).every((key) => {
+      if (!Array.isArray(configJson.config[key])) {
+        console.error('[Context Menu] CONFIG: Each item in config object has to be an array.')
+        return false;
+      }
+
+      return configJson.config[key].every((menuItem) => {
+        if (!DynamicMenuItem.isConfigValid(menuItem)) {
+          console.error(`[Context Menu] CONFIG: Entry is invalid: ${JSON.stringify(menuItem)}`);
+          return false;
+        }
+        return true;
+      });
+    })
+  }
+}
diff --git a/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.util.spec.ts b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.util.spec.ts
new file mode 100644
index 0000000..e938186
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.util.spec.ts
@@ -0,0 +1,42 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { merge } from './context-menu.util';
+
+describe('context-menu.util', () => {
+
+  it('merge function should be able to merge two objects', () => {
+    expect(merge( { first: 'aaa' }, { second: 'bbb' } )).toEqual({ first: 'aaa', second: 'bbb' });
+  })
+
+  it('merge should be able to merge many objects', () => {
+    const objects = [
+      { first: 'aaa' },
+      { second: 'bbb' },
+      { third: 'ccc' },
+      { fourth: 'ddd' },
+      { fiveth: 'eee' },
+      { sixth: 'fff' },
+      { seventh: 'ggg' },
+    ];
+
+    expect(merge.apply(null, objects)).toEqual(
+      objects.reduce((result, next) => Object.assign(result, next), {})
+    );
+  })
+
+});
diff --git a/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.util.ts b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.util.ts
new file mode 100644
index 0000000..bcced0a
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.util.ts
@@ -0,0 +1,22 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export function merge(...allObjects) {
+  return allObjects.reduce((merge, obj) => {
+    return Object.assign(merge, obj);
+  }, {});
+}
diff --git a/metron-interface/metron-alerts/src/app/shared/context-menu/dynamic-item.model.spec.ts b/metron-interface/metron-alerts/src/app/shared/context-menu/dynamic-item.model.spec.ts
new file mode 100644
index 0000000..e723cd0
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/shared/context-menu/dynamic-item.model.spec.ts
@@ -0,0 +1,42 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { DynamicMenuItem } from './dynamic-item.model';
+
+describe('dynamic-item.model', () => {
+
+  it('should return error if url pattern is missing', () => {
+    expect(DynamicMenuItem.isConfigValid({ label: 'test' })).toBeFalsy();
+  });
+
+  it('should return error if label is missing', () => {
+    expect(DynamicMenuItem.isConfigValid({ urlPattern: '/test' })).toBeFalsy();
+  });
+
+  it('should return error if url pattern is empty', () => {
+    expect(DynamicMenuItem.isConfigValid({ label: '', urlPattern: '/test' })).toBeFalsy();
+  });
+
+  it('should return error if label is empty', () => {
+    expect(DynamicMenuItem.isConfigValid({ label: 'test', urlPattern: '' })).toBeFalsy();
+  });
+
+  it('should instatiate if all good', () => {
+    expect(DynamicMenuItem.isConfigValid({ label: 'test', urlPattern: '/test' })).toBeTruthy();
+  });
+
+});
diff --git a/metron-interface/metron-alerts/src/app/shared/context-menu/dynamic-item.model.ts b/metron-interface/metron-alerts/src/app/shared/context-menu/dynamic-item.model.ts
new file mode 100644
index 0000000..0b3ab6d
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/shared/context-menu/dynamic-item.model.ts
@@ -0,0 +1,45 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export class DynamicMenuItem {
+
+  label: string;
+  urlPattern: string;
+
+  /**
+   * Validating server response and logging error if something required missing.
+   *
+   * @param config {} Menu config object received from and endpoint.
+   */
+  static isConfigValid(config: {}): boolean {
+    return ['label', 'urlPattern'].every((requiredField) => {
+      if (config.hasOwnProperty(requiredField) && config[requiredField] !== '') {
+        return true;
+      } else {
+        console.error(`[Context Menu] CONFIG: Entry is invalid. Missing field: ${requiredField}`);
+      }
+    })
+  }
+
+  /**
+   * Make sure you using isConfigValid before calling the constructor.
+   */
+  constructor(readonly config: any) {
+    this.label = config.label;
+    this.urlPattern = config.urlPattern;
+  }
+}
diff --git a/metron-interface/metron-alerts/src/app/shared/shared.module.ts b/metron-interface/metron-alerts/src/app/shared/shared.module.ts
index d5e4531..bb3200c 100644
--- a/metron-interface/metron-alerts/src/app/shared/shared.module.ts
+++ b/metron-interface/metron-alerts/src/app/shared/shared.module.ts
@@ -27,10 +27,12 @@
 import { MapKeysPipe } from './pipes/map-keys.pipe';
 import { AlertSeverityHexagonDirective } from './directives/alert-severity-hexagon.directive';
 import { TimeLapsePipe } from './pipes/time-lapse.pipe';
+import { ContextMenuModule } from './context-menu/context-menu.module';
 
 @NgModule({
   imports:  [
-    CommonModule
+    CommonModule,
+    ContextMenuModule,
   ],
   declarations: [
     AlertSeverityDirective,
@@ -45,6 +47,7 @@
   ],
   exports:  [
     CommonModule,
+    ContextMenuModule,
     FormsModule,
     AlertSeverityDirective,
     MetronTableDirective,
diff --git a/metron-interface/metron-alerts/src/assets/app-config.json b/metron-interface/metron-alerts/src/assets/app-config.json
index e485071..04dbc54 100644
--- a/metron-interface/metron-alerts/src/assets/app-config.json
+++ b/metron-interface/metron-alerts/src/assets/app-config.json
@@ -1,4 +1,5 @@
 {
   "apiRoot": "/api/v1",
-  "loginPath": "/login"
+  "loginPath": "/login",
+  "contextMenuConfigURL": "/assets/context-menu.conf.json"
 }
\ No newline at end of file
diff --git a/metron-interface/metron-alerts/src/assets/context-menu.conf.json b/metron-interface/metron-alerts/src/assets/context-menu.conf.json
new file mode 100644
index 0000000..76281df
--- /dev/null
+++ b/metron-interface/metron-alerts/src/assets/context-menu.conf.json
@@ -0,0 +1,49 @@
+{
+  "isEnabled": false,
+  "config": {
+    "alertEntry": [
+      {
+        "label": "Internal ticketing system",
+        "urlPattern": "http://mytickets.org/tickets/{id}"
+      }
+    ],
+    "metaAlertEntry": [
+      {
+        "label": "MetaAlert specific item",
+        "urlPattern": "http://mytickets.org/tickets/{id}"
+      }
+    ],
+    "id": [
+      {
+        "label": "Dynamic menu item 01",
+        "urlPattern": "http://mytickets.org/tickets/{}"
+      }
+    ],
+    "ip_src_addr": [
+      {
+        "label": "IP Investigation Notebook",
+        "urlPattern": "http://zepellin.example.com:9000/notebook/someid?ip={ip_src_addr}"
+      },
+      {
+        "label": "IP Conversation Investigation",
+        "urlPattern": "http://zepellin.example.com:9000/notebook/someid?ip_src_addr={ip_src_addr}&ip_dst_addr={ip_dst_addr}"
+      }
+    ],
+    "ip_dst_addr": [
+      {
+        "label": "IP Investigation Notebook",
+        "urlPattern": "http://zepellin.example.com:9000/notebook/someid?ip={ip_dst_addr}"
+      },
+      {
+        "label": "IP Conversation Investigation",
+        "urlPattern": "http://zepellin.example.com:9000/notebook/someid?ip_src_addr={ip_src_addr}&ip_dst_addr={ip_dst_addr}"
+      }
+    ],
+    "host": [
+      {
+        "label": "Whois Reputation Service",
+        "urlPattern": "https://www.whois.com/whois/{}"
+      }
+    ]
+  }
+}