METRON-2190 [UI] Alerts UI: Indicating loading and preventing parallel requests (tiborm via sardell) closes apache/metron#1514
diff --git a/metron-interface/metron-alerts/cypress/fixtures/search-1.1.json b/metron-interface/metron-alerts/cypress/fixtures/search-1.1.json
new file mode 100644
index 0000000..529f4bf
--- /dev/null
+++ b/metron-interface/metron-alerts/cypress/fixtures/search-1.1.json
@@ -0,0 +1,102 @@
+{
+ "total":1,
+ "results":[
+ {
+ "id":"test-alert-entry-id-1",
+ "source":{
+ "enrichments:geo:ip_dst_addr:locID":"5368361",
+ "bro_timestamp":"1537304979.801853",
+ "status_code":200,
+ "enrichments:geo:ip_dst_addr:location_point":"34.0494,-118.2641",
+ "ip_dst_port":80,
+ "threatinteljoinbolt:joiner:ts":"1537304981038",
+ "enrichments:geo:ip_dst_addr:dmaCode":"803",
+ "enrichmentsplitterbolt:splitter:begin:ts":"1537304981020",
+ "enrichmentjoinbolt:joiner:ts":"1537304981027",
+ "adapter:geoadapter:begin:ts":"1537304981022",
+ "enrichments:geo:ip_dst_addr:latitude":"34.0494",
+ "uid":"C6NKjA4tt5Xc1a6uzd",
+ "resp_mime_types":[
+ "text/plain"
+ ],
+ "trans_depth":1,
+ "protocol":"http",
+ "source:type":"bro",
+ "adapter:threatinteladapter:end:ts":"1537304981036",
+ "original_string":"HTTP | id.orig_p:49204 status_code:200 method:POST request_body_len:110 id.resp_p:80 orig_mime_types:[\"text\\/plain\"] uri:/wp-content/themes/grizzly/img5.php?u=ka6nnuvccqlw9 tags:[] uid:C6NKjA4tt5Xc1a6uzd resp_mime_types:[\"text\\/plain\"] trans_depth:1 orig_fuids:[\"Fr5Cg02TcSAxFeYoBh\"] host:comarksecurity.com status_msg:OK id.orig_h:192.168.138.158 response_body_len:14 user_agent:Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/4.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0) ts:1537304979.801853 id.resp_h:72.34.49.86 resp_fuids:[\"FQcLCtotjacEmeBEf\"]",
+ "ip_dst_addr":"72.34.49.86",
+ "adapter:hostfromjsonlistadapter:end:ts":"1537304981022",
+ "host":"comarksecurity.com",
+ "adapter:geoadapter:end:ts":"1537304981022",
+ "ip_src_addr":"192.168.138.158",
+ "threatintelsplitterbolt:splitter:end:ts":"1537304981029",
+ "enrichments:geo:ip_dst_addr:longitude":"-118.2641",
+ "user_agent":"Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/4.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0)",
+ "resp_fuids":[
+ "FQcLCtotjacEmeBEf"
+ ],
+ "timestamp":1537304979801,
+ "method":"POST",
+ "enrichmentsplitterbolt:splitter:end:ts":"1537304981020",
+ "request_body_len":110,
+ "enrichments:geo:ip_dst_addr:city":"Los Angeles",
+ "enrichments:geo:ip_dst_addr:postalCode":"90014",
+ "adapter:hostfromjsonlistadapter:begin:ts":"1537304981022",
+ "orig_mime_types":[
+ "text/plain"
+ ],
+ "uri":"/wp-content/themes/grizzly/img5.php?u=ka6nnuvccqlw9",
+ "tags":[
+
+ ],
+ "alert_status":"OPEN",
+ "orig_fuids":[
+ "Fr5Cg02TcSAxFeYoBh"
+ ],
+ "ip_src_port":49204,
+ "threatintelsplitterbolt:splitter:begin:ts":"1537304981029",
+ "adapter:threatinteladapter:begin:ts":"1537304981033",
+ "status_msg":"OK",
+ "guid":"test-id-1.1",
+ "enrichments:geo:ip_dst_addr:country":"US",
+ "response_body_len":14
+ },
+ "score":1.0,
+ "index":"bro_index_2018.09.18.21"
+ }
+ ],
+ "facetCounts":{
+ "source:type":{
+ "metaalert":1,
+ "bro":52319,
+ "snort":52273
+ },
+ "ip_dst_addr":{
+ "95.163.121.204":15832,
+ "72.34.49.86":5079,
+ "192.168.138.158":17989,
+ "188.165.164.184":995,
+ "192.168.138.2":6396,
+ "192.168.66.1":4226,
+ "62.75.195.236":15813,
+ "224.0.0.251":4979,
+ "192.168.66.121":28822,
+ "204.152.254.221":4461
+ },
+ "enrichments:geo:ip_dst_addr:country":{
+ "RU":15832,
+ "FR":16808,
+ "US":9540
+ },
+ "ip_src_addr":{
+ "95.163.121.204":2106,
+ "72.34.49.86":2284,
+ "192.168.138.158":48576,
+ "192.168.138.2":118,
+ "192.168.66.1":33801,
+ "62.75.195.236":12552,
+ "192.168.66.121":4226,
+ "204.152.254.221":929
+ }
+ }
+}
\ No newline at end of file
diff --git a/metron-interface/metron-alerts/cypress/fixtures/search-1.2.json b/metron-interface/metron-alerts/cypress/fixtures/search-1.2.json
new file mode 100644
index 0000000..13c7434
--- /dev/null
+++ b/metron-interface/metron-alerts/cypress/fixtures/search-1.2.json
@@ -0,0 +1,102 @@
+{
+ "total":1,
+ "results":[
+ {
+ "id":"test-alert-entry-id-2",
+ "source":{
+ "enrichments:geo:ip_dst_addr:locID":"5368361",
+ "bro_timestamp":"1537304979.801853",
+ "status_code":200,
+ "enrichments:geo:ip_dst_addr:location_point":"34.0494,-118.2641",
+ "ip_dst_port":80,
+ "threatinteljoinbolt:joiner:ts":"1537304981038",
+ "enrichments:geo:ip_dst_addr:dmaCode":"803",
+ "enrichmentsplitterbolt:splitter:begin:ts":"1537304981020",
+ "enrichmentjoinbolt:joiner:ts":"1537304981027",
+ "adapter:geoadapter:begin:ts":"1537304981022",
+ "enrichments:geo:ip_dst_addr:latitude":"34.0494",
+ "uid":"C6NKjA4tt5Xc1a6uzd",
+ "resp_mime_types":[
+ "text/plain"
+ ],
+ "trans_depth":1,
+ "protocol":"http",
+ "source:type":"bro",
+ "adapter:threatinteladapter:end:ts":"1537304981036",
+ "original_string":"HTTP | id.orig_p:49204 status_code:200 method:POST request_body_len:110 id.resp_p:80 orig_mime_types:[\"text\\/plain\"] uri:/wp-content/themes/grizzly/img5.php?u=ka6nnuvccqlw9 tags:[] uid:C6NKjA4tt5Xc1a6uzd resp_mime_types:[\"text\\/plain\"] trans_depth:1 orig_fuids:[\"Fr5Cg02TcSAxFeYoBh\"] host:comarksecurity.com status_msg:OK id.orig_h:192.168.138.158 response_body_len:14 user_agent:Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/4.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0) ts:1537304979.801853 id.resp_h:72.34.49.86 resp_fuids:[\"FQcLCtotjacEmeBEf\"]",
+ "ip_dst_addr":"72.34.49.86",
+ "adapter:hostfromjsonlistadapter:end:ts":"1537304981022",
+ "host":"comarksecurity.com",
+ "adapter:geoadapter:end:ts":"1537304981022",
+ "ip_src_addr":"192.168.138.158",
+ "threatintelsplitterbolt:splitter:end:ts":"1537304981029",
+ "enrichments:geo:ip_dst_addr:longitude":"-118.2641",
+ "user_agent":"Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/4.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0)",
+ "resp_fuids":[
+ "FQcLCtotjacEmeBEf"
+ ],
+ "timestamp":1537304979801,
+ "method":"POST",
+ "enrichmentsplitterbolt:splitter:end:ts":"1537304981020",
+ "request_body_len":110,
+ "enrichments:geo:ip_dst_addr:city":"Los Angeles",
+ "enrichments:geo:ip_dst_addr:postalCode":"90014",
+ "adapter:hostfromjsonlistadapter:begin:ts":"1537304981022",
+ "orig_mime_types":[
+ "text/plain"
+ ],
+ "uri":"/wp-content/themes/grizzly/img5.php?u=ka6nnuvccqlw9",
+ "tags":[
+
+ ],
+ "alert_status":"OPEN",
+ "orig_fuids":[
+ "Fr5Cg02TcSAxFeYoBh"
+ ],
+ "ip_src_port":49204,
+ "threatintelsplitterbolt:splitter:begin:ts":"1537304981029",
+ "adapter:threatinteladapter:begin:ts":"1537304981033",
+ "status_msg":"OK",
+ "guid":"test-id-2.1",
+ "enrichments:geo:ip_dst_addr:country":"US",
+ "response_body_len":14
+ },
+ "score":1.0,
+ "index":"bro_index_2018.09.18.21"
+ }
+ ],
+ "facetCounts":{
+ "source:type":{
+ "metaalert":1,
+ "bro":52319,
+ "snort":52273
+ },
+ "ip_dst_addr":{
+ "95.163.121.204":15832,
+ "72.34.49.86":5079,
+ "192.168.138.158":17989,
+ "188.165.164.184":995,
+ "192.168.138.2":6396,
+ "192.168.66.1":4226,
+ "62.75.195.236":15813,
+ "224.0.0.251":4979,
+ "192.168.66.121":28822,
+ "204.152.254.221":4461
+ },
+ "enrichments:geo:ip_dst_addr:country":{
+ "RU":15832,
+ "FR":16808,
+ "US":9540
+ },
+ "ip_src_addr":{
+ "95.163.121.204":2106,
+ "72.34.49.86":2284,
+ "192.168.138.158":48576,
+ "192.168.138.2":118,
+ "192.168.66.1":33801,
+ "62.75.195.236":12552,
+ "192.168.66.121":4226,
+ "204.152.254.221":929
+ }
+ }
+}
\ No newline at end of file
diff --git a/metron-interface/metron-alerts/cypress/integration/search/auto-polling.feature.spec.js b/metron-interface/metron-alerts/cypress/integration/search/auto-polling.feature.spec.js
new file mode 100644
index 0000000..c99f700
--- /dev/null
+++ b/metron-interface/metron-alerts/cypress/integration/search/auto-polling.feature.spec.js
@@ -0,0 +1,98 @@
+/// <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';
+
+describe('Automatic data polling on Alerts View', () => {
+
+ const configuringDefaultStubs = () => {
+ cy.route({
+ method: 'GET',
+ url: '/api/v1/user',
+ response: 'user'
+ });
+
+ cy.route('GET', '/api/v1/global/config', 'fixture:config.json');
+ cy.route('GET', appConfigJSON.contextMenuConfigURL, 'fixture:context-menu.conf.json');
+ };
+
+ beforeEach(() => {
+ cy.server();
+ configuringDefaultStubs();
+ });
+
+ it('auto polling should keep polling after start depending on polling interval', () => {
+ cy.visit('login');
+ cy.get('[name="user"]').type('user');
+ cy.get('[name="password"]').type('password');
+ cy.contains('LOG IN').click();
+
+ // defining response for initial poll request
+ cy.route({
+ url: '/api/v1/search/search',
+ method: 'POST',
+ response: 'fixture:search.json',
+ }).as('initReq');
+
+ cy.log('Turning polling on');
+ cy.get('app-auto-polling > .btn').click();
+
+ cy.log('changing interval to 5 sec');
+ cy.get('.settings').click();
+ cy.get('[value="5"]').click();
+ cy.get('.settings').click();
+
+ // defining respons for the first scheduled poll
+ cy.route({
+ url: '/api/v1/search/search',
+ method: 'POST',
+ response: 'fixture:search-1.1.json',
+ }).as('1stPoll');
+
+ // Waiting 5.5 sec for the request
+ cy.wait('@1stPoll', { timeout: 5500 });
+ // Validating dom change
+ cy.contains('test-id-1.1').should('be.visible');
+
+ // defining respons for the second scheduled poll
+ cy.route({
+ url: '/api/v1/search/search',
+ method: 'POST',
+ response: 'fixture:search-1.2.json',
+ }).as('2ndPoll');;
+
+ // Waiting 5.5 sec for the request
+ cy.wait('@2ndPoll', { timeout: 5500 });
+ // Validating dom change
+ cy.contains('test-id-2.1').should('be.visible');
+
+ // turning off polling
+ cy.get('app-auto-polling > .btn').click();
+
+ cy.route({
+ url: '/api/v1/search/search',
+ method: 'POST',
+ response: 'fixture:search.json',
+ });
+
+ cy.wait(5500).then(() => {
+ // same element should be visible bc the polling is turned off
+ cy.contains('test-id-2.1').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
old mode 100644
new mode 100755
index e56fb1b..ea09288
--- 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
@@ -12,6 +12,7 @@
the specific language governing permissions and limitations under the License.
-->
<div class="container-fluid px-0">
+ <app-modal-loading-indicator [show]="!!pendingSearch"></app-modal-loading-indicator>
<div class="mrow">
<div class="col-md-12 px-0">
<div >
@@ -19,26 +20,29 @@
<span class="input-group-prepend">
<button class="btn btn-secondary btn-saved-searches" type="button" (click)="showSavedSearches()">Searches</button>
</span>
- <div appAceEditor *ngIf="!hideQueryBuilder" class="flex-fill" placeholder="Search Alerts" [text]="queryBuilder.displayQuery" (textChanged)="onSearch($event)"> </div>
- <div class="flex-fill" [class.d-none]="!hideQueryBuilder">
- <input class="manual-query-input" data-qe-id="manual-query-input" type="text" #manualQuery >
+ <div appAceEditor *ngIf="!isQueryBuilderModeManual()" class="flex-fill" placeholder="Search Alerts" [text]="queryBuilder.displayQuery" (textChanged)="onSearch($event)"> </div>
+ <div class="flex-fill" *ngIf="isQueryBuilderModeManual()">
+ <input #manualQuery type="text"
+ class="manual-query-input"
+ [value]="queryBuilder.getManualQuery()"
+ data-qe-id="manual-query-input">
</div>
<span class="input-group-append">
- <button class="btn btn-secondary btn-options" (click)="toggleQueryBuilder()">
- <span *ngIf="hideQueryBuilder">Use Query Builder</span>
- <span *ngIf="!hideQueryBuilder">Use Manual Query</span>
+ <button class="btn btn-secondary btn-options" (click)="toggleQueryBuilderMode()">
+ <span *ngIf="isQueryBuilderModeManual()">Use Query Builder</span>
+ <span *ngIf="!isQueryBuilderModeManual()">Use Manual Query</span>
</button>
</span>
<span class="input-group-append">
<button class="btn btn-secondary btn-search-clear" type="button" (click)="onClear()"></button>
</span>
- <span class="input-group-append" style="white-space: nowrap;" [class.d-none]="hideQueryBuilder">
+ <span class="input-group-append" style="white-space: nowrap;" [class.d-none]="isQueryBuilderModeManual()">
<app-time-range class="d-flex position-relative" (timeRangeChange)="onTimeRangeChange($event)" [disabled]="timeStampFilterPresent" [selectedTimeRange]="selectedTimeRange"> </app-time-range>
</span>
- <span class="input-group-append" [class.d-none]="hideQueryBuilder">
+ <span class="input-group-append" [class.d-none]="isQueryBuilderModeManual()">
<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>
- <span class="input-group-append" [class.d-none]="!hideQueryBuilder">
+ <span class="input-group-append" [class.d-none]="!isQueryBuilderModeManual()">
<button class="btn btn-secondary btn-search rounded-right" type="button" data-name="search" (click)="search(false, null)"></button>
</span>
<div class="input-group-append">
@@ -62,11 +66,15 @@
<div #settingsIcon class="btn settings">
<i class="fa fa-sliders" aria-hidden="true"></i>
</div>
- <app-configure-rows [srcElement]="settingsIcon" [tableMetaData]="tableMetaData" [(interval)]="refreshInterval" [(size)]="tableMetaData.size" (configRowsChange)="onConfigRowsChange()" > </app-configure-rows>
- <div class="btn pause-play" (click)="onPausePlay()">
- <i *ngIf="!isRefreshPaused" class="fa fa-pause" aria-hidden="true"></i>
- <i *ngIf="isRefreshPaused" class="fa fa-play" aria-hidden="true"></i>
- </div>
+ <app-configure-rows
+ [srcElement]="settingsIcon"
+ [refreshInterval]="autoPollingSvc.getInterval()"
+ [pageSize]="tableMetaData.size"
+ (configRowsChange)="onConfigRowsChange($event)"
+ ></app-configure-rows>
+
+ <app-auto-polling #autoPolling></app-auto-polling>
+
<div id="table-actions" class="dropdown d-inline-block">
<button class="btn btn-primary dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">ACTIONS</button>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
@@ -84,14 +92,20 @@
<div class="container-fluid no-gutters">
<div class="row">
- <div class="px-0" style="width: 200px;max-width: 200px;" [class.d-none]="hideQueryBuilder">
+ <div class="px-0" style="width: 200px;max-width: 200px;" [class.d-none]="isQueryBuilderModeManual()">
<app-alert-filters [facets]="searchResponse.facetCounts" (facetFilterChange)="onAddFacetFilter($event)"> </app-alert-filters>
</div>
<div class="col px-0 pl-4" style="overflow: auto;">
- <div class="alert alert-warning" role="alert" *ngIf="staleDataState" data-qe-id="staleDataWarning">
- <i class="fa fa-warning" aria-hidden="true"></i> Data is in a stale state! Click
- <i class="fa fa-search" aria-hidden="true"></i> to update your view based on your current filter and time-range configuration!
- </div>
+ <div class="alert alert-warning" role="alert"
+ *ngIf="staleDataState"
+ [innerHTML]="getStaleDataWarning()"
+ data-qe-id="staleDataWarning"
+ ></div>
+ <div class="alert alert-warning" role="alert"
+ *ngIf="autoPollingSvc.getIsCongestion()"
+ [innerHTML]="getPollingCongestionWarning()"
+ data-qe-id="pollingCongestionWarning"
+ ></div>
<div class="col-xs-12 pl-0 pb-3">
<app-group-by [facets]="groupFacets" (groupsChange)="onGroupsChange($event)"> </app-group-by>
</div>
diff --git a/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.component.scss b/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.component.scss
index c39887d..4d27e4e 100644
--- a/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.component.scss
+++ b/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.component.scss
@@ -197,27 +197,6 @@
cursor: pointer;
}
-.pause-play {
- height: 38px;
- padding: 0px;
- border: 1px solid #0F6F9E;
- width: 38px;
- line-height: 39px;
- border-radius: 12px;
- margin-left: 15px;
- background: $mine-shaft-2;
- cursor: pointer;
-
- i {
- font-size: 17px;
- color: $piction-blue;
- }
-
- .fa-play {
- padding-left: 3px;
- }
-}
-
.settings, .cog {
height: 38px;
padding: 0px;
diff --git a/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.component.spec.ts b/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.component.spec.ts
index 8cbff8f..74d0f8c 100644
--- a/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.component.spec.ts
+++ b/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.component.spec.ts
@@ -16,8 +16,8 @@
* limitations under the License.
*/
import { AlertsListComponent } from './alerts-list.component';
-import { ComponentFixture, async, TestBed } from '@angular/core/testing';
-import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, async, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { Component, Input, Directive } from '@angular/core';
import { RouterTestingModule } from '@angular/router/testing';
import { SearchService } from 'app/service/search.service';
import { UpdateService } from 'app/service/update.service';
@@ -28,53 +28,136 @@
import { MetaAlertService } from 'app/service/meta-alert.service';
import { GlobalConfigService } from 'app/service/global-config.service';
import { DialogService } from 'app/service/dialog.service';
-import { SearchRequest } from 'app/model/search-request';
-import { Observable, of, Subject } from 'rxjs';
-import { Filter } from 'app/model/filter';
-import { QueryBuilder } from './query-builder';
import { TIMESTAMP_FIELD_NAME } from 'app/utils/constants';
-import { SearchResponse } from 'app/model/search-response';
import { By } from '@angular/platform-browser';
+import { Observable, Subject, of, throwError } from 'rxjs';
+import { Filter } from 'app/model/filter';
+import { QueryBuilder, FilteringMode } from './query-builder';
+import { SearchResponse } from 'app/model/search-response';
+import { AutoPollingService } from './auto-polling/auto-polling.service';
+import { Router } from '@angular/router';
+import { Alert } from 'app/model/alert';
+import { AlertSource } from 'app/model/alert-source';
+import { SearchRequest } from 'app/model/search-request';
+import { query } from '@angular/core/src/render3';
+import { RestError } from 'app/model/rest-error';
+import { DialogType } from 'app/shared/metron-dialog/metron-dialog.component';
+
+@Component({
+ selector: 'app-auto-polling',
+ template: '<div></div>',
+})
+class MockAutoPollingComponent {}
+
+@Component({
+ selector: 'app-configure-rows',
+ template: '<div></div>',
+})
+class MockConfigureRowsComponent {
+ @Input() refreshInterval = 0;
+ @Input() srcElement = {};
+ @Input() pageSize = 0;
+}
+
+@Component({
+ selector: 'app-modal-loading-indicator',
+ template: '<div></div>',
+})
+class MockModalLoadingIndicatorComponent {
+ @Input() show = false;
+}
+
+@Component({
+ selector: 'app-time-range',
+ template: '<div></div>',
+})
+class MockTimeRangeComponent {
+ @Input() disabled = false;
+ @Input() selectedTimeRange = {};
+}
+
+@Directive({
+ selector: '[appAceEditor]',
+})
+class MockAceEditorDirective {
+ @Input() text = '';
+}
+
+@Component({
+ selector: 'app-alert-filters',
+ template: '<div></div>',
+})
+class MockAlertFilterComponent {
+ @Input() facets = [];
+}
+
+@Component({
+ selector: 'app-group-by',
+ template: '<div></div>',
+})
+class MockGroupByComponent {
+ @Input() facets = [];
+}
+
+@Component({
+ selector: 'app-table-view',
+ template: '<div></div>',
+})
+class MockTableViewComponent {
+ @Input() alerts = [];
+ @Input() pagination = {};
+ @Input() alertsColumnsToDisplay = [];
+ @Input() selectedAlerts = [];
+}
+
+@Component({
+ selector: 'app-tree-view',
+ template: '<div></div>',
+})
+class MockTreeViewComponent {
+ @Input() alerts = [];
+ @Input() pagination = {};
+ @Input() alertsColumnsToDisplay = [];
+ @Input() selectedAlerts = [];
+ @Input() globalConfig = {};
+ @Input() query = '';
+ @Input() groups = [];
+}
+
describe('AlertsListComponent', () => {
let component: AlertsListComponent;
let fixture: ComponentFixture<AlertsListComponent>;
- let searchServiceStub = {
- search() { return of({
- total: 0,
- groupedBy: '',
- results: [],
- facetCounts: [],
- groups: []
- }) },
- pollSearch() { return of({}) }
- }
- let queryBuilderStub = {
- addOrUpdateFilter() { return {} },
- clearSearch() { return {} },
- generateSelect() { return '*' },
- isTimeStampFieldPresent() { return {} },
- filters: [{}],
- searchRequest: {
- from: 0
- }
- }
let queryBuilder: QueryBuilder;
let searchService: SearchService;
beforeEach(async(() => {
+
+ const searchResponseFake = new SearchResponse();
+ searchResponseFake.facetCounts = {};
+
TestBed.configureTestingModule({
- schemas: [ NO_ERRORS_SCHEMA ],
imports: [
- RouterTestingModule.withRoutes([]),
+ RouterTestingModule.withRoutes([{path: 'alerts-list', component: AlertsListComponent}]),
],
declarations: [
AlertsListComponent,
+ MockAutoPollingComponent,
+ MockModalLoadingIndicatorComponent,
+ MockTimeRangeComponent,
+ MockAceEditorDirective,
+ MockConfigureRowsComponent,
+ MockAlertFilterComponent,
+ MockGroupByComponent,
+ MockTableViewComponent,
+ MockTreeViewComponent,
],
providers: [
- { provide: SearchService, useValue: searchServiceStub },
+ { provide: SearchService, useClass: () => { return {
+ search: () => of(searchResponseFake),
+ } } },
{ provide: UpdateService, useClass: () => { return {
alertChanged$: new Observable(),
} } },
@@ -88,6 +171,7 @@
} } },
{ provide: SaveSearchService, useClass: () => { return {
loadSavedSearch$: new Observable(),
+ setCurrentQueryBuilderAndTableColumns: () => {},
} } },
{ provide: MetaAlertService, useClass: () => { return {
alertChanged$: new Observable(),
@@ -96,7 +180,29 @@
get: () => new Observable(),
} } },
{ provide: DialogService, useClass: () => { return {} } },
- { provide: QueryBuilder, useValue: queryBuilderStub },
+ { provide: QueryBuilder, useClass: () => { return {
+ filters: [],
+ query: '*',
+ get searchRequest() {
+ return new SearchResponse();
+ },
+ addOrUpdateFilter: () => {},
+ clearSearch: () => {},
+ isTimeStampFieldPresent: () => {},
+ getManualQuery: () => {},
+ setManualQuery: () => {},
+ getFilteringMode: () => {},
+ setFilteringMode: () => {},
+ } } },
+ { provide: AutoPollingService, useClass: () => { return {
+ data: new Subject<SearchResponse>(),
+ getIsCongestion: () => {},
+ getInterval: () => {},
+ getIsPollingActive: () => {},
+ dropNextAndContinue: () => {},
+ onDestroy: () => {},
+ setSuppression: () => {},
+ } } },
]
})
.compileComponents();
@@ -139,64 +245,110 @@
expect(fixture.nativeElement.querySelector('[data-qe-id="alert-subgroup-total"]')).toBeNull();
});
- it('should toggle the query builder with toggleQueryBuilder', () => {
- component.toggleQueryBuilder();
- fixture.detectChanges();
- expect(component.hideQueryBuilder).toBe(true);
+ describe('filtering by query builder or manual query', () => {
+ it('should be able to toggle the query builder mode', () => {
+ spyOn(component, 'setSearchRequestSize');
+ spyOn(queryBuilder, 'setFilteringMode');
- component.hideQueryBuilder = true;
- component.pagination.from = 0;
- component.pagination.size = 25;
+ queryBuilder.getFilteringMode = () => FilteringMode.BUILDER;
- fixture.detectChanges();
- component.toggleQueryBuilder();
- expect(component.hideQueryBuilder).toBe(false);
+ component.toggleQueryBuilderMode();
+ expect(queryBuilder.setFilteringMode).toHaveBeenCalledWith(FilteringMode.MANUAL);
+
+ queryBuilder.getFilteringMode = () => FilteringMode.MANUAL;
+
+ component.toggleQueryBuilderMode();
+ expect(queryBuilder.setFilteringMode).toHaveBeenCalledWith(FilteringMode.BUILDER);
+ });
+
+ it('isQueryBuilderModeManual should return true if queryBuilder is in manual mode', () => {
+ queryBuilder.getFilteringMode = () => FilteringMode.MANUAL;
+ expect(component.isQueryBuilderModeManual()).toBe(true);
+
+ queryBuilder.getFilteringMode = () => FilteringMode.BUILDER;
+ expect(component.isQueryBuilderModeManual()).toBe(false);
+ });
+
+ it('should show manual input dom element depending on mode', () => {
+ let input = fixture.debugElement.query(By.css('[data-qe-id="manual-query-input"]'));
+
+ expect(input).toBeFalsy();
+
+ queryBuilder.getFilteringMode = () => FilteringMode.MANUAL;
+ fixture.detectChanges();
+ input = fixture.debugElement.query(By.css('[data-qe-id="manual-query-input"]'));
+
+ expect(input).toBeTruthy();
+
+ queryBuilder.getFilteringMode = () => FilteringMode.BUILDER;
+ fixture.detectChanges();
+ input = fixture.debugElement.query(By.css('[data-qe-id="manual-query-input"]'));
+
+ expect(input).toBeFalsy();
+ });
+
+ it('should bind default manual query from query builder', () => {
+ spyOn(queryBuilder, 'getManualQuery').and.returnValue('test manual query string')
+
+ queryBuilder.getFilteringMode = () => FilteringMode.MANUAL;
+ fixture.detectChanges();
+ let input: HTMLInputElement = fixture.debugElement.query(By.css('[data-qe-id="manual-query-input"]')).nativeElement;
+
+ expect(input.value).toBe('test manual query string');
+ });
+
+ it('should pass the manual query value to the query builder when editing mode is manual', fakeAsync(() => {
+ spyOn(queryBuilder, 'setManualQuery');
+
+ queryBuilder.getFilteringMode = () => FilteringMode.MANUAL;
+ fixture.detectChanges();
+
+ const input = fixture.debugElement.query(By.css('[data-qe-id="manual-query-input"]'));
+ const el = input.nativeElement;
+
+ el.value = 'test';
+ (el as HTMLElement).dispatchEvent(new Event('keyup'));
+ fixture.detectChanges();
+ tick(300);
+
+ expect(queryBuilder.setManualQuery).toHaveBeenCalledWith('test');
+ }));
});
- it('should pass the manual query value when hideQueryBuilder is true', () => {
- const input = fixture.debugElement.query(By.css('[data-qe-id="manual-query-input"]'));
- const el = input.nativeElement;
+ describe('handling pending search requests', () => {
+ it('should set pendingSearch on search', () => {
+ spyOn(searchService, 'search').and.returnValue(of(new SearchResponse()));
+ spyOn(component, 'saveCurrentSearch');
+ spyOn(component, 'setSearchRequestSize');
+ spyOn(component, 'setSelectedTimeRange');
+ spyOn(component, 'createGroupFacets');
- expect(component.queryForTreeView()).toBe('*');
+ component.search();
+ expect(component.pendingSearch).toBeTruthy();
+ });
- component.toggleQueryBuilder();
- fixture.detectChanges();
- expect(component.hideQueryBuilder).toBe(true);
+ it('should clear pendingSearch on search success', (done) => {
+ const fakeObservable = new Subject();
+ spyOn(searchService, 'search').and.returnValue(fakeObservable);
+ spyOn(component, 'saveCurrentSearch');
+ spyOn(component, 'setSearchRequestSize');
+ spyOn(component, 'setSelectedTimeRange');
+ spyOn(component, 'createGroupFacets');
- el.value = 'test';
- expect(component.queryForTreeView()).toBe('test');
- });
+ component.search();
- it('should build a new search request if hideQueryBuilder is true', () => {
- const input = fixture.debugElement.query(By.css('[data-qe-id="manual-query-input"]'));
- const el = input.nativeElement;
- const searchServiceSpy = spyOn(searchService, 'search').and.returnValue(of());
- const newSearch = new SearchRequest();
+ setTimeout(() => {
+ fakeObservable.next(new SearchResponse());
+ }, 0);
- el.value = 'test';
- component.hideQueryBuilder = true;
- component.pagination.size = 25;
- newSearch.query = 'test'
- newSearch.size = 25
- newSearch.from = 0;
-
- fixture.detectChanges();
- component.search();
- expect(searchServiceSpy).toHaveBeenCalledWith(newSearch);
- });
-
- it('should poll with new search request if isRefreshPaused is true and manualSearch is present', () => {
- const searchServiceSpy = spyOn(searchService, 'pollSearch').and.returnValue(of());
- const newSearch = new SearchRequest();
-
- component.isRefreshPaused = false;
- fixture.detectChanges();
- component.tryStartPolling(newSearch);
- expect(searchServiceSpy).toHaveBeenCalledWith(newSearch);
+ fakeObservable.subscribe(() => {
+ expect(component.pendingSearch).toBe(null);
+ done();
+ })
+ });
});
describe('stale data state', () => {
-
it('should set staleDataState flag to true on filter change', () => {
expect(component.staleDataState).toBe(false);
component.onAddFilter(new Filter('ip_src_addr', '0.0.0.0'));
@@ -204,7 +356,7 @@
});
it('should set staleDataState flag to true on filter clearing', () => {
- queryBuilder.clearSearch = jasmine.createSpy('clearSearch');
+ spyOn(component, 'setSearchRequestSize');
expect(component.staleDataState).toBe(false);
component.onClear();
@@ -229,6 +381,21 @@
expect(component.staleDataState).toBe(false);
});
+ it('should set stale date true when query changes in manual mode', fakeAsync(() => {
+ queryBuilder.getFilteringMode = () => FilteringMode.MANUAL;
+
+ fixture.detectChanges();
+ const input = fixture.debugElement.query(By.css('[data-qe-id="manual-query-input"]'));
+ const el = input.nativeElement;
+
+ el.value = 'test';
+ (el as HTMLElement).dispatchEvent(new Event('keyup'));
+ fixture.detectChanges();
+ tick(300);
+
+ expect(component.staleDataState).toBe(true);
+ }));
+
it('should show warning if data is in a stale state', () => {
expect(fixture.debugElement.query(By.css('[data-qe-id="staleDataWarning"]'))).toBe(null);
@@ -238,6 +405,200 @@
expect(fixture.debugElement.query(By.css('[data-qe-id="staleDataWarning"]'))).toBeTruthy();
});
- })
+ });
+ describe('auto polling', () => {
+ it('should refresh view on data emit', () => {
+ const fakeResponse = new SearchResponse();
+ spyOn(component, 'setData');
+
+ TestBed.get(AutoPollingService).data.next(fakeResponse);
+
+ expect(component.setData).toHaveBeenCalledWith(fakeResponse);
+ });
+
+ it('should set staleDataState false on auto polling refresh', () => {
+ spyOn(component, 'setData');
+ component.staleDataState = true;
+
+ TestBed.get(AutoPollingService).data.next(new SearchResponse());
+
+ expect(component.staleDataState).toBe(false);
+ });
+
+ it('should show warning on auto polling congestion', () => {
+ expect(fixture.debugElement.query(By.css('[data-qe-id="pollingCongestionWarning"]'))).toBeFalsy();
+
+ TestBed.get(AutoPollingService).getIsCongestion = () => true;
+ fixture.detectChanges();
+
+ expect(fixture.debugElement.query(By.css('[data-qe-id="pollingCongestionWarning"]'))).toBeTruthy();
+
+ TestBed.get(AutoPollingService).getIsCongestion = () => false;
+ fixture.detectChanges();
+
+ expect(fixture.debugElement.query(By.css('[data-qe-id="pollingCongestionWarning"]'))).toBeFalsy();
+ });
+
+ it('should pass refresh interval to row config component', () => {
+ TestBed.get(AutoPollingService).getInterval = () => 44;
+ fixture.detectChanges();
+
+ expect(fixture.debugElement.query(By.directive(MockConfigureRowsComponent)).componentInstance.refreshInterval).toBe(44);
+ });
+
+ it('should drop pending auto polling result if user trigger search request manually', () => {
+ const autoPollingSvc = TestBed.get(AutoPollingService);
+ spyOn(autoPollingSvc, 'dropNextAndContinue');
+ spyOn(component, 'setSearchRequestSize');
+
+ autoPollingSvc.getIsPollingActive = () => false;
+ component.search()
+
+ expect(autoPollingSvc.dropNextAndContinue).not.toHaveBeenCalled();
+
+ autoPollingSvc.getIsPollingActive = () => true;
+ component.search()
+
+ expect(autoPollingSvc.dropNextAndContinue).toHaveBeenCalled();
+ });
+
+ it('should show different stale data warning when polling is active', () => {
+ const autoPollingSvc = TestBed.get(AutoPollingService);
+
+ autoPollingSvc.getIsPollingActive = () => false;
+ const warning = component.getStaleDataWarning();
+
+ autoPollingSvc.getIsPollingActive = () => true;
+ const warningWhenPolling = component.getStaleDataWarning();
+
+ expect(warning).not.toEqual(warningWhenPolling);
+ });
+
+ it('should show getIsCongestion scennarios', () => {
+ const autoPollingSvc = TestBed.get(AutoPollingService);
+
+ autoPollingSvc.getIsCongestion = () => false;
+ fixture.detectChanges();
+ expect(fixture.debugElement.query(By.css('[data-qe-id="pollingCongestionWarning"]'))).toBeFalsy();
+
+ autoPollingSvc.getIsCongestion = () => true;
+ fixture.detectChanges();
+ expect(fixture.debugElement.query(By.css('[data-qe-id="pollingCongestionWarning"]'))).toBeTruthy();
+
+ });
+
+ it('should suppress polling when user select alerts', () => {
+ const autoPollingSvc = TestBed.get(AutoPollingService);
+ spyOn(autoPollingSvc, 'setSuppression');
+
+ component.onSelectedAlertsChange([{ source: { metron_alert: [] } }]);
+
+ expect(autoPollingSvc.setSuppression).toHaveBeenCalledWith(true);
+ });
+
+ it('should restore polling from suppression when user deselect alerts', () => {
+ const autoPollingSvc = TestBed.get(AutoPollingService);
+ spyOn(autoPollingSvc, 'setSuppression');
+
+ component.onSelectedAlertsChange([]);
+
+ expect(autoPollingSvc.setSuppression).toHaveBeenCalledWith(false);
+ });
+
+ it('should suppress polling when open details pane', () => {
+ const autoPollingSvc = TestBed.get(AutoPollingService);
+ const router = TestBed.get(Router);
+ spyOn(router, 'navigate').and.returnValue(true);
+ spyOn(router, 'navigateByUrl').and.returnValue(true);
+ spyOn(autoPollingSvc, 'setSuppression');
+
+ component.showConfigureTable();
+
+ expect(autoPollingSvc.setSuppression).toHaveBeenCalledWith(true);
+ });
+
+ it('should suppress polling when open column config pane', () => {
+ const router = TestBed.get(Router);
+ const autoPollingSvc = TestBed.get(AutoPollingService);
+ spyOn(router, 'navigate');
+ spyOn(router, 'navigateByUrl');
+ spyOn(autoPollingSvc, 'setSuppression');
+
+ const fakeAlert = new Alert();
+ fakeAlert.source = new AlertSource();
+
+ component.showDetails(fakeAlert);
+
+ expect(autoPollingSvc.setSuppression).toHaveBeenCalledWith(true);
+ });
+
+ it('should suppress polling when open Saved Searches pane', () => {
+ const router = TestBed.get(Router);
+ const autoPollingSvc = TestBed.get(AutoPollingService);
+ spyOn(router, 'navigate');
+ spyOn(router, 'navigateByUrl');
+ spyOn(autoPollingSvc, 'setSuppression');
+
+ component.showSavedSearches();
+
+ expect(autoPollingSvc.setSuppression).toHaveBeenCalledWith(true);
+ });
+
+ it('should suppress polling when open Save Search dialogue pane', () => {
+ const router = TestBed.get(Router);
+ const autoPollingSvc = TestBed.get(AutoPollingService);
+ const saveSearchSvc = TestBed.get(SaveSearchService);
+ spyOn(router, 'navigate');
+ spyOn(router, 'navigateByUrl');
+ spyOn(autoPollingSvc, 'setSuppression');
+ spyOn(saveSearchSvc, 'setCurrentQueryBuilderAndTableColumns');
+
+ component.showSaveSearch();
+
+ expect(autoPollingSvc.setSuppression).toHaveBeenCalledWith(true);
+ });
+
+ it('should restore the polling supression on bulk status update (other scenario of deselecting alerts)', () => {
+ const autoPollingSvc = TestBed.get(AutoPollingService);
+ spyOn(autoPollingSvc, 'setSuppression');
+
+ component.updateSelectedAlertStatus('fakeState');
+
+ expect(autoPollingSvc.setSuppression).toHaveBeenCalledWith(false);
+ });
+
+ it('should restore the polling supression when returning from a subroute', fakeAsync(() => {
+ const autoPollingSvc = TestBed.get(AutoPollingService);
+ spyOn(autoPollingSvc, 'setSuppression');
+
+ autoPollingSvc.getIsPollingActive = () => false;
+ fixture.ngZone.run(() => {
+ TestBed.get(Router).navigate(['/alerts-list']);
+ });
+
+ expect(autoPollingSvc.setSuppression).not.toHaveBeenCalled();
+
+ autoPollingSvc.getIsPollingActive = () => true;
+ fixture.ngZone.run(() => {
+ TestBed.get(Router).navigate(['/alerts-list']);
+ });
+
+ expect(autoPollingSvc.setSuppression).toHaveBeenCalledWith(false);
+ }));
+ });
+
+ describe('search', () => {
+ it('should show notification on http error', fakeAsync(() => {
+ const fakeDialogService = TestBed.get(DialogService);
+
+ spyOn(searchService, 'search').and.returnValue(throwError(new RestError()));
+ fakeDialogService.launchDialog = () => {};
+ spyOn(fakeDialogService, 'launchDialog');
+
+ component.search();
+
+ expect(fakeDialogService.launchDialog).toHaveBeenCalledWith('Server were unable to apply query string.', DialogType.Error);
+ }));
+ });
});
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
old mode 100644
new mode 100755
index 2cd34a5..a644b76
--- 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
@@ -15,28 +15,27 @@
* 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, ChangeDetectorRef} from '@angular/core';
+import {forkJoin, noop, fromEvent} from 'rxjs';
+import {Component, OnInit, ViewChild, ElementRef, OnDestroy, ChangeDetectorRef, AfterViewInit} from '@angular/core';
import {Router, NavigationStart} from '@angular/router';
import {Subscription} from 'rxjs';
import {Alert} from '../../model/alert';
import {SearchService} from '../../service/search.service';
import {UpdateService} from '../../service/update.service';
-import {QueryBuilder} from './query-builder';
+import {QueryBuilder, FilteringMode} from './query-builder';
import {ConfigureTableService} from '../../service/configure-table.service';
import {AlertsService} from '../../service/alerts.service';
import {ClusterMetaDataService} from '../../service/cluster-metadata.service';
import {ColumnMetadata} from '../../model/column-metadata';
import {SaveSearchService} from '../../service/save-search.service';
-import {RefreshInterval} from '../configure-rows/configure-rows-enums';
import {SaveSearch} from '../../model/save-search';
import {TableMetadata} from '../../model/table-metadata';
import {AlertSearchDirective} from '../../shared/directives/alert-search.directive';
import {SearchResponse} from '../../model/search-response';
import {ElasticsearchUtils} from '../../utils/elasticsearch-utils';
import {Filter} from '../../model/filter';
-import { TIMESTAMP_FIELD_NAME, ALL_TIME, POLLING_DEFAULT_STATE } from '../../utils/constants';
+import { TIMESTAMP_FIELD_NAME, ALL_TIME } from '../../utils/constants';
import {TableViewComponent, PageChangedEvent, SortChangedEvent} from './table-view/table-view.component';
import {Pagination} from '../../model/pagination';
import {MetaAlertService} from '../../service/meta-alert.service';
@@ -46,14 +45,16 @@
import { DialogType } from 'app/model/dialog-type';
import { Utils } from 'app/utils/utils';
import { AlertSource } from '../../model/alert-source';
+import { AutoPollingService } from './auto-polling/auto-polling.service';
+import { ConfigureRowsModel } from '../configure-rows/configure-rows.component';
import { SearchRequest } from 'app/model/search-request';
+import { switchMap, map, debounceTime } from 'rxjs/operators';
@Component({
selector: 'app-alerts-list',
templateUrl: './alerts-list.component.html',
styleUrls: ['./alerts-list.component.scss']
})
-
export class AlertsListComponent implements OnInit, OnDestroy {
alertsColumns: ColumnMetadata[] = [];
@@ -62,10 +63,7 @@
alerts: Alert[] = [];
searchResponse: SearchResponse = new SearchResponse();
colNumberTimerId: number;
- refreshInterval = RefreshInterval.TEN_MIN;
- refreshTimer: Subscription;
- isRefreshPaused = POLLING_DEFAULT_STATE;
- lastIsRefreshPausedValue = false;
+
isMetaAlertPresentInSelectedAlerts = false;
timeStampFilterPresent = false;
@@ -75,7 +73,18 @@
@ViewChild('table') table: ElementRef;
@ViewChild('dataViewComponent') dataViewComponent: TableViewComponent;
@ViewChild(AlertSearchDirective) alertSearchDirective: AlertSearchDirective;
- @ViewChild('manualQuery') manualQuery: ElementRef;
+
+ private manualQueryFieldChangeSubs: Subscription;
+ private manualQueryInputEl: ElementRef;
+ @ViewChild('manualQuery') set manualQuery(el: ElementRef) {
+ if (el && !this.manualQueryInputEl) {
+ this.manualQueryInputEl = el;
+ this.manualQueryFieldChangeSubs = this.addManualQueryFieldChangeStream(el.nativeElement);
+ }
+ };
+ get manualQuery(): ElementRef {
+ return this.manualQueryInputEl;
+ }
tableMetaData = new TableMetadata();
pagination: Pagination = new Pagination();
@@ -85,8 +94,8 @@
configSubscription: Subscription;
groups = [];
subgroupTotal = 0;
- hideQueryBuilder = false;
+ pendingSearch: Subscription;
staleDataState = false;
constructor(private router: Router,
@@ -99,17 +108,23 @@
private metaAlertsService: MetaAlertService,
private globalConfigService: GlobalConfigService,
private dialogService: DialogService,
+ private cdRef: ChangeDetectorRef,
public queryBuilder: QueryBuilder,
- private cdRef: ChangeDetectorRef) {
+ public autoPollingSvc: AutoPollingService) {
router.events.subscribe(event => {
if (event instanceof NavigationStart && event.url === '/alerts-list') {
this.selectedAlerts = [];
- this.restoreRefreshState();
+ this.restoreAutoPollingState();
}
});
+
+ autoPollingSvc.data.subscribe((result: SearchResponse) => {
+ this.setData(result);
+ this.staleDataState = false;
+ })
}
- addAlertChangedListner() {
+ addAlertChangedListener() {
this.metaAlertsService.alertChanged$.subscribe(alertSource => {
if (alertSource['status'] === 'inactive') {
this.removeAlert(alertSource)
@@ -122,7 +137,7 @@
});
}
- addAlertColChangedListner() {
+ addAlertColChangedListener() {
this.configureTableService.tableChanged$.subscribe(colChanged => {
if (colChanged) {
this.getAlertColumnNames(false);
@@ -130,7 +145,7 @@
});
}
- addLoadSavedSearchListner() {
+ addLoadSavedSearchListener() {
this.saveSearchService.loadSavedSearch$.subscribe((savedSearch: SaveSearch) => {
this.queryBuilder.searchRequest = savedSearch.searchRequest;
this.queryBuilder.filters = savedSearch.filters;
@@ -170,7 +185,7 @@
}
getAlertColumnNames(resetPaginationForSearch: boolean) {
- observableForkJoin(
+ forkJoin(
this.configureTableService.getTableMetadata(),
this.clusterMetaDataService.getDefaultColumns()
).subscribe((response: any) => {
@@ -193,9 +208,13 @@
}
ngOnDestroy() {
- this.tryStopPolling();
+ this.autoPollingSvc.onDestroy();
this.removeAlertChangedListner();
this.configSubscription.unsubscribe();
+
+ if (this.manualQueryFieldChangeSubs) {
+ this.manualQueryFieldChangeSubs.unsubscribe();
+ }
}
ngOnInit() {
@@ -212,9 +231,23 @@
this.setDefaultTimeRange(this.DEFAULT_TIME_RANGE);
this.getAlertColumnNames(true);
- this.addAlertColChangedListner();
- this.addLoadSavedSearchListner();
- this.addAlertChangedListner();
+ this.addAlertColChangedListener();
+ this.addLoadSavedSearchListener();
+ this.addAlertChangedListener();
+ }
+
+ private addManualQueryFieldChangeStream(inputDomEl: HTMLInputElement) {
+ return fromEvent<KeyboardEvent>(inputDomEl, 'keyup').pipe(
+ map(event => (event.target as HTMLInputElement).value),
+ debounceTime(300),
+ ).subscribe((manualQuery) => {
+ this.onManualQueryInputChange(manualQuery);
+ });
+ }
+
+ private onManualQueryInputChange(value: string) {
+ this.queryBuilder.setManualQuery(value);
+ this.staleDataState = true;
}
private setDefaultTimeRange(timeRangeId: string) {
@@ -226,8 +259,6 @@
onClear() {
this.timeStampFilterPresent = false;
this.queryBuilder.clearSearch();
- if (this.hideQueryBuilder) { this.manualQuery.nativeElement.value = '*'; }
- this.search();
this.staleDataState = true;
}
@@ -258,11 +289,7 @@
alert => (alert.source.metron_alert && alert.source.metron_alert.length > 0)
);
- if (selectedAlerts.length > 0) {
- this.pause();
- } else {
- this.resume();
- }
+ this.autoPollingSvc.setSuppression(!!selectedAlerts.length);
}
onAddFilter(filter: Filter) {
@@ -271,9 +298,24 @@
this.staleDataState = true;
}
- onConfigRowsChange() {
- this.searchService.interval = this.refreshInterval;
- this.search();
+ onConfigRowsChange(config: ConfigureRowsModel) {
+ const { values, triggerQuery } = config;
+
+ this.tableMetaData.size = values.pageSize;
+ this.updatePollingInterval(values.refreshInterval);
+ this.saveSaveRowsConfig();
+
+ if (triggerQuery) {
+ this.search();
+ }
+ }
+
+ private saveSaveRowsConfig() {
+ this.configureTableService
+ .saveTableMetaData(this.tableMetaData).subscribe(
+ noop,
+ () => console.log('Unable to save settings ....')
+ );
}
onGroupsChange(groups) {
@@ -282,15 +324,6 @@
this.search();
}
- onPausePlay() {
- this.isRefreshPaused = !this.isRefreshPaused;
- if (this.isRefreshPaused) {
- this.tryStopPolling();
- } else {
- this.search(false);
- }
- }
-
onResize() {
clearTimeout(this.colNumberTimerId);
this.colNumberTimerId = window.setTimeout(() => { this.calcColumnsToDisplay(); }, 500);
@@ -311,16 +344,12 @@
prepareColumnData(configuredColumns: ColumnMetadata[], defaultColumns: ColumnMetadata[]) {
this.alertsColumns = (configuredColumns && configuredColumns.length > 0) ? configuredColumns : defaultColumns;
- this.queryBuilder.setFields(this.getColumnNamesForQuery());
this.calcColumnsToDisplay();
}
prepareData(tableMetaData: TableMetadata, defaultColumns: ColumnMetadata[]) {
- this.tableMetaData = tableMetaData;
- this.refreshInterval = this.tableMetaData.refreshInterval;
-
- this.updateConfigRowsSettings();
this.prepareColumnData(tableMetaData.tableColumns, defaultColumns);
+ this.tableMetaData = tableMetaData;
}
preventDropdownOptionIfDisabled(event: Event): boolean {
@@ -373,11 +402,6 @@
}
}
- restoreRefreshState() {
- this.isRefreshPaused = this.lastIsRefreshPausedValue;
- this.tryStartPolling();
- }
-
search(resetPaginationParams = true, savedSearch?: SaveSearch) {
if (savedSearch) { this.saveCurrentSearch(savedSearch); }
if (resetPaginationParams) {
@@ -386,31 +410,19 @@
this.setSearchRequestSize();
- if (this.hideQueryBuilder) {
- const newSearch = new SearchRequest();
- newSearch.query = this.manualQuery.nativeElement.value;
- newSearch.size = this.pagination.size;
- newSearch.from = 0;
-
- this.searchService.search(newSearch).subscribe(results => {
+ this.pendingSearch = this.searchService.search(this.queryBuilder.searchRequest).subscribe(
+ results => {
this.setData(results);
+ this.pendingSearch = null;
this.staleDataState = false;
}, error => {
this.setData(new SearchResponse());
- this.dialogService.launchDialog(ElasticsearchUtils.extractESErrorMessage(error), DialogType.Error);
+ this.pendingSearch = null;
+ this.dialogService.launchDialog('Server were unable to apply query string.', DialogType.Error);
});
- this.tryStartPolling(newSearch);
- } else {
- this.searchService.search(this.queryBuilder.searchRequest).subscribe(results => {
- this.setData(results);
- this.staleDataState = false;
- }, error => {
- this.setData(new SearchResponse());
- this.dialogService.launchDialog(ElasticsearchUtils.extractESErrorMessage(error), DialogType.Error);
- });
-
- this.tryStartPolling();
+ if (this.autoPollingSvc.getIsPollingActive()) {
+ this.autoPollingSvc.dropNextAndContinue();
}
}
@@ -460,69 +472,30 @@
}
showConfigureTable() {
- this.saveRefreshState();
+ this.autoPollingSvc.setSuppression(true);
this.router.navigateByUrl('/alerts-list(dialog:configure-table)');
}
showDetails(alert: Alert) {
this.selectedAlerts = [];
this.selectedAlerts = [alert];
- this.saveRefreshState();
+ this.autoPollingSvc.setSuppression(true);
let sourceType = alert.source[this.globalConfig['source.type.field']];
let url = '/alerts-list(dialog:details/' + sourceType + '/' + alert.source.guid + '/' + alert.index + ')';
this.router.navigateByUrl(url);
}
- saveRefreshState() {
- this.lastIsRefreshPausedValue = this.isRefreshPaused;
- this.tryStopPolling();
- }
-
- pause() {
- this.isRefreshPaused = true;
- this.tryStopPolling();
- }
-
- resume() {
- this.isRefreshPaused = false;
- this.tryStartPolling();
- }
-
showSavedSearches() {
- this.saveRefreshState();
+ this.autoPollingSvc.setSuppression(true);
this.router.navigateByUrl('/alerts-list(dialog:saved-searches)');
}
showSaveSearch() {
- this.saveRefreshState();
+ this.autoPollingSvc.setSuppression(true);
this.saveSearchService.setCurrentQueryBuilderAndTableColumns(this.queryBuilder, this.alertsColumns);
this.router.navigateByUrl('/alerts-list(dialog:save-search)');
}
- tryStartPolling(manualSearch?: SearchRequest) {
- if (!this.isRefreshPaused && !manualSearch) {
- this.tryStopPolling();
- this.refreshTimer = this.searchService.pollSearch(this.queryBuilder.searchRequest).subscribe(results => {
- this.setData(results);
- });
- } else if (!this.isRefreshPaused && manualSearch) {
- this.tryStopPolling();
- this.refreshTimer = this.searchService.pollSearch(manualSearch).subscribe(results => {
- this.setData(results);
- });
- }
- }
-
- tryStopPolling() {
- if (this.refreshTimer && !this.refreshTimer.closed) {
- this.refreshTimer.unsubscribe();
- }
- }
-
- updateConfigRowsSettings() {
- this.searchService.interval = this.refreshInterval;
- }
-
updateAlert(alertSource: AlertSource) {
this.alerts.filter(alert => alert.source.guid === alertSource.guid)
.map(alert => alert.source = alertSource);
@@ -537,7 +510,7 @@
selectedAlert.source['alert_status'] = status;
}
this.selectedAlerts = [];
- this.resume();
+ this.autoPollingSvc.setSuppression(false);
}
removeAlertChangedListner() {
@@ -549,24 +522,52 @@
this.cdRef.detectChanges();
}
- toggleQueryBuilder() {
- this.setSelectedTimeRange([this.selectedTimeRange]);
- if (!this.hideQueryBuilder) {
- this.hideQueryBuilder = true;
- this.manualQuery.nativeElement.value = this.queryBuilder.query;
+ getStaleDataWarning() {
+ if (this.autoPollingSvc.getIsPollingActive()) {
+ return `<i class="fa fa-warning" aria-hidden="true"></i> Data is in a stale state!
+ Click <i class="fa fa-search" aria-hidden="true"></i> to update your view based
+ on your current filter and time-range configuration!`;
} else {
- this.hideQueryBuilder = false;
+ return `<i class="fa fa-warning" aria-hidden="true"></i> Data is in a stale state!
+ Automatic refresh is turned on. Your filter and/or time-range changes will apply automatically on next refresh.`;
+ }
+ }
+
+ getPollingCongestionWarning() {
+ return `<i class="fa fa-warning" aria-hidden="true"></i> Refresh interval is shorter than the response time.
+ Please increase the refresh interval in the <i class="fa fa-sliders" aria-hidden="true"></i> menu above,
+ or try to simplify your query filter.`;
+ }
+
+ private updatePollingInterval(refreshInterval: number): void {
+ this.autoPollingSvc.setInterval(refreshInterval);
+ }
+
+ private restoreAutoPollingState() {
+ if (this.autoPollingSvc.getIsPollingActive()) {
+ this.autoPollingSvc.setSuppression(false);
+ }
+ }
+
+ isQueryBuilderModeManual() {
+ return this.queryBuilder.getFilteringMode() === FilteringMode.MANUAL;
+ }
+
+ toggleQueryBuilderMode() {
+ // FIXME setting timerange on toggle feels like a hack
+ this.setSelectedTimeRange([this.selectedTimeRange]);
+ if (this.queryBuilder.getFilteringMode() === FilteringMode.BUILDER) {
+ this.queryBuilder.setFilteringMode(FilteringMode.MANUAL);
+ } else {
+ this.queryBuilder.setFilteringMode(FilteringMode.BUILDER);
+ // FIXME: this could lead to a large blocking load depending on the response time
this.queryBuilder.clearSearch();
this.search();
}
}
queryForTreeView() {
- if (!this.hideQueryBuilder) {
- return this.queryBuilder.generateSelect();
- } else {
- return this.manualQuery.nativeElement.value;
- }
+ return this.queryBuilder.query;
}
}
diff --git a/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.module.ts b/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.module.ts
old mode 100644
new mode 100755
index 1126f14..2adcb90
--- a/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.module.ts
+++ b/metron-interface/metron-alerts/src/app/alerts/alerts-list/alerts-list.module.ts
@@ -31,6 +31,9 @@
import { AlertFiltersComponent } from './alert-filters/alert-filters.component';
import { TableViewComponent } from './table-view/table-view.component';
import { TreeViewComponent } from './tree-view/tree-view.component';
+import { ModalLoadingIndicatorComponent } from 'app/shared/modal-loading-indicator/modal-loading-indicator.component';
+import { AutoPollingComponent } from './auto-polling/auto-polling.component';
+import { AutoPollingService } from './auto-polling/auto-polling.service';
@NgModule({
imports: [
@@ -49,8 +52,13 @@
AlertsListComponent,
TableViewComponent,
TreeViewComponent,
- AlertFiltersComponent
+ AlertFiltersComponent,
+ ModalLoadingIndicatorComponent,
+ AutoPollingComponent,
],
- providers: [ DecimalPipe ]
+ providers: [
+ DecimalPipe,
+ AutoPollingService,
+ ]
})
export class AlertsListModule {}
diff --git a/metron-interface/metron-alerts/src/app/alerts/alerts-list/auto-polling/auto-polling.component.html b/metron-interface/metron-alerts/src/app/alerts/alerts-list/auto-polling/auto-polling.component.html
new file mode 100755
index 0000000..a4d925d
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/alerts/alerts-list/auto-polling/auto-polling.component.html
@@ -0,0 +1,17 @@
+<!--
+ 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.
+-->
+<div class="btn pause-play" (click)="onToggle()">
+ <i *ngIf="autoPollingSvc.getIsPollingActive()" class="fa fa-pause" aria-hidden="true"></i>
+ <i *ngIf="!autoPollingSvc.getIsPollingActive()" class="fa fa-play" aria-hidden="true"></i>
+</div>
diff --git a/metron-interface/metron-alerts/src/app/alerts/alerts-list/auto-polling/auto-polling.component.scss b/metron-interface/metron-alerts/src/app/alerts/alerts-list/auto-polling/auto-polling.component.scss
new file mode 100644
index 0000000..ebaeb99
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/alerts/alerts-list/auto-polling/auto-polling.component.scss
@@ -0,0 +1,73 @@
+/**
+ * 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 "../../../../vendor.scss";
+@import "../../../../variables.scss";
+
+.pause-play {
+ height: 38px;
+ padding: 0px;
+ border: 1px solid #0F6F9E;
+ width: 38px;
+ line-height: 35px;
+ border-radius: 12px;
+ margin-left: 15px;
+ background: $mine-shaft-2;
+ cursor: pointer;
+
+ i {
+ font-size: 17px;
+ color: $piction-blue;
+ }
+
+ .fa-play {
+ padding-left: 3px;
+ }
+}
+
+.auto-polling {
+ font-size: 0.9rem;
+
+ button.btn-sm {
+ font-size: 0.75rem;
+ }
+
+ button.btn-light {
+ font-size: 0.75rem;
+ background-color: #e1e1e1;
+ border-color: #d2d2d2;
+ }
+}
+
+.card {
+ width: 270px;
+ position: absolute;
+ left: -34px;
+ z-index: 1;
+ top: 50px;
+ border-radius: 3;
+ background: $mine-shaft-2;
+}
+
+.fa-sort-asc {
+ position: absolute;
+ bottom: -40px;
+ left: 96px;
+ font-size: 42px;
+ color: #333333;
+ z-index: 2;
+}
diff --git a/metron-interface/metron-alerts/src/app/alerts/alerts-list/auto-polling/auto-polling.component.spec.ts b/metron-interface/metron-alerts/src/app/alerts/alerts-list/auto-polling/auto-polling.component.spec.ts
new file mode 100644
index 0000000..21d966f
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/alerts/alerts-list/auto-polling/auto-polling.component.spec.ts
@@ -0,0 +1,80 @@
+/**
+ * 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 { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { AutoPollingComponent } from './auto-polling.component';
+import { AutoPollingService } from './auto-polling.service';
+
+describe('AutoPollingComponent', () => {
+ let component: AutoPollingComponent;
+ let fixture: ComponentFixture<AutoPollingComponent>;
+ let autoPollingSvc: AutoPollingService;
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ declarations: [ AutoPollingComponent ],
+ providers: [
+ { provide: AutoPollingService, useClass: () => { return {
+ getIsPollingActive: () => {},
+ start: () => {},
+ stop: () => {},
+ } } },
+ ],
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(AutoPollingComponent);
+ component = fixture.componentInstance;
+
+ autoPollingSvc = TestBed.get(AutoPollingService);
+
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should have auto polling service injected', () => {
+ expect(component.autoPollingSvc).toBeTruthy();
+ });
+
+ it('toggle should call stop on svc when polling is active', () => {
+ spyOn(autoPollingSvc, 'getIsPollingActive').and.returnValue(true);
+ spyOn(autoPollingSvc, 'stop');
+ spyOn(autoPollingSvc, 'start');
+
+ component.onToggle();
+
+ expect(autoPollingSvc.start).not.toHaveBeenCalled();
+ expect(autoPollingSvc.stop).toHaveBeenCalled();
+ });
+
+ it('toggle should call start on svc when polling is inactive', () => {
+ spyOn(autoPollingSvc, 'getIsPollingActive').and.returnValue(false);
+ spyOn(autoPollingSvc, 'stop');
+ spyOn(autoPollingSvc, 'start');
+
+ component.onToggle();
+
+ expect(autoPollingSvc.start).toHaveBeenCalled();
+ expect(autoPollingSvc.stop).not.toHaveBeenCalled();
+ });
+});
diff --git a/metron-interface/metron-alerts/src/environments/environment.js b/metron-interface/metron-alerts/src/app/alerts/alerts-list/auto-polling/auto-polling.component.ts
old mode 100644
new mode 100755
similarity index 60%
rename from metron-interface/metron-alerts/src/environments/environment.js
rename to metron-interface/metron-alerts/src/app/alerts/alerts-list/auto-polling/auto-polling.component.ts
index 29adadc..2248095
--- a/metron-interface/metron-alerts/src/environments/environment.js
+++ b/metron-interface/metron-alerts/src/app/alerts/alerts-list/auto-polling/auto-polling.component.ts
@@ -1,4 +1,3 @@
-"use strict";
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@@ -16,12 +15,22 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-// The file contents for the current environment will overwrite these during build.
-// The build system defaults to the dev environment which uses `environment.ts`, but if you do
-// `ng build --env=prod` then `environment.prod.ts` will be used instead.
-// The list of which env maps to which file can be found in `angular-cli.json`.
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.environment = {
- production: false,
- indices: null
-};
+import { Component } from '@angular/core';
+import { AutoPollingService } from './auto-polling.service';
+
+@Component({
+ selector: 'app-auto-polling',
+ templateUrl: './auto-polling.component.html',
+ styleUrls: ['./auto-polling.component.scss']
+})
+export class AutoPollingComponent {
+ constructor(public autoPollingSvc: AutoPollingService) {}
+
+ onToggle() {
+ if (!this.autoPollingSvc.getIsPollingActive()) {
+ this.autoPollingSvc.start();
+ } else {
+ this.autoPollingSvc.stop();
+ }
+ }
+}
diff --git a/metron-interface/metron-alerts/src/app/alerts/alerts-list/auto-polling/auto-polling.service.spec.ts b/metron-interface/metron-alerts/src/app/alerts/alerts-list/auto-polling/auto-polling.service.spec.ts
new file mode 100644
index 0000000..366e023
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/alerts/alerts-list/auto-polling/auto-polling.service.spec.ts
@@ -0,0 +1,519 @@
+/**
+ * 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 { AutoPollingService } from './auto-polling.service';
+import { TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { SearchService } from 'app/service/search.service';
+import { Subject, of, throwError } from 'rxjs';
+import { QueryBuilder } from '../query-builder';
+import { SearchResponse } from 'app/model/search-response';
+import { SearchRequest } from 'app/model/search-request';
+import { Spy } from 'jasmine-core';
+import { DialogService } from 'app/service/dialog.service';
+import { RestError } from 'app/model/rest-error';
+import { DialogType } from 'app/model/dialog-type';
+
+class QueryBuilderFake {
+ private _filter = '';
+ query: '*'
+
+ addOrUpdateFilter() {};
+
+ setFilter(filter: string): void {
+ this._filter = filter;
+ };
+
+ get searchRequest(): SearchRequest {
+ return {
+ query: this._filter,
+ fields: [],
+ size: 2,
+ indices: [],
+ from: 0,
+ sort: [],
+ facetFields: [],
+ };
+ };
+}
+
+describe('AutoPollingService', () => {
+
+ let autoPollingService: AutoPollingService;
+ let searchServiceFake: SearchService;
+
+ function getIntervalInMS(): number {
+ return autoPollingService.getInterval() * 1000;
+ }
+
+ beforeEach(() => {
+ localStorage.getItem = () => null;
+ localStorage.setItem = () => {};
+
+ TestBed.configureTestingModule({
+ providers: [
+ AutoPollingService,
+ { provide: DialogService, useClass: () => {} },
+ { provide: SearchService, useClass: () => { return {
+ search: () => of(new SearchResponse()),
+ } } },
+ { provide: QueryBuilder, useClass: QueryBuilderFake },
+ ]
+ });
+
+ autoPollingService = TestBed.get(AutoPollingService);
+ searchServiceFake = TestBed.get(SearchService);
+ });
+
+ afterEach(() => {
+ autoPollingService.onDestroy();
+ });
+
+
+ describe('polling basics', () => {
+ it('should mark polling as active after start', () => {
+ autoPollingService.start();
+ expect(autoPollingService.getIsPollingActive()).toBe(true);
+ });
+
+ it('should mark polling as inactive after stop', () => {
+ autoPollingService.start();
+ expect(autoPollingService.getIsPollingActive()).toBe(true);
+
+ autoPollingService.stop();
+ expect(autoPollingService.getIsPollingActive()).toBe(false);
+ });
+
+ it('should send an initial request on start', () => {
+ spyOn(searchServiceFake, 'search').and.callThrough();
+
+ autoPollingService.start();
+
+ expect(searchServiceFake.search).toHaveBeenCalled();
+ });
+
+ it('should broadcast response to initial request via data subject', () => {
+ autoPollingService.data.subscribe((result) => {
+ expect(result).toEqual(new SearchResponse());
+ });
+
+ autoPollingService.start();
+ });
+
+ it('should start polling when start called', fakeAsync(() => {
+ spyOn(searchServiceFake, 'search').and.callThrough();
+
+ autoPollingService.start();
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(2);
+
+ autoPollingService.stop();
+ }));
+
+ it('should broadcast polling response via data subject', fakeAsync(() => {
+ const searchObservableFake = new Subject<SearchResponse>();
+ const pollResponseFake = new SearchResponse();
+
+ autoPollingService.start();
+ // The reason am mocking the searchService.search here is to not interfere
+ // with the initial request triggered right after the start
+ searchServiceFake.search = () => searchObservableFake;
+
+ autoPollingService.data.subscribe((result) => {
+ expect(result).toBe(pollResponseFake);
+ autoPollingService.stop();
+ });
+
+ tick(autoPollingService.getInterval() * 1000);
+
+ searchObservableFake.next(pollResponseFake);
+ }));
+
+ it('should polling and broadcasting based on the interval', fakeAsync(() => {
+ const searchObservableFake = new Subject<SearchResponse>();
+ const broadcastObserverSpy = jasmine.createSpy('broadcastObserverSpy');
+ const testInterval = 2;
+
+ autoPollingService.setInterval(testInterval);
+ autoPollingService.start();
+
+ // The reason am mocking the searchService.search here is to not interfere
+ // with the initial request triggered right after the start
+ searchServiceFake.search = () => searchObservableFake;
+ spyOn(searchServiceFake, 'search').and.callThrough();
+
+ autoPollingService.data.subscribe(broadcastObserverSpy);
+
+ tick(testInterval * 1000);
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(1);
+
+ searchObservableFake.next({ total: 2 } as SearchResponse);
+ expect(broadcastObserverSpy).toHaveBeenCalledTimes(1);
+ expect(broadcastObserverSpy.calls.argsFor(0)[0]).toEqual({ total: 2 });
+
+ tick(testInterval * 1000);
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(2);
+
+ searchObservableFake.next({ total: 3 } as SearchResponse);
+ expect(broadcastObserverSpy).toHaveBeenCalledTimes(2);
+ expect(broadcastObserverSpy.calls.argsFor(1)[0]).toEqual({ total: 3 });
+
+ autoPollingService.stop();
+ }));
+
+ it('interval change should impact the polling even when it is active', fakeAsync(() => {
+ autoPollingService.start();
+
+ // The reason am mocking the searchService.search here is to not interfere
+ // with the initial request triggered right after the start
+ spyOn(searchServiceFake, 'search').and.callThrough();
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(1);
+
+ autoPollingService.setInterval(9);
+
+ tick(4000);
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(1);
+
+ tick(5000);
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(2);
+
+ autoPollingService.setInterval(2);
+
+ tick(1000);
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(2);
+
+ tick(1000);
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(3);
+
+ autoPollingService.stop();
+ }));
+
+ it('should stop polling when stop triggered', fakeAsync(() => {
+ const searchObservableFake = new Subject<SearchResponse>();
+ const broadcastObserverSpy = jasmine.createSpy('broadcastObserverSpy');
+
+ autoPollingService.start();
+
+ // The reason am mocking the searchService.search here is to not interfere
+ // with the initial request triggered right after the start
+ searchServiceFake.search = () => searchObservableFake;
+ spyOn(searchServiceFake, 'search').and.callThrough();
+
+ autoPollingService.data.subscribe(broadcastObserverSpy);
+
+ tick(getIntervalInMS());
+ searchObservableFake.next({ total: 3 } as SearchResponse);
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(1);
+
+ autoPollingService.stop();
+
+ tick(getIntervalInMS() * 4);
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(1);
+ }));
+
+ it('should use the latest query from query builder', fakeAsync(() => {
+ const queryBuilderFake = TestBed.get(QueryBuilder);
+ spyOn(searchServiceFake, 'search').and.callThrough();
+
+ queryBuilderFake.setFilter('testFieldAA:testValueAA');
+ autoPollingService.start();
+ expect((searchServiceFake.search as Spy).calls.argsFor(0)[0].query).toBe('testFieldAA:testValueAA');
+
+ queryBuilderFake.setFilter('testFieldBB:testValueBB');
+ tick(getIntervalInMS());
+ expect((searchServiceFake.search as Spy).calls.argsFor(1)[0].query).toBe('testFieldBB:testValueBB');
+
+ queryBuilderFake.setFilter('*');
+ tick(getIntervalInMS());
+ expect((searchServiceFake.search as Spy).calls.argsFor(2)[0].query).toBe('*');
+
+ autoPollingService.stop();
+ }));
+
+ it('should show notification on http error', fakeAsync(() => {
+ const fakeDialogService = TestBed.get(DialogService);
+ fakeDialogService.launchDialog = () => {};
+ spyOn(fakeDialogService, 'launchDialog');
+
+ autoPollingService.start();
+
+ spyOn(searchServiceFake, 'search').and.returnValue(throwError(new RestError()));
+
+ tick(getIntervalInMS());
+
+ expect(fakeDialogService.launchDialog).toHaveBeenCalledWith(
+ 'Server were unable to apply query string. Evaluate query string and restart polling.',
+ DialogType.Error
+ );
+
+ autoPollingService.stop();
+ }));
+ });
+
+ describe('polling suppression - to prevent collision with other features', () => {
+ it('should suspend polling even if it is started', fakeAsync(() => {
+ spyOn(searchServiceFake, 'search').and.callThrough();
+
+ autoPollingService.start();
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(1);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(2);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(3);
+
+ autoPollingService.setSuppression(true);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(3);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(3);
+
+ autoPollingService.stop();
+ }));
+
+ it('should continue polling when freed from suppression if it is started ', fakeAsync(() => {
+ spyOn(searchServiceFake, 'search').and.callThrough();
+
+ autoPollingService.start();
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(1);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(2);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(3);
+
+ autoPollingService.setSuppression(true);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(3);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(3);
+
+ autoPollingService.setSuppression(false);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(4);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(5);
+
+ autoPollingService.stop();
+ }));
+
+ it('should have no impact when polling stopped', fakeAsync(() => {
+ spyOn(searchServiceFake, 'search').and.callThrough();
+
+ autoPollingService.start();
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(1);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(2);
+
+ autoPollingService.stop();
+ autoPollingService.setSuppression(true);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(2);
+
+ autoPollingService.setSuppression(false);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(2);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(2);
+ }));
+ });
+
+ describe('request congestion handling - when refresh interval faster than response time', () => {
+ it('should skip new poll request when there is congestion', fakeAsync(() => {
+ const searchObservableFake = new Subject<SearchResponse>();
+
+ searchServiceFake.search = () => searchObservableFake;
+ spyOn(searchServiceFake, 'search').and.callThrough();
+
+ autoPollingService.start();
+
+ searchObservableFake.next({ total: 2 } as SearchResponse);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(2);
+ expect(autoPollingService.getIsCongestion()).toBe(false);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(2);
+ expect(autoPollingService.getIsCongestion()).toBe(true);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(2);
+ expect(autoPollingService.getIsCongestion()).toBe(true);
+
+ autoPollingService.stop();
+ }));
+
+ it('should continue polling when congestion resolves', fakeAsync(() => {
+ const searchObservableFake = new Subject<SearchResponse>();
+
+ searchServiceFake.search = () => searchObservableFake;
+ spyOn(searchServiceFake, 'search').and.callThrough();
+
+ autoPollingService.start();
+
+ searchObservableFake.next({ total: 2 } as SearchResponse);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(2);
+ expect(autoPollingService.getIsCongestion()).toBe(false);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(2);
+ expect(autoPollingService.getIsCongestion()).toBe(true);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(2);
+ expect(autoPollingService.getIsCongestion()).toBe(true);
+
+ searchObservableFake.next({ total: 2 } as SearchResponse);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(3);
+ expect(autoPollingService.getIsCongestion()).toBe(false);
+
+ autoPollingService.stop();
+ }));
+ });
+
+ describe('cancellation by manual request', () => {
+
+ it('should be able to drop current response and continue polling', fakeAsync(() => {
+ const broadcastObserverSpy = jasmine.createSpy('broadcastObserverSpy');
+ const searchObservableFake = new Subject<SearchResponse>();
+
+ autoPollingService.start();
+
+ searchServiceFake.search = () => searchObservableFake;
+ spyOn(searchServiceFake, 'search').and.callThrough();
+
+ autoPollingService.data.subscribe(broadcastObserverSpy);
+
+ tick(getIntervalInMS());
+
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(1);
+ searchObservableFake.next({ total: 2 } as SearchResponse);
+ expect(broadcastObserverSpy).toHaveBeenCalledTimes(1);
+
+ tick(getIntervalInMS() / 2);
+ autoPollingService.dropNextAndContinue();
+ tick(getIntervalInMS() / 2);
+
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(1);
+ searchObservableFake.next({ total: 3 } as SearchResponse);
+ expect(broadcastObserverSpy).toHaveBeenCalledTimes(1);
+
+ tick(getIntervalInMS());
+
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(2);
+ searchObservableFake.next({ total: 4 } as SearchResponse);
+ expect(broadcastObserverSpy).toHaveBeenCalledTimes(2);
+
+ autoPollingService.stop();
+ }));
+
+ });
+
+ describe('polling state persisting and restoring', () => {
+
+ it('should persist polling state on start', () => {
+ spyOn(localStorage, 'setItem');
+ autoPollingService.start();
+ expect(localStorage.setItem).toHaveBeenCalledWith('autoPolling', '{"isActive":true,"refreshInterval":10}');
+ });
+
+ it('should persist polling state on stop', () => {
+ spyOn(localStorage, 'setItem');
+ autoPollingService.stop();
+ expect(localStorage.setItem).toHaveBeenCalledWith('autoPolling', '{"isActive":false,"refreshInterval":10}');
+ });
+
+ it('should persist polling state on interval change', () => {
+ spyOn(localStorage, 'setItem');
+ autoPollingService.setInterval(4);
+ expect(localStorage.setItem).toHaveBeenCalledWith('autoPolling', '{"isActive":false,"refreshInterval":4}');
+ });
+
+ it('should restore polling state on construction', () => {
+ const queryBuilderFake = TestBed.get(QueryBuilder);
+ const dialogServiceFake = TestBed.get(QueryBuilder);
+
+ spyOn(localStorage, 'getItem').and.returnValue('{"isActive":true,"refreshInterval":443}');
+
+ const localAutoPollingSvc = new AutoPollingService(searchServiceFake, queryBuilderFake, dialogServiceFake);
+
+ expect(localStorage.getItem).toHaveBeenCalledWith('autoPolling');
+ expect(localAutoPollingSvc.getIsPollingActive()).toBe(true);
+ expect(localAutoPollingSvc.getInterval()).toBe(443);
+ });
+
+ it('should start polling on construction when persisted isActive==true', fakeAsync(() => {
+ const queryBuilderFake = TestBed.get(QueryBuilder);
+ const dialogServiceFake = TestBed.get(QueryBuilder);
+
+ spyOn(searchServiceFake, 'search').and.callThrough();
+ spyOn(localStorage, 'getItem').and.returnValue('{"isActive":true,"refreshInterval":10}');
+
+ const localAutoPollingSvc = new AutoPollingService(searchServiceFake, queryBuilderFake, dialogServiceFake);
+
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(1);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(2);
+
+ tick(getIntervalInMS());
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(3);
+
+ localAutoPollingSvc.stop();
+ }));
+
+ it('should start polling on construction with the persisted interval', fakeAsync(() => {
+ const queryBuilderFake = TestBed.get(QueryBuilder);
+ const dialogServiceFake = TestBed.get(QueryBuilder);
+
+ spyOn(searchServiceFake, 'search').and.callThrough();
+ spyOn(localStorage, 'getItem').and.returnValue('{"isActive":true,"refreshInterval":4}');
+
+ const localAutoPollingSvc = new AutoPollingService(searchServiceFake, queryBuilderFake, dialogServiceFake);
+
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(1);
+
+ tick(4000);
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(2);
+
+ tick(4000);
+ expect(searchServiceFake.search).toHaveBeenCalledTimes(3);
+
+ localAutoPollingSvc.stop();
+ }));
+ });
+
+});
diff --git a/metron-interface/metron-alerts/src/app/alerts/alerts-list/auto-polling/auto-polling.service.ts b/metron-interface/metron-alerts/src/app/alerts/alerts-list/auto-polling/auto-polling.service.ts
new file mode 100755
index 0000000..1530109
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/alerts/alerts-list/auto-polling/auto-polling.service.ts
@@ -0,0 +1,184 @@
+/**
+ * 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 { Subscription, Subject, Observable, interval, onErrorResumeNext } from 'rxjs';
+import { SearchService } from 'app/service/search.service';
+import { QueryBuilder } from '../query-builder';
+import { SearchResponse } from 'app/model/search-response';
+import { switchMap, filter, takeWhile, tap } from 'rxjs/operators';
+import { POLLING_DEFAULT_STATE } from 'app/utils/constants';
+import { RestError } from 'app/model/rest-error';
+import { DialogType } from 'app/shared/metron-dialog/metron-dialog.component';
+import { DialogService } from 'app/service/dialog.service';
+
+interface AutoPollingStateModel {
+ isActive: boolean,
+ refreshInterval: number,
+}
+
+@Injectable()
+export class AutoPollingService {
+ data = new Subject<SearchResponse>();
+
+ private isCongestion = false;
+ private refreshInterval = 10;
+ private isPollingActive = POLLING_DEFAULT_STATE;
+ private isPending = false;
+ private isPollingSuppressed = false;
+ private pollingIntervalSubs: Subscription;
+
+ public readonly AUTO_POLLING_STORAGE_KEY = 'autoPolling';
+
+ constructor(private searchService: SearchService,
+ private queryBuilder: QueryBuilder,
+ private dialogService: DialogService,
+ ) {
+ this.restoreState();
+ }
+
+ start() {
+ if (!this.isPollingActive) {
+ this.sendInitial();
+ this.activate();
+ }
+ this.isPollingActive = true;
+ this.persistState();
+ }
+
+ stop(persist = true) {
+ this.isPollingActive = false;
+ if (this.pollingIntervalSubs) {
+ this.pollingIntervalSubs.unsubscribe();
+ this.pollingIntervalSubs = null;
+ }
+
+ if (persist) {
+ this.persistState();
+ }
+ }
+
+ setSuppression(value: boolean) {
+ this.isPollingSuppressed = value;
+ }
+
+ dropNextAndContinue() {
+ this.reset();
+ }
+
+ setInterval(seconds: number) {
+ this.refreshInterval = seconds;
+ if (this.isPollingActive) {
+ this.reset();
+ }
+ this.persistState();
+ }
+
+ getInterval(): number {
+ return this.refreshInterval;
+ }
+
+ getIsPollingActive() {
+ return this.isPollingActive;
+ }
+
+ getIsCongestion() {
+ return this.isCongestion
+ }
+
+ private sendInitial() {
+ this.isPending = true;
+ this.searchService.search(this.queryBuilder.searchRequest).subscribe(this.onResult.bind(this));
+ }
+
+ private persistState(key = this.AUTO_POLLING_STORAGE_KEY): void {
+ localStorage.setItem(key, JSON.stringify(this.getStateModel()));
+ }
+
+ private restoreState(key = this.AUTO_POLLING_STORAGE_KEY): void {
+ const persistedState = JSON.parse(localStorage.getItem(key)) as AutoPollingStateModel;
+
+ if (persistedState) {
+ this.refreshInterval = persistedState.refreshInterval;
+
+ if (persistedState.isActive) {
+ this.start();
+ }
+ }
+ }
+
+ private getStateModel(): AutoPollingStateModel {
+ return {
+ isActive: this.isPollingActive,
+ refreshInterval: this.refreshInterval,
+ }
+ }
+
+ private reset() {
+ if (this.pollingIntervalSubs) {
+ this.pollingIntervalSubs.unsubscribe();
+ this.isPending = false;
+ }
+ this.activate();
+ }
+
+ private activate() {
+ this.pollingIntervalSubs = this.startPolling()
+ .subscribe(
+ this.onResult.bind(this),
+ this.onError.bind(this),
+ );
+ }
+
+ private onError(error: RestError) {
+ this.stop();
+ this.dialogService.launchDialog(
+ 'Server were unable to apply query string. ' +
+ 'Evaluate query string and restart polling.'
+ , DialogType.Error);
+ }
+
+ private onResult(result: SearchResponse) {
+ this.data.next(result);
+ this.isPending = false;
+ }
+
+ private startPolling(): Observable<SearchResponse> {
+ return interval(this.refreshInterval * 1000).pipe(
+ tap(() => this.checkCongestionOnTick()),
+ filter(() => !this.isPollingSuppressed && !this.isCongestion),
+ takeWhile(() => this.isPollingActive),
+ switchMap(() => {
+ this.isPending = true;
+ return this.searchService.search(this.queryBuilder.searchRequest);
+ }));
+ }
+
+ private checkCongestionOnTick() {
+ if (this.isPending) {
+ this.isCongestion = true;
+ } else {
+ this.isCongestion = false;
+ }
+ }
+
+ onDestroy() {
+ if (this.getIsPollingActive()) {
+ this.stop(false);
+ }
+ }
+}
diff --git a/metron-interface/metron-alerts/src/app/alerts/alerts-list/query-builder.spec.ts b/metron-interface/metron-alerts/src/app/alerts/alerts-list/query-builder.spec.ts
index 20f0ac4..6c12ed7 100644
--- a/metron-interface/metron-alerts/src/app/alerts/alerts-list/query-builder.spec.ts
+++ b/metron-interface/metron-alerts/src/app/alerts/alerts-list/query-builder.spec.ts
@@ -15,17 +15,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { QueryBuilder } from './query-builder';
+import { QueryBuilder, FilteringMode } from './query-builder';
import { Filter } from 'app/model/filter';
import { TIMESTAMP_FIELD_NAME } from '../../utils/constants';
import { Utils } from 'app/utils/utils';
-describe('query-builder', () => {
+describe('QueryBuilder', () => {
+ let queryBuilder: QueryBuilder;
+
+ beforeEach(() => {
+ queryBuilder = new QueryBuilder();
+ });
it('should be able to handle multiple filters', () => {
- const queryBuilder = new QueryBuilder();
-
queryBuilder.setSearch('alert_status:RESOLVE AND ip_src_addr:0.0.0.0');
expect(queryBuilder.searchRequest.query).toBe(
@@ -34,8 +37,6 @@
});
it('should be able to handle multiple EXCLUDING filters for the same field', () => {
- const queryBuilder = new QueryBuilder();
-
queryBuilder.setSearch('-alert_status:RESOLVE AND -alert_status:DISMISS');
expect(queryBuilder.searchRequest.query).toBe(
@@ -44,8 +45,6 @@
});
it('should be able to handle group multiple clauses to a single field, aka. field grouping', () => {
- const queryBuilder = new QueryBuilder();
-
queryBuilder.setSearch('alert_status:(RESOLVE OR DISMISS)');
expect(queryBuilder.searchRequest.query).toBe(
@@ -54,8 +53,6 @@
});
it('should trim whitespace', () => {
- const queryBuilder = new QueryBuilder();
-
queryBuilder.setSearch(' alert_status:(RESOLVE OR DISMISS) ');
expect(queryBuilder.searchRequest.query).toBe(
@@ -64,8 +61,6 @@
});
it('should remove wildcard', () => {
- const queryBuilder = new QueryBuilder();
-
queryBuilder.setSearch('* alert_status:(RESOLVE OR DISMISS)');
expect(queryBuilder.searchRequest.query).toBe(
@@ -74,8 +69,6 @@
});
it('should properly parse excluding filters event with wildcard and whitespaces', () => {
- const queryBuilder = new QueryBuilder();
-
queryBuilder.setSearch('* -alert_status:(RESOLVE OR DISMISS)');
expect(queryBuilder.searchRequest.query).toBe(
@@ -84,8 +77,6 @@
});
it('should remove wildcard from an excluding filter', () => {
- const queryBuilder = new QueryBuilder();
-
queryBuilder.setSearch('* -alert_status:(RESOLVE OR DISMISS)');
expect(queryBuilder.searchRequest.query).toBe(
@@ -94,26 +85,20 @@
});
it('should allow only one timerange filter', () => {
- const queryBuilder = new QueryBuilder();
-
queryBuilder.addOrUpdateFilter(new Filter(TIMESTAMP_FIELD_NAME, '[1552863600000 TO 1552950000000]'));
queryBuilder.addOrUpdateFilter(new Filter(TIMESTAMP_FIELD_NAME, '[1552863700000 TO 1552960000000]'));
- expect(queryBuilder.generateSelect()).toBe('(timestamp:[1552863700000 TO 1552960000000] OR ' +
+ expect(queryBuilder.query).toBe('(timestamp:[1552863700000 TO 1552960000000] OR ' +
'metron_alert.timestamp:[1552863700000 TO 1552960000000])');
});
it('should escape : chars in ElasticSearch field names', () => {
- const queryBuilder = new QueryBuilder();
-
queryBuilder.setSearch('source:type:bro');
expect(queryBuilder.searchRequest.query).toBe('(source\\:type:bro OR metron_alert.source\\:type:bro)');
});
it('should escape ALL : chars in ElasticSearch field names', () => {
- const queryBuilder = new QueryBuilder();
-
queryBuilder.setSearch('enrichments:geo:ip_dst_addr:country:US');
expect(queryBuilder.searchRequest.query).toBe('(enrichments\\:geo\\:ip_dst_addr\\:country:US ' +
@@ -121,8 +106,6 @@
});
it('should not multiply escaping in field name', () => {
- const queryBuilder = new QueryBuilder();
-
queryBuilder.setSearch('source:type:bro');
queryBuilder.setSearch('source:type:bro');
queryBuilder.setSearch('source:type:bro');
@@ -131,8 +114,6 @@
});
it('removeFilter should remove filter by reference', () => {
- const queryBuilder = new QueryBuilder();
-
const filter1 = new Filter(TIMESTAMP_FIELD_NAME, '[1552863600000 TO 1552950000000]');
const filter2 = new Filter('fieldName', 'value');
@@ -146,8 +127,6 @@
});
it('removeFilterByField should remove filter having the passed field name', () => {
- const queryBuilder = new QueryBuilder();
-
const filter1 = new Filter('fruit', 'banana');
const filter2 = new Filter('fruit', 'orange');
const filter3 = new Filter('animal', 'horse');
@@ -162,4 +141,91 @@
expect(queryBuilder.filters[0]).toBe(filter3);
});
+ describe('filter query builder modes', () => {
+ it('should have a getter for filtering mode', () => {
+ expect(typeof queryBuilder.getFilteringMode).toBe('function');
+ });
+
+ it('should have a setter for filtering mode', () => {
+ expect(typeof queryBuilder.setFilteringMode).toBe('function');
+
+ expect(queryBuilder.getFilteringMode()).toBe(FilteringMode.BUILDER);
+
+ queryBuilder.setFilteringMode(FilteringMode.MANUAL);
+ expect(queryBuilder.getFilteringMode()).toBe(FilteringMode.MANUAL);
+
+ queryBuilder.setFilteringMode(FilteringMode.BUILDER);
+ expect(queryBuilder.getFilteringMode()).toBe(FilteringMode.BUILDER);
+ });
+
+ it('filtering mode should be builder by default', () => {
+ expect(queryBuilder.getFilteringMode()).toBe(FilteringMode.BUILDER);
+ });
+
+ it('should have a getter for manual query', () => {
+ expect(typeof queryBuilder.getManualQuery).toBe('function');
+ });
+
+ it('should have a setter for manual query string', () => {
+ expect(typeof queryBuilder.setManualQuery).toBe('function');
+
+ queryBuilder.setManualQuery('test manual query');
+ expect(queryBuilder.getManualQuery()).toBe('test manual query');
+
+ queryBuilder.setManualQuery('another test manual query');
+ expect(queryBuilder.getManualQuery()).toBe('another test manual query');
+ });
+
+ it('getManualQuery should return the built query string first', () => {
+ const expected = '(timestamp:[1552863600000 TO 1552950000000] OR metron_alert.timestamp:[1552863600000 ' +
+ 'TO 1552950000000]) AND (animal:horse OR metron_alert.animal:horse)';
+
+ queryBuilder.clearSearch();
+
+ queryBuilder.addOrUpdateFilter(new Filter(TIMESTAMP_FIELD_NAME, '[1552863600000 TO 1552950000000]'));
+ queryBuilder.addOrUpdateFilter(new Filter('animal', 'horse'));
+
+ expect(queryBuilder.getManualQuery()).toBe(expected);
+
+ queryBuilder.setManualQuery('test:query');
+
+ expect(queryBuilder.getManualQuery()).toBe('test:query');
+ });
+
+ it('should use manual query string value in manual mode', () => {
+ queryBuilder.addOrUpdateFilter(new Filter(TIMESTAMP_FIELD_NAME, '[1552863600000 TO 1552950000000]'));
+ queryBuilder.addOrUpdateFilter(new Filter('animal', 'horse'));
+
+ queryBuilder.setFilteringMode(FilteringMode.MANUAL);
+ queryBuilder.setManualQuery('test:query');
+
+ expect(queryBuilder.searchRequest.query).toBe('test:query');
+ });
+
+ it('should use built query string value in builder mode', () => {
+ queryBuilder.addOrUpdateFilter(new Filter(TIMESTAMP_FIELD_NAME, '[1552863600000 TO 1552950000000]'));
+ queryBuilder.addOrUpdateFilter(new Filter('animal', 'horse'));
+
+ queryBuilder.setFilteringMode(FilteringMode.BUILDER);
+ queryBuilder.setManualQuery('test:query');
+
+ expect(queryBuilder.searchRequest.query).toBe(
+ '(timestamp:[1552863600000 TO 1552950000000] OR metron_alert.timestamp:[1552863600000 ' +
+ 'TO 1552950000000]) AND (animal:horse OR metron_alert.animal:horse)'
+ );
+ });
+
+ it('clearSearch should clear manual query value', () => {
+ queryBuilder.setFilteringMode(FilteringMode.MANUAL);
+ queryBuilder.setManualQuery('manual:test:query');
+
+ expect(queryBuilder.getManualQuery()).toBe('manual:test:query');
+
+ queryBuilder.clearSearch();
+
+ expect(queryBuilder.getManualQuery()).toBe('*');
+ });
+
+ });
+
});
diff --git a/metron-interface/metron-alerts/src/app/alerts/alerts-list/query-builder.ts b/metron-interface/metron-alerts/src/app/alerts/alerts-list/query-builder.ts
index a55a609..500cbb5 100644
--- a/metron-interface/metron-alerts/src/app/alerts/alerts-list/query-builder.ts
+++ b/metron-interface/metron-alerts/src/app/alerts/alerts-list/query-builder.ts
@@ -24,20 +24,27 @@
import {Group} from '../../model/group';
import { Injectable } from '@angular/core';
+export enum FilteringMode {
+ MANUAL = 'FilteringModeIsManual',
+ BUILDER = 'FilteringModeIsBuilder',
+}
+
@Injectable()
export class QueryBuilder {
private _searchRequest = new SearchRequest();
private _groupRequest = new GroupRequest();
- private _query = '*';
- private _displayQuery = this._query;
+
+ private _manualQuery;
private _filters: Filter[] = [];
+ private filteringMode: FilteringMode = FilteringMode.BUILDER;
+
get query(): string {
- return this._query;
+ return this.searchRequest.query;
}
get displayQuery(): string {
- return this._displayQuery;
+ return this.generateSelectForDisplay();
}
set filters(filters: Filter[]) {
@@ -51,7 +58,7 @@
}
get searchRequest(): SearchRequest {
- this._searchRequest.query = this.generateSelect();
+ this._searchRequest.query = this.getQueryString() || '*';
return this._searchRequest;
}
@@ -61,19 +68,18 @@
}
groupRequest(scoreField): GroupRequest {
- this._groupRequest.query = this.generateSelect();
+ this._groupRequest.query = this.getQueryString() || '*';
this._groupRequest.scoreField = scoreField;
return this._groupRequest;
}
setSearch(query: string) {
this.updateFilters(query, true);
- this.onSearchChange();
}
clearSearch() {
this._filters = [];
- this.onSearchChange();
+ this._manualQuery = null;
}
addOrUpdateFilter(filter: Filter) {
@@ -85,7 +91,6 @@
this.removeFilter(existingTimeRangeFilter);
}
this._filters.push(filter);
- this.onSearchChange();
return;
}
@@ -102,13 +107,18 @@
} else {
this._filters.push(filter);
}
-
- this.onSearchChange();
}
- generateSelect() {
- let select = this._filters.map(filter => filter.getQueryString()).join(' AND ');
- return (select.length === 0) ? '*' : select;
+ private getQueryString() {
+ if (this.filteringMode === FilteringMode.MANUAL) {
+ return this.getManualQuery();
+ } else {
+ return this.getBuilderQueryString();
+ }
+ }
+
+ private getBuilderQueryString() {
+ return this._filters.map(filter => filter.getQueryString()).join(' AND ');
}
generateNameForSearchRequest() {
@@ -117,41 +127,26 @@
}
generateSelectForDisplay() {
- let appliedFilters = [];
- this._filters.reduce((appliedFilters, filter) => {
+ return this._filters.reduce((appliedFilters, filter) => {
if (filter.display) {
appliedFilters.push(ColumnNamesService.getColumnDisplayValue(filter.field) + ':' + filter.value);
}
-
return appliedFilters;
- }, appliedFilters);
-
- let select = appliedFilters.join(' AND ');
- return (select.length === 0) ? '*' : select;
+ }, []).join(' AND ') || '*';
}
isTimeStampFieldPresent(): boolean {
return this._filters.some(filter => (filter.field === TIMESTAMP_FIELD_NAME && !isNaN(Number(filter.value))));
}
- onSearchChange() {
- this._query = this.generateSelect();
- this._displayQuery = this.generateSelectForDisplay();
- }
-
removeFilter(filter: Filter) {
this._filters = this._filters.filter(fItem => fItem !== filter );
- this.onSearchChange();
}
removeFilterByField(field: string): void {
this._filters = this._filters.filter(fItem => fItem.field !== field );
}
- setFields(fieldNames: string[]) {
- // this.searchRequest._source = fieldNames;
- }
-
setFromAndSize(from: number, size: number) {
this.searchRequest.from = from;
this.searchRequest.size = size;
@@ -166,6 +161,25 @@
this.searchRequest.sort = [sortField];
}
+ setFilteringMode(mode: FilteringMode) {
+ this.filteringMode = mode;
+ }
+
+ getFilteringMode() {
+ return this.filteringMode;
+ }
+
+ setManualQuery(query: string) {
+ this._manualQuery = query;
+ }
+
+ getManualQuery(): string {
+ if (!this._manualQuery) {
+ this._manualQuery = this.getBuilderQueryString() || '*';
+ }
+ return this._manualQuery;
+ }
+
private updateFilters(query: string, updateNameTransform = false) {
this.removeDisplayedFilters();
diff --git a/metron-interface/metron-alerts/src/app/alerts/alerts-list/tree-view/tree-view.component.ts b/metron-interface/metron-alerts/src/app/alerts/alerts-list/tree-view/tree-view.component.ts
index ef3cc35..61d3a49 100644
--- a/metron-interface/metron-alerts/src/app/alerts/alerts-list/tree-view/tree-view.component.ts
+++ b/metron-interface/metron-alerts/src/app/alerts/alerts-list/tree-view/tree-view.component.ts
@@ -39,7 +39,6 @@
import { DialogType } from 'app/model/dialog-type';
import { ConfirmationType } from 'app/model/confirmation-type';
import { AlertSource } from '../../../model/alert-source';
-import { QueryBuilder } from '../query-builder';
import { GroupRequest } from 'app/model/group-request';
import { Group } from 'app/model/group';
import { TimezoneConfigService } from 'app/alerts/configure-rows/timezone-config/timezone-config.service';
diff --git a/metron-interface/metron-alerts/src/app/alerts/configure-rows/configure-rows.component.html b/metron-interface/metron-alerts/src/app/alerts/configure-rows/configure-rows.component.html
index 98c62fd..ed3e988 100644
--- a/metron-interface/metron-alerts/src/app/alerts/configure-rows/configure-rows.component.html
+++ b/metron-interface/metron-alerts/src/app/alerts/configure-rows/configure-rows.component.html
@@ -17,28 +17,28 @@
<h6 class="card-title">Settings</h6>
<form>
<label> REFRESH RATE </label>
- <div #refreshInterval class="preset-row refresh-interval" (click)="onRefreshIntervalChange($event, refreshInterval)">
- <div class="preset-cell" [ngClass]="{'is-active': tableMetadata.refreshInterval===5}" [attr.value]="5"> 5s </div>
- <div class="preset-cell" [ngClass]="{'is-active': tableMetadata.refreshInterval===10}" [attr.value]="10"> 10s </div>
- <div class="preset-cell" [ngClass]="{'is-active': tableMetadata.refreshInterval===15}" [attr.value]="15"> 15s </div>
- <div class="preset-cell" [ngClass]="{'is-active': tableMetadata.refreshInterval===30}" [attr.value]="30"> 30s </div>
- <div class="preset-cell" [ngClass]="{'is-active': tableMetadata.refreshInterval===60}" [attr.value]="60"> 1m </div>
- <div class="preset-cell" [ngClass]="{'is-active': tableMetadata.refreshInterval===600}" [attr.value]="600"> 10m </div>
- <div class="preset-cell" [ngClass]="{'is-active': tableMetadata.refreshInterval===3600}" [attr.value]="3600"> 1h </div>
+ <div #refreshIntervalEl class="preset-row refresh-interval" (click)="onRefreshIntervalChange($event, refreshIntervalEl)">
+ <div class="preset-cell" [class.is-active]="refreshInterval===5" [attr.value]="5"> 5s </div>
+ <div class="preset-cell" [class.is-active]="refreshInterval===10" [attr.value]="10"> 10s </div>
+ <div class="preset-cell" [class.is-active]="refreshInterval===15" [attr.value]="15"> 15s </div>
+ <div class="preset-cell" [class.is-active]="refreshInterval===30" [attr.value]="30"> 30s </div>
+ <div class="preset-cell" [class.is-active]="refreshInterval===60" [attr.value]="60"> 1m </div>
+ <div class="preset-cell" [class.is-active]="refreshInterval===600" [attr.value]="600"> 10m </div>
+ <div class="preset-cell" [class.is-active]="refreshInterval===3600" [attr.value]="3600"> 1h </div>
</div>
<label> ROWS PER PAGE </label>
- <div #pageSize class="preset-row page-size" (click)="onPageSizeChange($event, pageSize)">
- <div class="preset-cell" [ngClass]="{'is-active': tableMetadata.size===10}"> 10 </div>
- <div class="preset-cell" [ngClass]="{'is-active': tableMetadata.size===25}"> 25 </div>
- <div class="preset-cell" [ngClass]="{'is-active': tableMetadata.size===50}"> 50 </div>
- <div class="preset-cell" [ngClass]="{'is-active': tableMetadata.size===100}"> 100 </div>
- <div class="preset-cell" [ngClass]="{'is-active': tableMetadata.size===250}"> 250 </div>
- <div class="preset-cell" [ngClass]="{'is-active': tableMetadata.size===500}"> 500 </div>
- <div class="preset-cell" [ngClass]="{'is-active': tableMetadata.size===1000}"> 1000 </div>
+ <div #pageSizeEl class="preset-row page-size" (click)="onPageSizeChange($event, pageSizeEl)">
+ <div class="preset-cell" [class.is-active]="pageSize===10"> 10 </div>
+ <div class="preset-cell" [class.is-active]="pageSize===25"> 25 </div>
+ <div class="preset-cell" [class.is-active]="pageSize===50"> 50 </div>
+ <div class="preset-cell" [class.is-active]="pageSize===100"> 100 </div>
+ <div class="preset-cell" [class.is-active]="pageSize===250"> 250 </div>
+ <div class="preset-cell" [class.is-active]="pageSize===500"> 500 </div>
+ <div class="preset-cell" [class.is-active]="pageSize===1000"> 1000 </div>
</div>
<label> HIDE ALERT ENTRIES </label>
- <app-show-hide-alert-entries (changed)="configRowsChange.emit($event)" ></app-show-hide-alert-entries>
+ <app-show-hide-alert-entries (changed)="onShowHideChange($event)" ></app-show-hide-alert-entries>
<label class="pt-2"> TIMEZONE CONFIGURATION </label>
<app-timezone-config></app-timezone-config>
diff --git a/metron-interface/metron-alerts/src/app/alerts/configure-rows/configure-rows.component.ts b/metron-interface/metron-alerts/src/app/alerts/configure-rows/configure-rows.component.ts
index f643e51..61c1693 100644
--- a/metron-interface/metron-alerts/src/app/alerts/configure-rows/configure-rows.component.ts
+++ b/metron-interface/metron-alerts/src/app/alerts/configure-rows/configure-rows.component.ts
@@ -16,53 +16,29 @@
* limitations under the License.
*/
import { Component, Input, HostListener, ElementRef, Output, EventEmitter } from '@angular/core';
-import { TableMetadata } from '../../model/table-metadata';
-import { ConfigureTableService } from '../../service/configure-table.service';
+export interface ConfigureRowsModel {
+ values: {
+ pageSize: number;
+ refreshInterval: number;
+ },
+ triggerQuery: boolean
+}
@Component({
selector: 'app-configure-rows',
templateUrl: './configure-rows.component.html',
styleUrls: ['./configure-rows.component.scss']
})
export class ConfigureRowsComponent {
-
showView = false;
- tableMetadata = new TableMetadata();
@Input() srcElement: HTMLElement;
- @Output() sizeChange = new EventEmitter();
- @Output() intervalChange = new EventEmitter();
- @Output() configRowsChange = new EventEmitter();
+ @Input() pageSize: number;
+ @Input() refreshInterval: number;
- constructor(private elementRef: ElementRef,
- private configureTableService: ConfigureTableService) {}
+ @Output() configRowsChange = new EventEmitter<ConfigureRowsModel>();
- @Input()
- get size() {
- return this.tableMetadata.size;
- }
-
- set size(val) {
- this.tableMetadata.size = val;
- }
-
- @Input()
- get interval() {
- return this.tableMetadata.refreshInterval;
- }
-
- set interval(val) {
- this.tableMetadata.refreshInterval = val;
- }
-
- @Input()
- get tableMetaData() {
- return this.tableMetadata;
- }
-
- set tableMetaData(val) {
- this.tableMetadata = val;
- }
+ constructor(private elementRef: ElementRef) {}
@HostListener('document:click', ['$event', '$event.target'])
public onClick(event: MouseEvent, targetElement: HTMLElement): void {
@@ -85,29 +61,29 @@
parentElement.querySelector('.is-active').classList.remove('is-active');
$event.target.classList.add('is-active');
- this.size = parseInt($event.target.textContent.trim(), 10);
- this.sizeChange.emit(this.tableMetadata.size);
- this.configRowsChange.emit();
- this.saveSettings();
+ this.pageSize = parseInt($event.target.textContent.trim(), 10);
+ this.propagateChanges(true);
}
onRefreshIntervalChange($event, parentElement) {
parentElement.querySelector('.is-active').classList.remove('is-active');
$event.target.classList.add('is-active');
-
- this.interval = parseInt($event.target.getAttribute('value').trim(), 10);
- this.intervalChange.emit(this.tableMetadata.refreshInterval);
- this.configRowsChange.emit();
- this.saveSettings();
+ this.refreshInterval = parseInt($event.target.getAttribute('value').trim(), 10);
+ this.propagateChanges();
}
- saveSettings() {
- if ( this.showView ) {
- this.configureTableService.saveTableMetaData(this.tableMetadata).subscribe(() => {
- }, error => {
- console.log('Unable to save settings ....');
- });
- }
+ onShowHideChange() {
+ this.propagateChanges(true);
+ }
+
+ private propagateChanges(triggerQuery = false): void {
+ this.configRowsChange.emit({
+ values: {
+ pageSize: this.pageSize,
+ refreshInterval: this.refreshInterval,
+ },
+ triggerQuery,
+ });
}
}
diff --git a/metron-interface/metron-alerts/src/app/alerts/configure-rows/show-hide/show-hide-alert-entries.component.spec.ts b/metron-interface/metron-alerts/src/app/alerts/configure-rows/show-hide/show-hide-alert-entries.component.spec.ts
index 3539d07..fdb559c 100644
--- a/metron-interface/metron-alerts/src/app/alerts/configure-rows/show-hide/show-hide-alert-entries.component.spec.ts
+++ b/metron-interface/metron-alerts/src/app/alerts/configure-rows/show-hide/show-hide-alert-entries.component.spec.ts
@@ -15,7 +15,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { ShowHideAlertEntriesComponent, ShowHideChanged } from './show-hide-alert-entries.component';
+import { ShowHideAlertEntriesComponent, ShowHideStateModel } from './show-hide-alert-entries.component';
import { ComponentFixture, async, TestBed } from '@angular/core/testing';
import { SwitchComponent } from 'app/shared/switch/switch.component';
import { By } from '@angular/platform-browser';
@@ -104,29 +104,26 @@
expect(component.onVisibilityChanged).toHaveBeenCalledWith('DISMISS', false);
});
- it('should trigger changed event on any toggle changes', () => {
+ it('should trigger changed event on any toggle changes and propagate state', () => {
+ const serviceSpy = TestBed.get(ShowHideService);
spyOn(component.changed, 'emit');
fixture.detectChanges();
- fixture.debugElement.query(By.css('[data-qe-id="hideDismissedAlertsToggle"] input')).nativeElement.click();
- fixture.detectChanges();
-
- expect((component.changed.emit as Spy).calls.argsFor(0)[0]).toEqual(new ShowHideChanged('DISMISS', true));
-
- fixture.debugElement.query(By.css('[data-qe-id="hideResolvedAlertsToggle"] input')).nativeElement.click();
- fixture.detectChanges();
-
- expect((component.changed.emit as Spy).calls.argsFor(1)[0]).toEqual(new ShowHideChanged('RESOLVE', true));
+ component.showHideService.hideResolved = false;
+ component.showHideService.hideDismissed = true;
fixture.debugElement.query(By.css('[data-qe-id="hideDismissedAlertsToggle"] input')).nativeElement.click();
fixture.detectChanges();
- expect((component.changed.emit as Spy).calls.argsFor(2)[0]).toEqual(new ShowHideChanged('DISMISS', false));
+ expect((component.changed.emit as Spy).calls.argsFor(0)[0]).toEqual({ hideResolved: false, hideDismissed: true });
+
+ component.showHideService.hideResolved = true;
+ component.showHideService.hideDismissed = true;
fixture.debugElement.query(By.css('[data-qe-id="hideResolvedAlertsToggle"] input')).nativeElement.click();
fixture.detectChanges();
- expect((component.changed.emit as Spy).calls.argsFor(3)[0]).toEqual(new ShowHideChanged('RESOLVE', false));
+ expect((component.changed.emit as Spy).calls.argsFor(1)[0]).toEqual({ hideResolved: true, hideDismissed: true });
})
});
diff --git a/metron-interface/metron-alerts/src/app/alerts/configure-rows/show-hide/show-hide-alert-entries.component.ts b/metron-interface/metron-alerts/src/app/alerts/configure-rows/show-hide/show-hide-alert-entries.component.ts
index 9076282..b8be2de 100644
--- a/metron-interface/metron-alerts/src/app/alerts/configure-rows/show-hide/show-hide-alert-entries.component.ts
+++ b/metron-interface/metron-alerts/src/app/alerts/configure-rows/show-hide/show-hide-alert-entries.component.ts
@@ -18,14 +18,9 @@
import { Component, Output, EventEmitter } from '@angular/core';
import { ShowHideService } from './show-hide.service';
-export class ShowHideChanged {
- value: string;
- isHide: boolean;
-
- constructor(value: string, isHide: boolean) {
- this.value = value;
- this.isHide = isHide;
- }
+export interface ShowHideStateModel {
+ hideResolved: boolean,
+ hideDismissed: boolean,
}
@Component({
@@ -39,13 +34,16 @@
})
export class ShowHideAlertEntriesComponent {
- @Output() changed = new EventEmitter<ShowHideChanged>();
+ @Output() changed = new EventEmitter<ShowHideStateModel>();
constructor(public showHideService: ShowHideService) {}
- onVisibilityChanged(alertStatus, isHide) {
+ onVisibilityChanged(alertStatus: string, isHide: boolean): void {
this.showHideService.setFilterFor(alertStatus, isHide);
- this.changed.emit(new ShowHideChanged(alertStatus, isHide));
+ this.changed.emit({
+ hideResolved: this.showHideService.hideResolved,
+ hideDismissed: this.showHideService.hideDismissed,
+ });
}
}
diff --git a/metron-interface/metron-alerts/src/app/model/search-response.ts b/metron-interface/metron-alerts/src/app/model/search-response.ts
index c71f9be..b3fc933 100644
--- a/metron-interface/metron-alerts/src/app/model/search-response.ts
+++ b/metron-interface/metron-alerts/src/app/model/search-response.ts
@@ -23,7 +23,7 @@
total = 0;
groupedBy: string;
results: Alert[] = [];
- facetCounts: Facets;
+ facetCounts: Facets = {};
groups: SearchResultGroup[];
}
diff --git a/metron-interface/metron-alerts/src/app/model/table-metadata.ts b/metron-interface/metron-alerts/src/app/model/table-metadata.ts
index 0417041..791400e 100644
--- a/metron-interface/metron-alerts/src/app/model/table-metadata.ts
+++ b/metron-interface/metron-alerts/src/app/model/table-metadata.ts
@@ -15,23 +15,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import {PageSize, RefreshInterval} from '../alerts/configure-rows/configure-rows-enums';
-import {ColumnMetadata} from './column-metadata';
+import { PageSize } from '../alerts/configure-rows/configure-rows-enums';
+import { ColumnMetadata } from './column-metadata';
export class TableMetadata {
size = PageSize.TWENTY_FIVE;
- refreshInterval = RefreshInterval.TEN_MIN;
- hideResolvedAlerts = true;
- hideDismissedAlerts = true;
tableColumns: ColumnMetadata[];
static fromJSON(obj: any): TableMetadata {
let tableMetadata = new TableMetadata();
if (obj) {
tableMetadata.size = obj.size;
- tableMetadata.refreshInterval = obj.refreshInterval;
- tableMetadata.hideResolvedAlerts = obj.hideResolvedAlerts;
- tableMetadata.hideDismissedAlerts = obj.hideDismissedAlerts;
tableMetadata.tableColumns = (typeof (obj.tableColumns) === 'string') ? JSON.parse(obj.tableColumns) : obj.tableColumns;
}
diff --git a/metron-interface/metron-alerts/src/app/service/elasticsearch-localstorage-impl.ts b/metron-interface/metron-alerts/src/app/service/elasticsearch-localstorage-impl.ts
index 88036a4..5544e75 100644
--- a/metron-interface/metron-alerts/src/app/service/elasticsearch-localstorage-impl.ts
+++ b/metron-interface/metron-alerts/src/app/service/elasticsearch-localstorage-impl.ts
@@ -1,7 +1,3 @@
-
-import {throwError as observableThrowError} from 'rxjs';
-
-import {catchError, map, onErrorResumeNext} from 'rxjs/operators';
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@@ -20,6 +16,8 @@
* limitations under the License.
*/
import {Observable} from 'rxjs';
+import {throwError as observableThrowError} from 'rxjs';
+import {catchError, map, onErrorResumeNext} from 'rxjs/operators';
import { Injectable } from '@angular/core';
import {HttpUtil} from '../utils/httpUtil';
import {DataSource} from './data-source';
diff --git a/metron-interface/metron-alerts/src/app/service/search.service.spec.ts b/metron-interface/metron-alerts/src/app/service/search.service.spec.ts
new file mode 100644
index 0000000..3518070
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/service/search.service.spec.ts
@@ -0,0 +1,78 @@
+/**
+ * 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, fakeAsync } from '@angular/core/testing';
+import { SearchService } from './search.service';
+import { HttpTestingController, HttpClientTestingModule, TestRequest } from '@angular/common/http/testing';
+import { AppConfigService } from './app-config.service';
+import { SearchRequest } from 'app/model/search-request';
+import { noop } from 'rxjs';
+import { HttpUtil } from 'app/utils/httpUtil';
+
+describe('SearchService', () => {
+
+ let searchService: SearchService;
+ let mockBackend: HttpTestingController;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [ HttpClientTestingModule ],
+ providers: [
+ SearchService,
+ { provide: AppConfigService, useClass: () => {
+ return {
+ getApiRoot: () => '/api/v1',
+ }
+ } },
+ ]
+ });
+
+ searchService = TestBed.get(SearchService);
+ mockBackend = TestBed.get(HttpTestingController);
+ });
+
+ it('should not swallow errors', fakeAsync(() => {
+ searchService.search(new SearchRequest())
+ .subscribe(
+ noop,
+ (error) => {
+ expect(error.status).toBe(500);
+ },
+ );
+
+ const expectedReq: TestRequest = mockBackend.expectOne('/api/v1/search/search');
+ expect(expectedReq.request.method).toEqual('POST');
+
+ expectedReq.error(new ErrorEvent('internal server error'), { status: 500 });
+ }));
+
+ it('should redirect to login on session expiration or unauthorized access', () => {
+ spyOn(HttpUtil, 'navigateToLogin');
+
+ searchService.search(new SearchRequest()).subscribe(
+ noop,
+ (error) => {
+ expect(HttpUtil.navigateToLogin).toHaveBeenCalled();
+ },
+ );
+
+ const expectedReq: TestRequest = mockBackend.expectOne('/api/v1/search/search');
+ expect(expectedReq.request.method).toEqual('POST');
+
+ expectedReq.error(new ErrorEvent('internal server error'), { status: 401 });
+ });
+});
diff --git a/metron-interface/metron-alerts/src/app/service/search.service.ts b/metron-interface/metron-alerts/src/app/service/search.service.ts
index 47f211b..35a5a9c 100644
--- a/metron-interface/metron-alerts/src/app/service/search.service.ts
+++ b/metron-interface/metron-alerts/src/app/service/search.service.ts
@@ -16,10 +16,9 @@
* limitations under the License.
*/
import { HttpClient } from '@angular/common/http';
-import {Injectable, NgZone} from '@angular/core';
+import {Injectable} from '@angular/core';
import {Observable} from 'rxjs';
-import { map, onErrorResumeNext, catchError, switchMap } from 'rxjs/operators';
-import { interval as observableInterval } from 'rxjs';
+import { map, onErrorResumeNext, catchError } from 'rxjs/operators';
import {HttpUtil} from '../utils/httpUtil';
import {SearchResponse} from '../model/search-response';
import {SearchRequest} from '../model/search-request';
@@ -34,8 +33,6 @@
@Injectable()
export class SearchService {
- interval = 80000;
-
private static extractColumnNameDataFromRestApi(res): ColumnMetadata[] {
let response: any = res || {};
let processedKeys: string[] = [];
@@ -52,7 +49,7 @@
}
constructor(private http: HttpClient,
- private ngZone: NgZone, private appConfigService: AppConfigService) { }
+ private appConfigService: AppConfigService) { }
groups(groupRequest: GroupRequest): Observable<GroupResult> {
let url = this.appConfigService.getApiRoot() + '/search/group';
@@ -79,21 +76,12 @@
catchError(HttpUtil.handleError));
}
- public pollSearch(searchRequest: SearchRequest): Observable<SearchResponse> {
- return this.ngZone.runOutsideAngular(() => {
- return this.ngZone.run(() => {
- return observableInterval(this.interval * 1000).pipe(switchMap(() => {
- return this.search(searchRequest);
- }));
- });
- });
- }
-
public search(searchRequest: SearchRequest): Observable<SearchResponse> {
let url = this.appConfigService.getApiRoot() + '/search/search';
+
return this.http.post(url, searchRequest).pipe(
- map(HttpUtil.extractData),
- catchError(HttpUtil.handleError),
- onErrorResumeNext());
+ map(HttpUtil.extractData),
+ catchError(HttpUtil.sessionExpiration),
+ );
}
}
diff --git a/metron-interface/metron-alerts/src/app/shared/modal-loading-indicator/modal-loading-indicator.component.html b/metron-interface/metron-alerts/src/app/shared/modal-loading-indicator/modal-loading-indicator.component.html
new file mode 100644
index 0000000..29030f6
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/shared/modal-loading-indicator/modal-loading-indicator.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.
+ -->
+<div *ngIf="show" class="modal-backdrop show"></div>
+<div *ngIf="show" class="modal modal-loader">
+ <div class="modal-dialog modal-dialog-centered" role="document">
+ <div class="modal-content">
+ <div class="modal-body">
+ <img src="/assets/images/logo.png">
+ <div class="spinner-border text-info" role="status"></div>
+ <div class="pt-2">Fetching alerts...</div>
+ </div>
+ </div>
+ </div>
+</div>
\ No newline at end of file
diff --git a/metron-interface/metron-alerts/src/environments/environment.prod.js b/metron-interface/metron-alerts/src/app/shared/modal-loading-indicator/modal-loading-indicator.component.scss
similarity index 73%
rename from metron-interface/metron-alerts/src/environments/environment.prod.js
rename to metron-interface/metron-alerts/src/app/shared/modal-loading-indicator/modal-loading-indicator.component.scss
index abb15bb..871f0a0 100644
--- a/metron-interface/metron-alerts/src/environments/environment.prod.js
+++ b/metron-interface/metron-alerts/src/app/shared/modal-loading-indicator/modal-loading-indicator.component.scss
@@ -15,7 +15,26 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-"use strict";
-exports.environment = {
- production: true
-};
+.modal-loader {
+ display: block;
+
+ img {
+ width: 140px;
+ }
+
+ .spinner-border {
+ margin: 0.8rem 0 0.4rem 0;
+ }
+
+ .modal-dialog {
+ width: 200px;
+
+ .modal-content {
+ background-color: #232323;
+ }
+
+ .modal-body {
+ text-align: center;
+ }
+ }
+}
\ No newline at end of file
diff --git a/metron-interface/metron-alerts/src/app/shared/modal-loading-indicator/modal-loading-indicator.component.spec.ts b/metron-interface/metron-alerts/src/app/shared/modal-loading-indicator/modal-loading-indicator.component.spec.ts
new file mode 100644
index 0000000..ec031af
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/shared/modal-loading-indicator/modal-loading-indicator.component.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 { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ModalLoadingIndicatorComponent } from './modal-loading-indicator.component';
+
+describe('ModalLoadingIndicatorComponent', () => {
+ let component: ModalLoadingIndicatorComponent;
+ let fixture: ComponentFixture<ModalLoadingIndicatorComponent>;
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ declarations: [ ModalLoadingIndicatorComponent ]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ModalLoadingIndicatorComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/metron-interface/metron-alerts/src/app/shared/modal-loading-indicator/modal-loading-indicator.component.ts b/metron-interface/metron-alerts/src/app/shared/modal-loading-indicator/modal-loading-indicator.component.ts
new file mode 100644
index 0000000..ea15813
--- /dev/null
+++ b/metron-interface/metron-alerts/src/app/shared/modal-loading-indicator/modal-loading-indicator.component.ts
@@ -0,0 +1,29 @@
+/**
+* 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, OnInit, Input } from '@angular/core';
+
+@Component({
+ selector: 'app-modal-loading-indicator',
+ templateUrl: './modal-loading-indicator.component.html',
+ styleUrls: ['./modal-loading-indicator.component.scss']
+})
+export class ModalLoadingIndicatorComponent {
+
+ @Input() show = false;
+
+}
diff --git a/metron-interface/metron-alerts/src/app/utils/constants.ts b/metron-interface/metron-alerts/src/app/utils/constants.ts
index 929d140..6e05de0 100644
--- a/metron-interface/metron-alerts/src/app/utils/constants.ts
+++ b/metron-interface/metron-alerts/src/app/utils/constants.ts
@@ -36,7 +36,7 @@
export const TREE_SUB_GROUP_SIZE = 5;
export const INDEXES = environment.indices ? environment.indices.split(',') : [];
-export const POLLING_DEFAULT_STATE = !environment.defaultPollingState;
+export const POLLING_DEFAULT_STATE = environment.defaultPollingState;
export const MAX_ALERTS_IN_META_ALERTS = 350;
diff --git a/metron-interface/metron-alerts/src/app/utils/httpUtil.ts b/metron-interface/metron-alerts/src/app/utils/httpUtil.ts
index e1a5f8e..718b71c 100644
--- a/metron-interface/metron-alerts/src/app/utils/httpUtil.ts
+++ b/metron-interface/metron-alerts/src/app/utils/httpUtil.ts
@@ -18,8 +18,8 @@
*/
import {HttpErrorResponse, HttpResponse} from '@angular/common/http';
import {RestError} from '../model/rest-error';
-import {throwError as observableThrowError, Observable} from 'rxjs';
-import {AppConfigService} from "../service/app-config.service";
+import {throwError, Observable} from 'rxjs';
+import {AppConfigService} from '../service/app-config.service';
export class HttpUtil {
@@ -33,9 +33,12 @@
return body || {};
}
+ /**
+ * @deprecated Turning all errors to 404 and hiding actual errors from the consumers
+ * could limit how we can recover or react to errors.
+ * Use sessionExpiration instead and/or introduce new composable error handlers.
+ */
public static handleError(res: HttpErrorResponse): Observable<RestError> {
- // In a real world app, we might use a remote logging infrastructure
- // We'd also dig deeper into the error to get a better message
let restError: RestError;
if (res.status === 401) {
HttpUtil.navigateToLogin();
@@ -45,7 +48,14 @@
restError = new RestError();
restError.status = 404;
}
- return observableThrowError(restError);
+ return throwError(restError);
+ }
+
+ public static sessionExpiration(res: HttpErrorResponse): Observable<RestError> {
+ if (res.status === 401) {
+ HttpUtil.navigateToLogin();
+ }
+ return throwError(res);
}
public static navigateToLogin() {