You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pinot.apache.org by ak...@apache.org on 2019/04/04 18:07:42 UTC

[incubator-pinot] branch master updated: [TE] frontend - harleyjj/anomalies - prototype anomalies route replacement (#4066)

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

akshayrai09 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new 0655993  [TE] frontend - harleyjj/anomalies - prototype anomalies route replacement (#4066)
0655993 is described below

commit 065599332cbd010ce550945456972c897f587596
Author: Harley Jackson <ha...@gmail.com>
AuthorDate: Thu Apr 4 11:07:37 2019 -0700

    [TE] frontend - harleyjj/anomalies - prototype anomalies route replacement (#4066)
---
 .../app/pods/anomalies/controller.js               | 407 +++++++++++++++++++++
 .../thirdeye-frontend/app/pods/anomalies/route.js  | 134 +++++++
 .../app/pods/anomalies/template.hbs                |  98 +++++
 .../pods/components/anomaly-summary/component.js   | 276 ++++++++++++++
 .../pods/components/anomaly-summary/template.hbs   | 111 ++++++
 .../app/pods/components/entity-filter/component.js |   7 +-
 thirdeye/thirdeye-frontend/app/router.js           |   1 +
 .../styles/components/range-pill-selectors.scss    |   1 -
 thirdeye/thirdeye-frontend/app/utils/anomaly.js    |  15 +-
 .../thirdeye-frontend/app/utils/api/anomaly.js     |  13 +-
 10 files changed, 1054 insertions(+), 9 deletions(-)

diff --git a/thirdeye/thirdeye-frontend/app/pods/anomalies/controller.js b/thirdeye/thirdeye-frontend/app/pods/anomalies/controller.js
new file mode 100644
index 0000000..91b9ca5
--- /dev/null
+++ b/thirdeye/thirdeye-frontend/app/pods/anomalies/controller.js
@@ -0,0 +1,407 @@
+/**
+ * Handles alert list and filter settings
+ * @module manage/alerts/controller
+ * @exports alerts controller
+ */
+import _ from 'lodash';
+import {
+  set,
+  get,
+  computed,
+  getProperties,
+  setProperties,
+  observer
+} from '@ember/object';
+import { isPresent } from '@ember/utils';
+import Controller from '@ember/controller';
+import { reads } from '@ember/object/computed';
+import { setUpTimeRangeOptions, powerSort } from 'thirdeye-frontend/utils/manage-alert-utils';
+import moment from 'moment';
+
+const TIME_PICKER_INCREMENT = 5; // tells date picker hours field how granularly to display time
+const DEFAULT_ACTIVE_DURATION = '1d'; // default duration for time picker timeRangeOptions - see TIME_RANGE_OPTIONS below
+const UI_DATE_FORMAT = 'MMM D, YYYY hh:mm a'; // format for date picker to use (usually varies by route or metric)
+const DISPLAY_DATE_FORMAT = 'YYYY-MM-DD HH:mm'; // format used consistently across app to display custom date range
+const TIME_RANGE_OPTIONS = ['1d', '1w', '1m', '3m'];
+
+export default Controller.extend({
+
+  queryParams: ['testMode'],
+
+  /**
+   * One-way CP to store all sub groups
+   */
+  initialFiltersLocal: reads('model.initialFiltersLocal'),
+
+  /**
+   * Used to help display filter settings in page header
+   */
+  primaryFilterVal: 'All Anomalies',
+  isFilterStrLenMax: false,
+  maxFilterStrngLength: 84,
+
+  /**
+   * Used to trigger re-render of alerts list
+   */
+  filtersTriggered: false,
+
+  /**
+   * Used to surface newer features pre-launch
+   */
+  testMode: null,
+
+  /**
+   * Boolean to display or hide summary of all filters
+   */
+  allowFilterSummary: true,
+
+  /**
+   * Default Sort Mode
+   */
+  selectedSortMode: 'Edited:last',
+
+  /**
+   * Filter settings
+   */
+  anomalyFilters: [],
+  resetFiltersLocal: null,
+  alertFoundByName: null,
+
+  /**
+   * The first and broadest entity search property
+   */
+  topSearchKeyName: 'application',
+
+  // default current Page
+  currentPage: 1,
+
+  // Alerts to display per PAge
+  pageSize: 10,
+
+  // Number of pages to display
+  paginationSize: computed(
+    'pagesNum',
+    'pageSize',
+    function() {
+      const pagesNum = this.get('pagesNum');
+      const pageSize = this.get('pageSize');
+
+      return Math.min(pagesNum, pageSize/2);
+    }
+  ),
+
+  // When the user changes the time range, this will fetch the anomaly ids
+  updateVisuals: observer(
+    'anomaliesRange',
+    function() {
+      const {
+        anomaliesRange,
+        updateAnomalies
+      } = this.getProperties('anomaliesRange', 'updateAnomalies');
+      set(this, 'isLoading', true);
+      const [ start, end ] = anomaliesRange;
+      updateAnomalies(start, end)
+        .then(res => {
+          this.setProperties({
+            anomaliesById: res,
+            anomalyIds: res.anomalyIds
+          });
+          this._resetLocalFilters();
+          set(this, 'isLoading', false);
+        })
+        .catch(() => {
+          this._resetLocalFilters();
+          set(this, 'isLoading', false);
+        });
+
+    }
+  ),
+
+  // Total Number of pages to display
+  pagesNum: computed(
+    'totalAnomalies',
+    'pageSize',
+    function() {
+      const { pageSize, totalAnomalies } = getProperties(this, 'pageSize', 'totalAnomalies');
+      return Math.ceil(totalAnomalies/pageSize);
+    }
+  ),
+
+  // creates the page Array for view
+  viewPages: computed(
+    'pages',
+    'currentPage',
+    'paginationSize',
+    'pageNums',
+    function() {
+      const size = this.get('paginationSize');
+      const currentPage = this.get('currentPage');
+      const max = this.get('pagesNum');
+      const step = Math.floor(size / 2);
+
+      if (max === 1) { return; }
+
+      const startingNumber = ((max - currentPage) < step)
+        ? Math.max(max - size + 1, 1)
+        : Math.max(currentPage - step, 1);
+
+      return [...new Array(size)].map((page, index) =>  startingNumber + index);
+    }
+  ),
+
+  // return list of anomalyIds according to filter(s) applied
+  selectedAnomalies: computed(
+    'anomalyFilters',
+    'anomalyIds',
+    'activeFiltersString',
+    function() {
+      const {
+        anomalyIds,
+        anomalyFilters,
+        anomaliesById,
+        activeFiltersString
+      } = this.getProperties('anomalyIds', 'anomalyFilters', 'anomaliesById', 'activeFiltersString');
+      const filterMaps = ['statusFilterMap', 'functionFilterMap', 'datasetFilterMap', 'metricFilterMap', 'dimensionFilterMap'];
+      if (activeFiltersString === 'All Anomalies') {
+        // no filter applied, just return all
+        return anomalyIds;
+      }
+      let selectedAnomalies = [];
+      filterMaps.forEach(map => {
+        if (anomalyFilters[map]) {
+          // a filter is selected, grab relevant anomalyIds
+          if (selectedAnomalies.length === 0) {
+            selectedAnomalies = this._unionOfArrays(anomaliesById, map, anomalyFilters[map]);
+          } else {
+            selectedAnomalies = this._intersectOfArrays(selectedAnomalies, this._unionOfArrays(anomaliesById, map, anomalyFilters[map]));
+          }
+        }
+      });
+      return selectedAnomalies;
+    }
+  ),
+
+  totalAnomalies: computed(
+    'selectedAnomalies',
+    function() {
+      return get(this, 'selectedAnomalies').length;
+    }
+  ),
+
+  paginatedSelectedAnomalies: computed(
+    'selectedAnomalies.@each',
+    'filtersTriggered',
+    'pageSize',
+    'currentPage',
+    function() {
+      const {
+        pageSize,
+        currentPage
+      } = getProperties(this, 'pageSize', 'currentPage');
+      // Initial set of anomalies
+      let anomalies = this.get('selectedAnomalies');
+      // Return one page of sorted anomalies
+      return anomalies.slice((currentPage - 1) * pageSize, currentPage * pageSize);
+    }
+  ),
+
+  /**
+   * Date types to display in the pills
+   * @type {Object[]} - array of objects, each of which represents each date pill
+   */
+  pill: computed(
+    'anomaliesRange', 'startDate', 'endDate', 'duration',
+    function() {
+      const anomaliesRange = get(this, 'anomaliesRange');
+      const startDate = Number(anomaliesRange[0]);
+      const endDate = Number(anomaliesRange[1]);
+      const duration = get(this, 'duration') || DEFAULT_ACTIVE_DURATION;
+      const predefinedRanges = {
+        'Today': [moment().startOf('day'), moment().startOf('day').add(1, 'days')],
+        'Last 24 hours': [moment().subtract(1, 'day'), moment()],
+        'Yesterday': [moment().subtract(1, 'day').startOf('day'), moment().startOf('day')],
+        'Last Week': [moment().subtract(1, 'week').startOf('day'), moment().startOf('day')]
+      };
+
+      return {
+        uiDateFormat: UI_DATE_FORMAT,
+        activeRangeStart: moment(startDate).format(DISPLAY_DATE_FORMAT),
+        activeRangeEnd: moment(endDate).format(DISPLAY_DATE_FORMAT),
+        timeRangeOptions: setUpTimeRangeOptions(TIME_RANGE_OPTIONS, duration),
+        timePickerIncrement: TIME_PICKER_INCREMENT,
+        predefinedRanges
+      };
+    }
+  ),
+
+  // String containing all selected filters for display
+  activeFiltersString: computed(
+    'anomalyFilters',
+    'filtersTriggered',
+    function() {
+      const anomalyFilters = get(this, 'anomalyFilters');
+      const filterAbbrevMap = {
+        functionFilterMap: 'function',
+        datasetFilterMap: 'dataset',
+        statusFilterMap: 'status',
+        metricFilterMap: 'metric',
+        dimensionFilterMap: 'dimension'
+      };
+      let filterStr = 'All Anomalies';
+      if (isPresent(anomalyFilters)) {
+        if (anomalyFilters.primary) {
+          filterStr = anomalyFilters.primary;
+          set(this, 'primaryFilterVal', filterStr);
+        } else {
+          let filterArr = [get(this, 'primaryFilterVal')];
+          Object.keys(anomalyFilters).forEach((filterKey) => {
+            const value = anomalyFilters[filterKey];
+            const isStatusAll = filterKey === 'status' && Array.isArray(value) && value.length > 1;
+            // Only display valid search filters
+            if (filterKey !== 'triggerType' && value !== null && value.length && !isStatusAll) {
+              let concatVal = filterKey === 'status' && !value.length ? 'Active' : value.join(', ');
+              let abbrevKey = filterAbbrevMap[filterKey] || filterKey;
+              filterArr.push(`${abbrevKey}: ${concatVal}`);
+            }
+          });
+          filterStr = filterArr.join(' | ');
+        }
+      }
+      return filterStr;
+    }
+  ),
+
+  /**
+   * When user chooses to either find an alert by name, or use a global filter,
+   * we should re-set all local filters.
+   * @method _resetFilters
+   * @param {Boolean} isSelectDisabled
+   * @returns {undefined}
+   * @private
+   */
+  _resetLocalFilters() {
+    let anomalyFilters = {};
+    const newFilterBlocksLocal = _.cloneDeep(get(this, 'initialFiltersLocal'));
+    const anomaliesById = get(this, 'anomaliesById');
+
+    // Fill in select options for these filters ('filterKeys') based on alert properties from model.alerts
+    newFilterBlocksLocal.forEach((filter) => {
+      if (filter.name === "dimensionFilterMap") {
+        const anomalyPropertyArray = Object.keys(anomaliesById.searchFilters[filter.name]);
+        let filterKeys = [];
+        anomalyPropertyArray.forEach(dimensionType => {
+          let group = Object.keys(anomaliesById.searchFilters[filter.name][dimensionType]);
+          group = group.map(dim => `${dimensionType}::${dim}`);
+          filterKeys = [...filterKeys, ...group];
+        });
+        Object.assign(filter, { filterKeys });
+      } else {
+        const anomalyPropertyArray = Object.keys(anomaliesById.searchFilters[filter.name]);
+        const filterKeys = [ ...new Set(powerSort(anomalyPropertyArray, null))];
+        // Add filterKeys prop to each facet or filter block
+        Object.assign(filter, { filterKeys });
+      }
+    });
+
+    // Reset local (secondary) filters, and set select fields to 'disabled'
+    setProperties(this, {
+      filterBlocksLocal: newFilterBlocksLocal,
+      resetFiltersLocal: moment().valueOf(),
+      anomalyFilters
+    });
+  },
+
+  // method to union anomalyId arrays for filters applied of same type
+  _unionOfArrays(anomaliesById, filterType, selectedFilters) {
+    //handle dimensions separately, since they are nested
+    if (filterType === 'dimensionFilterMap') {
+      let addedIds = [];
+      selectedFilters.forEach(filter => {
+        const [type, dimension] = filter.split('::');
+        addedIds = [...addedIds, ...anomaliesById.searchFilters.dimensionFilterMap[type][dimension]];
+      });
+      return addedIds;
+    } else {
+      let addedIds = [];
+      selectedFilters.forEach(filter => {
+        addedIds = [...addedIds, ...anomaliesById.searchFilters[filterType][filter]];
+      });
+      return addedIds;
+    }
+  },
+
+  // method to intersect anomalyId arrays for filters applied of different types
+  // i.e. we want anomalies that have both characteristics when filter type is different
+  _intersectOfArrays(existingArray, incomingArray) {
+    return existingArray.filter(anomalyId => incomingArray.includes(anomalyId));
+  },
+
+  actions: {
+    // Handles filter selections (receives array of filter options)
+    userDidSelectFilter(filterObj) {
+      const filterBlocksLocal = get(this, 'filterBlocksLocal');
+      filterBlocksLocal.forEach(block => {
+        block.selected = filterObj[block.name];
+      });
+      setProperties(this, {
+        filtersTriggered: true,
+        allowFilterSummary: true,
+        anomalyFilters: filterObj
+      });
+      // Reset current page
+      set(this, 'currentPage', 1);
+    },
+
+    /**
+     * Sets the new custom date range for anomaly coverage
+     * @method onRangeSelection
+     * @param {Object} rangeOption - the user-selected time range to load
+     */
+    onRangeSelection(timeRangeOptions) {
+      const {
+        start,
+        end,
+        value: duration
+      } = timeRangeOptions;
+
+      const startDate = moment(start).valueOf();
+      const endDate = moment(end).valueOf();
+      //Update the time range option selected
+      set(this, 'anomaliesRange', [startDate, endDate]);
+      set(this, 'duration', duration);
+    },
+
+    // Handles UI sort change
+    onSortModeChange(mode) {
+      this.set('selectedSortMode', mode);
+    },
+
+    /**
+     * action handler for page clicks
+     * @param {Number|String} page
+     */
+    onPaginationClick(page) {
+      let newPage = page;
+      let currentPage = this.get('currentPage');
+
+      switch (page) {
+        case 'previous':
+          if (currentPage > 1) {
+            newPage = --currentPage;
+          } else {
+            newPage = currentPage;
+          }
+          break;
+        case 'next':
+          if (currentPage < this.get('pagesNum')) {
+            newPage = ++currentPage;
+          } else {
+            newPage = currentPage;
+          }
+          break;
+      }
+
+      this.set('currentPage', newPage);
+    }
+  }
+});
diff --git a/thirdeye/thirdeye-frontend/app/pods/anomalies/route.js b/thirdeye/thirdeye-frontend/app/pods/anomalies/route.js
new file mode 100644
index 0000000..bc43585
--- /dev/null
+++ b/thirdeye/thirdeye-frontend/app/pods/anomalies/route.js
@@ -0,0 +1,134 @@
+import { hash } from 'rsvp';
+import Route from '@ember/routing/route';
+import moment from 'moment';
+import { inject as service } from '@ember/service';
+import { powerSort } from 'thirdeye-frontend/utils/manage-alert-utils';
+import {  getAnomalyIdsByTimeRange } from 'thirdeye-frontend/utils/anomaly';
+
+const start = moment().subtract(1, 'day').valueOf();
+const end = moment().valueOf();
+
+export default Route.extend({
+
+  // Make duration service accessible
+  durationCache: service('services/duration'),
+  session: service(),
+
+  model() {
+    return hash({
+      updateAnomalies:  getAnomalyIdsByTimeRange,
+      anomaliesById: getAnomalyIdsByTimeRange(start, end)
+    });
+  },
+
+  setupController(controller, model) {
+
+    // This filter category is "secondary". To add more, add an entry here and edit the controller's "filterToPropertyMap"
+    const filterBlocksLocal = [
+      {
+        name: 'statusFilterMap',
+        title: 'Anomaly Status',
+        type: 'select',
+        matchWidth: true,
+        filterKeys: []
+      },
+      {
+        name: 'functionFilterMap',
+        title: 'Functions',
+        type: 'select',
+        filterKeys: []
+      },
+      {
+        name: 'datasetFilterMap',
+        title: 'Dataset',
+        type: 'select',
+        filterKeys: []
+      },
+      {
+        name: 'metricFilterMap',
+        title: 'Metric',
+        type: 'select',
+        filterKeys: []
+      },
+      {
+        name: 'dimensionFilterMap',
+        title: 'Dimension',
+        type: 'select',
+        matchWidth: true,
+        filterKeys: []
+      }
+    ];
+
+    // Fill in select options for these filters ('filterKeys') based on alert properties from model.alerts
+    filterBlocksLocal.forEach((filter) => {
+      if (filter.name === "dimensionFilterMap") {
+        const anomalyPropertyArray = Object.keys(model.anomaliesById.searchFilters[filter.name]);
+        let filterKeys = [];
+        anomalyPropertyArray.forEach(dimensionType => {
+          let group = Object.keys(model.anomaliesById.searchFilters[filter.name][dimensionType]);
+          group = group.map(dim => `${dimensionType}::${dim}`);
+          filterKeys = [...filterKeys, ...group];
+        });
+        Object.assign(filter, { filterKeys });
+      } else {
+        const anomalyPropertyArray = Object.keys(model.anomaliesById.searchFilters[filter.name]);
+        const filterKeys = [ ...new Set(powerSort(anomalyPropertyArray, null))];
+        // Add filterKeys prop to each facet or filter block
+        Object.assign(filter, { filterKeys });
+      }
+    });
+
+    // Keep an initial copy of the secondary filter blocks in memory
+    Object.assign(model, {
+      initialFiltersLocal: filterBlocksLocal
+    });
+
+    // Send filters to controller
+    controller.setProperties({
+      model,
+      anomaliesById: model.anomaliesById,
+      resultsActive: true,
+      updateAnomalies: model.updateAnomalies,  //requires start and end time in epoch ex updateAnomalies(start, end)
+      filterBlocksLocal,
+      anomalyIds: model.anomaliesById.anomalyIds,
+      anomaliesRange: [start, end]
+    });
+  },
+
+  actions: {
+    /**
+     * Clear duration cache (time range is reset to default when entering new alert page from index)
+     * @method willTransition
+     */
+    willTransition(transition) {
+      this.get('durationCache').resetDuration();
+      this.controller.set('isLoading', true);
+
+      //saving session url - TODO: add a util or service - lohuynh
+      if (transition.intent.name && transition.intent.name !== 'logout') {
+        this.set('session.store.fromUrl', {lastIntentTransition: transition});
+      }
+    },
+    error() {
+      // The `error` hook is also provided the failed
+      // `transition`, which can be stored and later
+      // `.retry()`d if desired.
+      return true;
+    },
+
+    /**
+     * Once transition is complete, remove loader
+     */
+    didTransition() {
+      this.controller.set('isLoading', false);
+    },
+
+    /**
+    * Refresh route's model.
+    * @method refreshModel
+    */
+    refreshModel() {
+      this.refresh();
+    }
+  }
+});
diff --git a/thirdeye/thirdeye-frontend/app/pods/anomalies/template.hbs b/thirdeye/thirdeye-frontend/app/pods/anomalies/template.hbs
new file mode 100644
index 0000000..89fe486
--- /dev/null
+++ b/thirdeye/thirdeye-frontend/app/pods/anomalies/template.hbs
@@ -0,0 +1,98 @@
+<div class="container">
+  <aside class="manage-anomaly-filters col-md-3">
+    <div class="manage-anomaly-filters__wrapper">
+      {{entity-filter
+        title="Filters"
+        maxStrLen=25
+        currentFilterState=anomalyFilters
+        resetFilters=resetFiltersLocal
+        filterBlocks=filterBlocksLocal
+        onSelectFilter=(action "userDidSelectFilter")
+      }}
+    </div>
+  </aside>
+
+  {{range-pill-selectors
+    title="Showing"
+    uiDateFormat=pill.uiDateFormat
+    activeRangeEnd=pill.activeRangeEnd
+    activeRangeStart=pill.activeRangeStart
+    timeRangeOptions=pill.timeRangeOptions
+    timePickerIncrement=pill.timePickerIncrement
+    predefinedRanges=pill.predefinedRanges
+    selectAction=(action "onRangeSelection")
+  }}
+
+  <main class="manage-anomaly-container card-container card-container--padded col-md-9">
+    {{#if isLoading}}
+      <div class="spinner-wrapper-self-serve spinner-wrapper-self-serve__content-block">
+        {{ember-spinner}}
+      </div>
+    {{else}}
+      <div class="manage-anomaly-results">
+        <section class="te-search-header">
+          <span class="te-search-title">Anomalies ({{totalAnomalies}})</span>
+          {{#if allowFilterSummary}}
+            <span class="te-search-filters">{{activeFiltersString}}</span>
+            {{#if (gt activeFiltersString.length maxFilterStrngLength)}}
+              <span class="te-search-header__icon">
+                <i class="glyphicon glyphicon-resize-full"></i>
+                {{#popover-on-element side="left" class="te-search-header__tooltip te-tooltip"}}{{activeFiltersString}}{{/popover-on-element}}
+              </span>
+            {{/if}}
+          {{/if}}
+          {{#if paginatedSelectedAnomalies}}
+            <span class="te-search-header__displaynum pull-right">
+              Showing {{paginatedSelectedAnomalies.length}} of
+              {{totalAnomalies}} {{if (gt totalAnomalies 1) 'anomalies' 'anomaly'}}
+            </span>
+          {{/if}}
+        </section>
+
+        {{#if isLoading}}
+          <div class="spinner-wrapper-self-serve spinner-wrapper-self-serve--fixed">{{ember-spinner}}</div>
+        {{/if}}
+
+        {{#each paginatedSelectedAnomalies as |anomaly|}}
+          <section class="te-search-results">
+            {{anomaly-summary
+              anomalyId=anomaly
+            }}
+          </section>
+        {{/each}}
+
+      </div>
+
+    {{!--pagination--}}
+      {{#if (gt pagesNum 1)}}
+        <nav class="text-center" aria-label="Page navigation">
+          <ul class="pagination">
+            <li class={{if (eq currentPage 1) 'active disabled'}} >
+              <a href="#" {{action "onPaginationClick" 1}} aria-label="First">
+                <span aria-hidden="true">First</span>
+              </a>
+            </li>
+            <li class={{if (eq currentPage 1) 'active disabled'}}>
+              <a href="#" {{action "onPaginationClick" "previous"}} aria-label="Previous">
+                <span aria-hidden="true">Previous</span>
+              </a>
+            </li>
+            {{#each viewPages as |page|}}
+              <li class={{if (eq page currentPage) 'active'}}><a href="#" {{action "onPaginationClick" page}}>{{page}}</a></li>
+            {{/each}}
+            <li class={{if (eq currentPage pagesNum) 'disabled'}} >
+              <a href="#" {{action "onPaginationClick" "next"}} aria-label="Next">
+                <span aria-hidden="true">Next</span>
+              </a>
+            </li>
+            <li class={{if (eq currentPage pagesNum) 'disabled'}} >
+              <a href="#" {{action "onPaginationClick" pagesNum}} aria-label="Last">
+                <span aria-hidden="true">Last</span>
+              </a>
+            </li>
+          </ul>
+        </nav>
+      {{/if}}
+    {{/if}}
+  </main>
+</div>
diff --git a/thirdeye/thirdeye-frontend/app/pods/components/anomaly-summary/component.js b/thirdeye/thirdeye-frontend/app/pods/components/anomaly-summary/component.js
new file mode 100644
index 0000000..cce5376
--- /dev/null
+++ b/thirdeye/thirdeye-frontend/app/pods/components/anomaly-summary/component.js
@@ -0,0 +1,276 @@
+/**
+ * Displays summary for each anomaly in anomalies route
+ * @module components/anomaly-summary
+ * @exports anomaly-summary
+ */
+import Component from '@ember/component';
+import {
+  set,
+  get,
+  computed,
+  getProperties
+} from '@ember/object';
+import { colorMapping, toColor, makeTime } from 'thirdeye-frontend/utils/rca-utils';
+import { getFormattedDuration,
+  anomalyResponseMapNew,
+  verifyAnomalyFeedback,
+  anomalyResponseObj,
+  anomalyResponseObjNew,
+  updateAnomalyFeedback
+} from 'thirdeye-frontend/utils/anomaly';
+import RSVP from "rsvp";
+import fetch from 'fetch';
+import { checkStatus, humanizeFloat } from 'thirdeye-frontend/utils/utils';
+import columns from 'thirdeye-frontend/shared/anomaliesTableColumns';
+import moment from 'moment';
+import _ from 'lodash';
+
+const TABLE_DATE_FORMAT = 'MMM DD, hh:mm A'; // format for anomaly table
+
+export default Component.extend({
+  /**
+   * Overrides ember-models-table's css classes
+   */
+  classes: {
+    table: 'table table-striped table-bordered table-condensed'
+  },
+
+  columns: columns,
+  /**
+   * Anomaly Id, passed from parent
+   */
+  anomalyId: null,
+  /**
+   * Anomaly data, fetched using the anomalyId
+   */
+  anomalyData: {},
+  /**
+   * Anomaly data, fetched using the anomalyId
+   */
+  current: null,
+  /**
+   * Anomaly data, fetched using the anomalyId
+   */
+  predicted: null,
+  /**
+   * List of associated classes
+   */
+  colorMapping: colorMapping,
+  zoom: {
+    enabled: false,
+    rescale: true
+  },
+
+  legend: {
+    show: true,
+    position: 'right'
+  },
+  isLoading: false,
+  feedbackOptions: ['Not reviewed yet', 'Yes - unexpected', 'Expected temporary change', 'Expected permanent change', 'No change observed'],
+  labelMap: anomalyResponseMapNew,
+  labelResponse: {},
+
+  init() {
+    this._super(...arguments);
+    this._fetchAnomalyData();
+  },
+
+  axis: computed(
+    'anomalyData',
+    function () {
+      const anomalyData = get(this, 'anomalyData');
+
+      return {
+        y: {
+          show: true,
+          tick: {
+            format: function(d){return humanizeFloat(d);}
+          }
+        },
+        y2: {
+          show: false,
+          min: 0,
+          max: 1
+        },
+        x: {
+          type: 'timeseries',
+          show: true,
+          min: anomalyData.startTime,
+          max: anomalyData.endTime,
+          tick: {
+            fit: false,
+            format: (d) => {
+              const t = makeTime(d);
+              if (t.valueOf() === t.clone().startOf('day').valueOf()) {
+                return t.format('MMM D (ddd)');
+              }
+              return t.format('h:mm a');
+            }
+          }
+        }
+      };
+    }
+  ),
+
+  series: computed(
+    'anomalyData',
+    'current',
+    'predicted',
+    function () {
+      const {
+        anomalyData, current, predicted
+      } = getProperties(this, 'anomalyData', 'current', 'predicted');
+
+      const series = {};
+
+      if (!_.isEmpty(anomalyData)) {
+        const key = this._formatAnomaly(anomalyData);
+        series[key] = {
+          timestamps: [anomalyData.startTime, anomalyData.endTime],
+          values: [1, 1],
+          type: 'region',
+          color: 'orange'
+        };
+      }
+
+      if (current && !_.isEmpty(current.value)) {
+        series['current'] = {
+          timestamps: current.timestamp,
+          values: current.value,
+          type: 'line',
+          color: toColor(anomalyData.metricUrn)
+        };
+      }
+
+      if (predicted && !_.isEmpty(predicted.value)) {
+        series['predicted'] = {
+          timestamps: predicted.timestamp,
+          values: predicted.value,
+          type: 'line',
+          color: 'light-' + toColor(anomalyData.metricUrn)
+        };
+      }
+      return series;
+    }
+  ),
+
+  /**
+   * formats anomaly for table
+   * @method anomaly
+   * @return {Object}
+   */
+  anomaly: computed(
+    'anomalyData',
+    'labelResponse',
+    function() {
+      const anomalyData = get(this, 'anomalyData');
+      const labelResponse = get(this, 'labelResponse');
+      let tableAnomaly = {};
+
+      if (anomalyData) {
+        const a = anomalyData; //for convenience below
+        const change = (a.avgBaselineVal !== 0 && a.avgBaselineVal !== "Infinity" && a.avgCurrentVal !== "Infinity") ? (a.avgCurrentVal/a.avgBaselineVal - 1.0) * 100.0 : 0;
+        tableAnomaly = {
+          anomalyId: a.id,
+          metricUrn: a.metricUrn,
+          start: a.startTime,
+          end: a.endTime,
+          metricName: a.metric,
+          dataset: a.collection,
+          startDateStr: this._formatAnomaly(a),
+          durationStr: getFormattedDuration(a.startTime, a.endTime),
+          shownCurrent: a.avgCurrentVal === "Infinity" ? 0 : humanizeFloat(a.avgCurrentVal),
+          shownBaseline: a.avgBaselineVal === "Infinity" ? 0 : humanizeFloat(a.avgBaselineVal),
+          change: change,
+          shownChangeRate: humanizeFloat(change),
+          anomalyFeedback: a.feedback ? a.feedback.feedbackType : "NONE",
+          showResponseSaved: (labelResponse.anomalyId === a.id) ? labelResponse.showResponseSaved : false,
+          showResponseFailed: (labelResponse.anomalyId === a.id) ? labelResponse.showResponseFailed: false
+        };
+      }
+      return tableAnomaly;
+    }
+  ),
+
+  _fetchAnomalyData() {
+    const anomalyId = get(this, 'anomalyId');
+    const anomalyUrl = `/dashboard/anomalies/view/${anomalyId}`;
+
+    set(this, 'isLoading', true);
+
+    fetch(anomalyUrl)
+      .then(checkStatus)
+      .then(res => {
+        set(this, 'anomalyData', res);
+        const timeZone = 'America/Los_Angeles';
+        const currentUrl = `/rootcause/metric/timeseries?urn=${res.metricUrn}&start=${res.startTime}&end=${res.endTime}&offset=current&timezone=${timeZone}`;
+        const predictedUrl = `/detection/predicted-baseline/${anomalyId}?start=${res.startTime}&end=${res.endTime}`;
+        const timeseriesHash = {
+          current: fetch(currentUrl).then(res => checkStatus(res, 'get', true)),
+          predicted: fetch(predictedUrl).then(res => checkStatus(res, 'get', true))
+        };
+        return RSVP.hash(timeseriesHash);
+      })
+      .then((res) => {
+        set(this, 'current', res.current);
+        set(this, 'predicted', res.predicted);
+        set(this, 'isLoading', false);
+      })
+      .catch(err => {
+        set(this, 'isLoading', false);
+      });
+  },
+
+  _formatAnomaly(anomaly) {
+    return `${moment(anomaly.startTime).format(TABLE_DATE_FORMAT)}`;
+  },
+
+  actions: {
+    /**
+     * Handle dynamically saving anomaly feedback responses
+     * @method onChangeAnomalyResponse
+     * @param {Object} anomalyRecord - the anomaly being responded to
+     * @param {String} selectedResponse - user-selected anomaly feedback option
+     * @param {Object} inputObj - the selection object
+     */
+    onChangeAnomalyFeedback: async function(anomalyRecord, selectedResponse) {
+      const anomalyData = get(this, 'anomalyData');
+      // Reset status icon
+      set(this, 'renderStatusIcon', false);
+      const responseObj = anomalyResponseObj.find(res => res.name === selectedResponse);
+      // get the response object from anomalyResponseObjNew
+      const newFeedbackValue = anomalyResponseObjNew.find(res => res.name === selectedResponse).value;
+      try {
+        // Save anomaly feedback
+        await updateAnomalyFeedback(anomalyRecord.anomalyId, responseObj.value);
+        // We make a call to ensure our new response got saved
+        const anomaly = await verifyAnomalyFeedback(anomalyRecord.anomalyId);
+
+        if (anomaly.feedback && responseObj.value === anomaly.feedback.feedbackType) {
+          this.set('labelResponse', {
+            anomalyId: anomalyRecord.anomalyId,
+            showResponseSaved: true,
+            showResponseFailed: false
+          });
+
+          // replace anomaly feedback with selectedFeedback
+          anomalyData.feedback = {
+            feedbackType: newFeedbackValue
+          };
+
+          set(this, 'anomalyData', anomalyData);
+        } else {
+          throw 'Response not saved';
+        }
+      } catch (err) {
+        this.set('labelResponse', {
+          anomalyId: anomalyRecord.anomalyId,
+          showResponseSaved: false,
+          showResponseFailed: true
+        });
+      }
+      // Force status icon to refresh
+      set(this, 'renderStatusIcon', true);
+    }
+  }
+});
diff --git a/thirdeye/thirdeye-frontend/app/pods/components/anomaly-summary/template.hbs b/thirdeye/thirdeye-frontend/app/pods/components/anomaly-summary/template.hbs
new file mode 100644
index 0000000..ed5c669
--- /dev/null
+++ b/thirdeye/thirdeye-frontend/app/pods/components/anomaly-summary/template.hbs
@@ -0,0 +1,111 @@
+<div class="te-content-block">
+  <h4 class="te-self-serve__block-title">Anomaly #{{anomaly.anomalyId}}</h4>
+  <span>from metric <strong>{{anomaly.metricName}}</strong></span>
+  <span>in dataset <strong>{{anomaly.dataset}}</strong></span>
+
+  {{#if isLoading}}
+    <div class="spinner-wrapper-self-serve spinner-wrapper-self-serve__content-block">
+      {{ember-spinner}}
+    </div>
+  {{/if}}
+  <div class="col-xs-12 te-graph-container">
+    {{timeseries-chart
+      series=series
+      colorMapping=colorMapping
+      axis=axis
+      zoom=zoom
+      subchart=subchart
+      legend=legend
+    }}
+  </div>
+  {{!-- Alert anomaly table --}}
+  <div class="te-block-container">
+    <table class="te-anomaly-table">
+      <thead>
+        <tr class="te-anomaly-table__row te-anomaly-table__head">
+          <th class="te-anomaly-table__cell-head te-anomaly-table__cell-head--left">
+            <a class="te-anomaly-table__cell-link">
+              Start/Duration (PDT)
+            </a>
+          </th>
+          <th class="te-anomaly-table__cell-head">
+            <a class="te-anomaly-table__cell-link">
+              Average Current / Average Predicted
+            </a>
+          </th>
+          <th class="te-anomaly-table__cell-head">
+            <a class="te-anomaly-table__cell-link">
+              Feedback
+            </a>
+          </th>
+          <th class="te-anomaly-table__cell-head"></th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr class="te-anomaly-table__row">
+           <td class="te-anomaly-table__cell">
+            <ul class="te-anomaly-table__list te-anomaly-table__list--left">
+              <li class="te-anomaly-table__list-item te-anomaly-table__list-item--stronger">
+                <a target="_blank" class="te-anomaly-table__link" href="/app/#/rootcause?anomalyId={{anomaly.anomalyId}}">
+                  {{anomaly.startDateStr}}
+                </a>
+              </li>
+              <li class="te-anomaly-table__list-item te-anomaly-table__list-item--lighter">{{anomaly.durationStr}}</li>
+            </ul>
+           </td>
+           <td class="te-anomaly-table__cell">
+            <ul class="te-anomaly-table__list">
+              <li>{{anomaly.shownCurrent}} / {{anomaly.shownBaseline}}</li>
+              <li class="te-anomaly-table__value-label te-anomaly-table__value-label--{{calculate-direction anomaly.shownChangeRate}}">
+                {{#if (not anomaly.isNullChangeRate)}}
+                  ({{anomaly.shownChangeRate}}%)
+                {{else}}
+                  (N/A)
+                {{/if}}
+              </li>
+            </ul>
+           </td>
+           <td class="te-anomaly-table__cell">
+              {{#if renderStatusIcon}}
+                {{#if anomaly.showResponseSaved}}
+                  <i class="te-anomaly-table__icon--status glyphicon glyphicon-ok-circle"></i>
+                {{/if}}
+                {{#if anomaly.showResponseFailed}}
+                  <i class="te-anomaly-table__icon--status te-anomaly-table__icon--error glyphicon glyphicon-remove-circle"></i>
+                {{/if}}
+              {{/if}}
+
+              {{#if anomaly.isUserReported}}
+                <div class="te-anomaly-table__text te-anomaly-table__text--explore">User Reported</div>
+                <div class="te-anomaly-table__comment">
+                  <i class="glyphicon glyphicon-th-list"></i>
+                  {{#tooltip-on-element class="te-anomaly-table__tooltip"}}
+                    {{anomaly.anomalyFeedbackComments}}
+                  {{/tooltip-on-element}}
+                </div>
+              {{else}}
+                {{#power-select
+                  triggerId=anomaly.anomalyId
+                  triggerClass="te-anomaly-table__select"
+                  options=feedbackOptions
+                  searchEnabled=false
+                  selected=(get labelMap anomaly.anomalyFeedback)
+                  onchange=(action "onChangeAnomalyFeedback" anomaly)
+                  as |response|
+                }}
+                  {{response}}
+                {{/power-select}}
+              {{/if}}
+           </td>
+           <td class="te-anomaly-table__cell te-anomaly-table__cell--feedback">
+              <div class="te-anomaly-table__link-wrapper">
+                {{#link-to 'rootcause' (query-params anomalyId=anomaly.anomalyId) target="_blank" class="te-anomaly-table__link"}}
+                  Investigate
+                {{/link-to}}
+              </div>
+           </td>
+        </tr>
+      </tbody>
+    </table>
+  </div>
+</div>
diff --git a/thirdeye/thirdeye-frontend/app/pods/components/entity-filter/component.js b/thirdeye/thirdeye-frontend/app/pods/components/entity-filter/component.js
index 276a6e7..bfb43a6 100644
--- a/thirdeye/thirdeye-frontend/app/pods/components/entity-filter/component.js
+++ b/thirdeye/thirdeye-frontend/app/pods/components/entity-filter/component.js
@@ -33,13 +33,9 @@
 import {
   set,
   get,
-  computed,
-  getProperties,
-  setProperties,
-  getWithDefault
+  setProperties
 } from '@ember/object';
 import { isPresent } from '@ember/utils';
-import { later } from '@ember/runloop';
 import Component from '@ember/component';
 
 export default Component.extend({
@@ -133,7 +129,6 @@ export default Component.extend({
         // Make sure 'Active' is selected by default when both are un-checked
         if (filterObj.filtersArray.filter(item => item.isActive).length === 0) {
           selectedArr = ['Active'];
-          const activeItem = filterObj.filtersArray.find(item => item.id === 'active');
         }
       }
       // Handle 'global' or 'primary' filter field toggling
diff --git a/thirdeye/thirdeye-frontend/app/router.js b/thirdeye/thirdeye-frontend/app/router.js
index a517960..023ed95 100644
--- a/thirdeye/thirdeye-frontend/app/router.js
+++ b/thirdeye/thirdeye-frontend/app/router.js
@@ -18,6 +18,7 @@ Router.map(function() {
     this.route('share-dashboard');
   });
 
+  this.route('anomalies');
 
   this.route('manage', function() {
     this.route('alert', { path: 'alert/:alert_id' }, function() {
diff --git a/thirdeye/thirdeye-frontend/app/styles/components/range-pill-selectors.scss b/thirdeye/thirdeye-frontend/app/styles/components/range-pill-selectors.scss
index 93235a5..22189e9 100644
--- a/thirdeye/thirdeye-frontend/app/styles/components/range-pill-selectors.scss
+++ b/thirdeye/thirdeye-frontend/app/styles/components/range-pill-selectors.scss
@@ -1,5 +1,4 @@
 .range-pill-selectors {
-  clear: left;
   margin-top: 12px;
 
   &__list {
diff --git a/thirdeye/thirdeye-frontend/app/utils/anomaly.js b/thirdeye/thirdeye-frontend/app/utils/anomaly.js
index 3611aad..0cd30e2 100644
--- a/thirdeye/thirdeye-frontend/app/utils/anomaly.js
+++ b/thirdeye/thirdeye-frontend/app/utils/anomaly.js
@@ -10,7 +10,8 @@ import fetch from 'fetch';
 import {
   anomalyApiUrls,
   getAnomaliesForYamlPreviewUrl,
-  getAnomaliesByAlertIdUrl
+  getAnomaliesByAlertIdUrl,
+  getAnomalyIdsByTimeRangeUrl
 } from 'thirdeye-frontend/utils/api/anomaly';
 
 /**
@@ -121,6 +122,18 @@ export function getAnomaliesByAlertId(alertId, startTime, endTime) {
 }
 
 /**
+ * Get anomaly ids over a specified time range
+ * @method getAnomalyIdsByTimeRange
+ * @param {Number} startTime - start time of query range
+ * @param {Number} endTime - end time of query range
+ * @return {Ember.RSVP.Promise}
+ */
+export function getAnomalyIdsByTimeRange(startTime, endTime) {
+  const url = getAnomalyIdsByTimeRangeUrl(startTime, endTime);
+  return fetch(url).then(checkStatus);
+}
+
+/**
  * Fetch a single anomaly record for verification
  * @method verifyAnomalyFeedback
  * @param {Number} anomalyId
diff --git a/thirdeye/thirdeye-frontend/app/utils/api/anomaly.js b/thirdeye/thirdeye-frontend/app/utils/api/anomaly.js
index 9d5017e..d17c1d1 100644
--- a/thirdeye/thirdeye-frontend/app/utils/api/anomaly.js
+++ b/thirdeye/thirdeye-frontend/app/utils/api/anomaly.js
@@ -31,10 +31,21 @@ export function getAnomaliesByAlertIdUrl(alertId, startTime, endTime) {
   return `/detection/${alertId}/anomalies?start=${startTime}&end=${endTime}`;
 }
 
+/**
+ * Returns the url for getting anomaly ids of all anomalies over the specified time range
+ * @param {Number} startTime - beginning of time range of interest
+ * @param {Number} endTime - end of time range of interest
+ * @example getAnomalyIdsByTimeRangeUrl(1508472700000, 1508472800000) // yields => /anomalies/search/time/1508472700000/1508472800000/1?filterOnly=true
+ */
+export function getAnomalyIdsByTimeRangeUrl(startTime, endTime) {
+  return `/anomalies/search/time/${startTime}/${endTime}/1?filterOnly=true`;
+}
+
 export const anomalyApiUrls = {
   getAnomalyDataUrl,
   getAnomaliesForYamlPreviewUrl,
-  getAnomaliesByAlertIdUrl
+  getAnomaliesByAlertIdUrl,
+  getAnomalyIdsByTimeRangeUrl
 };
 
 export default {


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org