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/{}"
+ }
+ ]
+ }
+}