This is an automated email from the ASF dual-hosted git repository. sardell pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/metron.git
The following commit(s) were added to refs/heads/master by this push: new d693e28 METRON-2190 [UI] Alerts UI: Indicating loading and preventing parallel requests (tiborm via sardell) closes apache/metron#1514 d693e28 is described below commit d693e282342afb8e67a57744ee3505262295dd35 Author: tiborm <tibor.mel...@gmail.com> AuthorDate: Fri Oct 4 08:52:28 2019 -0500 METRON-2190 [UI] Alerts UI: Indicating loading and preventing parallel requests (tiborm via sardell) closes apache/metron#1514 --- .../metron-alerts/cypress/fixtures/search-1.1.json | 102 ++++ .../metron-alerts/cypress/fixtures/search-1.2.json | 102 ++++ .../search/auto-polling.feature.spec.js | 98 ++++ .../alerts/alerts-list/alerts-list.component.html | 52 ++- .../alerts/alerts-list/alerts-list.component.scss | 21 - .../alerts-list/alerts-list.component.spec.ts | 513 +++++++++++++++++--- .../alerts/alerts-list/alerts-list.component.ts | 249 +++++----- .../app/alerts/alerts-list/alerts-list.module.ts | 12 +- .../auto-polling/auto-polling.component.html | 17 + .../auto-polling/auto-polling.component.scss | 73 +++ .../auto-polling/auto-polling.component.spec.ts | 80 ++++ .../auto-polling/auto-polling.component.ts} | 29 +- .../auto-polling/auto-polling.service.spec.ts | 519 +++++++++++++++++++++ .../auto-polling/auto-polling.service.ts | 184 ++++++++ .../app/alerts/alerts-list/query-builder.spec.ts | 124 +++-- .../src/app/alerts/alerts-list/query-builder.ts | 74 +-- .../alerts-list/tree-view/tree-view.component.ts | 1 - .../configure-rows/configure-rows.component.html | 34 +- .../configure-rows/configure-rows.component.ts | 76 ++- .../show-hide-alert-entries.component.spec.ts | 23 +- .../show-hide/show-hide-alert-entries.component.ts | 20 +- .../metron-alerts/src/app/model/search-response.ts | 2 +- .../metron-alerts/src/app/model/table-metadata.ts | 10 +- .../app/service/elasticsearch-localstorage-impl.ts | 6 +- .../src/app/service/search.service.spec.ts | 78 ++++ .../src/app/service/search.service.ts | 26 +- .../modal-loading-indicator.component.html | 25 + .../modal-loading-indicator.component.scss} | 27 +- .../modal-loading-indicator.component.spec.ts} | 33 +- .../modal-loading-indicator.component.ts | 29 ++ .../metron-alerts/src/app/utils/constants.ts | 2 +- .../metron-alerts/src/app/utils/httpUtil.ts | 20 +- 32 files changed, 2206 insertions(+), 455 deletions(-) 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 [...] + "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 [...] + "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 @@ $searchbox-height: 42px; 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 { SaveSearchService } from 'app/service/save-search.service'; 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 @@ describe('AlertsListComponent', () => { } } }, { provide: SaveSearchService, useClass: () => { return { loadSavedSearch$: new Observable(), + setCurrentQueryBuilderAndTableColumns: () => {}, } } }, { provide: MetaAlertService, useClass: () => { return { alertChanged$: new Observable(), @@ -96,7 +180,29 @@ describe('AlertsListComponent', () => { 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 @@ describe('AlertsListComponent', () => { 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); - 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; + queryBuilder.getFilteringMode = () => FilteringMode.MANUAL; - expect(component.queryForTreeView()).toBe('*'); + component.toggleQueryBuilderMode(); + expect(queryBuilder.setFilteringMode).toHaveBeenCalledWith(FilteringMode.BUILDER); + }); - component.toggleQueryBuilder(); - fixture.detectChanges(); - expect(component.hideQueryBuilder).toBe(true); + it('isQueryBuilderModeManual should return true if queryBuilder is in manual mode', () => { + queryBuilder.getFilteringMode = () => FilteringMode.MANUAL; + expect(component.isQueryBuilderModeManual()).toBe(true); - el.value = 'test'; - expect(component.queryForTreeView()).toBe('test'); - }); + queryBuilder.getFilteringMode = () => FilteringMode.BUILDER; + expect(component.isQueryBuilderModeManual()).toBe(false); + }); - 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(); + it('should show manual input dom element depending on mode', () => { + let input = fixture.debugElement.query(By.css('[data-qe-id="manual-query-input"]')); - el.value = 'test'; - component.hideQueryBuilder = true; - component.pagination.size = 25; - newSearch.query = 'test' - newSearch.size = 25 - newSearch.from = 0; + expect(input).toBeFalsy(); - fixture.detectChanges(); - component.search(); - expect(searchServiceSpy).toHaveBeenCalledWith(newSearch); + 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 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(); + 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'); - component.isRefreshPaused = false; - fixture.detectChanges(); - component.tryStartPolling(newSearch); - expect(searchServiceSpy).toHaveBeenCalledWith(newSearch); + component.search(); + expect(component.pendingSearch).toBeTruthy(); + }); + + 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'); + + component.search(); + + setTimeout(() => { + fakeObservable.next(new SearchResponse()); + }, 0); + + 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 @@ describe('AlertsListComponent', () => { }); 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 @@ describe('AlertsListComponent', () => { 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 @@ describe('AlertsListComponent', () => { 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 { DialogService } from 'app/service/dialog.service'; 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 @@ export class AlertsListComponent implements OnInit, OnDestroy { 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 @@ export class AlertsListComponent implements OnInit, OnDestroy { @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 @@ export class AlertsListComponent implements OnInit, OnDestroy { configSubscription: Subscription; groups = []; subgroupTotal = 0; - hideQueryBuilder = false; + pendingSearch: Subscription; staleDataState = false; constructor(private router: Router, @@ -99,17 +108,23 @@ export class AlertsListComponent implements OnInit, OnDestroy { 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 @@ export class AlertsListComponent implements OnInit, OnDestroy { }); } - addAlertColChangedListner() { + addAlertColChangedListener() { this.configureTableService.tableChanged$.subscribe(colChanged => { if (colChanged) { this.getAlertColumnNames(false); @@ -130,7 +145,7 @@ export class AlertsListComponent implements OnInit, OnDestroy { }); } - addLoadSavedSearchListner() { + addLoadSavedSearchListener() { this.saveSearchService.loadSavedSearch$.subscribe((savedSearch: SaveSearch) => { this.queryBuilder.searchRequest = savedSearch.searchRequest; this.queryBuilder.filters = savedSearch.filters; @@ -170,7 +185,7 @@ export class AlertsListComponent implements OnInit, OnDestroy { } getAlertColumnNames(resetPaginationForSearch: boolean) { - observableForkJoin( + forkJoin( this.configureTableService.getTableMetadata(), this.clusterMetaDataService.getDefaultColumns() ).subscribe((response: any) => { @@ -193,9 +208,13 @@ export class AlertsListComponent implements OnInit, OnDestroy { } ngOnDestroy() { - this.tryStopPolling(); + this.autoPollingSvc.onDestroy(); this.removeAlertChangedListner(); this.configSubscription.unsubscribe(); + + if (this.manualQueryFieldChangeSubs) { + this.manualQueryFieldChangeSubs.unsubscribe(); + } } ngOnInit() { @@ -212,9 +231,23 @@ export class AlertsListComponent implements OnInit, OnDestroy { 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 @@ export class AlertsListComponent implements OnInit, OnDestroy { onClear() { this.timeStampFilterPresent = false; this.queryBuilder.clearSearch(); - if (this.hideQueryBuilder) { this.manualQuery.nativeElement.value = '*'; } - this.search(); this.staleDataState = true; } @@ -258,11 +289,7 @@ export class AlertsListComponent implements OnInit, OnDestroy { 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 @@ export class AlertsListComponent implements OnInit, OnDestroy { 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 @@ export class AlertsListComponent implements OnInit, OnDestroy { 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 @@ export class AlertsListComponent implements OnInit, OnDestroy { 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 @@ export class AlertsListComponent implements OnInit, OnDestroy { } } - restoreRefreshState() { - this.isRefreshPaused = this.lastIsRefreshPausedValue; - this.tryStartPolling(); - } - search(resetPaginationParams = true, savedSearch?: SaveSearch) { if (savedSearch) { this.saveCurrentSearch(savedSearch); } if (resetPaginationParams) { @@ -386,31 +410,19 @@ export class AlertsListComponent implements OnInit, OnDestroy { 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.setData(results); - this.staleDataState = false; - }, error => { - this.setData(new SearchResponse()); - this.dialogService.launchDialog(ElasticsearchUtils.extractESErrorMessage(error), DialogType.Error); - }); - - this.tryStartPolling(newSearch); - } else { - this.searchService.search(this.queryBuilder.searchRequest).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(); + if (this.autoPollingSvc.getIsPollingActive()) { + this.autoPollingSvc.dropNextAndContinue(); } } @@ -460,69 +472,30 @@ export class AlertsListComponent implements OnInit, OnDestroy { } 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 @@ export class AlertsListComponent implements OnInit, OnDestroy { selectedAlert.source['alert_status'] = status; } this.selectedAlerts = []; - this.resume(); + this.autoPollingSvc.setSuppression(false); } removeAlertChangedListner() { @@ -549,24 +522,52 @@ export class AlertsListComponent implements OnInit, OnDestroy { this.cdRef.detectChanges(); } - toggleQueryBuilder() { + 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 { + 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.hideQueryBuilder) { - this.hideQueryBuilder = true; - this.manualQuery.nativeElement.value = this.queryBuilder.query; + if (this.queryBuilder.getFilteringMode() === FilteringMode.BUILDER) { + this.queryBuilder.setFilteringMode(FilteringMode.MANUAL); } else { - this.hideQueryBuilder = false; + 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 { GroupByModule } from '../../shared/group-by/group-by.module'; 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 @@ import { TreeViewComponent } from './tree-view/tree-view.component'; 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 61% 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; - it('should be able to handle multiple filters', () => { - const queryBuilder = new QueryBuilder(); + beforeEach(() => { + queryBuilder = new QueryBuilder(); + }); + it('should be able to handle multiple filters', () => { queryBuilder.setSearch('alert_status:RESOLVE AND ip_src_addr:0.0.0.0'); expect(queryBuilder.searchRequest.query).toBe( @@ -34,8 +37,6 @@ describe('query-builder', () => { }); 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 @@ describe('query-builder', () => { }); 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 @@ describe('query-builder', () => { }); it('should trim whitespace', () => { - const queryBuilder = new QueryBuilder(); - queryBuilder.setSearch(' alert_status:(RESOLVE OR DISMISS) '); expect(queryBuilder.searchRequest.query).toBe( @@ -64,8 +61,6 @@ describe('query-builder', () => { }); it('should remove wildcard', () => { - const queryBuilder = new QueryBuilder(); - queryBuilder.setSearch('* alert_status:(RESOLVE OR DISMISS)'); expect(queryBuilder.searchRequest.query).toBe( @@ -74,8 +69,6 @@ describe('query-builder', () => { }); 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 @@ describe('query-builder', () => { }); 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 @@ describe('query-builder', () => { }); 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 @@ describe('query-builder', () => { }); 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 @@ describe('query-builder', () => { }); 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 @@ describe('query-builder', () => { }); 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 @@ describe('query-builder', () => { 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 {GroupRequest} from '../../model/group-request'; 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 @@ export class QueryBuilder { } get searchRequest(): SearchRequest { - this._searchRequest.query = this.generateSelect(); + this._searchRequest.query = this.getQueryString() || '*'; return this._searchRequest; } @@ -61,19 +68,18 @@ export class QueryBuilder { } 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 @@ export class QueryBuilder { this.removeFilter(existingTimeRangeFilter); } this._filters.push(filter); - this.onSearchChange(); return; } @@ -102,13 +107,18 @@ export class QueryBuilder { } else { this._filters.push(filter); } + } - this.onSearchChange(); + private getQueryString() { + if (this.filteringMode === FilteringMode.MANUAL) { + return this.getManualQuery(); + } else { + return this.getBuilderQueryString(); + } } - generateSelect() { - let select = this._filters.map(filter => filter.getQueryString()).join(' AND '); - return (select.length === 0) ? '*' : select; + private getBuilderQueryString() { + return this._filters.map(filter => filter.getQueryString()).join(' AND '); } generateNameForSearchRequest() { @@ -117,41 +127,26 @@ export class QueryBuilder { } 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 @@ export class QueryBuilder { 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 { DialogService } from '../../../service/dialog.service'; 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(); - - constructor(private elementRef: ElementRef, - private configureTableService: ConfigureTableService) {} - - @Input() - get size() { - return this.tableMetadata.size; - } + @Input() pageSize: number; + @Input() refreshInterval: number; - set size(val) { - this.tableMetadata.size = val; - } + @Output() configRowsChange = new EventEmitter<ConfigureRowsModel>(); - @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 @@ export class ConfigureRowsComponent { 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.refreshInterval = parseInt($event.target.getAttribute('value').trim(), 10); + this.propagateChanges(); + } - this.interval = parseInt($event.target.getAttribute('value').trim(), 10); - this.intervalChange.emit(this.tableMetadata.refreshInterval); - this.configRowsChange.emit(); - this.saveSettings(); + onShowHideChange() { + this.propagateChanges(true); } - saveSettings() { - if ( this.showView ) { - this.configureTableService.saveTableMetaData(this.tableMetadata).subscribe(() => { - }, error => { - console.log('Unable to save settings ....'); - }); - } + 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 @@ describe('ShowHideAlertEntriesComponent', () => { 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 ShowHideChanged { }) 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 @@ export class SearchResponse { 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 @@ import {catchError, map, onErrorResumeNext} from 'rxjs/operators'; * 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 @@ import { AppConfigService } from './app-config.service'; @Injectable() export class SearchService { - interval = 80000; - private static extractColumnNameDataFromRestApi(res): ColumnMetadata[] { let response: any = res || {}; let processedKeys: string[] = []; @@ -52,7 +49,7 @@ export class SearchService { } 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 @@ export class SearchService { 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 74% 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/model/search-response.ts b/metron-interface/metron-alerts/src/app/shared/modal-loading-indicator/modal-loading-indicator.component.spec.ts similarity index 52% copy from metron-interface/metron-alerts/src/app/model/search-response.ts copy to metron-interface/metron-alerts/src/app/shared/modal-loading-indicator/modal-loading-indicator.component.spec.ts index c71f9be..ec031af 100644 --- a/metron-interface/metron-alerts/src/app/model/search-response.ts +++ b/metron-interface/metron-alerts/src/app/shared/modal-loading-indicator/modal-loading-indicator.component.spec.ts @@ -15,15 +15,28 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import {Alert} from './alert'; -import {Facets} from './facets'; -import {SearchResultGroup} from './search-result-group'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -export class SearchResponse { - total = 0; - groupedBy: string; - results: Alert[] = []; - facetCounts: Facets; - groups: SearchResultGroup[]; -} +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 CUSTOMM_DATE_RANGE_LABEL = 'Date Range'; 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 @@ export class HttpUtil { 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 @@ export class HttpUtil { 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() {