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