Skip to content

Commit fc1e1f7

Browse files
authored
Add min/max/first selector for alerts (#7076)
1 parent 8725fa4 commit fc1e1f7

File tree

6 files changed

+128
-33
lines changed

6 files changed

+128
-33
lines changed

client/app/components/proptypes.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const Query = PropTypes.shape({
6565

6666
export const AlertOptions = PropTypes.shape({
6767
column: PropTypes.string,
68+
selector: PropTypes.oneOf(["first", "min", "max"]),
6869
op: PropTypes.oneOf([">", ">=", "<", "<=", "==", "!="]),
6970
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
7071
custom_subject: PropTypes.string,
@@ -83,6 +84,7 @@ export const Alert = PropTypes.shape({
8384
query: Query,
8485
options: PropTypes.shape({
8586
column: PropTypes.string,
87+
selector: PropTypes.string,
8688
op: PropTypes.string,
8789
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
8890
}).isRequired,

client/app/pages/alert/Alert.jsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ class Alert extends React.Component {
6464
this.setState({
6565
alert: {
6666
options: {
67+
selector: "first",
6768
op: ">",
6869
value: 1,
6970
muted: false,
@@ -75,7 +76,7 @@ class Alert extends React.Component {
7576
} else {
7677
const { alertId } = this.props;
7778
AlertService.get({ id: alertId })
78-
.then(alert => {
79+
.then((alert) => {
7980
if (this._isMounted) {
8081
const canEdit = currentUser.canEdit(alert);
8182

@@ -93,7 +94,7 @@ class Alert extends React.Component {
9394
this.onQuerySelected(alert.query);
9495
}
9596
})
96-
.catch(error => {
97+
.catch((error) => {
9798
if (this._isMounted) {
9899
this.props.onError(error);
99100
}
@@ -112,7 +113,7 @@ class Alert extends React.Component {
112113
alert.rearm = pendingRearm || null;
113114

114115
return AlertService.save(alert)
115-
.then(alert => {
116+
.then((alert) => {
116117
notification.success("Saved.");
117118
navigateTo(`alerts/${alert.id}`, true);
118119
this.setState({ alert, mode: MODES.VIEW });
@@ -122,15 +123,15 @@ class Alert extends React.Component {
122123
});
123124
};
124125

125-
onQuerySelected = query => {
126+
onQuerySelected = (query) => {
126127
this.setState(({ alert }) => ({
127128
alert: Object.assign(alert, { query }),
128129
queryResult: null,
129130
}));
130131

131132
if (query) {
132133
// get cached result for column names and values
133-
new QueryService(query).getQueryResultPromise().then(queryResult => {
134+
new QueryService(query).getQueryResultPromise().then((queryResult) => {
134135
if (this._isMounted) {
135136
this.setState({ queryResult });
136137
let { column } = this.state.alert.options;
@@ -146,18 +147,18 @@ class Alert extends React.Component {
146147
}
147148
};
148149

149-
onNameChange = name => {
150+
onNameChange = (name) => {
150151
const { alert } = this.state;
151152
this.setState({
152153
alert: Object.assign(alert, { name }),
153154
});
154155
};
155156

156-
onRearmChange = pendingRearm => {
157+
onRearmChange = (pendingRearm) => {
157158
this.setState({ pendingRearm });
158159
};
159160

160-
setAlertOptions = obj => {
161+
setAlertOptions = (obj) => {
161162
const { alert } = this.state;
162163
const options = { ...alert.options, ...obj };
163164
this.setState({
@@ -258,22 +259,22 @@ routes.register(
258259
routeWithUserSession({
259260
path: "/alerts/new",
260261
title: "New Alert",
261-
render: pageProps => <Alert {...pageProps} mode={MODES.NEW} />,
262+
render: (pageProps) => <Alert {...pageProps} mode={MODES.NEW} />,
262263
})
263264
);
264265
routes.register(
265266
"Alerts.View",
266267
routeWithUserSession({
267268
path: "/alerts/:alertId",
268269
title: "Alert",
269-
render: pageProps => <Alert {...pageProps} mode={MODES.VIEW} />,
270+
render: (pageProps) => <Alert {...pageProps} mode={MODES.VIEW} />,
270271
})
271272
);
272273
routes.register(
273274
"Alerts.Edit",
274275
routeWithUserSession({
275276
path: "/alerts/:alertId/edit",
276277
title: "Alert",
277-
render: pageProps => <Alert {...pageProps} mode={MODES.EDIT} />,
278+
render: (pageProps) => <Alert {...pageProps} mode={MODES.EDIT} />,
278279
})
279280
);

client/app/pages/alert/components/Criteria.jsx

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,23 +54,70 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh
5454
return null;
5555
})();
5656

57-
const columnHint = (
58-
<small className="alert-criteria-hint">
59-
Top row value is <code className="p-0">{toString(columnValue) || "unknown"}</code>
60-
</small>
61-
);
57+
let columnHint;
58+
59+
if (alertOptions.selector === "first") {
60+
columnHint = (
61+
<small className="alert-criteria-hint">
62+
Top row value is <code className="p-0">{toString(columnValue) || "unknown"}</code>
63+
</small>
64+
);
65+
} else if (alertOptions.selector === "max") {
66+
columnHint = (
67+
<small className="alert-criteria-hint">
68+
Max column value is{" "}
69+
<code className="p-0">
70+
{toString(Math.max(...resultValues.map((o) => o[alertOptions.column]))) || "unknown"}
71+
</code>
72+
</small>
73+
);
74+
} else if (alertOptions.selector === "min") {
75+
columnHint = (
76+
<small className="alert-criteria-hint">
77+
Min column value is{" "}
78+
<code className="p-0">
79+
{toString(Math.min(...resultValues.map((o) => o[alertOptions.column]))) || "unknown"}
80+
</code>
81+
</small>
82+
);
83+
}
6284

6385
return (
6486
<div data-test="Criteria">
87+
<div className="input-title">
88+
<span className="input-label">Selector</span>
89+
{editMode ? (
90+
<Select
91+
value={alertOptions.selector}
92+
onChange={(selector) => onChange({ selector })}
93+
optionLabelProp="label"
94+
dropdownMatchSelectWidth={false}
95+
style={{ width: 80 }}
96+
>
97+
<Select.Option value="first" label="first">
98+
first
99+
</Select.Option>
100+
<Select.Option value="min" label="min">
101+
min
102+
</Select.Option>
103+
<Select.Option value="max" label="max">
104+
max
105+
</Select.Option>
106+
</Select>
107+
) : (
108+
<DisabledInput minWidth={60}>{alertOptions.selector}</DisabledInput>
109+
)}
110+
</div>
65111
<div className="input-title">
66112
<span className="input-label">Value column</span>
67113
{editMode ? (
68114
<Select
69115
value={alertOptions.column}
70-
onChange={column => onChange({ column })}
116+
onChange={(column) => onChange({ column })}
71117
dropdownMatchSelectWidth={false}
72-
style={{ minWidth: 100 }}>
73-
{columnNames.map(name => (
118+
style={{ minWidth: 100 }}
119+
>
120+
{columnNames.map((name) => (
74121
<Select.Option key={name}>{name}</Select.Option>
75122
))}
76123
</Select>
@@ -83,10 +130,11 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh
83130
{editMode ? (
84131
<Select
85132
value={alertOptions.op}
86-
onChange={op => onChange({ op })}
133+
onChange={(op) => onChange({ op })}
87134
optionLabelProp="label"
88135
dropdownMatchSelectWidth={false}
89-
style={{ width: 55 }}>
136+
style={{ width: 55 }}
137+
>
90138
<Select.Option value=">" label={CONDITIONS[">"]}>
91139
{CONDITIONS[">"]} greater than
92140
</Select.Option>
@@ -125,7 +173,7 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh
125173
id="threshold-criterion"
126174
style={{ width: 90 }}
127175
value={alertOptions.value}
128-
onChange={e => onChange({ value: e.target.value })}
176+
onChange={(e) => onChange({ value: e.target.value })}
129177
/>
130178
) : (
131179
<DisabledInput minWidth={50}>{alertOptions.value}</DisabledInput>

client/cypress/support/redash-api/index.js

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
const { extend, get, merge, find } = Cypress._;
44

5-
const post = options =>
5+
const post = (options) =>
66
cy
77
.getCookie("csrf_token")
8-
.then(csrf => cy.request({ ...options, method: "POST", headers: { "X-CSRF-TOKEN": csrf.value } }));
8+
.then((csrf) => cy.request({ ...options, method: "POST", headers: { "X-CSRF-TOKEN": csrf.value } }));
99

10-
Cypress.Commands.add("createDashboard", name => {
10+
Cypress.Commands.add("createDashboard", (name) => {
1111
return post({ url: "api/dashboards", body: { name } }).then(({ body }) => body);
1212
});
1313

@@ -28,7 +28,7 @@ Cypress.Commands.add("createQuery", (data, shouldPublish = true) => {
2828
// eslint-disable-next-line cypress/no-assigning-return-values
2929
let request = post({ url: "/api/queries", body: merged }).then(({ body }) => body);
3030
if (shouldPublish) {
31-
request = request.then(query =>
31+
request = request.then((query) =>
3232
post({ url: `/api/queries/${query.id}`, body: { is_draft: false } }).then(() => query)
3333
);
3434
}
@@ -86,6 +86,7 @@ Cypress.Commands.add("addWidget", (dashboardId, visualizationId, options = {}) =
8686
Cypress.Commands.add("createAlert", (queryId, options = {}, name) => {
8787
const defaultOptions = {
8888
column: "?column?",
89+
selector: "first",
8990
op: "greater than",
9091
rearm: 0,
9192
value: 1,
@@ -109,7 +110,7 @@ Cypress.Commands.add("createUser", ({ name, email, password }) => {
109110
url: "api/users?no_invite=yes",
110111
body: { name, email },
111112
failOnStatusCode: false,
112-
}).then(xhr => {
113+
}).then((xhr) => {
113114
const { status, body } = xhr;
114115
if (status < 200 || status > 400) {
115116
throw new Error(xhr);
@@ -146,7 +147,7 @@ Cypress.Commands.add("getDestinations", () => {
146147
Cypress.Commands.add("addDestinationSubscription", (alertId, destinationName) => {
147148
return cy
148149
.getDestinations()
149-
.then(destinations => {
150+
.then((destinations) => {
150151
const destination = find(destinations, { name: destinationName });
151152
if (!destination) {
152153
throw new Error("Destination not found");
@@ -166,6 +167,6 @@ Cypress.Commands.add("addDestinationSubscription", (alertId, destinationName) =>
166167
});
167168
});
168169

169-
Cypress.Commands.add("updateOrgSettings", settings => {
170+
Cypress.Commands.add("updateOrgSettings", (settings) => {
170171
return post({ url: "api/settings/organization", body: settings }).then(({ body }) => body);
171172
});

redash/models/__init__.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,7 @@ class Alert(TimestampMixin, BelongsToOrgMixin, db.Model):
926926
UNKNOWN_STATE = "unknown"
927927
OK_STATE = "ok"
928928
TRIGGERED_STATE = "triggered"
929+
TEST_STATE = "test"
929930

930931
id = primary_key("Alert")
931932
name = Column(db.String(255))
@@ -960,7 +961,24 @@ def evaluate(self):
960961
if data["rows"] and self.options["column"] in data["rows"][0]:
961962
op = OPERATORS.get(self.options["op"], lambda v, t: False)
962963

963-
value = data["rows"][0][self.options["column"]]
964+
if "selector" not in self.options:
965+
selector = "first"
966+
else:
967+
selector = self.options["selector"]
968+
969+
if selector == "max":
970+
max_val = float("-inf")
971+
for i in range(0, len(data["rows"])):
972+
max_val = max(max_val, data["rows"][i][self.options["column"]])
973+
value = max_val
974+
elif selector == "min":
975+
min_val = float("inf")
976+
for i in range(0, len(data["rows"])):
977+
min_val = min(min_val, data["rows"][i][self.options["column"]])
978+
value = min_val
979+
else:
980+
value = data["rows"][0][self.options["column"]]
981+
964982
threshold = self.options["value"]
965983

966984
new_state = next_state(op, value, threshold)
@@ -988,11 +1006,12 @@ def render_template(self, template):
9881006
result_table = [] # A two-dimensional array which can rendered as a table in Mustache
9891007
for row in data["rows"]:
9901008
result_table.append([row[col["name"]] for col in data["columns"]])
991-
1009+
print("OPTIONS", self.options)
9921010
context = {
9931011
"ALERT_NAME": self.name,
9941012
"ALERT_URL": "{host}/alerts/{alert_id}".format(host=host, alert_id=self.id),
9951013
"ALERT_STATUS": self.state.upper(),
1014+
"ALERT_SELECTOR": self.options["selector"],
9961015
"ALERT_CONDITION": self.options["op"],
9971016
"ALERT_THRESHOLD": self.options["value"],
9981017
"QUERY_NAME": self.query_rel.name,

tests/models/test_alerts.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ class TestAlertEvaluate(BaseTestCase):
4949
def create_alert(self, results, column="foo", value="1"):
5050
result = self.factory.create_query_result(data=results)
5151
query = self.factory.create_query(latest_query_data_id=result.id)
52-
alert = self.factory.create_alert(query_rel=query, options={"op": "equals", "column": column, "value": value})
52+
alert = self.factory.create_alert(
53+
query_rel=query, options={"selector": "first", "op": "equals", "column": column, "value": value}
54+
)
5355
return alert
5456

5557
def test_evaluate_triggers_alert_when_equal(self):
@@ -69,6 +71,24 @@ def test_evaluate_return_unknown_when_empty_results(self):
6971
alert = self.create_alert(results)
7072
self.assertEqual(alert.evaluate(), Alert.UNKNOWN_STATE)
7173

74+
def test_evaluates_correctly_with_max_selector(self):
75+
results = {"rows": [{"foo": 1}, {"foo": 2}], "columns": [{"name": "foo", "type": "STRING"}]}
76+
alert = self.create_alert(results)
77+
alert.options["selector"] = "max"
78+
self.assertEqual(alert.evaluate(), Alert.OK_STATE)
79+
80+
def test_evaluates_correctly_with_min_selector(self):
81+
results = {"rows": [{"foo": 2}, {"foo": 1}], "columns": [{"name": "foo", "type": "STRING"}]}
82+
alert = self.create_alert(results)
83+
alert.options["selector"] = "min"
84+
self.assertEqual(alert.evaluate(), Alert.TRIGGERED_STATE)
85+
86+
def test_evaluates_correctly_with_first_selector(self):
87+
results = {"rows": [{"foo": 1}, {"foo": 2}], "columns": [{"name": "foo", "type": "STRING"}]}
88+
alert = self.create_alert(results)
89+
alert.options["selector"] = "first"
90+
self.assertEqual(alert.evaluate(), Alert.TRIGGERED_STATE)
91+
7292

7393
class TestNextState(TestCase):
7494
def test_numeric_value(self):
@@ -94,14 +114,17 @@ class TestAlertRenderTemplate(BaseTestCase):
94114
def create_alert(self, results, column="foo", value="5"):
95115
result = self.factory.create_query_result(data=results)
96116
query = self.factory.create_query(latest_query_data_id=result.id)
97-
alert = self.factory.create_alert(query_rel=query, options={"op": "equals", "column": column, "value": value})
117+
alert = self.factory.create_alert(
118+
query_rel=query, options={"selector": "first", "op": "equals", "column": column, "value": value}
119+
)
98120
return alert
99121

100122
def test_render_custom_alert_template(self):
101123
alert = self.create_alert(get_results(1))
102124
custom_alert = """
103125
<pre>
104126
ALERT_STATUS {{ALERT_STATUS}}
127+
ALERT_SELECTOR {{ALERT_SELECTOR}}
105128
ALERT_CONDITION {{ALERT_CONDITION}}
106129
ALERT_THRESHOLD {{ALERT_THRESHOLD}}
107130
ALERT_NAME {{ALERT_NAME}}
@@ -116,6 +139,7 @@ def test_render_custom_alert_template(self):
116139
expected = """
117140
<pre>
118141
ALERT_STATUS UNKNOWN
142+
ALERT_SELECTOR first
119143
ALERT_CONDITION equals
120144
ALERT_THRESHOLD 5
121145
ALERT_NAME %s

0 commit comments

Comments
 (0)