You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ambari.apache.org by ab...@apache.org on 2018/02/05 11:32:23 UTC

[ambari] branch trunk updated: AMBARI-22909 Log Search UI: implement filter by username for access logs

This is an automated email from the ASF dual-hosted git repository.

ababiichuk pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ambari.git


The following commit(s) were added to refs/heads/trunk by this push:
     new fee42d5  AMBARI-22909 Log Search UI: implement filter by username for access logs
fee42d5 is described below

commit fee42d5ce53ce4426cd25f58ff93d65cd92f6f03
Author: ababiichuk <ab...@hortonworks.com>
AuthorDate: Mon Feb 5 12:26:02 2018 +0200

    AMBARI-22909 Log Search UI: implement filter by username for access logs
---
 .../src/app/classes/models/store.ts                |  33 ++++--
 .../app/classes/queries/audit-logs-query-params.ts |  33 +-----
 .../audit-logs-top-resources-query-params.ts       |   4 +-
 ...t-logs-query-params.ts => logs-query-params.ts} |  12 +-
 .../classes/queries/service-logs-query-params.ts   |   4 +-
 .../queries/service-logs-truncated-query-params.ts |   2 +-
 .../filters-panel/filters-panel.component.html     |   3 +
 .../ambari-logsearch-web/src/app/mock-data.ts      |  16 +--
 .../src/app/services/logs-container.service.ts     |  53 +++++++--
 .../src/app/services/mock-api-data.service.ts      |  55 ++++++---
 .../src/app/services/utils.service.spec.ts         | 125 +++++++++++++++++++++
 .../src/app/services/utils.service.ts              |  20 ++++
 .../ambari-logsearch-web/src/assets/i18n/en.json   |   3 +-
 13 files changed, 277 insertions(+), 86 deletions(-)

diff --git a/ambari-logsearch/ambari-logsearch-web/src/app/classes/models/store.ts b/ambari-logsearch/ambari-logsearch-web/src/app/classes/models/store.ts
index 1764c93..eb87714 100644
--- a/ambari-logsearch/ambari-logsearch-web/src/app/classes/models/store.ts
+++ b/ambari-logsearch/ambari-logsearch-web/src/app/classes/models/store.ts
@@ -16,6 +16,7 @@
  * limitations under the License.
  */
 
+import {ReflectiveInjector} from '@angular/core';
 import {Observable} from 'rxjs/Observable';
 import {Store, Action} from '@ngrx/store';
 import {AppSettings} from '@app/classes/models/app-settings';
@@ -29,17 +30,22 @@ import {UserConfig} from '@app/classes/models/user-config';
 import {AuditLogField} from '@app/classes/models/audit-log-field';
 import {ServiceLogField} from '@app/classes/models/service-log-field';
 import {Tab} from '@app/classes/models/tab';
+import {UtilsService} from '@app/services/utils.service';
 
 const storeActions = {
-  'ARRAY.ADD': 'ADD',
-  'ARRAY.ADD.START': 'ADD_TO_START',
-  'ARRAY.DELETE.PRIMITIVE': 'DELETE_PRIMITIVE',
-  'ARRAY.DELETE.OBJECT': 'DELETE_OBJECT',
-  'ARRAY.CLEAR': 'CLEAR',
-  'ARRAY.MAP': 'MAP',
-
-  'OBJECT.SET': 'SET'
-};
+    'ARRAY.ADD': 'ADD',
+    'ARRAY.ADD.START': 'ADD_TO_START',
+    'ARRAY.ADD.UNIQUE': 'ADD_UNIQUE',
+    'ARRAY.DELETE.PRIMITIVE': 'DELETE_PRIMITIVE',
+    'ARRAY.DELETE.OBJECT': 'DELETE_OBJECT',
+    'ARRAY.CLEAR': 'CLEAR',
+    'ARRAY.MAP': 'MAP',
+
+    'OBJECT.SET': 'SET'
+  },
+  provider = ReflectiveInjector.resolve([UtilsService]),
+  injector = ReflectiveInjector.fromResolvedProviders(provider),
+  utils = injector.get(UtilsService);
 
 export interface AppStore {
   appSettings: AppSettings;
@@ -96,6 +102,13 @@ export class CollectionModelService extends ModelService {
     });
   }
 
