Skip to content

Commit

Permalink
Add min/max/first selector for alerts (#7076)
Browse files Browse the repository at this point in the history
  • Loading branch information
ezraodio1 authored Aug 1, 2024
1 parent 8725fa4 commit fc1e1f7
Show file tree
Hide file tree
Showing 6 changed files with 128 additions and 33 deletions.
2 changes: 2 additions & 0 deletions client/app/components/proptypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const Query = PropTypes.shape({

export const AlertOptions = PropTypes.shape({
column: PropTypes.string,
selector: PropTypes.oneOf(["first", "min", "max"]),
op: PropTypes.oneOf([">", ">=", "<", "<=", "==", "!="]),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
custom_subject: PropTypes.string,
Expand All @@ -83,6 +84,7 @@ export const Alert = PropTypes.shape({
query: Query,
options: PropTypes.shape({
column: PropTypes.string,
selector: PropTypes.string,
op: PropTypes.string,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
}).isRequired,
Expand Down
23 changes: 12 additions & 11 deletions client/app/pages/alert/Alert.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class Alert extends React.Component {
this.setState({
alert: {
options: {
selector: "first",
op: ">",
value: 1,
muted: false,
Expand All @@ -75,7 +76,7 @@ class Alert extends React.Component {
} else {
const { alertId } = this.props;
AlertService.get({ id: alertId })
.then(alert => {
.then((alert) => {
if (this._isMounted) {
const canEdit = currentUser.canEdit(alert);

Expand All @@ -93,7 +94,7 @@ class Alert extends React.Component {
this.onQuerySelected(alert.query);
}
})
.catch(error => {
.catch((error) => {
if (this._isMounted) {
this.props.onError(error);
}
Expand All @@ -112,7 +113,7 @@ class Alert extends React.Component {
alert.rearm = pendingRearm || null;

return AlertService.save(alert)
.then(alert => {
.then((alert) => {
notification.success("Saved.");
navigateTo(`alerts/${alert.id}`, true);
this.setState({ alert, mode: MODES.VIEW });
Expand All @@ -122,15 +123,15 @@ class Alert extends React.Component {
});
};

onQuerySelected = query => {
onQuerySelected = (query) => {
this.setState(({ alert }) => ({
alert: Object.assign(alert, { query }),
queryResult: null,
}));

if (query) {
// get cached result for column names and values
new QueryService(query).getQueryResultPromise().then(queryResult => {
new QueryService(query).getQueryResultPromise().then((queryResult) => {
if (this._isMounted) {
this.setState({ queryResult });
let { column } = this.state.alert.options;
Expand All @@ -146,18 +147,18 @@ class Alert extends React.Component {
}
};

onNameChange = name => {
onNameChange = (name) => {
const { alert } = this.state;
this.setState({
alert: Object.assign(alert, { name }),
});
};

onRearmChange = pendingRearm => {
onRearmChange = (pendingRearm) => {
this.setState({ pendingRearm });
};

setAlertOptions = obj => {
setAlertOptions = (obj) => {
const { alert } = this.state;
const options = { ...alert.options, ...obj };
this.setState({
Expand Down Expand Up @@ -258,22 +259,22 @@ routes.register(
routeWithUserSession({
path: "/alerts/new",
title: "New Alert",
render: pageProps => <Alert {...pageProps} mode={MODES.NEW} />,
render: (pageProps) => <Alert {...pageProps} mode={MODES.NEW} />,
})
);
routes.register(
"Alerts.View",
routeWithUserSession({
path: "/alerts/:alertId",
title: "Alert",
render: pageProps => <Alert {...pageProps} mode={MODES.VIEW} />,
render: (pageProps) => <Alert {...pageProps} mode={MODES.VIEW} />,
})
);
routes.register(
"Alerts.Edit",
routeWithUserSession({
path: "/alerts/:alertId/edit",
title: "Alert",
render: pageProps => <Alert {...pageProps} mode={MODES.EDIT} />,
render: (pageProps) => <Alert {...pageProps} mode={MODES.EDIT} />,
})
);
70 changes: 59 additions & 11 deletions client/app/pages/alert/components/Criteria.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,23 +54,70 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh
return null;
})();

const columnHint = (
<small className="alert-criteria-hint">
Top row value is <code className="p-0">{toString(columnValue) || "unknown"}</code>
</small>
);
let columnHint;

if (alertOptions.selector === "first") {
columnHint = (
<small className="alert-criteria-hint">
Top row value is <code className="p-0">{toString(columnValue) || "unknown"}</code>
</small>
);
} else if (alertOptions.selector === "max") {
columnHint = (
<small className="alert-criteria-hint">
Max column value is{" "}
<code className="p-0">
{toString(Math.max(...resultValues.map((o) => o[alertOptions.column]))) || "unknown"}
</code>
</small>
);
} else if (alertOptions.selector === "min") {
columnHint = (
<small className="alert-criteria-hint">
Min column value is{" "}
<code className="p-0">
{toString(Math.min(...resultValues.map((o) => o[alertOptions.column]))) || "unknown"}
</code>
</small>
);
}

return (
<div data-test="Criteria">
<div className="input-title">
<span className="input-label">Selector</span>
{editMode ? (
<Select
value={alertOptions.selector}
onChange={(selector) => onChange({ selector })}
optionLabelProp="label"
dropdownMatchSelectWidth={false}
style={{ width: 80 }}
>
<Select.Option value="first" label="first">
first
</Select.Option>
<Select.Option value="min" label="min">
min
</Select.Option>
<Select.Option value="max" label="max">
max
</Select.Option>
</Select>
) : (
<DisabledInput minWidth={60}>{alertOptions.selector}</DisabledInput>
)}
</div>
<div className="input-title">
<span className="input-label">Value column</span>
{editMode ? (
<Select
value={alertOptions.column}
onChange={column => onChange({ column })}
onChange={(column) => onChange({ column })}
dropdownMatchSelectWidth={false}
style={{ minWidth: 100 }}>
{columnNames.map(name => (
style={{ minWidth: 100 }}
>
{columnNames.map((name) => (
<Select.Option key={name}>{name}</Select.Option>
))}
</Select>
Expand All @@ -83,10 +130,11 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh
{editMode ? (
<Select
value={alertOptions.op}
onChange={op => onChange({ op })}
onChange={(op) => onChange({ op })}
optionLabelProp="label"
dropdownMatchSelectWidth={false}
style={{ width: 55 }}>
style={{ width: 55 }}
>
<Select.Option value=">" label={CONDITIONS[">"]}>
{CONDITIONS[">"]} greater than
</Select.Option>
Expand Down Expand Up @@ -125,7 +173,7 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh
id="threshold-criterion"
style={{ width: 90 }}
value={alertOptions.value}
onChange={e => onChange({ value: e.target.value })}
onChange={(e) => onChange({ value: e.target.value })}
/>
) : (
<DisabledInput minWidth={50}>{alertOptions.value}</DisabledInput>
Expand Down
15 changes: 8 additions & 7 deletions client/cypress/support/redash-api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

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

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

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

Expand All @@ -28,7 +28,7 @@ Cypress.Commands.add("createQuery", (data, shouldPublish = true) => {
// eslint-disable-next-line cypress/no-assigning-return-values
let request = post({ url: "/api/queries", body: merged }).then(({ body }) => body);
if (shouldPublish) {
request = request.then(query =>
request = request.then((query) =>
post({ url: `/api/queries/${query.id}`, body: { is_draft: false } }).then(() => query)
);
}
Expand Down Expand Up @@ -86,6 +86,7 @@ Cypress.Commands.add("addWidget", (dashboardId, visualizationId, options = {}) =
Cypress.Commands.add("createAlert", (queryId, options = {}, name) => {
const defaultOptions = {
column: "?column?",
selector: "first",
op: "greater than",
rearm: 0,
value: 1,
Expand All @@ -109,7 +110,7 @@ Cypress.Commands.add("createUser", ({ name, email, password }) => {
url: "api/users?no_invite=yes",
body: { name, email },
failOnStatusCode: false,
}).then(xhr => {
}).then((xhr) => {
const { status, body } = xhr;
if (status < 200 || status > 400) {
throw new Error(xhr);
Expand Down Expand Up @@ -146,7 +147,7 @@ Cypress.Commands.add("getDestinations", () => {
Cypress.Commands.add("addDestinationSubscription", (alertId, destinationName) => {
return cy
.getDestinations()
.then(destinations => {
.then((destinations) => {
const destination = find(destinations, { name: destinationName });
if (!destination) {
throw new Error("Destination not found");
Expand All @@ -166,6 +167,6 @@ Cypress.Commands.add("addDestinationSubscription", (alertId, destinationName) =>
});
});

Cypress.Commands.add("updateOrgSettings", settings => {
Cypress.Commands.add("updateOrgSettings", (settings) => {
return post({ url: "api/settings/organization", body: settings }).then(({ body }) => body);
});
23 changes: 21 additions & 2 deletions redash/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,7 @@ class Alert(TimestampMixin, BelongsToOrgMixin, db.Model):
UNKNOWN_STATE = "unknown"
OK_STATE = "ok"
TRIGGERED_STATE = "triggered"
TEST_STATE = "test"

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

value = data["rows"][0][self.options["column"]]
if "selector" not in self.options:
selector = "first"
else:
selector = self.options["selector"]

if selector == "max":
max_val = float("-inf")
for i in range(0, len(data["rows"])):
max_val = max(max_val, data["rows"][i][self.options["column"]])
value = max_val
elif selector == "min":
min_val = float("inf")
for i in range(0, len(data["rows"])):
min_val = min(min_val, data["rows"][i][self.options["column"]])
value = min_val
else:
value = data["rows"][0][self.options["column"]]

threshold = self.options["value"]

new_state = next_state(op, value, threshold)
Expand Down Expand Up @@ -988,11 +1006,12 @@ def render_template(self, template):
result_table = [] # A two-dimensional array which can rendered as a table in Mustache
for row in data["rows"]:
result_table.append([row[col["name"]] for col in data["columns"]])

print("OPTIONS", self.options)
context = {
"ALERT_NAME": self.name,
"ALERT_URL": "{host}/alerts/{alert_id}".format(host=host, alert_id=self.id),
"ALERT_STATUS": self.state.upper(),
"ALERT_SELECTOR": self.options["selector"],
"ALERT_CONDITION": self.options["op"],
"ALERT_THRESHOLD": self.options["value"],
"QUERY_NAME": self.query_rel.name,
Expand Down
28 changes: 26 additions & 2 deletions tests/models/test_alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ class TestAlertEvaluate(BaseTestCase):
def create_alert(self, results, column="foo", value="1"):
result = self.factory.create_query_result(data=results)
query = self.factory.create_query(latest_query_data_id=result.id)
alert = self.factory.create_alert(query_rel=query, options={"op": "equals", "column": column, "value": value})
alert = self.factory.create_alert(
query_rel=query, options={"selector": "first", "op": "equals", "column": column, "value": value}
)
return alert

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

def test_evaluates_correctly_with_max_selector(self):
results = {"rows": [{"foo": 1}, {"foo": 2}], "columns": [{"name": "foo", "type": "STRING"}]}
alert = self.create_alert(results)
alert.options["selector"] = "max"
self.assertEqual(alert.evaluate(), Alert.OK_STATE)

def test_evaluates_correctly_with_min_selector(self):
results = {"rows": [{"foo": 2}, {"foo": 1}], "columns": [{"name": "foo", "type": "STRING"}]}
alert = self.create_alert(results)
alert.options["selector"] = "min"
self.assertEqual(alert.evaluate(), Alert.TRIGGERED_STATE)

def test_evaluates_correctly_with_first_selector(self):
results = {"rows": [{"foo": 1}, {"foo": 2}], "columns": [{"name": "foo", "type": "STRING"}]}
alert = self.create_alert(results)
alert.options["selector"] = "first"
self.assertEqual(alert.evaluate(), Alert.TRIGGERED_STATE)


class TestNextState(TestCase):
def test_numeric_value(self):
Expand All @@ -94,14 +114,17 @@ class TestAlertRenderTemplate(BaseTestCase):
def create_alert(self, results, column="foo", value="5"):
result = self.factory.create_query_result(data=results)
query = self.factory.create_query(latest_query_data_id=result.id)
alert = self.factory.create_alert(query_rel=query, options={"op": "equals", "column": column, "value": value})
alert = self.factory.create_alert(
query_rel=query, options={"selector": "first", "op": "equals", "column": column, "value": value}
)
return alert

def test_render_custom_alert_template(self):
alert = self.create_alert(get_results(1))
custom_alert = """
<pre>
ALERT_STATUS {{ALERT_STATUS}}
ALERT_SELECTOR {{ALERT_SELECTOR}}
ALERT_CONDITION {{ALERT_CONDITION}}
ALERT_THRESHOLD {{ALERT_THRESHOLD}}
ALERT_NAME {{ALERT_NAME}}
Expand All @@ -116,6 +139,7 @@ def test_render_custom_alert_template(self):
expected = """
<pre>
ALERT_STATUS UNKNOWN
ALERT_SELECTOR first
ALERT_CONDITION equals
ALERT_THRESHOLD 5
ALERT_NAME %s
Expand Down

0 comments on commit fc1e1f7

Please sign in to comment.