Merge pull request #852 from influxdata/feature/kapacitor-detail-field

Update kapacitor alert rule to have detail field
pull/10616/head
Chris Goller 2017-02-08 20:50:48 -06:00 committed by GitHub
commit a4aa900bf8
12 changed files with 344 additions and 17 deletions

View File

@ -3,6 +3,7 @@
### Upcoming Bug Fixes
### Upcoming Features
1. [#838](https://github.com/influxdata/chronograf/issues/838): Add detail node to kapacitor alerts
### Upcoming UI Improvements

108
bolt/alerts_test.go Normal file
View File

@ -0,0 +1,108 @@
package bolt_test
import (
"context"
"reflect"
"testing"
"github.com/influxdata/chronograf"
)
func setupTestClient() (*TestClient, error) {
if c, err := NewTestClient(); err != nil {
return nil, err
} else if err := c.Open(); err != nil {
return nil, err
} else {
return c, nil
}
}
// Ensure an AlertRuleStore can be stored.
func TestAlertRuleStoreAdd(t *testing.T) {
c, err := setupTestClient()
if err != nil {
t.Fatal(err)
}
defer c.Close()
s := c.AlertsStore
alerts := []chronograf.AlertRule{
chronograf.AlertRule{
ID: "one",
},
chronograf.AlertRule{
ID: "two",
Details: "howdy",
},
}
// Add new alert.
ctx := context.Background()
for i, a := range alerts {
// Adding should return an identical copy
actual, err := s.Add(ctx, 0, 0, a)
if err != nil {
t.Errorf("erroring adding alert to store: %v", err)
}
if !reflect.DeepEqual(actual, alerts[i]) {
t.Fatalf("alert returned is different then alert saved; actual: %v, expected %v", actual, alerts[i])
}
}
}
func setupWithRule(ctx context.Context, alert chronograf.AlertRule) (*TestClient, error) {
c, err := setupTestClient()
if err != nil {
return nil, err
}
// Add test alert
if _, err := c.AlertsStore.Add(ctx, 0, 0, alert); err != nil {
return nil, err
}
return c, nil
}
// Ensure an AlertRuleStore can be loaded.
func TestAlertRuleStoreGet(t *testing.T) {
ctx := context.Background()
alert := chronograf.AlertRule{
ID: "one",
}
c, err := setupWithRule(ctx, alert)
if err != nil {
t.Fatalf("Error adding test alert to store: %v", err)
}
defer c.Close()
actual, err := c.AlertsStore.Get(ctx, 0, 0, "one")
if err != nil {
t.Fatalf("Error loading rule from store: %v", err)
}
if !reflect.DeepEqual(actual, alert) {
t.Fatalf("alert returned is different then alert saved; actual: %v, expected %v", actual, alert)
}
}
// Ensure an AlertRuleStore can be load with a detail.
func TestAlertRuleStoreGetDetail(t *testing.T) {
ctx := context.Background()
alert := chronograf.AlertRule{
ID: "one",
Details: "my details",
}
c, err := setupWithRule(ctx, alert)
if err != nil {
t.Fatalf("Error adding test alert to store: %v", err)
}
defer c.Close()
actual, err := c.AlertsStore.Get(ctx, 0, 0, "one")
if err != nil {
t.Fatalf("Error loading rule from store: %v", err)
}
if !reflect.DeepEqual(actual, alert) {
t.Fatalf("alert returned is different then alert saved; actual: %v, expected %v", actual, alert)
}
}

View File

@ -109,6 +109,7 @@ type AlertRule struct {
Every string `json:"every"` // Every how often to check for the alerting criteria
Alerts []string `json:"alerts"` // AlertServices name all the services to notify (e.g. pagerduty)
Message string `json:"message"` // Message included with alert
Details string `json:"details"` // Details is generally used for the Email alert. If empty will not be added.
Trigger string `json:"trigger"` // Trigger is a type that defines when to trigger the alert
TriggerValues TriggerValues `json:"values"` // Defines the values that cause the alert to trigger
Name string `json:"name"` // Name is the user-defined name for the alert

View File

@ -199,6 +199,154 @@ trigger
}
}
func TestThresholdDetail(t *testing.T) {
alert := chronograf.AlertRule{
Name: "name",
Trigger: "threshold",
Alerts: []string{"slack", "victorops", "email"},
TriggerValues: chronograf.TriggerValues{
Operator: "greater than",
Value: "90",
},
Every: "30s",
Message: "message",
Details: "details",
Query: chronograf.QueryConfig{
Database: "telegraf",
Measurement: "cpu",
RetentionPolicy: "autogen",
Fields: []chronograf.Field{
{
Field: "usage_user",
Funcs: []string{"mean"},
},
},
Tags: map[string][]string{
"host": []string{
"acc-0eabc309-eu-west-1-data-3",
"prod",
},
"cpu": []string{
"cpu_total",
},
},
GroupBy: chronograf.GroupBy{
Time: "10m",
Tags: []string{"host", "cluster_id"},
},
AreTagsAccepted: true,
RawText: "",
},
}
tests := []struct {
name string
alert chronograf.AlertRule
want chronograf.TICKScript
wantErr bool
}{
{
name: "Test valid template alert",
alert: alert,
want: `var db = 'telegraf'
var rp = 'autogen'
var measurement = 'cpu'
var groupBy = ['host', 'cluster_id']
var whereFilter = lambda: ("cpu" == 'cpu_total') AND ("host" == 'acc-0eabc309-eu-west-1-data-3' OR "host" == 'prod')
var period = 10m
var every = 30s
var name = 'name'
var idVar = name + ':{{.Group}}'
var message = 'message'
var idTag = 'alertID'
var levelTag = 'level'
var messageField = 'message'
var durationField = 'duration'
var outputDB = 'chronograf'
var outputRP = 'autogen'
var outputMeasurement = 'alerts'
var triggerType = 'threshold'
var details = 'details'
var crit = 90
var data = stream
|from()
.database(db)
.retentionPolicy(rp)
.measurement(measurement)
.groupBy(groupBy)
.where(whereFilter)
|window()
.period(period)
.every(every)
.align()
|mean('usage_user')
.as('value')
var trigger = data
|alert()
.crit(lambda: "value" > crit)
.stateChangesOnly()
.message(message)
.id(idVar)
.idTag(idTag)
.levelTag(levelTag)
.messageField(messageField)
.durationField(durationField)
.details(details)
.slack()
.victorOps()
.email()
trigger
|influxDBOut()
.create()
.database(outputDB)
.retentionPolicy(outputRP)
.measurement(outputMeasurement)
.tag('alertName', name)
.tag('triggerType', triggerType)
trigger
|httpOut('output')
`,
wantErr: false,
},
}
for _, tt := range tests {
gen := Alert{}
got, err := gen.Generate(tt.alert)
if (err != nil) != tt.wantErr {
t.Errorf("%q. Threshold() error = %v, wantErr %v", tt.name, err, tt.wantErr)
continue
}
if got != tt.want {
diff := diffmatchpatch.New()
delta := diff.DiffMain(string(tt.want), string(got), true)
t.Errorf("%q\n%s", tt.name, diff.DiffPrettyText(delta))
}
}
}
func TestThresholdInsideRange(t *testing.T) {
alert := chronograf.AlertRule{
Name: "name",

View File

@ -27,13 +27,19 @@ var AllAlerts = `
.durationField(durationField)
`
// ThresholdTrigger is the trickscript trigger for alerts that exceed a value
// Details is used only for alerts that specify detail string
var Details = `
.details(details)
`
// ThresholdTrigger is the tickscript trigger for alerts that exceed a value
var ThresholdTrigger = `
var trigger = data
|alert()
.crit(lambda: "value" %s crit)
`
// ThresholdRangeTrigger is the alert when data does not intersect the range.
var ThresholdRangeTrigger = `
var trigger = data
|alert()
@ -102,7 +108,11 @@ func Trigger(rule chronograf.AlertRule) (string, error) {
return "", err
}
return trigger + AllAlerts, nil
trigger += AllAlerts
if rule.Details != "" {
trigger += Details
}
return trigger, nil
}
func relativeTrigger(rule chronograf.AlertRule) (string, error) {
@ -132,7 +142,7 @@ func thresholdRangeTrigger(rule chronograf.AlertRule) (string, error) {
if err != nil {
return "", err
}
var iops []interface{} = make([]interface{}, len(ops))
var iops = make([]interface{}, len(ops))
for i, o := range ops {
iops[i] = o
}

View File

@ -100,7 +100,7 @@ func commonVars(rule chronograf.AlertRule) (string, error) {
var outputMeasurement = '%s'
var triggerType = '%s'
`
return fmt.Sprintf(common,
res := fmt.Sprintf(common,
rule.Query.Database,
rule.Query.RetentionPolicy,
rule.Query.Measurement,
@ -117,7 +117,14 @@ func commonVars(rule chronograf.AlertRule) (string, error) {
RP,
Measurement,
rule.Trigger,
), nil
)
if rule.Details != "" {
res += fmt.Sprintf(`
var details = '%s'
`, rule.Details)
}
return res, nil
}
// window is only used if deadman or threshold/relative with aggregate. Will return empty

View File

@ -1731,6 +1731,10 @@
"type": "string",
"description": "Message to send when alert occurs."
},
"details": {
"type": "string",
"description": "Template for constructing a detailed HTML message for the alert. (Currently, only used for email/smtp"
},
"trigger": {
"type": "string",
"description": "Trigger defines the alerting structure; deadman alert if no data are received for the specified time range; relative alert if the data change relative to the data in a different time range; threshold alert if the data cross a boundary",

View File

@ -4,6 +4,7 @@ import {defaultRuleConfigs} from 'src/kapacitor/constants';
import {
chooseTrigger,
updateRuleValues,
updateDetails,
updateMessage,
updateAlerts,
updateRuleName,
@ -117,4 +118,20 @@ describe('Kapacitor.Reducers.rules', () => {
expect(Object.keys(newState).length).to.equal(1);
expect(newState[rule1]).to.equal(initialState[rule1]);
});
it('can update details', () => {
const ruleID = 1;
const details = 'im some rule details';
const initialState = {
[ruleID]: {
id: ruleID,
queryID: 988,
details: '',
}
};
const newState = reducer(initialState, updateDetails(ruleID, details));
expect(newState[ruleID].details).to.equal(details);
});
});

View File

@ -87,6 +87,16 @@ export function updateMessage(ruleID, message) {
};
}
export function updateDetails(ruleID, details) {
return {
type: 'UPDATE_RULE_DETAILS',
payload: {
ruleID,
details,
},
};
}
export function updateAlerts(ruleID, alerts) {
return {
type: 'UPDATE_RULE_ALERTS',

View File

@ -15,15 +15,11 @@ export const RuleMessage = React.createClass({
rule: shape({}).isRequired,
actions: shape({
updateMessage: func.isRequired,
updateDetails: func.isRequired,
}).isRequired,
enabledAlerts: arrayOf(string.isRequired).isRequired,
},
handleChangeMessage() {
const {actions, rule} = this.props;
actions.updateMessage(rule.id, this.message.value);
},
handleChooseAlert(item) {
const {actions} = this.props;
actions.updateAlerts(item.ruleID, [item.text]);
@ -34,19 +30,19 @@ export const RuleMessage = React.createClass({
const alerts = this.props.enabledAlerts.map((text) => {
return {text, ruleID: rule.id};
});
const selectedAlert = rule.alerts[0];
return (
<div className="kapacitor-rule-section">
<h3 className="rule-section-heading">Alert Message</h3>
<div className="rule-section-body">
<textarea
className="alert-message"
className="alert-text message"
ref={(r) => this.message = r}
onChange={() => actions.updateMessage(rule.id, this.message.value)}
placeholder='Example: {{ .ID }} is {{ .Level }} value: {{ index .Fields "value" }}'
value={rule.message}
/>
<div className="alert-message--formatting">
<p>Templates:</p>
{
@ -62,9 +58,19 @@ export const RuleMessage = React.createClass({
}
<ReactTooltip effect="solid" html={true} offset={{top: -4}} class="influx-tooltip kapacitor-tooltip" />
</div>
{
selectedAlert === 'smtp' ?
<textarea
className="alert-text details"
ref={(r) => this.details = r}
onChange={() => actions.updateDetails(rule.id, this.details.value)}
placeholder="Put email body text here"
value={rule.details}
/> : null
}
<div className="rule-section--item bottom alert-message--endpoint">
<p>Send this Alert to:</p>
<Dropdown className="size-256 dropdown-kapacitor" selected={rule.alerts[0] || 'Choose an output'} items={alerts} onChoose={this.handleChooseAlert} />
<Dropdown className="size-256 dropdown-kapacitor" selected={selectedAlert || 'Choose an output'} items={alerts} onChoose={this.handleChooseAlert} />
</div>
</div>
</div>

View File

@ -85,6 +85,14 @@ export default function rules(state = {}, action) {
delete state[ruleID];
return Object.assign({}, state);
}
case 'UPDATE_RULE_DETAILS': {
const {ruleID, details} = action.payload;
return {...state, ...{
[ruleID]: {...state[ruleID], details},
}};
}
}
return state;
}

View File

@ -345,9 +345,8 @@ div.qeditor.kapacitor-metric-selector {
}
}
.alert-message {
.alert-text {
border: 2px solid $g3-castle;
border-radius: $kap-radius-lg $kap-radius-lg 0 0;
background-color: $kapacitor-graphic-color;
margin: 0;
padding: $kap-padding-sm $kap-padding-lg;
@ -362,8 +361,8 @@ div.qeditor.kapacitor-metric-selector {
font-size: $kapacitor-font-sm;
line-height: 17px;
transition:
color 0.25s ease,
border-color 0.25s ease;
color 0.25s ease,
border-color 0.25s ease;
@include custom-scrollbar($kapacitor-graphic-color,$kapacitor-accent);
@ -379,6 +378,14 @@ div.qeditor.kapacitor-metric-selector {
&::-moz-placeholder { color: $g9-mountain; }
&:-ms-input-placeholder { color: $g9-mountain; }
&:-moz-placeholder { color: $g9-mountain; }
&.message {
border-radius: $kap-radius-lg $kap-radius-lg 0 0;
}
&.details {
border-radius: 0;
}
}
.alert-message--endpoint {