+  addUniqueInstances(instances: any[]): void {
+    this.store.dispatch({
+      type: `${storeActions['ARRAY.ADD.UNIQUE']}_${this.modelName}`,
+      payload: instances
+    });
+  }
+
   deleteObjectInstance(instance: any): void {
     this.store.dispatch({
       type: `${storeActions['ARRAY.DELETE.OBJECT']}_${this.modelName}`,
@@ -163,6 +176,8 @@ export function getCollectionReducer(modelName: string, defaultState: any = []):
         return [...state, ...action.payload];
       case `${storeActions['ARRAY.ADD.START']}_${modelName}`:
         return [...action.payload, ...state];
+      case `${storeActions['ARRAY.ADD.UNIQUE']}_${modelName}`:
+        return utils.pushUniqueValues(state.slice(), action.payload);
       case `${storeActions['ARRAY.DELETE.OBJECT']}_${modelName}`:
         return state.filter(instance => instance.id !== action.payload.id);
       case `${storeActions['ARRAY.DELETE.PRIMITIVE']}_${modelName}`:
diff --git a/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/audit-logs-query-params.ts b/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/audit-logs-query-params.ts
index dc82b9e..e9c0149 100644
--- a/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/audit-logs-query-params.ts
+++ b/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/audit-logs-query-params.ts
@@ -16,35 +16,8 @@
  * limitations under the License.
  */
 
-import {QueryParams} from '@app/classes/queries/query-params';
-import {SortingType} from '@app/classes/string';
+import {LogsListQueryParams} from '@app/classes/queries/logs-query-params';
 
-export const defaultParams = {
-  page: '0',
-  pageSize: '10'
-};
-
-export class AuditLogsQueryParams extends QueryParams {
-  clusters?: string;
-  mustBe?: string;
-  mustNot?: string;
-  includeQuery?: string;
-  excludeQuery?: string;
-  from?: string;
-  to?: string;
-}
-
-export class AuditLogsListQueryParams extends AuditLogsQueryParams {
-  constructor(options: AuditLogsListQueryParams) {
-    let finalParams = Object.assign({}, defaultParams, options);
-    const page = parseInt(finalParams.page),
-      pageSize = parseInt(finalParams.pageSize);
-    finalParams.startIndex = isNaN(page) || isNaN(pageSize) ? '' : (page * pageSize).toString();
-    super(finalParams);
-  }
-  page: string;
-  pageSize: string;
-  startIndex: string;
-  sortBy?: string;
-  sortType?: SortingType;
+export class AuditLogsListQueryParams extends LogsListQueryParams {
+  userList?: string;
 }
diff --git a/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/audit-logs-top-resources-query-params.ts b/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/audit-logs-top-resources-query-params.ts
index 0d12539..d5e9cc5 100644
--- a/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/audit-logs-top-resources-query-params.ts
+++ b/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/audit-logs-top-resources-query-params.ts
@@ -16,8 +16,8 @@
  * limitations under the License.
  */
 
-import {AuditLogsQueryParams} from '@app/classes/queries/audit-logs-query-params';
+import {LogsQueryParams} from '@app/classes/queries/logs-query-params';
 
-export class AuditLogsTopResourcesQueryParams extends AuditLogsQueryParams {
+export class AuditLogsTopResourcesQueryParams extends LogsQueryParams {
   field: string;
 }
diff --git a/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/audit-logs-query-params.ts b/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/logs-query-params.ts
similarity index 83%
copy from ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/audit-logs-query-params.ts
copy to ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/logs-query-params.ts
index dc82b9e..9e7f5d0 100644
--- a/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/audit-logs-query-params.ts
+++ b/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/logs-query-params.ts
@@ -16,15 +16,15 @@
  * limitations under the License.
  */
 
-import {QueryParams} from '@app/classes/queries/query-params';
 import {SortingType} from '@app/classes/string';
+import {QueryParams} from '@app/classes/queries/query-params';
 
-export const defaultParams = {
+const defaultListParams = {
   page: '0',
   pageSize: '10'
 };
 
-export class AuditLogsQueryParams extends QueryParams {
+export class LogsQueryParams extends QueryParams {
   clusters?: string;
   mustBe?: string;
   mustNot?: string;
@@ -34,9 +34,9 @@ export class AuditLogsQueryParams extends QueryParams {
   to?: string;
 }
 
-export class AuditLogsListQueryParams extends AuditLogsQueryParams {
-  constructor(options: AuditLogsListQueryParams) {
-    let finalParams = Object.assign({}, defaultParams, options);
+export class LogsListQueryParams extends LogsQueryParams {
+  constructor(options: LogsListQueryParams) {
+    let finalParams = Object.assign({}, defaultListParams, options);
     const page = parseInt(finalParams.page),
       pageSize = parseInt(finalParams.pageSize);
     finalParams.startIndex = isNaN(page) || isNaN(pageSize) ? '' : (page * pageSize).toString();
diff --git a/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/service-logs-query-params.ts b/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/service-logs-query-params.ts
index 60c3d5c..ad88449 100644
--- a/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/service-logs-query-params.ts
+++ b/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/service-logs-query-params.ts
@@ -16,9 +16,9 @@
  * limitations under the License.
  */
 
-import {AuditLogsListQueryParams} from '@app/classes/queries/audit-logs-query-params';
+import {LogsListQueryParams} from '@app/classes/queries/logs-query-params';
 
-export class ServiceLogsQueryParams extends AuditLogsListQueryParams {
+export class ServiceLogsQueryParams extends LogsListQueryParams {
   level?: string;
   file_name?: string;
   bundle_id?: string;
diff --git a/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/service-logs-truncated-query-params.ts b/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/service-logs-truncated-query-params.ts
index 3b08e11..071a443 100644
--- a/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/service-logs-truncated-query-params.ts
+++ b/ambari-logsearch/ambari-logsearch-web/src/app/classes/queries/service-logs-truncated-query-params.ts
@@ -19,7 +19,7 @@
 import {QueryParams} from '@app/classes/queries/query-params';
 import {ScrollType} from '@app/classes/string';
 
-export const defaultParams = {
+const defaultParams = {
   numberRows: '10',
   scrollType: ''
 };
diff --git a/ambari-logsearch/ambari-logsearch-web/src/app/components/filters-panel/filters-panel.component.html b/ambari-logsearch/ambari-logsearch-web/src/app/components/filters-panel/filters-panel.component.html
index 51233ff..db1cb3e 100644
--- a/ambari-logsearch/ambari-logsearch-web/src/app/components/filters-panel/filters-panel.component.html
+++ b/ambari-logsearch/ambari-logsearch-web/src/app/components/filters-panel/filters-panel.component.html
@@ -36,6 +36,9 @@
                    label="{{filters.hosts.label | translate}}" [iconClass]="filters.hosts.iconClass"
                    [subItems]="filters.hosts.options" [isMultipleChoice]="true" [isRightAlign]="true"
                    additionalLabelComponentSetter="getDataForHostsNodeBar"></filter-button>
+    <filter-button *ngIf="isFilterConditionDisplayed('users')" formControlName="users"
+                   label="{{filters.users.label | translate}}" [iconClass]="filters.users.iconClass"
+                   [subItems]="filters.users.options" [isMultipleChoice]="true" [isRightAlign]="true"></filter-button>
     <filter-button *ngIf="isFilterConditionDisplayed('components')" formControlName="components"
                    label="{{filters.components.label | translate}}" [iconClass]="filters.components.iconClass"
                    [subItems]="filters.components.options" [isMultipleChoice]="true" [isRightAlign]="true"
diff --git a/ambari-logsearch/ambari-logsearch-web/src/app/mock-data.ts b/ambari-logsearch/ambari-logsearch-web/src/app/mock-data.ts
index fd4270b..cf5d2a5 100644
--- a/ambari-logsearch/ambari-logsearch-web/src/app/mock-data.ts
+++ b/ambari-logsearch/ambari-logsearch-web/src/app/mock-data.ts
@@ -49,7 +49,7 @@ export const mockData = {
               proxyUsers: [
                 'admin'
               ],
-              evtTime: 1496057422531,
+              evtTime: currentTime.valueOf(),
               enforcer: 'ambari-acl',
               reqContext: 'ambari',
               cliType: 'GET',
@@ -72,7 +72,7 @@ export const mockData = {
               event_md5: '09876543211234567890',
               event_dur_ms: 100,
               _ttl_: '+7DAYS',
-              _expire_at_: '2017-05-29T11:30:22.531Z',
+              _expire_at_: currentTime.clone().add(2, 'd').valueOf(),
               _router_field_: 5
             },
             {
@@ -96,7 +96,7 @@ export const mockData = {
               proxyUsers: [
                 'user'
               ],
-              evtTime: 1496057422531,
+              evtTime: currentTime.clone().subtract(2, 'h').valueOf(),
               enforcer: 'hdfs',
               reqContext: 'ambari_server',
               cliType: 'PUT',
@@ -119,7 +119,7 @@ export const mockData = {
               event_md5: '01928374650192837465',
               event_dur_ms: 500,
               _ttl_: '+7DAYS',
-              _expire_at_: '2017-05-29T11:30:22.531Z',
+              _expire_at_: currentTime.clone().add(1, 'd').valueOf(),
               _router_field_: 10
             }
           ],
@@ -995,7 +995,7 @@ export const mockData = {
                 path: '/var/log/ambari-metrics-collector/ambari-metrics-collector.log',
                 host: 'h0',
                 level: 'WARN',
-                logtime: '2017-05-28T11:30:22.531Z',
+                logtime: currentTime.valueOf(),
                 ip: '192.168.0.1',
                 logfile_line_number: 8,
                 type: 'ams_collector',
@@ -1012,14 +1012,14 @@ export const mockData = {
                 event_md5: '1908755391',
                 event_dur_ms: 200,
                 _ttl_: '+5DAYS',
-                _expire_at_: '2017-05-29T11:30:22.531Z',
+                _expire_at_: currentTime.clone().add(1, 'd').valueOf(),
                 _router_field_: 20
               },
               {
                 path: '/var/log/ambari-metrics-collector/ambari-metrics-collector.log',
                 host: 'h1',
                 level: 'ERROR',
-                logtime: '2017-05-28T10:30:22.531Z',
+                logtime: currentTime.clone().subtract(1, 'd').valueOf(),
                 ip: '192.168.0.2',
                 type: 'ams_collector',
                 _version_: 14,
@@ -1036,7 +1036,7 @@ export const mockData = {
                 event_md5: '1029384756',
                 event_dur_ms: 700,
                 _ttl_: '+5DAYS',
-                _expire_at_: '2017-05-29T10:30:22.531Z',
+                _expire_at_: currentTime.clone().add(2, 'd').valueOf(),
                 _router_field_: 5
               }
             ]
diff --git a/ambari-logsearch/ambari-logsearch-web/src/app/services/logs-container.service.ts b/ambari-logsearch/ambari-logsearch-web/src/app/services/logs-container.service.ts
index a523e03..7664b3d 100644
--- a/ambari-logsearch/ambari-logsearch-web/src/app/services/logs-container.service.ts
+++ b/ambari-logsearch/ambari-logsearch-web/src/app/services/logs-container.service.ts
@@ -122,6 +122,13 @@ export class LogsContainerService {
           this.loadLogs();
       });
     });
+    this.auditLogsSource.subscribe((logs: AuditLog[]): void => {
+      const userNames = logs.map((log: AuditLog): string => log.reqUser);
+      this.utils.pushUniqueValues(
+        this.filters.users.options, userNames.map(this.utils.getListItemFromString),
+        this.compareFilterOptions
+      );
+    });
   }
 
   private readonly paginationOptions: string[] = ['10', '25', '50', '100'];
@@ -172,7 +179,7 @@ export class LogsContainerService {
       fieldName: 'cluster'
     },
     timeRange: {
-      label: 'filter.duration',
+      label: 'logs.duration',
       options: [
         [
           {
@@ -496,6 +503,13 @@ export class LogsContainerService {
     query: {
       defaultSelection: []
     },
+    users: {
+      label: 'filter.users',
+      iconClass: 'fa fa-server',
+      options: [],
+      defaultSelection: [],
+      fieldName: 'reqUser'
+    },
     isUndoOrRedo: {
       defaultSelection: false
     }
@@ -511,7 +525,8 @@ export class LogsContainerService {
     serviceLogsSorting: ['sortType', 'sortBy'],
     pageSize: ['pageSize'],
     page: ['page'],
-    query: ['includeQuery', 'excludeQuery']
+    query: ['includeQuery', 'excludeQuery'],
+    users: ['userList']
   };
 
   private readonly graphFilters = {
@@ -520,7 +535,8 @@ export class LogsContainerService {
     components: ['mustBe'],
     levels: ['level'],
     hosts: ['hostList'],
-    query: ['includeQuery', 'excludeQuery']
+    query: ['includeQuery', 'excludeQuery'],
+    users: ['userList']
   };
 
   readonly customTimeRangeKey: string = 'filter.timeRange.custom';
@@ -533,8 +549,7 @@ export class LogsContainerService {
     auditLogs: {
       logsModel: this.auditLogsStorage,
       fieldsModel: this.auditLogsFieldsStorage,
-      // TODO add all the required fields
-      listFilters: ['clusters', 'timeRange', 'auditLogsSorting', 'pageSize', 'page', 'query'],
+      listFilters: ['clusters', 'timeRange', 'auditLogsSorting', 'pageSize', 'page', 'query', 'users'],
       topResourcesFilters: ['clusters', 'timeRange', 'query'],
       graphFilters: ['clusters', 'timeRange', 'query'],
       graphRequestName: 'auditLogsGraph',
@@ -612,13 +627,21 @@ export class LogsContainerService {
     }
   }
 
+  private auditLogsSource: Observable<AuditLog[]> = this.auditLogsStorage.getAll();
+
+  private serviceLogsSource: Observable<ServiceLog[]> = this.serviceLogsStorage.getAll();
+
   auditLogsColumns: Observable<ListItem[]> = this.auditLogsFieldsStorage.getAll().map(this.columnsMapper);
 
   serviceLogsColumns: Observable<ListItem[]> = this.serviceLogsFieldsStorage.getAll().map(this.columnsMapper);
 
-  serviceLogs: Observable<ServiceLog[]> = Observable.combineLatest(this.serviceLogsStorage.getAll(), this.serviceLogsColumns).map(this.logsMapper);
+  serviceLogs: Observable<ServiceLog[]> = Observable.combineLatest(
+    this.serviceLogsSource, this.serviceLogsColumns
+  ).map(this.logsMapper);
 
-  auditLogs: Observable<AuditLog[]> = Observable.combineLatest(this.auditLogsStorage.getAll(), this.auditLogsColumns).map(this.logsMapper);
+  auditLogs: Observable<AuditLog[]> = Observable.combineLatest(
+    this.auditLogsSource, this.auditLogsColumns
+  ).map(this.logsMapper);
 
   queryParameterNameChange: Subject<SearchBoxParameterTriggered> = new Subject();
 
@@ -642,6 +665,16 @@ export class LogsContainerService {
 
   topResourcesGraphData: HomogeneousObject<HomogeneousObject<number>> = {};
 
+  /**
+   * Compares two options list items by values (so that isChecked flags are ignored)
+   * @param {ListItem} sourceItem
+   * @param {ListItem} newItem
+   * @returns {boolean}
+   */
+  private compareFilterOptions = (sourceItem: ListItem, newItem: ListItem): boolean => {
+    return this.utils.isEqual(sourceItem.value, newItem.value);
+  };
+
   private isFormUnchanged = (valueA: object, valueB: object): boolean => {
     const trackedControlNames = this.logsTypeMap[this.activeLogsType].listFilters;
     for (let name of trackedControlNames) {
@@ -956,7 +989,7 @@ export class LogsContainerService {
     request.subscribe((response: Response): void => {
       const clusterNames = response.json();
       if (clusterNames) {
-        this.filters.clusters.options.push(...clusterNames.map(this.utils.getListItemFromString));
+        this.utils.pushUniqueValues(this.filters.clusters.options, clusterNames.map(this.utils.getListItemFromString));
         this.clustersStorage.addInstances(clusterNames);
       }
     });
@@ -973,7 +1006,7 @@ export class LogsContainerService {
             }, 0)
           }));
       if (components) {
-        this.filters.components.options.push(...components.map(this.utils.getListItemFromNode));
+        this.utils.pushUniqueValues(this.filters.components.options, components.map(this.utils.getListItemFromNode));
         this.componentsStorage.addInstances(components);
       }
     });
@@ -986,7 +1019,7 @@ export class LogsContainerService {
       const jsonResponse = response.json(),
         hosts = jsonResponse && jsonResponse.vNodeList;
       if (hosts) {
-        this.filters.hosts.options.push(...hosts.map(this.utils.getListItemFromNode));
+        this.utils.pushUniqueValues(this.filters.hosts.options, hosts.map(this.utils.getListItemFromNode));
         this.hostsStorage.addInstances(hosts);
       }
     });
diff --git a/ambari-logsearch/ambari-logsearch-web/src/app/services/mock-api-data.service.ts b/ambari-logsearch/ambari-logsearch-web/src/app/services/mock-api-data.service.ts
index 985b52f..b7c93a1 100644
--- a/ambari-logsearch/ambari-logsearch-web/src/app/services/mock-api-data.service.ts
+++ b/ambari-logsearch/ambari-logsearch-web/src/app/services/mock-api-data.service.ts
@@ -31,6 +31,18 @@ export class mockBackendService extends InMemoryBackendService {
 
 export class mockApiDataService implements InMemoryDbService {
 
+  private filterByMessage = (value: string, filterValue: string): boolean => {
+    return value.toLowerCase().indexOf(filterValue.toLowerCase()) > -1;
+  };
+
+  private filterByStartTime = (value: number, filterValue: number | string | Date | moment.Moment): boolean => {
+    return value >= moment(filterValue).valueOf();
+  };
+
+  private filterByEndTime = (value: number, filterValue: number | string | Date | moment.Moment): boolean => {
+    return value <= moment(filterValue).valueOf();
+  };
+
   private readonly filterMap = {
     'api/v1/service/logs': {
       pathToCollection: 'logList',
@@ -50,15 +62,15 @@ export class mockApiDataService implements InMemoryDbService {
         },
         iMessage: {
           key: 'log_message',
-          filterFunction: (value, filterValue) => value.toLowerCase().indexOf(filterValue.toLowerCase()) > -1
+          filterFunction: this.filterByMessage
         },
         from: {
           key: 'logtime',
-          filterFunction: (value, filterValue) => value >= moment(filterValue).valueOf()
+          filterFunction: this.filterByStartTime
         },
         to: {
           key: 'logtime',
-          filterFunction: (value, filterValue) => value < moment(filterValue).valueOf()
+          filterFunction: this.filterByEndTime
         },
         hostList: {
           key: 'host',
@@ -76,15 +88,19 @@ export class mockApiDataService implements InMemoryDbService {
         },
         iMessage: {
           key: 'log_message',
-          filterFunction: (value, filterValue) => value.toLowerCase().indexOf(filterValue.toLowerCase()) > -1
+          filterFunction: this.filterByMessage
         },
         from: {
           key: 'evtTime',
-          filterFunction: (value, filterValue) => value >= moment(filterValue).valueOf()
+          filterFunction: this.filterByStartTime
         },
         to: {
           key: 'evtTime',
-          filterFunction: (value, filterValue) => value < moment(filterValue).valueOf()
+          filterFunction: this.filterByEndTime
+        },
+        userList: {
+          key: 'reqUser',
+          isValuesList: true
         }
       }
     }
@@ -126,9 +142,11 @@ export class mockApiDataService implements InMemoryDbService {
         }
       }, interceptorArgs.db);
       if (is404) {
-        return new Observable<Response>((subscriber: Subscriber<Response>) => subscriber.error(new Response(createErrorResponse(
-          interceptorArgs.requestInfo.req, 404, 'Not found'
-        ))));
+        return new Observable<Response>((subscriber: Subscriber<Response>) => subscriber.error(
+          new Response(createErrorResponse(
+            interceptorArgs.requestInfo.req, 404, 'Not found'
+          )))
+        );
       } else {
         let filteredData;
         const filterMapItem = this.filterMap[path];
@@ -143,10 +161,11 @@ export class mockApiDataService implements InMemoryDbService {
                 paramFilter = filterMapItem.filters[key],
                 paramValuesList = paramFilter && paramFilter.isValuesList && paramValue ? paramValue.split(',') : [],
                 currentValue = paramFilter && item[paramFilter.key];
-              if (paramFilter &&
-                ((paramFilter.filterFunction && !paramFilter.filterFunction(currentValue, paramValue)) ||
+              if (
+                paramFilter && ((paramFilter.filterFunction && !paramFilter.filterFunction(currentValue, paramValue)) ||
                 (!paramFilter.filterFunction && !paramFilter.isValuesList && currentValue !== paramValue) ||
-                (!paramFilter.filterFunction && paramFilter.isValuesList && paramValuesList.indexOf(currentValue) === -1))) {
+                (!paramFilter.filterFunction && paramFilter.isValuesList && paramValuesList.indexOf(currentValue) === -1))
+              ) {
                 result = false;
               }
             });
@@ -181,15 +200,17 @@ export class mockApiDataService implements InMemoryDbService {
         } else {
           filteredData = allData;
         }
-        return new Observable<Response>((subscriber: Subscriber<Response>) => subscriber.next(new Response(new ResponseOptions({
-          status: 200,
-          body: filteredData
-        }))));
+        return new Observable<Response>((subscriber: Subscriber<Response>) => subscriber.next(
+          new Response(new ResponseOptions({
+            status: 200,
+            body: filteredData
+          })))
+        );
       }
     }
   }
 
-  post(interceptorArgs: any) {
+  post(interceptorArgs: any): Observable<Response> {
     // TODO implement posting data to mock object except login call
     return this.get(interceptorArgs);
   }
diff --git a/ambari-logsearch/ambari-logsearch-web/src/app/services/utils.service.spec.ts b/ambari-logsearch/ambari-logsearch-web/src/app/services/utils.service.spec.ts
index 861e5c3..33b6b7c 100644
--- a/ambari-logsearch/ambari-logsearch-web/src/app/services/utils.service.spec.ts
+++ b/ambari-logsearch/ambari-logsearch-web/src/app/services/utils.service.spec.ts
@@ -441,4 +441,129 @@ describe('UtilsService', () => {
     }));
   });
 
+  describe('#pushUniqueValues()', () => {
+    const cases = [
+      {
+        source: [1, 2, 3],
+        itemsToPush: [2, 4, 5, 1],
+        compareFunction: undefined,
+        result: [1, 2, 3, 4, 5],
+        title: 'primitives array'
+      },
+      {
+        source: [
+          {
+            p0: 'v0'
+          },
+          {
+            p1: 'v1'
+          },
+          {
+            p2: 'v2'
+          }
+        ],
+        itemsToPush: [
+          {
+            p3: 'v3'
+          },
+          {
+            p2: 'v2'
+          },
+          {
+            p2: 'v3'
+          },
+          {
+            p4: 'v4'
+          }
+        ],
+        compareFunction: undefined,
+        result: [
+          {
+            p0: 'v0'
+          },
+          {
+            p1: 'v1'
+          },
+          {
+            p2: 'v2'
+          },
+          {
+            p3: 'v3'
+          },
+          {
+            p2: 'v3'
+          },
+          {
+            p4: 'v4'
+          }
+        ],
+        title: 'objects array'
+      },
+      {
+        source: [
+          {
+            id: 0,
+            value: 'v0'
+          },
+          {
+            id: 1,
+            value: 'v1'
+          },
+          {
+            id: 2,
+            value: 'v2'
+          }
+        ],
+        itemsToPush: [
+          {
+            id: 3,
+            value: 'v3'
+          },
+          {
+            id: 4,
+            value: 'v4'
+          },
+          {
+            id: 0,
+            value: 'v5'
+          },
+          {
+            id: 1,
+            value: 'v6'
+          }
+        ],
+        compareFunction: (itemA: any, itemB: any): boolean => itemA.id === itemB.id,
+        result: [
+          {
+            id: 0,
+            value: 'v0'
+          },
+          {
+            id: 1,
+            value: 'v1'
+          },
+          {
+            id: 2,
+            value: 'v2'
+          },
+          {
+            id: 3,
+            value: 'v3'
+          },
+          {
+            id: 4,
+            value: 'v4'
+          }
+        ],
+        title: 'custom comparison function'
+      }
+    ];
+
+    cases.forEach(test => {
+      it(test.title, inject([UtilsService], (service: UtilsService) => {
+        expect(service.pushUniqueValues(test.source, test.itemsToPush, test.compareFunction)).toEqual(test.result);
+      }));
+    });
+  });
+
 });
diff --git a/ambari-logsearch/ambari-logsearch-web/src/app/services/utils.service.ts b/ambari-logsearch/ambari-logsearch-web/src/app/services/utils.service.ts
index 952a1d1..c960cd1 100644
--- a/ambari-logsearch/ambari-logsearch-web/src/app/services/utils.service.ts
+++ b/ambari-logsearch/ambari-logsearch-web/src/app/services/utils.service.ts
@@ -131,4 +131,24 @@ export class UtilsService {
     };
   }
 
+  /**
+   * Method that updates source array with only the values which aren't already present there
+   * @param {Array} sourceArray
+   * @param {Array} itemsToPush
+   * @param {Function} [compareFunction=this.isEqual] - custom comparison function;
+   * item is skipped if it returns true, and pushed - if false
+   * @returns {Array}
+   */
+  pushUniqueValues = (
+    sourceArray: any[], itemsToPush: any[], compareFunction: (x: any, y: any) => boolean = this.isEqual
+  ): any[] => {
+    itemsToPush.forEach((item: any) => {
+      const itemExists = sourceArray.some((sourceItem: any): boolean => compareFunction(item, sourceItem));
+      if (!itemExists) {
+        sourceArray.push(item);
+      }
+    });
+    return sourceArray;
+  }
+
 }
diff --git a/ambari-logsearch/ambari-logsearch-web/src/assets/i18n/en.json b/ambari-logsearch/ambari-logsearch-web/src/assets/i18n/en.json
index 7f16078..3627e12 100644
--- a/ambari-logsearch/ambari-logsearch-web/src/assets/i18n/en.json
+++ b/ambari-logsearch/ambari-logsearch-web/src/assets/i18n/en.json
@@ -34,7 +34,7 @@
   "filter.include": "Include",
   "filter.exclude": "Exclude",
   "filter.hosts": "Hosts",
-  "filter.duration": "Duration",
+  "filter.users": "Users",
 
   "filter.capture": "Capture",
   "filter.capture.triggeringRefresh": "Triggering auto-refresh in {{remainingSeconds}} sec",
@@ -173,6 +173,7 @@
   "logs.tableLayoutBtnTooltip": "Table layout. Optimal when you want to display only few columns.",
   "logs.flexLayoutBtnTooltip": "Flexible layout. Optimal when your screen is narrow or you want to display more columns.",
   "logs.toggleLabels": "Turn on/off the labels.",
+  "logs.duration": "Duration",
 
   "histogram.gap": "gap",
   "histogram.gaps": "gaps",

-- 
To stop receiving notification emails like this one, please contact
ababiichuk@apache.org.