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');
+});