This is an automated email from the ASF dual-hosted git repository.
mmiklavcic pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/metron.git
The following commit(s) were added to refs/heads/master by this push:
new 5bd7e01 METRON-2102 [UI] Adding click-through navigation to Alerts
table (tiborm via mmiklavc) closes apache/metron#1431
5bd7e01 is described below
commit 5bd7e010c2f389c2c14476337df982f2e781af8d
Author: tiborm <[email protected]>
AuthorDate: Wed Jun 12 07:56:34 2019 -0600
METRON-2102 [UI] Adding click-through navigation to Alerts table (tiborm
via mmiklavc) closes apache/metron#1431
---
.../CURRENT/package/scripts/params/params_linux.py | 1 +
.../package/templates/alerts-ui-app-config.json.j2 | 3 +-
.../packaging/docker/rpm-docker/SPECS/metron.spec | 1 +
metron-interface/metron-alerts/README.md | 4 +
.../cypress/fixtures/context-menu.conf.json | 49 ++++
.../integration/alert-list/context-menu.spec.js | 89 +++++++
.../alerts/alerts-list/alerts-list.component.html | 2 +-
.../alerts/alerts-list/alerts-list.component.ts | 3 +-
.../table-view/table-view.component.html | 89 +++++--
.../table-view/table-view.component.spec.ts | 2 +
.../alerts-list/table-view/table-view.component.ts | 9 +-
.../app-config.service.ts => app.module.spec.ts} | 33 +--
.../src/app/service/app-config.service.spec.ts | 154 +++++++++++
.../src/app/service/app-config.service.ts | 21 +-
.../src/app/shared/context-menu/README.md | 203 +++++++++++++++
.../context-menu/context-menu.component.html | 25 ++
.../context-menu/context-menu.component.scss} | 55 ++--
.../context-menu/context-menu.component.spec.ts | 281 +++++++++++++++++++++
.../shared/context-menu/context-menu.component.ts | 164 ++++++++++++
.../context-menu/context-menu.module.spec.ts} | 39 +--
.../context-menu/context-menu.module.ts} | 52 ++--
.../context-menu/context-menu.service.spec.ts | 229 +++++++++++++++++
.../shared/context-menu/context-menu.service.ts | 94 +++++++
.../context-menu/context-menu.util.spec.ts} | 57 ++---
.../context-menu/context-menu.util.ts} | 37 +--
.../shared/context-menu/dynamic-item.model.spec.ts | 42 +++
.../context-menu/dynamic-item.model.ts} | 54 ++--
.../metron-alerts/src/app/shared/shared.module.ts | 5 +-
.../metron-alerts/src/assets/app-config.json | 3 +-
.../src/assets/context-menu.conf.json | 49 ++++
30 files changed, 1614 insertions(+), 235 deletions(-)
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 @@ knox_group =
config['configurations']['knox-env']['knox_group']
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 @@ This package installs the Metron Alerts UI %{metron_home}
%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 @@ limitations under the License.
- [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 @@ From the dashboard, you'll be able to run tests separately
and reach additional
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 @@ import {forkJoin as observableForkJoin} from 'rxjs';
* 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 { GlobalConfigService } from
'../../../service/global-config.service';
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 @@ describe('TableViewComponent', () => {
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 { DialogService } from
'../../../service/dialog.service';
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 @@ export class TableViewComponent implements OnInit, OnChanges,
OnDestroy {
globalConfig: {} = {};
configSubscription: Subscription;
+ merge: Function = merge;
+
@Input() alerts: Alert[] = [];
@Input() queryBuilder: QueryBuilder;
@Input() pagination: Pagination;
@@ -108,10 +112,9 @@ export class TableViewComponent implements OnInit,
OnChanges, OnDestroy {
}
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/service/app-config.service.ts
b/metron-interface/metron-alerts/src/app/app.module.spec.ts
similarity index 52%
copy from metron-interface/metron-alerts/src/app/service/app-config.service.ts
copy to metron-interface/metron-alerts/src/app/app.module.spec.ts
index a3b7414..88dcd92 100644
--- a/metron-interface/metron-alerts/src/app/service/app-config.service.ts
+++ b/metron-interface/metron-alerts/src/app/app.module.spec.ts
@@ -15,35 +15,4 @@
* 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';
-
-@Injectable()
-export class AppConfigService {
-
- private static appConfigStatic;
-
- constructor(private http: HttpClient) { }
-
- loadAppConfig() {
- return this.http.get('assets/app-config.json')
- // APP_INITIALIZER only supports promises
- .toPromise()
- .then(data => {
- AppConfigService.appConfigStatic = data;
- });
- }
-
- getApiRoot() {
- return AppConfigService.appConfigStatic['apiRoot'];
- }
-
- getLoginPath() {
- return AppConfigService.appConfigStatic['loginPath'];
- }
-
- static getAppConfigStatic() {
- return AppConfigService.appConfigStatic;
- }
-}
\ No newline at end of file
+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 @@ export class AppConfigService {
private static appConfigStatic;
+ static getAppConfigStatic() {
+ return AppConfigService.appConfigStatic;
+ }
+
constructor(private http: HttpClient) { }
loadAppConfig() {
@@ -36,14 +40,23 @@ export class AppConfigService {
}
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/service/app-config.service.ts
b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.component.scss
similarity index 54%
copy from metron-interface/metron-alerts/src/app/service/app-config.service.ts
copy to
metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.component.scss
index a3b7414..be7e28a 100644
--- a/metron-interface/metron-alerts/src/app/service/app-config.service.ts
+++
b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.component.scss
@@ -15,35 +15,50 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+@import "_variables.scss";
-import { Injectable } from '@angular/core';
-import { HttpClient } from '@angular/common/http';
+$menu-z-index: 98888;
-@Injectable()
-export class AppConfigService {
+.transparent {
+ opacity: 0;
+}
- private static appConfigStatic;
+.viewport-sized {
+ display: block;
+ position: fixed;
+ z-index: $menu-z-index;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: aqua;
+}
- constructor(private http: HttpClient) { }
+.dropdown-menu {
+ display: block;
+ z-index: $menu-z-index + 1;
+ font-size: .9em;
+ padding: 0 0 .5rem 0;
- loadAppConfig() {
- return this.http.get('assets/app-config.json')
- // APP_INITIALIZER only supports promises
- .toPromise()
- .then(data => {
- AppConfigService.appConfigStatic = data;
- });
+ .card-header {
+ padding: .3rem .6rem .2rem .6rem;
+ margin-bottom: .5rem;
+ background-color: $gray;
+ color: $black;
+ font-size: .8rem;
}
- getApiRoot() {
- return AppConfigService.appConfigStatic['apiRoot'];
+ .dropdown-item {
+ border: none !important;
+ cursor: pointer;
}
- getLoginPath() {
- return AppConfigService.appConfigStatic['loginPath'];
+ .dropdown-item:hover {
+ background-color: $abbey !important;
}
- static getAppConfigStatic() {
- return AppConfigService.appConfigStatic;
+ .dropdown-divider {
+ border-top: 1px solid #4d4d4d;
}
-}
\ No newline at end of file
+}
+
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/service/app-config.service.ts
b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.module.spec.ts
similarity index 53%
copy from metron-interface/metron-alerts/src/app/service/app-config.service.ts
copy to
metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.module.spec.ts
index a3b7414..726e3ca 100644
--- a/metron-interface/metron-alerts/src/app/service/app-config.service.ts
+++
b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.module.spec.ts
@@ -15,35 +15,16 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+import { ContextMenuModule } from './context-menu.module';
-import { Injectable } from '@angular/core';
-import { HttpClient } from '@angular/common/http';
+describe('ContextMenuModule', () => {
+ let contextMenuModule: ContextMenuModule;
-@Injectable()
-export class AppConfigService {
+ beforeEach(() => {
+ contextMenuModule = new ContextMenuModule();
+ });
- private static appConfigStatic;
-
- constructor(private http: HttpClient) { }
-
- loadAppConfig() {
- return this.http.get('assets/app-config.json')
- // APP_INITIALIZER only supports promises
- .toPromise()
- .then(data => {
- AppConfigService.appConfigStatic = data;
- });
- }
-
- getApiRoot() {
- return AppConfigService.appConfigStatic['apiRoot'];
- }
-
- getLoginPath() {
- return AppConfigService.appConfigStatic['loginPath'];
- }
-
- static getAppConfigStatic() {
- return AppConfigService.appConfigStatic;
- }
-}
\ No newline at end of file
+ it('should create an instance', () => {
+ expect(contextMenuModule).toBeTruthy();
+ });
+});
diff --git
a/metron-interface/metron-alerts/src/app/service/app-config.service.ts
b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.module.ts
similarity index 52%
copy from metron-interface/metron-alerts/src/app/service/app-config.service.ts
copy to
metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.module.ts
index a3b7414..bc952ec 100644
--- a/metron-interface/metron-alerts/src/app/service/app-config.service.ts
+++
b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.module.ts
@@ -15,35 +15,23 @@
* 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';
-
-@Injectable()
-export class AppConfigService {
-
- private static appConfigStatic;
-
- constructor(private http: HttpClient) { }
-
- loadAppConfig() {
- return this.http.get('assets/app-config.json')
- // APP_INITIALIZER only supports promises
- .toPromise()
- .then(data => {
- AppConfigService.appConfigStatic = data;
- });
- }
-
- getApiRoot() {
- return AppConfigService.appConfigStatic['apiRoot'];
- }
-
- getLoginPath() {
- return AppConfigService.appConfigStatic['loginPath'];
- }
-
- static getAppConfigStatic() {
- return AppConfigService.appConfigStatic;
- }
-}
\ No newline at end of file
+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/service/app-config.service.ts
b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.util.spec.ts
similarity index 53%
copy from metron-interface/metron-alerts/src/app/service/app-config.service.ts
copy to
metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.util.spec.ts
index a3b7414..e938186 100644
--- a/metron-interface/metron-alerts/src/app/service/app-config.service.ts
+++
b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.util.spec.ts
@@ -15,35 +15,28 @@
* 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';
-
-@Injectable()
-export class AppConfigService {
-
- private static appConfigStatic;
-
- constructor(private http: HttpClient) { }
-
- loadAppConfig() {
- return this.http.get('assets/app-config.json')
- // APP_INITIALIZER only supports promises
- .toPromise()
- .then(data => {
- AppConfigService.appConfigStatic = data;
- });
- }
-
- getApiRoot() {
- return AppConfigService.appConfigStatic['apiRoot'];
- }
-
- getLoginPath() {
- return AppConfigService.appConfigStatic['loginPath'];
- }
-
- static getAppConfigStatic() {
- return AppConfigService.appConfigStatic;
- }
-}
\ No newline at end of file
+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/service/app-config.service.ts
b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.util.ts
similarity index 52%
copy from metron-interface/metron-alerts/src/app/service/app-config.service.ts
copy to
metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.util.ts
index a3b7414..bcced0a 100644
--- a/metron-interface/metron-alerts/src/app/service/app-config.service.ts
+++
b/metron-interface/metron-alerts/src/app/shared/context-menu/context-menu.util.ts
@@ -15,35 +15,8 @@
* 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';
-
-@Injectable()
-export class AppConfigService {
-
- private static appConfigStatic;
-
- constructor(private http: HttpClient) { }
-
- loadAppConfig() {
- return this.http.get('assets/app-config.json')
- // APP_INITIALIZER only supports promises
- .toPromise()
- .then(data => {
- AppConfigService.appConfigStatic = data;
- });
- }
-
- getApiRoot() {
- return AppConfigService.appConfigStatic['apiRoot'];
- }
-
- getLoginPath() {
- return AppConfigService.appConfigStatic['loginPath'];
- }
-
- static getAppConfigStatic() {
- return AppConfigService.appConfigStatic;
- }
-}
\ No newline at end of file
+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/service/app-config.service.ts
b/metron-interface/metron-alerts/src/app/shared/context-menu/dynamic-item.model.ts
similarity index 51%
copy from metron-interface/metron-alerts/src/app/service/app-config.service.ts
copy to
metron-interface/metron-alerts/src/app/shared/context-menu/dynamic-item.model.ts
index a3b7414..0b3ab6d 100644
--- a/metron-interface/metron-alerts/src/app/service/app-config.service.ts
+++
b/metron-interface/metron-alerts/src/app/shared/context-menu/dynamic-item.model.ts
@@ -15,35 +15,31 @@
* 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';
-
-@Injectable()
-export class AppConfigService {
-
- private static appConfigStatic;
-
- constructor(private http: HttpClient) { }
-
- loadAppConfig() {
- return this.http.get('assets/app-config.json')
- // APP_INITIALIZER only supports promises
- .toPromise()
- .then(data => {
- AppConfigService.appConfigStatic = data;
- });
- }
-
- getApiRoot() {
- return AppConfigService.appConfigStatic['apiRoot'];
- }
-
- getLoginPath() {
- return AppConfigService.appConfigStatic['loginPath'];
+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}`);
+ }
+ })
}
- static getAppConfigStatic() {
- return AppConfigService.appConfigStatic;
+ /**
+ * Make sure you using isConfigValid before calling the constructor.
+ */
+ constructor(readonly config: any) {
+ this.label = config.label;
+ this.urlPattern = config.urlPattern;
}
-}
\ No newline at end of file
+}
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 { ColumnNameTranslatePipe } from
'./pipes/column-name-translate.pipe';
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 @@ import { TimeLapsePipe } from './pipes/time-lapse.pipe';
],
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/{}"
+ }
+ ]
+ }
+}