diff --git a/thirdeye/thirdeye-frontend/app/mirage/fixtures/applicationAnomalies.js b/thirdeye/thirdeye-frontend/app/mirage/fixtures/applicationAnomalies.js index 5f8a2c21b94..565373ce26c 100644 --- a/thirdeye/thirdeye-frontend/app/mirage/fixtures/applicationAnomalies.js +++ b/thirdeye/thirdeye-frontend/app/mirage/fixtures/applicationAnomalies.js @@ -4,8 +4,10 @@ import moment from 'moment'; export default [ { id: 1, + anomalyId: 26020862, start: 1491804013000, end: 1491890413000, + anomalyFeedback: 'True anomaly', dimensions: { app: 'company', country: 'US', @@ -22,6 +24,7 @@ export default [ functionName: 'Alert Name 1' }, { id: 2, + anomalyId: 33330876, start: 1491804013000, end: moment().valueOf(), dimensions: { @@ -38,6 +41,7 @@ export default [ functionName: 'Alert Name 1' }, { id: 3, + anomalyId: 33330893, start: 1491804013000, end: moment().valueOf(), dimensions: { @@ -54,6 +58,7 @@ export default [ functionName: 'Alert Name 1' }, { id: 4, + anomalyId: 33340923, start: 1491804013000, end: moment().valueOf(), severity: 0.86, @@ -65,6 +70,7 @@ export default [ functionName: 'Alert Name 1' }, { id: 5, + anomalyId: 33381509, start: 1491804013000, end: moment().valueOf(), dimensions: { @@ -81,6 +87,7 @@ export default [ functionName: 'Alert Name 1' }, { id: 6, + anomalyId: 33381877, start: 1491804013000, end: 1491890413000, dimensions: { @@ -97,6 +104,7 @@ export default [ functionName: 'Alert Name 1' }, { id: 7, + anomalyId: 33302982, start: 1491804013000, end: 1491890413000, dimensions: { @@ -113,6 +121,7 @@ export default [ functionName: 'Alert Name 1' }, { id: 8, + anomalyId: 33306049, start: 1491804013000, end: 1491890413000, dimensions: { @@ -129,6 +138,7 @@ export default [ functionName: 'Alert Name 2' }, { id: 9, + anomalyId: 33306058, start: 1491804013000, end: moment().valueOf(), dimensions: { @@ -145,6 +155,24 @@ export default [ functionName: 'Alert Name 2' }, { id: 10, + anomalyId: 33314704, + start: 1491804013000, + end: moment().valueOf(), + dimensions: { + country: ['US', 'FR'], + pageName: ['some_page'], + random1: ['partial'] + }, + severity: 0.86, + current: 1444, + baseline: 1000, + feedback: 'Yes, but New Trend', + metricName: 'Metric Name 2', + metricId: 12345, + functionName: 'Alert Name 2' + }, { + id: 11, + anomalyId: 33314705, start: 1491804013000, end: moment().valueOf(), dimensions: { diff --git a/thirdeye/thirdeye-frontend/app/pods/custom/anomalies-table/resolution/component.js b/thirdeye/thirdeye-frontend/app/pods/custom/anomalies-table/resolution/component.js new file mode 100644 index 00000000000..f3811e00c67 --- /dev/null +++ b/thirdeye/thirdeye-frontend/app/pods/custom/anomalies-table/resolution/component.js @@ -0,0 +1,60 @@ +/** + * Custom model table component + * Constructs the select box for the resolution + * @module custom/anomalies-table/resolution + * + * @example for usage in models table columns definitions + * { + * propertyName: 'feedback', + * component: 'custom/anomalies-table/resolution', + * title: 'Resolution', + * className: 'anomalies-table__column', + * disableFiltering: true + * }, + */ +import Component from "@ember/component"; +import { getWithDefault } from '@ember/object'; +import * as anomalyUtil from 'thirdeye-frontend/utils/anomaly'; +import { set, setProperties } from '@ember/object'; +import { getAnomalyDataUrl } from 'thirdeye-frontend/utils/api/anomaly'; + +export default Component.extend({ + tagName: '',//using tagless so i can add my own in hbs + anomalyResponseNames: anomalyUtil.anomalyResponseObj.mapBy('name'), + anomalyDataUrl: getAnomalyDataUrl(), + + 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 + */ + onChangeAnomalyResponse: async function(anomalyRecord, selectedResponse, inputObj) { + const responseObj = anomalyUtil.anomalyResponseObj.find(res => res.name === selectedResponse); + set(inputObj, 'selected', selectedResponse); + let res; + try { + // Save anomaly feedback + res = await anomalyUtil.updateAnomalyFeedback(anomalyRecord.anomalyId, responseObj.value) + // We make a call to ensure our new response got saved + res = await anomalyUtil.verifyAnomalyFeedback(anomalyRecord.anomalyId, responseObj.status) + const filterMap = getWithDefault(res, 'searchFilters.statusFilterMap', null); + if (filterMap && filterMap.hasOwnProperty(responseObj.status)) { + setProperties(anomalyRecord, { + anomalyFeedback: selectedResponse, + showResponseSaved: true + }); + } else { + return Promise.reject(new Error('Response not saved')); + } + } catch (err) { + setProperties(anomalyRecord, { + showResponseFailed: true, + showResponseSaved: false + }); + } + } + } +}); diff --git a/thirdeye/thirdeye-frontend/app/pods/custom/anomalies-table/resolution/template.hbs b/thirdeye/thirdeye-frontend/app/pods/custom/anomalies-table/resolution/template.hbs new file mode 100644 index 00000000000..178ec42813c --- /dev/null +++ b/thirdeye/thirdeye-frontend/app/pods/custom/anomalies-table/resolution/template.hbs @@ -0,0 +1,23 @@ +
+ {{#if record.showResponseSaved}} + + {{else}} + + {{/if}} + + {{#if record.isUserReported}} + User Reported + {{else}} + {{#power-select + triggerId=record.anomalyId + triggerClass="te-anomaly-table__select te-anomaly-table__select--margin-left" + options=anomalyResponseNames + searchEnabled=false + selected=record.anomalyFeedback + onchange=(action "onChangeAnomalyResponse" record) + as |response| + }} + {{response}} + {{/power-select}} + {{/if}} +
diff --git a/thirdeye/thirdeye-frontend/app/pods/home/controller.js b/thirdeye/thirdeye-frontend/app/pods/home/controller.js index d0f4e2b46a4..3951e6f98f6 100644 --- a/thirdeye/thirdeye-frontend/app/pods/home/controller.js +++ b/thirdeye/thirdeye-frontend/app/pods/home/controller.js @@ -35,7 +35,6 @@ export default Controller.extend({ ), actions: { - /** * Sets the selected application property based on user selection * @param {Object} selectedApplication - object that represents selected application diff --git a/thirdeye/thirdeye-frontend/app/pods/manage/alert/explore/controller.js b/thirdeye/thirdeye-frontend/app/pods/manage/alert/explore/controller.js index 3e3f779000a..131f18ecc98 100644 --- a/thirdeye/thirdeye-frontend/app/pods/manage/alert/explore/controller.js +++ b/thirdeye/thirdeye-frontend/app/pods/manage/alert/explore/controller.js @@ -27,6 +27,7 @@ import { setDuration } from 'thirdeye-frontend/utils/manage-alert-utils'; import floatToPercent from 'thirdeye-frontend/utils/float-to-percent'; +import * as anomalyUtil from 'thirdeye-frontend/utils/anomaly'; export default Controller.extend({ /** @@ -398,30 +399,6 @@ export default Controller.extend({ }); }), - /** - * Update feedback status on any anomaly - * @method updateAnomalyFeedback - * @param {Number} anomalyId - the id of the anomaly to update - * @param {String} feedbackType - key for feedback type - * @return {Ember.RSVP.Promise} - */ - updateAnomalyFeedback(anomalyId, feedbackType) { - const url = `/anomalies/updateFeedback/${anomalyId}`; - const data = { feedbackType, comment: '' }; - return fetch(url, postProps(data)).then((res) => checkStatus(res, 'post')); - }, - - /** - * Fetch a single anomaly record for verification - * @method verifyAnomalyFeedback - * @param {Number} anomalyId - * @return {undefined} - */ - verifyAnomalyFeedback(anomalyId) { - const anomalyUrl = this.get('anomalyDataUrl') + anomalyId; - return fetch(anomalyUrl).then(checkStatus); - }, - /** * Send a POST request to the report anomaly API (2-step process) * http://go/te-ss-alert-flow-api @@ -504,29 +481,30 @@ export default Controller.extend({ * @param {String} selectedResponse - user-selected anomaly feedback option * @param {Object} inputObj - the selection object */ - onChangeAnomalyResponse(anomalyRecord, selectedResponse, inputObj) { - const responseObj = this.get('anomalyResponseObj').find(res => res.name === selectedResponse); + onChangeAnomalyResponse: async function(anomalyRecord, selectedResponse, inputObj) { + const responseObj = anomalyUtil.anomalyResponseObj.find(res => res.name === selectedResponse); set(inputObj, 'selected', selectedResponse); - - // Save anomaly feedback - this.updateAnomalyFeedback(anomalyRecord.anomalyId, responseObj.value) - .then((res) => { - // We make a call to ensure our new response got saved - this.verifyAnomalyFeedback(anomalyRecord.anomalyId, responseObj.status) - .then((res) => { - const filterMap = res.searchFilters ? res.searchFilters.statusFilterMap : null; - if (filterMap && filterMap.hasOwnProperty(responseObj.status)) { - set(anomalyRecord, 'anomalyFeedback', selectedResponse); - set(anomalyRecord, 'showResponseSaved', true); - } else { - return Promise.reject(new Error('Response not saved')); - } - }); // verifyAnomalyFeedback - }) // updateAnomalyFeedback - .catch((err) => { - set(anomalyRecord, 'showResponseFailed', true); - set(anomalyRecord, 'showResponseSaved', false); + let res; + try { + // Save anomaly feedback + res = await anomalyUtil.updateAnomalyFeedback(anomalyRecord.anomalyId, responseObj.value) + // We make a call to ensure our new response got saved + res = await anomalyUtil.verifyAnomalyFeedback(anomalyRecord.anomalyId, responseObj.status) + const filterMap = getWithDefault(res, 'searchFilters.statusFilterMap', null); + if (filterMap && filterMap.hasOwnProperty(responseObj.status)) { + setProperties(anomalyRecord, { + anomalyFeedback: selectedResponse, + showResponseSaved: true + }); + } else { + return Promise.reject(new Error('Response not saved')); + } + } catch (err) { + setProperties(anomalyRecord, { + showResponseFailed: true, + showResponseSaved: false }); + } }, /** @@ -670,6 +648,7 @@ export default Controller.extend({ this.transitionToRoute({ queryParams: { duration, startDate, endDate }}); }, + /** * Load tuning sub-route and properly toggle alert nav button */ diff --git a/thirdeye/thirdeye-frontend/app/pods/manage/alert/explore/route.js b/thirdeye/thirdeye-frontend/app/pods/manage/alert/explore/route.js index 80007958be0..5fa7947b7b4 100644 --- a/thirdeye/thirdeye-frontend/app/pods/manage/alert/explore/route.js +++ b/thirdeye/thirdeye-frontend/app/pods/manage/alert/explore/route.js @@ -26,6 +26,8 @@ import { buildMetricDataUrl, extractSeverity } from 'thirdeye-frontend/utils/manage-alert-utils'; +import { anomalyResponseObj } from 'thirdeye-frontend/utils/anomaly'; +import { getAnomalyDataUrl } from 'thirdeye-frontend/utils/api/anomaly'; /** * Shorthand for setting date defaults @@ -55,28 +57,6 @@ const defaultDurationObj = { endDate: moment() }; -/** - * Response type options for anomalies - */ -const anomalyResponseObj = [ - { name: 'Not reviewed yet', - value: 'NO_FEEDBACK', - status: 'Not Resolved' - }, - { name: 'True anomaly', - value: 'ANOMALY', - status: 'Confirmed Anomaly' - }, - { name: 'False alarm', - value: 'NOT_ANOMALY', - status: 'False Alarm' - }, - { name: 'Confirmed - New Trend', - value: 'ANOMALY_NEW_TREND', - status: 'New Trend' - } -]; - /** * Build WoW array from basic options */ @@ -229,7 +209,7 @@ export default Route.extend({ // Load endpoints for projected metrics. TODO: consolidate into CP if duplicating this logic const qsParams = `start=${baseStart.utc().format(dateFormat)}&end=${baseEnd.utc().format(dateFormat)}&useNotified=true`; - const anomalyDataUrl = `/anomalies/search/anomalyIds/${startStamp}/${endStamp}/1?anomalyIds=`; + const anomalyDataUrl = getAnomalyDataUrl(startStamp, endStamp); const metricsUrl = `/data/autocomplete/metric?name=${dataset}::${metricName}`; const anomaliesUrl = `/dashboard/anomaly-function/${alertId}/anomalies?${qsParams}`; @@ -303,7 +283,6 @@ export default Route.extend({ DEFAULT_SEVERITY, anomalyDataUrl, baselineOptions, - anomalyResponseObj, alertEvalMetrics, anomaliesLoaded: false, isMetricDataInvalid: false, @@ -462,7 +441,7 @@ export default Route.extend({ // Process anomaly records to make them template-ready const anomalyData = yield enhanceAnomalies(rawAnomalies, severityScores); // Prepare de-duped power-select option array for anomaly feedback - resolutionOptions.push(...new Set(anomalyData.map(record => record.anomalyFeedback))); + //resolutionOptions.push(...new Set(anomalyData.map(record => record.anomalyFeedback))); // Populate dimensions power-select options if dimensions exist if (hasDimensions) { dimensionOptions.push(...new Set(anomalyData.map(anomaly => anomaly.dimensionString))); diff --git a/thirdeye/thirdeye-frontend/app/pods/manage/alert/explore/template.hbs b/thirdeye/thirdeye-frontend/app/pods/manage/alert/explore/template.hbs index c241f9c9146..83460d2b07d 100644 --- a/thirdeye/thirdeye-frontend/app/pods/manage/alert/explore/template.hbs +++ b/thirdeye/thirdeye-frontend/app/pods/manage/alert/explore/template.hbs @@ -240,7 +240,7 @@ {{else}} {{#power-select triggerId=anomaly.anomalyId - triggerClass="te-anomaly-table__select" + triggerClass="te-anomaly-table__select te-anomaly-table__select--margin-right" options=responseOptions searchEnabled=false selected=anomaly.anomalyFeedback diff --git a/thirdeye/thirdeye-frontend/app/shared/anomaliesTableColumns.js b/thirdeye/thirdeye-frontend/app/shared/anomaliesTableColumns.js index 55fd5d25ee7..539ea5fa20a 100644 --- a/thirdeye/thirdeye-frontend/app/shared/anomaliesTableColumns.js +++ b/thirdeye/thirdeye-frontend/app/shared/anomaliesTableColumns.js @@ -29,6 +29,7 @@ export default [ }, { propertyName: 'feedback', + component: 'custom/anomalies-table/resolution', title: 'Resolution', className: 'anomalies-table__column', disableFiltering: true diff --git a/thirdeye/thirdeye-frontend/app/styles/components/te-anomaly-table.scss b/thirdeye/thirdeye-frontend/app/styles/components/te-anomaly-table.scss index 9e81c2e0f81..d1feff7117a 100644 --- a/thirdeye/thirdeye-frontend/app/styles/components/te-anomaly-table.scss +++ b/thirdeye/thirdeye-frontend/app/styles/components/te-anomaly-table.scss @@ -111,8 +111,15 @@ &__select { display: inline-block; - margin-right: 30px; width: 180px; + + &--margin-right { + margin-right: 30px; + } + + &--margin-left { + margin-left: 5px; + } } &__link-wrapper { @@ -142,6 +149,17 @@ margin: 14px 0 0 -26px; } + &--status-no-margin { + -webkit-animation: fadeinout 4s linear forwards; + animation: fadeinout 4s linear forwards; + font-size: 20px; + } + + &--hidden { + visibility: hidden; + font-size: 20px; + } + &--small { font-size: 11px; margin-left: 0px; diff --git a/thirdeye/thirdeye-frontend/app/styles/pods/home/alerts-dashboard.scss b/thirdeye/thirdeye-frontend/app/styles/pods/home/alerts-dashboard.scss index 08be32ca03f..ebd62b22406 100644 --- a/thirdeye/thirdeye-frontend/app/styles/pods/home/alerts-dashboard.scss +++ b/thirdeye/thirdeye-frontend/app/styles/pods/home/alerts-dashboard.scss @@ -7,3 +7,10 @@ width: 200px; } } + +.anomalies-table { + &__column-cell { + display: flex; + align-items: center; + } +} diff --git a/thirdeye/thirdeye-frontend/app/utils/anomaly.js b/thirdeye/thirdeye-frontend/app/utils/anomaly.js index 1d04b54356f..ed3d244a833 100644 --- a/thirdeye/thirdeye-frontend/app/utils/anomaly.js +++ b/thirdeye/thirdeye-frontend/app/utils/anomaly.js @@ -1,6 +1,58 @@ -import moment from 'moment'; import { isPresent } from '@ember/utils'; +import moment from 'moment'; import _ from 'lodash'; +import { + checkStatus, + postProps +} from 'thirdeye-frontend/utils/utils'; +import fetch from 'fetch'; +import { getAnomalyDataUrl } from 'thirdeye-frontend/utils/api/anomaly'; + +/** + * Response type options for anomalies + */ +export const anomalyResponseObj = [ + { name: 'Not reviewed yet', + value: 'NO_FEEDBACK', + status: 'Not Resolved' + }, + { name: 'True anomaly', + value: 'ANOMALY', + status: 'Confirmed Anomaly' + }, + { name: 'False alarm', + value: 'NOT_ANOMALY', + status: 'False Alarm' + }, + { name: 'Confirmed - New Trend', + value: 'ANOMALY_NEW_TREND', + status: 'New Trend' + } +]; + +/** + * Update feedback status on any anomaly + * @method updateAnomalyFeedback + * @param {Number} anomalyId - the id of the anomaly to update + * @param {String} feedbackType - key for feedback type + * @return {Ember.RSVP.Promise} + */ +export function updateAnomalyFeedback(anomalyId, feedbackType) { + const url = `/anomalies/updateFeedback/${anomalyId}`; + const data = { feedbackType, comment: '' }; + return fetch(url, postProps(data)).then((res) => checkStatus(res, 'post')); +} + +/** + * Fetch a single anomaly record for verification + * @method verifyAnomalyFeedback + * @param {Number} anomalyId + * @return {undefined} + */ +export function verifyAnomalyFeedback(anomalyId) { + const anomalyUrl = getAnomalyDataUrl() + anomalyId; + return fetch(anomalyUrl).then(checkStatus); +} /** * Formats anomaly duration property for display on the table @@ -37,6 +89,10 @@ export function pluralizeTime(time, unit) { } export default { + anomalyResponseObj, + getAnomalyDataUrl, + updateAnomalyFeedback, getFormatedDuration, + verifyAnomalyFeedback, pluralizeTime }; diff --git a/thirdeye/thirdeye-frontend/app/utils/api/anomaly.js b/thirdeye/thirdeye-frontend/app/utils/api/anomaly.js new file mode 100644 index 00000000000..b140d26ec58 --- /dev/null +++ b/thirdeye/thirdeye-frontend/app/utils/api/anomaly.js @@ -0,0 +1,14 @@ +/** + * Returns the anomaly data url + * @param {Number} startStamp - the anomaly start time + * @param {Number} endStamp - the anomaly end time + * @returns {String} the complete anomaly data url + * @example getAnomalyDataUrl(1491804013000, 1491890413000) // yields => /anomalies/search/anomalyIds/1491804013000/1491890413000/1?anomalyIds= + */ +export function getAnomalyDataUrl(startStamp = 0, endStamp = 0) { + return `/anomalies/search/anomalyIds/${startStamp}/${endStamp}/1?anomalyIds=`; +} + +export default { + getAnomalyDataUrl +}; diff --git a/thirdeye/thirdeye-frontend/tests/unit/utils/api/anomaly-test.js b/thirdeye/thirdeye-frontend/tests/unit/utils/api/anomaly-test.js new file mode 100644 index 00000000000..c841f24d519 --- /dev/null +++ b/thirdeye/thirdeye-frontend/tests/unit/utils/api/anomaly-test.js @@ -0,0 +1,8 @@ +import { module, test } from 'qunit'; +import { getAnomalyDataUrl } from 'thirdeye-frontend/utils/api/anomaly'; + +module('Unit | Utility | api/anomaly'); + +test('it returns anomaly data url correctly', function(assert) { + assert.equal(getAnomalyDataUrl(0, 0), '/anomalies/search/anomalyIds/0/0/1?anomalyIds=', 'it returns anomaly data url duration ok'); +});