Merge branch 'master' into feature/persistent-legend

pull/2789/head
deniz kusefoglu 2018-02-06 10:52:02 -08:00
commit 120d0b4af8
52 changed files with 1500 additions and 1136 deletions

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 1.4.0.0 current_version = 1.4.0.1
files = README.md server/swagger.json files = README.md server/swagger.json
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\.(?P<release>\d+) parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\.(?P<release>\d+)
serialize = {major}.{minor}.{patch}.{release} serialize = {major}.{minor}.{patch}.{release}

View File

@ -1,13 +1,24 @@
## v1.4.1.0 [unreleased] ## v1.4.1.0 [unreleased]
### Features ### Features
1. [#2409](https://github.com/influxdata/chronograf/pull/2409): Allow adding multiple event handlers to a rule
1. [#2709](https://github.com/influxdata/chronograf/pull/2709): Add "send test alert" button to test kapacitor alert configurations
1. [#2708](https://github.com/influxdata/chronograf/pull/2708): Link to specified kapacitor config panel from rule builder alert handlers
1. [#2722](https://github.com/influxdata/chronograf/pull/2722): Add auto refresh widget to hosts list page
### UI Improvements ### UI Improvements
1. [#2698](https://github.com/influxdata/chronograf/pull/2698): Improve clarity of terminology surrounding InfluxDB & Kapacitor connections
1. [#2746](https://github.com/influxdata/chronograf/pull/2746): Separate saving TICKscript from exiting editor page
### Bug Fixes
1. [#2684](https://github.com/influxdata/chronograf/pull/2684): Fix TICKscript Sensu alerts when no group by tags selected
1. [#2735](https://github.com/influxdata/chronograf/pull/2735): Remove cli options from systemd service file
## v1.4.0.1 [2017-1-9]
### Features
1. [#2690](https://github.com/influxdata/chronograf/pull/2690): Add separate CLI flag for canned sources, kapacitors, dashboards, and organizations
1. [#2672](https://github.com/influxdata/chronograf/pull/2672): Add telegraf interval configuration
### Bug Fixes ### Bug Fixes
1. [#2689](https://github.com/influxdata/chronograf/pull/2689): Allow insecure (self-signed) certificates for kapacitor and influxdb 1. [#2689](https://github.com/influxdata/chronograf/pull/2689): Allow insecure (self-signed) certificates for kapacitor and influxdb
1. [#2664](https://github.com/influxdata/chronograf/pull/2664): Fix positioning of custom time indicator
## v1.4.0.1 [unreleased]
### UI Improvements
1. [#2690](https://github.com/influxdata/chronograf/pull/2690): Add separate CLI flag for canned sources, kapacitors, dashboards, and organizations
## v1.4.0.0 [2017-12-22] ## v1.4.0.0 [2017-12-22]
### UI Improvements ### UI Improvements
@ -103,7 +114,6 @@
1. [#2460](https://github.com/influxdata/chronograf/pull/2460): Update kapacitor alerts to cast to float before sending to influx 1. [#2460](https://github.com/influxdata/chronograf/pull/2460): Update kapacitor alerts to cast to float before sending to influx
1. [#2479](https://github.com/influxdata/chronograf/pull/2479): Support authentication for Enterprise Meta Nodes 1. [#2479](https://github.com/influxdata/chronograf/pull/2479): Support authentication for Enterprise Meta Nodes
1. [#2477](https://github.com/influxdata/chronograf/pull/2477): Improve performance of hoverline rendering 1. [#2477](https://github.com/influxdata/chronograf/pull/2477): Improve performance of hoverline rendering
1. [#2409](https://github.com/influxdata/chronograf/pull/2409): Add multiple event handlers to rules
### UI Improvements ### UI Improvements

View File

@ -136,7 +136,7 @@ option.
## Versions ## Versions
The most recent version of Chronograf is The most recent version of Chronograf is
[v1.4.0.0](https://www.influxdata.com/downloads/). [v1.4.0.1](https://www.influxdata.com/downloads/).
Spotted a bug or have a feature request? Please open Spotted a bug or have a feature request? Please open
[an issue](https://github.com/influxdata/chronograf/issues/new)! [an issue](https://github.com/influxdata/chronograf/issues/new)!
@ -156,7 +156,7 @@ The Chronograf team has identified and is working on the following issues:
## Installation ## Installation
Check out the Check out the
[INSTALLATION](https://docs.influxdata.com/chronograf/v1.3/introduction/installation/) [INSTALLATION](https://docs.influxdata.com/chronograf/v1.4/introduction/installation/)
guide to get up and running with Chronograf with as little configuration and guide to get up and running with Chronograf with as little configuration and
code as possible. code as possible.
@ -178,7 +178,7 @@ By default, chronograf runs on port `8888`.
To get started right away with Docker, you can pull down our latest release: To get started right away with Docker, you can pull down our latest release:
```sh ```sh
docker pull chronograf:1.4.0.0 docker pull chronograf:1.4.0.1
``` ```
### From Source ### From Source
@ -198,10 +198,10 @@ docker pull chronograf:1.4.0.0
## Documentation ## Documentation
[Getting Started](https://docs.influxdata.com/chronograf/v1.3/introduction/getting-started/) [Getting Started](https://docs.influxdata.com/chronograf/v1.4/introduction/getting-started/)
will get you up and running with Chronograf with as little configuration and will get you up and running with Chronograf with as little configuration and
code as possible. See our code as possible. See our
[guides](https://docs.influxdata.com/chronograf/v1.3/guides/) to get familiar [guides](https://docs.influxdata.com/chronograf/v1.4/guides/) to get familiar
with Chronograf's main features. with Chronograf's main features.
Documentation for Telegraf, InfluxDB, and Kapacitor are available at Documentation for Telegraf, InfluxDB, and Kapacitor are available at

View File

@ -274,6 +274,10 @@ func MarshalDashboard(d chronograf.Dashboard) ([]byte, error) {
Type: c.Type, Type: c.Type,
Axes: axes, Axes: axes,
Colors: colors, Colors: colors,
Legend: &Legend{
Type: c.Legend.Type,
Orientation: c.Legend.Orientation,
},
} }
} }
templates := make([]*Template, len(d.Templates)) templates := make([]*Template, len(d.Templates))
@ -394,6 +398,12 @@ func UnmarshalDashboard(data []byte, d *chronograf.Dashboard) error {
} }
} }
legend := chronograf.Legend{}
if c.Legend != nil {
legend.Type = c.Legend.Type
legend.Orientation = c.Legend.Orientation
}
cells[i] = chronograf.DashboardCell{ cells[i] = chronograf.DashboardCell{
ID: c.ID, ID: c.ID,
X: c.X, X: c.X,
@ -405,6 +415,7 @@ func UnmarshalDashboard(data []byte, d *chronograf.Dashboard) error {
Type: c.Type, Type: c.Type,
Axes: axes, Axes: axes,
CellColors: colors, CellColors: colors,
Legend: legend,
} }
} }
@ -570,10 +581,7 @@ func UnmarshalRole(data []byte, r *chronograf.Role) error {
// UnmarshalRolePB decodes a role from binary protobuf data. // UnmarshalRolePB decodes a role from binary protobuf data.
func UnmarshalRolePB(data []byte, r *Role) error { func UnmarshalRolePB(data []byte, r *Role) error {
if err := proto.Unmarshal(data, r); err != nil { return proto.Unmarshal(data, r)
return err
}
return nil
} }
// MarshalOrganization encodes a organization to binary protobuf format. // MarshalOrganization encodes a organization to binary protobuf format.
@ -607,10 +615,7 @@ func UnmarshalOrganization(data []byte, o *chronograf.Organization) error {
// UnmarshalOrganizationPB decodes a organization from binary protobuf data. // UnmarshalOrganizationPB decodes a organization from binary protobuf data.
func UnmarshalOrganizationPB(data []byte, o *Organization) error { func UnmarshalOrganizationPB(data []byte, o *Organization) error {
if err := proto.Unmarshal(data, o); err != nil { return proto.Unmarshal(data, o)
return err
}
return nil
} }
// MarshalConfig encodes a config to binary protobuf format. // MarshalConfig encodes a config to binary protobuf format.
@ -643,8 +648,5 @@ func UnmarshalConfig(data []byte, c *chronograf.Config) error {
// UnmarshalConfigPB decodes a config from binary protobuf data. // UnmarshalConfigPB decodes a config from binary protobuf data.
func UnmarshalConfigPB(data []byte, c *Config) error { func UnmarshalConfigPB(data []byte, c *Config) error {
if err := proto.Unmarshal(data, c); err != nil { return proto.Unmarshal(data, c)
return err
}
return nil
} }

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,7 @@ message DashboardCell {
string ID = 8; // id is the unique id of the dashboard. MIGRATED FIELD added in 1.2.0-beta6 string ID = 8; // id is the unique id of the dashboard. MIGRATED FIELD added in 1.2.0-beta6
map<string, Axis> axes = 9; // Axes represent the graphical viewport for a cell's visualizations map<string, Axis> axes = 9; // Axes represent the graphical viewport for a cell's visualizations
repeated Color colors = 10; // Colors represent encoding data values to color repeated Color colors = 10; // Colors represent encoding data values to color
Legend legend = 11; // Legend is summary information for a cell
} }
message Color { message Color {
@ -46,6 +47,11 @@ message Color {
string Value = 5; // Value is the data value mapped to this color string Value = 5; // Value is the data value mapped to this color
} }
message Legend {
string Type = 1; // Type is how the legend is used
string Orientation = 2; // Orientation is the location of the legend on the cell
}
message Axis { message Axis {
repeated int64 legacyBounds = 1; // legacyBounds are an ordered 2-tuple consisting of lower and upper axis extents, respectively repeated int64 legacyBounds = 1; // legacyBounds are an ordered 2-tuple consisting of lower and upper axis extents, respectively
repeated string bounds = 2; // bounds are an arbitrary list of client-defined bounds. repeated string bounds = 2; // bounds are an arbitrary list of client-defined bounds.

View File

@ -251,6 +251,10 @@ func Test_MarshalDashboard_WithLegacyBounds(t *testing.T) {
Value: "100", Value: "100",
}, },
}, },
Legend: chronograf.Legend{
Type: "static",
Orientation: "bottom",
},
Type: "line", Type: "line",
}, },
}, },
@ -301,6 +305,10 @@ func Test_MarshalDashboard_WithLegacyBounds(t *testing.T) {
Value: "100", Value: "100",
}, },
}, },
Legend: chronograf.Legend{
Type: "static",
Orientation: "bottom",
},
Type: "line", Type: "line",
}, },
}, },

View File

@ -25,6 +25,9 @@ const (
ErrInvalidAxis = Error("Unexpected axis in cell. Valid axes are 'x', 'y', and 'y2'") ErrInvalidAxis = Error("Unexpected axis in cell. Valid axes are 'x', 'y', and 'y2'")
ErrInvalidColorType = Error("Invalid color type. Valid color types are 'min', 'max', 'threshold', 'text', and 'background'") ErrInvalidColorType = Error("Invalid color type. Valid color types are 'min', 'max', 'threshold', 'text', and 'background'")
ErrInvalidColor = Error("Invalid color. Accepted color format is #RRGGBB") ErrInvalidColor = Error("Invalid color. Accepted color format is #RRGGBB")
ErrInvalidLegend = Error("Invalid legend. Both type and orientation must be set")
ErrInvalidLegendType = Error("Invalid legend type. Valid legend type is 'static'")
ErrInvalidLegendOrient = Error("Invalid orientation type. Valid orientation types are 'top', 'bottom', 'right', 'left'")
ErrUserAlreadyExists = Error("user already exists") ErrUserAlreadyExists = Error("user already exists")
ErrOrganizationNotFound = Error("organization not found") ErrOrganizationNotFound = Error("organization not found")
ErrOrganizationAlreadyExists = Error("organization already exists") ErrOrganizationAlreadyExists = Error("organization already exists")
@ -531,6 +534,12 @@ type CellColor struct {
Value string `json:"value"` // Value is the data value mapped to this color Value string `json:"value"` // Value is the data value mapped to this color
} }
// Legend represents the encoding of data into a legend
type Legend struct {
Type string `json:"type,omitempty"`
Orientation string `json:"orientation,omitempty"`
}
// DashboardCell holds visual and query information for a cell // DashboardCell holds visual and query information for a cell
type DashboardCell struct { type DashboardCell struct {
ID string `json:"i"` ID string `json:"i"`
@ -543,6 +552,7 @@ type DashboardCell struct {
Axes map[string]Axis `json:"axes"` Axes map[string]Axis `json:"axes"`
Type string `json:"type"` Type string `json:"type"`
CellColors []CellColor `json:"colors"` CellColors []CellColor `json:"colors"`
Legend Legend `json:"legend"`
} }
// DashboardsStore is the storage and retrieval of dashboards // DashboardsStore is the storage and retrieval of dashboards

View File

@ -24,6 +24,7 @@ DATA_DIR = "/var/lib/chronograf"
SCRIPT_DIR = "/usr/lib/chronograf/scripts" SCRIPT_DIR = "/usr/lib/chronograf/scripts"
LOGROTATE_DIR = "/etc/logrotate.d" LOGROTATE_DIR = "/etc/logrotate.d"
CANNED_DIR = "/usr/share/chronograf/canned" CANNED_DIR = "/usr/share/chronograf/canned"
RESOURCES_DIR = "/usr/share/chronograf/resources"
INIT_SCRIPT = "etc/scripts/init.sh" INIT_SCRIPT = "etc/scripts/init.sh"
SYSTEMD_SCRIPT = "etc/scripts/chronograf.service" SYSTEMD_SCRIPT = "etc/scripts/chronograf.service"
@ -115,7 +116,8 @@ def create_package_fs(build_root):
DATA_DIR[1:], DATA_DIR[1:],
SCRIPT_DIR[1:], SCRIPT_DIR[1:],
LOGROTATE_DIR[1:], LOGROTATE_DIR[1:],
CANNED_DIR[1:] CANNED_DIR[1:],
RESOURCES_DIR[1:]
] ]
for d in dirs: for d in dirs:
os.makedirs(os.path.join(build_root, d)) os.makedirs(os.path.join(build_root, d))

View File

@ -8,8 +8,12 @@ After=network-online.target
[Service] [Service]
User=chronograf User=chronograf
Group=chronograf Group=chronograf
Environment="HOST=0.0.0.0"
Environment="PORT=8888"
Environment="BOLT_PATH=/var/lib/chronograf/chronograf-v1.db"
Environment="CANNED_PATH=/usr/share/chronograf/canned"
EnvironmentFile=-/etc/default/chronograf EnvironmentFile=-/etc/default/chronograf
ExecStart=/usr/bin/chronograf --host 0.0.0.0 --port 8888 -b /var/lib/chronograf/chronograf-v1.db -c /usr/share/chronograf/canned $CHRONOGRAF_OPTS ExecStart=/usr/bin/chronograf $CHRONOGRAF_OPTS
KillMode=control-group KillMode=control-group
Restart=on-failure Restart=on-failure

View File

@ -539,6 +539,10 @@ func TestServer(t *testing.T) {
"value": "100" "value": "100"
} }
], ],
"legend":{
"type": "static",
"orientation": "bottom"
},
"links": { "links": {
"self": "/chronograf/v1/dashboards/1000/cells/8f61c619-dd9b-4761-8aa8-577f27247093" "self": "/chronograf/v1/dashboards/1000/cells/8f61c619-dd9b-4761-8aa8-577f27247093"
} }
@ -778,6 +782,10 @@ func TestServer(t *testing.T) {
"value": "100" "value": "100"
} }
], ],
"legend":{
"type": "static",
"orientation": "bottom"
},
"links": { "links": {
"self": "/chronograf/v1/dashboards/1000/cells/8f61c619-dd9b-4761-8aa8-577f27247093" "self": "/chronograf/v1/dashboards/1000/cells/8f61c619-dd9b-4761-8aa8-577f27247093"
} }

View File

@ -86,7 +86,11 @@
"name": "comet", "name": "comet",
"value": "100" "value": "100"
} }
] ],
"legend": {
"type": "static",
"orientation": "bottom"
}
} }
], ],
"templates": [ "templates": [

View File

@ -1293,7 +1293,157 @@ func TestClient_Create(t *testing.T) {
createTaskOptions *client.CreateTaskOptions createTaskOptions *client.CreateTaskOptions
}{ }{
{ {
name: "create alert rule", name: "create alert rule with tags",
fields: fields{
kapaClient: func(url, username, password string, insecureSkipVerify bool) (KapaClient, error) {
return kapa, nil
},
Ticker: &Alert{},
ID: &MockID{
ID: "howdy",
},
},
args: args{
ctx: context.Background(),
rule: chronograf.AlertRule{
ID: "howdy",
Name: "myname's",
Query: &chronograf.QueryConfig{
Database: "db",
RetentionPolicy: "rp",
Measurement: "meas",
GroupBy: chronograf.GroupBy{
Tags: []string{
"tag1",
"tag2",
},
},
},
Trigger: Deadman,
TriggerValues: chronograf.TriggerValues{
Period: "1d",
},
},
},
resTask: client.Task{
ID: "chronograf-v1-howdy",
Status: client.Enabled,
Type: client.StreamTask,
DBRPs: []client.DBRP{
{
Database: "db",
RetentionPolicy: "rp",
},
},
Link: client.Link{
Href: "/kapacitor/v1/tasks/chronograf-v1-howdy",
},
},
createTaskOptions: &client.CreateTaskOptions{
TICKscript: `var db = 'db'
var rp = 'rp'
var measurement = 'meas'
var groupBy = ['tag1', 'tag2']
var whereFilter = lambda: TRUE
var period = 1d
var name = 'myname\'s'
var idVar = name + ':{{.Group}}'
var message = ''
var idTag = 'alertID'
var levelTag = 'level'
var messageField = 'message'
var durationField = 'duration'
var outputDB = 'chronograf'
var outputRP = 'autogen'
var outputMeasurement = 'alerts'
var triggerType = 'deadman'
var threshold = 0.0
var data = stream
|from()
.database(db)
.retentionPolicy(rp)
.measurement(measurement)
.groupBy(groupBy)
.where(whereFilter)
var trigger = data
|deadman(threshold, period)
.stateChangesOnly()
.message(message)
.id(idVar)
.idTag(idTag)
.levelTag(levelTag)
.messageField(messageField)
.durationField(durationField)
trigger
|eval(lambda: "emitted")
.as('value')
.keep('value', messageField, durationField)
|eval(lambda: float("value"))
.as('value')
.keep()
|influxDBOut()
.create()
.database(outputDB)
.retentionPolicy(outputRP)
.measurement(outputMeasurement)
.tag('alertName', name)
.tag('triggerType', triggerType)
trigger
|httpOut('output')
`,
ID: "chronograf-v1-howdy",
Type: client.StreamTask,
Status: client.Enabled,
DBRPs: []client.DBRP{
{
Database: "db",
RetentionPolicy: "rp",
},
},
},
want: &Task{
ID: "chronograf-v1-howdy",
Href: "/kapacitor/v1/tasks/chronograf-v1-howdy",
HrefOutput: "/kapacitor/v1/tasks/chronograf-v1-howdy/output",
Rule: chronograf.AlertRule{
Type: "stream",
DBRPs: []chronograf.DBRP{
{
DB: "db",
RP: "rp",
},
},
Status: "enabled",
ID: "chronograf-v1-howdy",
Name: "chronograf-v1-howdy",
},
},
},
{
name: "create alert rule with no tags",
fields: fields{ fields: fields{
kapaClient: func(url, username, password string, insecureSkipVerify bool) (KapaClient, error) { kapaClient: func(url, username, password string, insecureSkipVerify bool) (KapaClient, error) {
return kapa, nil return kapa, nil
@ -1348,7 +1498,7 @@ var period = 1d
var name = 'myname\'s' var name = 'myname\'s'
var idVar = name + ':{{.Group}}' var idVar = name
var message = '' var message = ''

View File

@ -123,7 +123,7 @@ func commonVars(rule chronograf.AlertRule) (string, error) {
%s %s
var name = '%s' var name = '%s'
var idVar = name + ':{{.Group}}' var idVar = %s
var message = '%s' var message = '%s'
var idTag = '%s' var idTag = '%s'
var levelTag = '%s' var levelTag = '%s'
@ -143,6 +143,7 @@ func commonVars(rule chronograf.AlertRule) (string, error) {
whereFilter(rule.Query), whereFilter(rule.Query),
wind, wind,
Escape(rule.Name), Escape(rule.Name),
idVar(rule.Query),
Escape(rule.Message), Escape(rule.Message),
IDTag, IDTag,
LevelTag, LevelTag,
@ -197,6 +198,13 @@ func groupBy(q *chronograf.QueryConfig) string {
return "[" + strings.Join(groups, ",") + "]" return "[" + strings.Join(groups, ",") + "]"
} }
func idVar(q *chronograf.QueryConfig) string {
if len(q.GroupBy.Tags) > 0 {
return `name + ':{{.Group}}'`
}
return "name"
}
func field(q *chronograf.QueryConfig) (string, error) { func field(q *chronograf.QueryConfig) (string, error) {
if q == nil { if q == nil {
return "", fmt.Errorf("No fields set in query") return "", fmt.Errorf("No fields set in query")

View File

@ -28,37 +28,31 @@ type dashboardCellResponse struct {
func newCellResponse(dID chronograf.DashboardID, cell chronograf.DashboardCell) dashboardCellResponse { func newCellResponse(dID chronograf.DashboardID, cell chronograf.DashboardCell) dashboardCellResponse {
base := "/chronograf/v1/dashboards" base := "/chronograf/v1/dashboards"
newCell := chronograf.DashboardCell{} if cell.Queries == nil {
newCell.Queries = make([]chronograf.DashboardQuery, len(cell.Queries)) cell.Queries = []chronograf.DashboardQuery{}
copy(newCell.Queries, cell.Queries) }
if cell.CellColors == nil {
cell.CellColors = []chronograf.CellColor{}
}
newCell.CellColors = make([]chronograf.CellColor, len(cell.CellColors)) // Copy to handle race condition
copy(newCell.CellColors, cell.CellColors) newAxes := make(map[string]chronograf.Axis, len(cell.Axes))
for k, v := range cell.Axes {
newAxes[k] = v
}
// ensure x, y, and y2 axes always returned // ensure x, y, and y2 axes always returned
labels := []string{"x", "y", "y2"} for _, lbl := range []string{"x", "y", "y2"} {
newCell.Axes = make(map[string]chronograf.Axis, len(labels)) if _, found := newAxes[lbl]; !found {
newAxes[lbl] = chronograf.Axis{
newCell.X = cell.X
newCell.Y = cell.Y
newCell.W = cell.W
newCell.H = cell.H
newCell.Name = cell.Name
newCell.ID = cell.ID
newCell.Type = cell.Type
for _, lbl := range labels {
if axis, found := cell.Axes[lbl]; !found {
newCell.Axes[lbl] = chronograf.Axis{
Bounds: []string{}, Bounds: []string{},
} }
} else {
newCell.Axes[lbl] = axis
} }
} }
cell.Axes = newAxes
return dashboardCellResponse{ return dashboardCellResponse{
DashboardCell: newCell, DashboardCell: cell,
Links: dashboardCellLinks{ Links: dashboardCellLinks{
Self: fmt.Sprintf("%s/%d/cells/%s", base, dID, cell.ID), Self: fmt.Sprintf("%s/%d/cells/%s", base, dID, cell.ID),
}, },
@ -91,7 +85,10 @@ func ValidDashboardCellRequest(c *chronograf.DashboardCell) error {
if err != nil { if err != nil {
return err return err
} }
return HasCorrectColors(c) if err = HasCorrectColors(c); err != nil {
return err
}
return HasCorrectLegend(c)
} }
// HasCorrectAxes verifies that only permitted axes exist within a DashboardCell // HasCorrectAxes verifies that only permitted axes exist within a DashboardCell
@ -126,6 +123,27 @@ func HasCorrectColors(c *chronograf.DashboardCell) error {
return nil return nil
} }
// HasCorrectLegend verifies that the format of the legend is correct
func HasCorrectLegend(c *chronograf.DashboardCell) error {
// No legend set
if c.Legend.Type == "" && c.Legend.Orientation == "" {
return nil
}
if c.Legend.Type == "" || c.Legend.Orientation == "" {
return chronograf.ErrInvalidLegend
}
if !oneOf(c.Legend.Orientation, "top", "bottom", "right", "left") {
return chronograf.ErrInvalidLegendOrient
}
// Remember! if we add other types, update ErrInvalidLegendType
if !oneOf(c.Legend.Type, "static") {
return chronograf.ErrInvalidLegendType
}
return nil
}
// oneOf reports whether a provided string is a member of a variadic list of // oneOf reports whether a provided string is a member of a variadic list of
// valid options // valid options
func oneOf(prop string, validOpts ...string) bool { func oneOf(prop string, validOpts ...string) bool {

View File

@ -532,7 +532,7 @@ func TestService_ReplaceDashboardCell(t *testing.T) {
} }
} }
`))), `))),
want: `{"i":"3c5c4102-fa40-4585-a8f9-917c77e37192","x":0,"y":0,"w":4,"h":4,"name":"Untitled Cell","queries":[{"query":"SELECT mean(\"usage_user\") AS \"mean_usage_user\" FROM \"telegraf\".\"autogen\".\"cpu\" WHERE time \u003e :dashboardTime: AND \"cpu\"=:cpu: GROUP BY :interval: FILL(null)","queryConfig":{"id":"3cd3eaa4-a4b8-44b3-b69e-0c7bf6b91d9e","database":"telegraf","measurement":"cpu","retentionPolicy":"autogen","fields":[{"value":"mean","type":"func","alias":"mean_usage_user","args":[{"value":"usage_user","type":"field","alias":""}]}],"tags":{"cpu":["ChristohersMBP2.lan"]},"groupBy":{"time":"2s","tags":[]},"areTagsAccepted":true,"fill":"null","rawText":"SELECT mean(\"usage_user\") AS \"mean_usage_user\" FROM \"telegraf\".\"autogen\".\"cpu\" WHERE time \u003e :dashboardTime: AND \"cpu\"=:cpu: GROUP BY :interval: FILL(null)","range":{"upper":"","lower":"now() - 15m"},"shifts":[]},"source":""}],"axes":{"x":{"bounds":[],"label":"","prefix":"","suffix":"","base":"","scale":""},"y":{"bounds":[],"label":"","prefix":"","suffix":"","base":"","scale":""},"y2":{"bounds":[],"label":"","prefix":"","suffix":"","base":"","scale":""}},"type":"line","colors":[{"id":"0","type":"min","hex":"#00C9FF","name":"laser","value":"0"},{"id":"1","type":"max","hex":"#9394FF","name":"comet","value":"100"}],"links":{"self":"/chronograf/v1/dashboards/1/cells/3c5c4102-fa40-4585-a8f9-917c77e37192"}} want: `{"i":"3c5c4102-fa40-4585-a8f9-917c77e37192","x":0,"y":0,"w":4,"h":4,"name":"Untitled Cell","queries":[{"query":"SELECT mean(\"usage_user\") AS \"mean_usage_user\" FROM \"telegraf\".\"autogen\".\"cpu\" WHERE time \u003e :dashboardTime: AND \"cpu\"=:cpu: GROUP BY :interval: FILL(null)","queryConfig":{"id":"3cd3eaa4-a4b8-44b3-b69e-0c7bf6b91d9e","database":"telegraf","measurement":"cpu","retentionPolicy":"autogen","fields":[{"value":"mean","type":"func","alias":"mean_usage_user","args":[{"value":"usage_user","type":"field","alias":""}]}],"tags":{"cpu":["ChristohersMBP2.lan"]},"groupBy":{"time":"2s","tags":[]},"areTagsAccepted":true,"fill":"null","rawText":"SELECT mean(\"usage_user\") AS \"mean_usage_user\" FROM \"telegraf\".\"autogen\".\"cpu\" WHERE time \u003e :dashboardTime: AND \"cpu\"=:cpu: GROUP BY :interval: FILL(null)","range":{"upper":"","lower":"now() - 15m"},"shifts":[]},"source":""}],"axes":{"x":{"bounds":[],"label":"","prefix":"","suffix":"","base":"","scale":""},"y":{"bounds":[],"label":"","prefix":"","suffix":"","base":"","scale":""},"y2":{"bounds":[],"label":"","prefix":"","suffix":"","base":"","scale":""}},"type":"line","colors":[{"id":"0","type":"min","hex":"#00C9FF","name":"laser","value":"0"},{"id":"1","type":"max","hex":"#9394FF","name":"comet","value":"100"}],"legend":{},"links":{"self":"/chronograf/v1/dashboards/1/cells/3c5c4102-fa40-4585-a8f9-917c77e37192"}}
`, `,
}, },
{ {
@ -695,7 +695,7 @@ func Test_newCellResponses(t *testing.T) {
want []dashboardCellResponse want []dashboardCellResponse
}{ }{
{ {
name: "foo", name: "all fields set",
dID: chronograf.DashboardID(1), dID: chronograf.DashboardID(1),
dcells: []chronograf.DashboardCell{ dcells: []chronograf.DashboardCell{
chronograf.DashboardCell{ chronograf.DashboardCell{
@ -752,6 +752,10 @@ func Test_newCellResponses(t *testing.T) {
chronograf.CellColor{ID: "0", Type: "min", Hex: "#00C9FF", Name: "laser", Value: "0"}, chronograf.CellColor{ID: "0", Type: "min", Hex: "#00C9FF", Name: "laser", Value: "0"},
chronograf.CellColor{ID: "1", Type: "max", Hex: "#9394FF", Name: "comet", Value: "100"}, chronograf.CellColor{ID: "1", Type: "max", Hex: "#9394FF", Name: "comet", Value: "100"},
}, },
Legend: chronograf.Legend{
Type: "static",
Orientation: "bottom",
},
}, },
}, },
want: []dashboardCellResponse{ want: []dashboardCellResponse{
@ -817,6 +821,50 @@ func Test_newCellResponses(t *testing.T) {
Value: "100", Value: "100",
}, },
}, },
Legend: chronograf.Legend{
Type: "static",
Orientation: "bottom",
},
},
Links: dashboardCellLinks{
Self: "/chronograf/v1/dashboards/1/cells/445f8dc0-4d73-4168-8477-f628690d18a3"},
},
},
},
{
name: "nothing set",
dID: chronograf.DashboardID(1),
dcells: []chronograf.DashboardCell{
chronograf.DashboardCell{
ID: "445f8dc0-4d73-4168-8477-f628690d18a3",
X: 0,
Y: 0,
W: 4,
H: 4,
Name: "Untitled Cell",
},
},
want: []dashboardCellResponse{
{
DashboardCell: chronograf.DashboardCell{
ID: "445f8dc0-4d73-4168-8477-f628690d18a3",
W: 4,
H: 4,
Name: "Untitled Cell",
Queries: []chronograf.DashboardQuery{},
Axes: map[string]chronograf.Axis{
"x": chronograf.Axis{
Bounds: []string{},
},
"y": chronograf.Axis{
Bounds: []string{},
},
"y2": chronograf.Axis{
Bounds: []string{},
},
},
CellColors: []chronograf.CellColor{},
Legend: chronograf.Legend{},
}, },
Links: dashboardCellLinks{ Links: dashboardCellLinks{
Self: "/chronograf/v1/dashboards/1/cells/445f8dc0-4d73-4168-8477-f628690d18a3"}, Self: "/chronograf/v1/dashboards/1/cells/445f8dc0-4d73-4168-8477-f628690d18a3"},
@ -832,3 +880,97 @@ func Test_newCellResponses(t *testing.T) {
}) })
} }
} }
func TestHasCorrectLegend(t *testing.T) {
tests := []struct {
name string
c *chronograf.DashboardCell
wantErr bool
}{
{
name: "empty legend is ok",
c: &chronograf.DashboardCell{},
},
{
name: "must have both an orientation and type",
c: &chronograf.DashboardCell{
Legend: chronograf.Legend{
Type: "static",
},
},
wantErr: true,
},
{
name: "must have both a type and orientation",
c: &chronograf.DashboardCell{
Legend: chronograf.Legend{
Orientation: "bottom",
},
},
wantErr: true,
},
{
name: "invalid types",
c: &chronograf.DashboardCell{
Legend: chronograf.Legend{
Type: "no such type",
Orientation: "bottom",
},
},
wantErr: true,
},
{
name: "invalid orientation",
c: &chronograf.DashboardCell{
Legend: chronograf.Legend{
Type: "static",
Orientation: "no such orientation",
},
},
wantErr: true,
},
{
name: "orientation bottom valid",
c: &chronograf.DashboardCell{
Legend: chronograf.Legend{
Type: "static",
Orientation: "bottom",
},
},
},
{
name: "orientation top valid",
c: &chronograf.DashboardCell{
Legend: chronograf.Legend{
Type: "static",
Orientation: "top",
},
},
},
{
name: "orientation right valid",
c: &chronograf.DashboardCell{
Legend: chronograf.Legend{
Type: "static",
Orientation: "right",
},
},
},
{
name: "orientation left valid",
c: &chronograf.DashboardCell{
Legend: chronograf.Legend{
Type: "static",
Orientation: "left",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := HasCorrectLegend(tt.c); (err != nil) != tt.wantErr {
t.Errorf("HasCorrectLegend() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View File

@ -3,7 +3,7 @@
"info": { "info": {
"title": "Chronograf", "title": "Chronograf",
"description": "API endpoints for Chronograf", "description": "API endpoints for Chronograf",
"version": "1.4.0.0" "version": "1.4.0.1"
}, },
"schemes": ["http"], "schemes": ["http"],
"basePath": "/chronograf/v1", "basePath": "/chronograf/v1",
@ -3970,6 +3970,24 @@
"$ref": "#/definitions/DashboardColor" "$ref": "#/definitions/DashboardColor"
} }
}, },
"legend": {
"description":
"Legend define encoding of the data into a cell's legend",
"type": "object",
"properties": {
"type": {
"description": "type is the style of the legend",
"type": "string",
"enum": ["static"]
},
"orientation": {
"description":
"orientation is the location of the legend with respect to the cell graph",
"type": "string",
"enum": ["top", "bottom", "left", "right"]
}
}
},
"links": { "links": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -1,6 +1,6 @@
{ {
"name": "chronograf-ui", "name": "chronograf-ui",
"version": "1.4.0-0", "version": "1.4.0-1",
"private": false, "private": false,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"description": "", "description": "",

View File

@ -1,12 +1,16 @@
import React, {PropTypes, Component} from 'react' import React, {PropTypes, Component} from 'react'
import {connect} from 'react-redux' import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import _ from 'lodash' import _ from 'lodash'
import HostsTable from 'src/hosts/components/HostsTable' import HostsTable from 'src/hosts/components/HostsTable'
import SourceIndicator from 'shared/components/SourceIndicator' import SourceIndicator from 'shared/components/SourceIndicator'
import AutoRefreshDropdown from 'shared/components/AutoRefreshDropdown'
import ManualRefresh from 'src/shared/components/ManualRefresh'
import {getCpuAndLoadForHosts, getLayouts, getAppsForHosts} from '../apis' import {getCpuAndLoadForHosts, getLayouts, getAppsForHosts} from '../apis'
import {getEnv} from 'src/shared/apis/env' import {getEnv} from 'src/shared/apis/env'
import {setAutoRefresh} from 'shared/actions/app'
class HostsPage extends Component { class HostsPage extends Component {
constructor(props) { constructor(props) {
@ -19,59 +23,26 @@ class HostsPage extends Component {
} }
} }
async componentDidMount() { async fetchHostsData() {
const {source, links, addFlashMessage} = this.props const {source, links, addFlashMessage} = this.props
const {telegrafSystemInterval} = await getEnv(links.environment) const {telegrafSystemInterval} = await getEnv(links.environment)
const hostsError = 'Unable to get hosts'
const hostsError = 'Unable to get apps for hosts'
let hosts, layouts
try { try {
const [h, {data}] = await Promise.all([ const hosts = await getCpuAndLoadForHosts(
getCpuAndLoadForHosts(
source.links.proxy, source.links.proxy,
source.telegraf, source.telegraf,
telegrafSystemInterval telegrafSystemInterval
), )
getLayouts(), if (!hosts) {
new Promise(resolve => { throw new Error(hostsError)
this.setState({hostsLoading: true})
resolve()
}),
])
hosts = h
layouts = data.layouts
this.setState({
hosts,
hostsLoading: false,
})
} catch (error) {
this.setState({
hostsError: error.toString(),
hostsLoading: false,
})
console.error(error)
} }
if (!hosts || !layouts) {
addFlashMessage({type: 'error', text: hostsError})
return this.setState({
hostsError,
hostsLoading: false,
})
}
try {
const newHosts = await getAppsForHosts( const newHosts = await getAppsForHosts(
source.links.proxy, source.links.proxy,
hosts, hosts,
layouts, this.layouts,
source.telegraf source.telegraf
) )
this.setState({ this.setState({
hosts: newHosts, hosts: newHosts,
hostsError: '', hostsError: '',
@ -87,8 +58,50 @@ class HostsPage extends Component {
} }
} }
async componentDidMount() {
const {addFlashMessage, autoRefresh} = this.props
this.setState({hostsLoading: true}) // Only print this once
const {data} = await getLayouts()
this.layouts = data.layouts
if (!this.layouts) {
const layoutError = 'Unable to get apps for hosts'
addFlashMessage({type: 'error', text: layoutError})
this.setState({
hostsError: layoutError,
hostsLoading: false,
})
return
}
await this.fetchHostsData()
if (autoRefresh) {
this.intervalID = setInterval(() => this.fetchHostsData(), autoRefresh)
}
}
componentWillReceiveProps(nextProps) {
if (this.props.manualRefresh !== nextProps.manualRefresh) {
this.fetchHostsData()
}
if (this.props.autoRefresh !== nextProps.autoRefresh) {
clearInterval(this.intervalID)
if (nextProps.autoRefresh) {
this.intervalID = setInterval(
() => this.fetchHostsData(),
nextProps.autoRefresh
)
}
}
}
render() { render() {
const {source} = this.props const {
source,
autoRefresh,
onChooseAutoRefresh,
onManualRefresh,
} = this.props
const {hosts, hostsLoading, hostsError} = this.state const {hosts, hostsLoading, hostsError} = this.state
return ( return (
<div className="page hosts-list-page"> <div className="page hosts-list-page">
@ -99,6 +112,12 @@ class HostsPage extends Component {
</div> </div>
<div className="page-header__right"> <div className="page-header__right">
<SourceIndicator /> <SourceIndicator />
<AutoRefreshDropdown
iconName="refresh"
selected={autoRefresh}
onChoose={onChooseAutoRefresh}
onManualRefresh={onManualRefresh}
/>
</div> </div>
</div> </div>
</div> </div>
@ -119,13 +138,20 @@ class HostsPage extends Component {
</div> </div>
) )
} }
componentWillUnmount() {
clearInterval(this.intervalID)
this.intervalID = false
}
} }
const {func, shape, string} = PropTypes const {func, shape, string, number} = PropTypes
const mapStateToProps = ({links}) => { const mapStateToProps = state => {
const {app: {persisted: {autoRefresh}}, links} = state
return { return {
links, links,
autoRefresh,
} }
} }
@ -143,6 +169,20 @@ HostsPage.propTypes = {
environment: string.isRequired, environment: string.isRequired,
}), }),
addFlashMessage: func, addFlashMessage: func,
autoRefresh: number.isRequired,
manualRefresh: number,
onChooseAutoRefresh: func.isRequired,
onManualRefresh: func.isRequired,
} }
export default connect(mapStateToProps, null)(HostsPage) HostsPage.defaultProps = {
manualRefresh: 0,
}
const mapDispatchToProps = dispatch => ({
onChooseAutoRefresh: bindActionCreators(setAutoRefresh, dispatch),
})
export default connect(mapStateToProps, mapDispatchToProps)(
ManualRefresh(HostsPage)
)

View File

@ -133,6 +133,10 @@ const Root = React.createClass({
<Route path="tickscript/:ruleID" component={TickscriptPage} /> <Route path="tickscript/:ruleID" component={TickscriptPage} />
<Route path="kapacitors/new" component={KapacitorPage} /> <Route path="kapacitors/new" component={KapacitorPage} />
<Route path="kapacitors/:id/edit" component={KapacitorPage} /> <Route path="kapacitors/:id/edit" component={KapacitorPage} />
<Route
path="kapacitors/:id/edit:hash"
component={KapacitorPage}
/>
<Route path="kapacitor-tasks" component={KapacitorTasksPage} /> <Route path="kapacitor-tasks" component={KapacitorTasksPage} />
<Route path="admin-chronograf" component={AdminChronografPage} /> <Route path="admin-chronograf" component={AdminChronografPage} />
<Route path="admin-influxdb" component={AdminInfluxDBPage} /> <Route path="admin-influxdb" component={AdminInfluxDBPage} />

View File

@ -195,16 +195,10 @@ export const updateRuleStatus = (rule, status) => dispatch => {
}) })
} }
export const createTask = ( export const createTask = (kapacitor, task) => async dispatch => {
kapacitor,
task,
router,
sourceID
) => async dispatch => {
try { try {
const {data} = await createTaskAJAX(kapacitor, task) const {data} = await createTaskAJAX(kapacitor, task)
router.push(`/sources/${sourceID}/alert-rules`) dispatch(publishNotification('success', 'TICKscript successfully created'))
dispatch(publishNotification('success', 'You made a TICKscript!'))
return data return data
} catch (error) { } catch (error) {
if (!error) { if (!error) {
@ -220,20 +214,17 @@ export const updateTask = (
kapacitor, kapacitor,
task, task,
ruleID, ruleID,
router,
sourceID sourceID
) => async dispatch => { ) => async dispatch => {
try { try {
const {data} = await updateTaskAJAX(kapacitor, task, ruleID, sourceID) const {data} = await updateTaskAJAX(kapacitor, task, ruleID, sourceID)
router.push(`/sources/${sourceID}/alert-rules`) dispatch(publishNotification('success', 'TICKscript saved'))
dispatch(publishNotification('success', 'TICKscript updated successully'))
return data return data
} catch (error) { } catch (error) {
if (!error) { if (!error) {
dispatch(errorThrown('Could not communicate with server')) dispatch(errorThrown('Could not communicate with server'))
return return
} }
return error.data return error.data
} }
} }

View File

@ -2,7 +2,11 @@ import React, {Component, PropTypes} from 'react'
import _ from 'lodash' import _ from 'lodash'
import {Tab, Tabs, TabPanel, TabPanels, TabList} from 'shared/components/Tabs' import {Tab, Tabs, TabPanel, TabPanels, TabList} from 'shared/components/Tabs'
import {getKapacitorConfig, updateKapacitorConfigSection} from 'shared/apis' import {
getKapacitorConfig,
updateKapacitorConfigSection,
testAlertOutput,
} from 'shared/apis'
import { import {
AlertaConfig, AlertaConfig,
@ -23,7 +27,6 @@ class AlertTabs extends Component {
super(props) super(props)
this.state = { this.state = {
selectedHandler: 'smtp',
configSections: null, configSections: null,
} }
} }
@ -38,18 +41,17 @@ class AlertTabs extends Component {
} }
} }
refreshKapacitorConfig = kapacitor => { refreshKapacitorConfig = async kapacitor => {
getKapacitorConfig(kapacitor) try {
.then(({data: {sections}}) => { const {data: {sections}} = await getKapacitorConfig(kapacitor)
this.setState({configSections: sections}) this.setState({configSections: sections})
}) } catch (error) {
.catch(() => {
this.setState({configSections: null}) this.setState({configSections: null})
this.props.addFlashMessage({ this.props.addFlashMessage({
type: 'error', type: 'error',
text: 'There was an error getting the Kapacitor config', text: 'There was an error getting the Kapacitor config',
}) })
}) }
} }
getSection = (sections, section) => { getSection = (sections, section) => {
@ -68,22 +70,42 @@ class AlertTabs extends Component {
return this.getSection(sections, section) return this.getSection(sections, section)
} }
handleSaveConfig = section => properties => { handleSaveConfig = section => async properties => {
if (section !== '') { if (section !== '') {
const propsToSend = this.sanitizeProperties(section, properties) const propsToSend = this.sanitizeProperties(section, properties)
updateKapacitorConfigSection(this.props.kapacitor, section, propsToSend) try {
.then(() => { await updateKapacitorConfigSection(
this.props.kapacitor,
section,
propsToSend
)
this.refreshKapacitorConfig(this.props.kapacitor) this.refreshKapacitorConfig(this.props.kapacitor)
this.props.addFlashMessage({ this.props.addFlashMessage({
type: 'success', type: 'success',
text: `Alert for ${section} successfully saved`, text: `Alert configuration for ${section} successfully saved.`,
}) })
}) } catch (error) {
.catch(() => {
this.props.addFlashMessage({ this.props.addFlashMessage({
type: 'error', type: 'error',
text: 'There was an error saving the kapacitor config', text: `There was an error saving the alert configuration for ${section}.`,
}) })
}
}
}
handleTestConfig = section => async e => {
e.preventDefault()
try {
await testAlertOutput(this.props.kapacitor, section)
this.props.addFlashMessage({
type: 'success',
text: `Successfully triggered an alert to ${section}. If the alert does not reach its destination, please check your configuration settings.`,
})
} catch (error) {
this.props.addFlashMessage({
type: 'error',
text: `There was an error sending an alert to ${section}.`,
}) })
} }
} }
@ -102,8 +124,14 @@ class AlertTabs extends Component {
return cleanProps return cleanProps
} }
getInitialIndex = (supportedConfigs, hash) => {
const index = _.indexOf(_.keys(supportedConfigs), _.replace(hash, '#', ''))
return index >= 0 ? index : 0
}
render() { render() {
const {configSections} = this.state const {configSections} = this.state
const {hash} = this.props
if (!configSections) { if (!configSections) {
return null return null
@ -116,6 +144,8 @@ class AlertTabs extends Component {
<AlertaConfig <AlertaConfig
onSave={this.handleSaveConfig('alerta')} onSave={this.handleSaveConfig('alerta')}
config={this.getSection(configSections, 'alerta')} config={this.getSection(configSections, 'alerta')}
onTest={this.handleTestConfig('alerta')}
enabled={this.getEnabled(configSections, 'alerta')}
/>, />,
}, },
hipchat: { hipchat: {
@ -125,6 +155,8 @@ class AlertTabs extends Component {
<HipChatConfig <HipChatConfig
onSave={this.handleSaveConfig('hipchat')} onSave={this.handleSaveConfig('hipchat')}
config={this.getSection(configSections, 'hipchat')} config={this.getSection(configSections, 'hipchat')}
onTest={this.handleTestConfig('hipchat')}
enabled={this.getEnabled(configSections, 'hipchat')}
/>, />,
}, },
opsgenie: { opsgenie: {
@ -134,6 +166,8 @@ class AlertTabs extends Component {
<OpsGenieConfig <OpsGenieConfig
onSave={this.handleSaveConfig('opsgenie')} onSave={this.handleSaveConfig('opsgenie')}
config={this.getSection(configSections, 'opsgenie')} config={this.getSection(configSections, 'opsgenie')}
onTest={this.handleTestConfig('opsgenie')}
enabled={this.getEnabled(configSections, 'opsgenie')}
/>, />,
}, },
pagerduty: { pagerduty: {
@ -143,6 +177,8 @@ class AlertTabs extends Component {
<PagerDutyConfig <PagerDutyConfig
onSave={this.handleSaveConfig('pagerduty')} onSave={this.handleSaveConfig('pagerduty')}
config={this.getSection(configSections, 'pagerduty')} config={this.getSection(configSections, 'pagerduty')}
onTest={this.handleTestConfig('pagerduty')}
enabled={this.getEnabled(configSections, 'pagerduty')}
/>, />,
}, },
pushover: { pushover: {
@ -152,6 +188,8 @@ class AlertTabs extends Component {
<PushoverConfig <PushoverConfig
onSave={this.handleSaveConfig('pushover')} onSave={this.handleSaveConfig('pushover')}
config={this.getSection(configSections, 'pushover')} config={this.getSection(configSections, 'pushover')}
onTest={this.handleTestConfig('pushover')}
enabled={this.getEnabled(configSections, 'pushover')}
/>, />,
}, },
sensu: { sensu: {
@ -161,6 +199,8 @@ class AlertTabs extends Component {
<SensuConfig <SensuConfig
onSave={this.handleSaveConfig('sensu')} onSave={this.handleSaveConfig('sensu')}
config={this.getSection(configSections, 'sensu')} config={this.getSection(configSections, 'sensu')}
onTest={this.handleTestConfig('sensu')}
enabled={this.getEnabled(configSections, 'sensu')}
/>, />,
}, },
slack: { slack: {
@ -170,6 +210,8 @@ class AlertTabs extends Component {
<SlackConfig <SlackConfig
onSave={this.handleSaveConfig('slack')} onSave={this.handleSaveConfig('slack')}
config={this.getSection(configSections, 'slack')} config={this.getSection(configSections, 'slack')}
onTest={this.handleTestConfig('slack')}
enabled={this.getEnabled(configSections, 'slack')}
/>, />,
}, },
smtp: { smtp: {
@ -179,6 +221,8 @@ class AlertTabs extends Component {
<SMTPConfig <SMTPConfig
onSave={this.handleSaveConfig('smtp')} onSave={this.handleSaveConfig('smtp')}
config={this.getSection(configSections, 'smtp')} config={this.getSection(configSections, 'smtp')}
onTest={this.handleTestConfig('smtp')}
enabled={this.getEnabled(configSections, 'smtp')}
/>, />,
}, },
talk: { talk: {
@ -188,6 +232,8 @@ class AlertTabs extends Component {
<TalkConfig <TalkConfig
onSave={this.handleSaveConfig('talk')} onSave={this.handleSaveConfig('talk')}
config={this.getSection(configSections, 'talk')} config={this.getSection(configSections, 'talk')}
onTest={this.handleTestConfig('talk')}
enabled={this.getEnabled(configSections, 'talk')}
/>, />,
}, },
telegram: { telegram: {
@ -197,6 +243,8 @@ class AlertTabs extends Component {
<TelegramConfig <TelegramConfig
onSave={this.handleSaveConfig('telegram')} onSave={this.handleSaveConfig('telegram')}
config={this.getSection(configSections, 'telegram')} config={this.getSection(configSections, 'telegram')}
onTest={this.handleTestConfig('telegram')}
enabled={this.getEnabled(configSections, 'telegram')}
/>, />,
}, },
victorops: { victorops: {
@ -206,10 +254,11 @@ class AlertTabs extends Component {
<VictorOpsConfig <VictorOpsConfig
onSave={this.handleSaveConfig('victorops')} onSave={this.handleSaveConfig('victorops')}
config={this.getSection(configSections, 'victorops')} config={this.getSection(configSections, 'victorops')}
onTest={this.handleTestConfig('victorops')}
enabled={this.getEnabled(configSections, 'victorops')}
/>, />,
}, },
} }
return ( return (
<div> <div>
<div className="panel panel-minimal"> <div className="panel panel-minimal">
@ -218,7 +267,10 @@ class AlertTabs extends Component {
</div> </div>
</div> </div>
<Tabs tabContentsClass="config-endpoint"> <Tabs
tabContentsClass="config-endpoint"
initialIndex={this.getInitialIndex(supportedConfigs, hash)}
>
<TabList customClass="config-endpoint--tabs"> <TabList customClass="config-endpoint--tabs">
{_.reduce( {_.reduce(
configSections, configSections,
@ -269,6 +321,7 @@ AlertTabs.propTypes = {
}).isRequired, }).isRequired,
}), }),
addFlashMessage: func.isRequired, addFlashMessage: func.isRequired,
hash: string.isRequired,
} }
export default AlertTabs export default AlertTabs

View File

@ -65,7 +65,7 @@ class HandlerOptions extends Component {
<EmailHandler <EmailHandler
selectedHandler={selectedHandler} selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler} handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig} onGoToConfig={onGoToConfig('smtp')}
validationError={validationError} validationError={validationError}
updateDetails={updateDetails} updateDetails={updateDetails}
rule={rule} rule={rule}
@ -76,7 +76,7 @@ class HandlerOptions extends Component {
<AlertaHandler <AlertaHandler
selectedHandler={selectedHandler} selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler} handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig} onGoToConfig={onGoToConfig('alerta')}
validationError={validationError} validationError={validationError}
/> />
) )
@ -85,7 +85,7 @@ class HandlerOptions extends Component {
<HipchatHandler <HipchatHandler
selectedHandler={selectedHandler} selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler} handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig} onGoToConfig={onGoToConfig('hipchat')}
validationError={validationError} validationError={validationError}
/> />
) )
@ -94,7 +94,7 @@ class HandlerOptions extends Component {
<OpsgenieHandler <OpsgenieHandler
selectedHandler={selectedHandler} selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler} handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig} onGoToConfig={onGoToConfig('opsgenie')}
validationError={validationError} validationError={validationError}
/> />
) )
@ -103,7 +103,7 @@ class HandlerOptions extends Component {
<PagerdutyHandler <PagerdutyHandler
selectedHandler={selectedHandler} selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler} handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig} onGoToConfig={onGoToConfig('pagerduty')}
validationError={validationError} validationError={validationError}
/> />
) )
@ -112,7 +112,7 @@ class HandlerOptions extends Component {
<PushoverHandler <PushoverHandler
selectedHandler={selectedHandler} selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler} handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig} onGoToConfig={onGoToConfig('pushover')}
validationError={validationError} validationError={validationError}
/> />
) )
@ -121,7 +121,7 @@ class HandlerOptions extends Component {
<SensuHandler <SensuHandler
selectedHandler={selectedHandler} selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler} handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig} onGoToConfig={onGoToConfig('sensu')}
validationError={validationError} validationError={validationError}
/> />
) )
@ -130,7 +130,7 @@ class HandlerOptions extends Component {
<SlackHandler <SlackHandler
selectedHandler={selectedHandler} selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler} handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig} onGoToConfig={onGoToConfig('slack')}
validationError={validationError} validationError={validationError}
/> />
) )
@ -139,7 +139,7 @@ class HandlerOptions extends Component {
<TalkHandler <TalkHandler
selectedHandler={selectedHandler} selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler} handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig} onGoToConfig={onGoToConfig('talk')}
validationError={validationError} validationError={validationError}
/> />
) )
@ -148,7 +148,7 @@ class HandlerOptions extends Component {
<TelegramHandler <TelegramHandler
selectedHandler={selectedHandler} selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler} handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig} onGoToConfig={onGoToConfig('telegram')}
validationError={validationError} validationError={validationError}
/> />
) )
@ -157,7 +157,7 @@ class HandlerOptions extends Component {
<VictoropsHandler <VictoropsHandler
selectedHandler={selectedHandler} selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler} handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig} onGoToConfig={onGoToConfig('victorops')}
validationError={validationError} validationError={validationError}
/> />
) )

View File

@ -6,14 +6,15 @@ import FancyScrollbar from 'shared/components/FancyScrollbar'
class KapacitorForm extends Component { class KapacitorForm extends Component {
render() { render() {
const {onInputChange, onReset, kapacitor, onSubmit, exists} = this.props const {onInputChange, onReset, kapacitor, onSubmit, exists} = this.props
const {url, name, username, password} = kapacitor const {url: kapaUrl, name, username, password} = kapacitor
return ( return (
<div className="page"> <div className="page">
<div className="page-header"> <div className="page-header">
<div className="page-header__container"> <div className="page-header__container">
<div className="page-header__left"> <div className="page-header__left">
<h1 className="page-header__title">Configure Kapacitor</h1> <h1 className="page-header__title">{`${exists
? 'Configure'
: 'Add a New'} Kapacitor Connection`}</h1>
</div> </div>
</div> </div>
</div> </div>
@ -29,13 +30,13 @@ class KapacitorForm extends Component {
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<div> <div>
<div className="form-group"> <div className="form-group">
<label htmlFor="url">Kapacitor URL</label> <label htmlFor="kapaUrl">Kapacitor URL</label>
<input <input
className="form-control" className="form-control"
id="url" id="kapaUrl"
name="url" name="kapaUrl"
placeholder={url} placeholder={kapaUrl}
value={url} value={kapaUrl}
onChange={onInputChange} onChange={onInputChange}
spellCheck="false" spellCheck="false"
/> />
@ -60,7 +61,7 @@ class KapacitorForm extends Component {
id="username" id="username"
name="username" name="username"
placeholder="username" placeholder="username"
value={username} value={username || ''}
onChange={onInputChange} onChange={onInputChange}
spellCheck="false" spellCheck="false"
/> />
@ -73,7 +74,7 @@ class KapacitorForm extends Component {
type="password" type="password"
name="password" name="password"
placeholder="password" placeholder="password"
value={password} value={password || ''}
onChange={onInputChange} onChange={onInputChange}
spellCheck="false" spellCheck="false"
/> />
@ -108,7 +109,7 @@ class KapacitorForm extends Component {
// TODO: move these to another page. they dont belong on this page // TODO: move these to another page. they dont belong on this page
renderAlertOutputs() { renderAlertOutputs() {
const {exists, kapacitor, addFlashMessage, source} = this.props const {exists, kapacitor, addFlashMessage, source, hash} = this.props
if (exists) { if (exists) {
return ( return (
@ -116,6 +117,7 @@ class KapacitorForm extends Component {
source={source} source={source}
kapacitor={kapacitor} kapacitor={kapacitor}
addFlashMessage={addFlashMessage} addFlashMessage={addFlashMessage}
hash={hash}
/> />
) )
} }
@ -153,6 +155,7 @@ KapacitorForm.propTypes = {
source: shape({}).isRequired, source: shape({}).isRequired,
addFlashMessage: func.isRequired, addFlashMessage: func.isRequired,
exists: bool.isRequired, exists: bool.isRequired,
hash: string.isRequired,
} }
export default KapacitorForm export default KapacitorForm

View File

@ -75,10 +75,12 @@ class KapacitorRule extends Component {
}) })
} }
handleSaveToConfig = () => { handleSaveToConfig = configName => () => {
const {rule, configLink, router} = this.props const {rule, configLink, router} = this.props
if (this.validationError()) { if (this.validationError()) {
router.push(configLink) router.push({
pathname: `${configLink}#${configName}`,
})
} else if (rule.id === DEFAULT_RULE_ID) { } else if (rule.id === DEFAULT_RULE_ID) {
this.handleCreate(configLink) this.handleCreate(configLink)
} else { } else {

View File

@ -1,16 +1,16 @@
import React, {PropTypes} from 'react' import React, {PropTypes} from 'react'
const LogsToggle = ({areLogsVisible, onToggleLogsVisbility}) => const LogsToggle = ({areLogsVisible, onToggleLogsVisibility}) =>
<ul className="nav nav-tablist nav-tablist-sm nav-tablist-malachite logs-toggle"> <ul className="nav nav-tablist nav-tablist-sm nav-tablist-malachite logs-toggle">
<li <li
className={areLogsVisible ? null : 'active'} className={areLogsVisible ? null : 'active'}
onClick={onToggleLogsVisbility} onClick={onToggleLogsVisibility}
> >
Editor Editor
</li> </li>
<li <li
className={areLogsVisible ? 'active' : null} className={areLogsVisible ? 'active' : null}
onClick={onToggleLogsVisbility} onClick={onToggleLogsVisibility}
> >
Editor + Logs Editor + Logs
</li> </li>
@ -20,7 +20,7 @@ const {bool, func} = PropTypes
LogsToggle.propTypes = { LogsToggle.propTypes = {
areLogsVisible: bool, areLogsVisible: bool,
onToggleLogsVisbility: func.isRequired, onToggleLogsVisibility: func.isRequired,
} }
export default LogsToggle export default LogsToggle

View File

@ -8,25 +8,29 @@ import LogsTable from 'src/kapacitor/components/LogsTable'
const Tickscript = ({ const Tickscript = ({
onSave, onSave,
onExit,
task, task,
logs, logs,
validation, consoleMessage,
onSelectDbrps, onSelectDbrps,
onChangeScript, onChangeScript,
onChangeType, onChangeType,
onChangeID, onChangeID,
unsavedChanges,
isNewTickscript, isNewTickscript,
areLogsVisible, areLogsVisible,
areLogsEnabled, areLogsEnabled,
onToggleLogsVisbility, onToggleLogsVisibility,
}) => }) =>
<div className="page"> <div className="page">
<TickscriptHeader <TickscriptHeader
task={task} task={task}
onSave={onSave} onSave={onSave}
onExit={onExit}
unsavedChanges={unsavedChanges}
areLogsVisible={areLogsVisible} areLogsVisible={areLogsVisible}
areLogsEnabled={areLogsEnabled} areLogsEnabled={areLogsEnabled}
onToggleLogsVisbility={onToggleLogsVisbility} onToggleLogsVisibility={onToggleLogsVisibility}
isNewTickscript={isNewTickscript} isNewTickscript={isNewTickscript}
/> />
<div className="page-contents--split"> <div className="page-contents--split">
@ -38,11 +42,14 @@ const Tickscript = ({
onChangeID={onChangeID} onChangeID={onChangeID}
task={task} task={task}
/> />
<TickscriptEditorConsole validation={validation} />
<TickscriptEditor <TickscriptEditor
script={task.tickscript} script={task.tickscript}
onChangeScript={onChangeScript} onChangeScript={onChangeScript}
/> />
<TickscriptEditorConsole
consoleMessage={consoleMessage}
unsavedChanges={unsavedChanges}
/>
</div> </div>
{areLogsVisible ? <LogsTable logs={logs} /> : null} {areLogsVisible ? <LogsTable logs={logs} /> : null}
</div> </div>
@ -53,12 +60,13 @@ const {arrayOf, bool, func, shape, string} = PropTypes
Tickscript.propTypes = { Tickscript.propTypes = {
logs: arrayOf(shape()).isRequired, logs: arrayOf(shape()).isRequired,
onSave: func.isRequired, onSave: func.isRequired,
onExit: func.isRequired,
source: shape({ source: shape({
id: string, id: string,
}), }),
areLogsVisible: bool, areLogsVisible: bool,
areLogsEnabled: bool, areLogsEnabled: bool,
onToggleLogsVisbility: func.isRequired, onToggleLogsVisibility: func.isRequired,
task: shape({ task: shape({
id: string, id: string,
script: string, script: string,
@ -66,10 +74,11 @@ Tickscript.propTypes = {
}).isRequired, }).isRequired,
onChangeScript: func.isRequired, onChangeScript: func.isRequired,
onSelectDbrps: func.isRequired, onSelectDbrps: func.isRequired,
validation: string, consoleMessage: string,
onChangeType: func.isRequired, onChangeType: func.isRequired,
onChangeID: func.isRequired, onChangeID: func.isRequired,
isNewTickscript: bool.isRequired, isNewTickscript: bool.isRequired,
unsavedChanges: bool,
} }
export default Tickscript export default Tickscript

View File

@ -1,22 +1,31 @@
import React, {PropTypes} from 'react' import React, {PropTypes} from 'react'
const TickscriptEditorConsole = ({validation}) => const TickscriptEditorConsole = ({consoleMessage, unsavedChanges}) => {
<div className="tickscript-console"> let consoleOutput = 'TICKscript is valid'
<div className="tickscript-console--output"> let consoleClass = 'tickscript-console--valid'
{validation
? <p>
{validation}
</p>
: <p className="tickscript-console--default">
Save your TICKscript to validate it
</p>}
</div>
</div>
const {string} = PropTypes if (consoleMessage) {
consoleOutput = consoleMessage
consoleClass = 'tickscript-console--error'
} else if (unsavedChanges) {
consoleOutput = 'You have unsaved changes, save to validate TICKscript'
consoleClass = 'tickscript-console--default'
}
return (
<div className="tickscript-console">
<p className={consoleClass}>
{consoleOutput}
</p>
</div>
)
}
const {bool, string} = PropTypes
TickscriptEditorConsole.propTypes = { TickscriptEditorConsole.propTypes = {
validation: string, consoleMessage: string,
unsavedChanges: bool,
} }
export default TickscriptEditorConsole export default TickscriptEditorConsole

View File

@ -2,14 +2,17 @@ import React, {PropTypes} from 'react'
import SourceIndicator from 'shared/components/SourceIndicator' import SourceIndicator from 'shared/components/SourceIndicator'
import LogsToggle from 'src/kapacitor/components/LogsToggle' import LogsToggle from 'src/kapacitor/components/LogsToggle'
import ConfirmButton from 'src/shared/components/ConfirmButton'
const TickscriptHeader = ({ const TickscriptHeader = ({
task: {id}, task: {id},
onSave, onSave,
onExit,
unsavedChanges,
areLogsVisible, areLogsVisible,
areLogsEnabled, areLogsEnabled,
isNewTickscript, isNewTickscript,
onToggleLogsVisbility, onToggleLogsVisibility,
}) => }) =>
<div className="page-header full-width"> <div className="page-header full-width">
<div className="page-header__container"> <div className="page-header__container">
@ -20,18 +23,40 @@ const TickscriptHeader = ({
<LogsToggle <LogsToggle
areLogsVisible={areLogsVisible} areLogsVisible={areLogsVisible}
areLogsEnabled={areLogsEnabled} areLogsEnabled={areLogsEnabled}
onToggleLogsVisbility={onToggleLogsVisbility} onToggleLogsVisibility={onToggleLogsVisibility}
/>} />}
<div className="page-header__right"> <div className="page-header__right">
<SourceIndicator /> <SourceIndicator />
<button {isNewTickscript
? <button
className="btn btn-success btn-sm" className="btn btn-success btn-sm"
title={id ? '' : 'ID your TICKscript to save'} title="Name your TICKscript to save"
onClick={onSave} onClick={onSave}
disabled={!id} disabled={!id}
> >
{isNewTickscript ? 'Save New TICKscript' : 'Save TICKscript'} Save New TICKscript
</button> </button>
: <button
className="btn btn-success btn-sm"
title="You have unsaved changes"
onClick={onSave}
disabled={!unsavedChanges}
>
Save Changes
</button>}
{unsavedChanges
? <ConfirmButton
text="Exit"
confirmText="Discard unsaved changes?"
confirmAction={onExit}
/>
: <button
className="btn btn-default btn-sm"
title="Return to Alert Rules"
onClick={onExit}
>
Exit
</button>}
</div> </div>
</div> </div>
</div> </div>
@ -41,9 +66,10 @@ const {arrayOf, bool, func, shape, string} = PropTypes
TickscriptHeader.propTypes = { TickscriptHeader.propTypes = {
isNewTickscript: bool, isNewTickscript: bool,
onSave: func, onSave: func,
onExit: func.isRequired,
areLogsVisible: bool, areLogsVisible: bool,
areLogsEnabled: bool, areLogsEnabled: bool,
onToggleLogsVisbility: func.isRequired, onToggleLogsVisibility: func.isRequired,
task: shape({ task: shape({
dbrps: arrayOf( dbrps: arrayOf(
shape({ shape({
@ -52,6 +78,7 @@ TickscriptHeader.propTypes = {
}) })
), ),
}), }),
unsavedChanges: bool,
} }
export default TickscriptHeader export default TickscriptHeader

View File

@ -5,9 +5,12 @@ import RedactedInput from './RedactedInput'
class AlertaConfig extends Component { class AlertaConfig extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = {
testEnabled: this.props.enabled,
}
} }
handleSaveAlert = e => { handleSubmit = e => {
e.preventDefault() e.preventDefault()
const properties = { const properties = {
@ -18,6 +21,11 @@ class AlertaConfig extends Component {
} }
this.props.onSave(properties) this.props.onSave(properties)
this.setState({testEnabled: true})
}
disableTest = () => {
this.setState({testEnabled: false})
} }
handleTokenRef = r => (this.token = r) handleTokenRef = r => (this.token = r)
@ -26,7 +34,7 @@ class AlertaConfig extends Component {
const {environment, origin, token, url} = this.props.config.options const {environment, origin, token, url} = this.props.config.options
return ( return (
<form onSubmit={this.handleSaveAlert}> <form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12"> <div className="form-group col-xs-12">
<label htmlFor="environment">Environment</label> <label htmlFor="environment">Environment</label>
<input <input
@ -35,6 +43,7 @@ class AlertaConfig extends Component {
type="text" type="text"
ref={r => (this.environment = r)} ref={r => (this.environment = r)}
defaultValue={environment || ''} defaultValue={environment || ''}
onChange={this.disableTest}
/> />
</div> </div>
@ -46,6 +55,7 @@ class AlertaConfig extends Component {
type="text" type="text"
ref={r => (this.origin = r)} ref={r => (this.origin = r)}
defaultValue={origin || ''} defaultValue={origin || ''}
onChange={this.disableTest}
/> />
</div> </div>
@ -55,6 +65,7 @@ class AlertaConfig extends Component {
defaultValue={token} defaultValue={token}
id="token" id="token"
refFunc={this.handleTokenRef} refFunc={this.handleTokenRef}
disableTest={this.disableTest}
/> />
</div> </div>
@ -66,12 +77,26 @@ class AlertaConfig extends Component {
type="text" type="text"
ref={r => (this.url = r)} ref={r => (this.url = r)}
defaultValue={url || ''} defaultValue={url || ''}
onChange={this.disableTest}
/> />
</div> </div>
<div className="form-group-submit col-xs-12 text-center"> <div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit"> <button
Update Alerta Config className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button> </button>
</div> </div>
</form> </form>
@ -91,6 +116,8 @@ AlertaConfig.propTypes = {
}).isRequired, }).isRequired,
}).isRequired, }).isRequired,
onSave: func.isRequired, onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
} }
export default AlertaConfig export default AlertaConfig

View File

@ -7,9 +7,12 @@ import RedactedInput from './RedactedInput'
class HipchatConfig extends Component { class HipchatConfig extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = {
testEnabled: this.props.enabled,
}
} }
handleSaveAlert = e => { handleSubmit = e => {
e.preventDefault() e.preventDefault()
const properties = { const properties = {
@ -19,6 +22,11 @@ class HipchatConfig extends Component {
} }
this.props.onSave(properties) this.props.onSave(properties)
this.setState({testEnabled: true})
}
disableTest = () => {
this.setState({testEnabled: false})
} }
handleTokenRef = r => (this.token = r) handleTokenRef = r => (this.token = r)
@ -32,7 +40,7 @@ class HipchatConfig extends Component {
.replace('.hipchat.com/v2/room', '') .replace('.hipchat.com/v2/room', '')
return ( return (
<form onSubmit={this.handleSaveAlert}> <form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12"> <div className="form-group col-xs-12">
<label htmlFor="url">Subdomain</label> <label htmlFor="url">Subdomain</label>
<input <input
@ -42,6 +50,7 @@ class HipchatConfig extends Component {
placeholder="your-subdomain" placeholder="your-subdomain"
ref={r => (this.url = r)} ref={r => (this.url = r)}
defaultValue={subdomain && subdomain.length ? subdomain : ''} defaultValue={subdomain && subdomain.length ? subdomain : ''}
onChange={this.disableTest}
/> />
</div> </div>
@ -54,6 +63,7 @@ class HipchatConfig extends Component {
placeholder="your-hipchat-room" placeholder="your-hipchat-room"
ref={r => (this.room = r)} ref={r => (this.room = r)}
defaultValue={room || ''} defaultValue={room || ''}
onChange={this.disableTest}
/> />
</div> </div>
@ -66,12 +76,26 @@ class HipchatConfig extends Component {
defaultValue={token} defaultValue={token}
id="token" id="token"
refFunc={this.handleTokenRef} refFunc={this.handleTokenRef}
disableTest={this.disableTest}
/> />
</div> </div>
<div className="form-group-submit col-xs-12 text-center"> <div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit"> <button
Update HipChat Config className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button> </button>
</div> </div>
</form> </form>
@ -90,6 +114,8 @@ HipchatConfig.propTypes = {
}).isRequired, }).isRequired,
}).isRequired, }).isRequired,
onSave: func.isRequired, onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
} }
export default HipchatConfig export default HipchatConfig

View File

@ -12,10 +12,11 @@ class OpsGenieConfig extends Component {
this.state = { this.state = {
currentTeams: teams || [], currentTeams: teams || [],
currentRecipients: recipients || [], currentRecipients: recipients || [],
testEnabled: this.props.enabled,
} }
} }
handleSaveAlert = e => { handleSubmit = e => {
e.preventDefault() e.preventDefault()
const properties = { const properties = {
@ -25,6 +26,11 @@ class OpsGenieConfig extends Component {
} }
this.props.onSave(properties) this.props.onSave(properties)
this.setState({testEnabled: true})
}
disableTest = () => {
this.setState({testEnabled: false})
} }
handleAddTeam = team => { handleAddTeam = team => {
@ -59,13 +65,14 @@ class OpsGenieConfig extends Component {
const {currentTeams, currentRecipients} = this.state const {currentTeams, currentRecipients} = this.state
return ( return (
<form onSubmit={this.handleSaveAlert}> <form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12"> <div className="form-group col-xs-12">
<label htmlFor="api-key">API Key</label> <label htmlFor="api-key">API Key</label>
<RedactedInput <RedactedInput
defaultValue={apiKey} defaultValue={apiKey}
id="api-key" id="api-key"
refFunc={this.handleApiKeyRef} refFunc={this.handleApiKeyRef}
disableTest={this.disableTest}
/> />
</div> </div>
@ -74,17 +81,32 @@ class OpsGenieConfig extends Component {
onAddTag={this.handleAddTeam} onAddTag={this.handleAddTeam}
onDeleteTag={this.handleDeleteTeam} onDeleteTag={this.handleDeleteTeam}
tags={currentTeams} tags={currentTeams}
disableTest={this.disableTest}
/> />
<TagInput <TagInput
title="Recipients" title="Recipients"
onAddTag={this.handleAddRecipient} onAddTag={this.handleAddRecipient}
onDeleteTag={this.handleDeleteRecipient} onDeleteTag={this.handleDeleteRecipient}
tags={currentRecipients} tags={currentRecipients}
disableTest={this.disableTest}
/> />
<div className="form-group-submit col-xs-12 text-center"> <div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit"> <button
Update OpsGenie Config className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button> </button>
</div> </div>
</form> </form>
@ -103,6 +125,8 @@ OpsGenieConfig.propTypes = {
}).isRequired, }).isRequired,
}).isRequired, }).isRequired,
onSave: func.isRequired, onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
} }
class TagInput extends Component { class TagInput extends Component {
@ -121,6 +145,7 @@ class TagInput extends Component {
this.input.value = '' this.input.value = ''
onAddTag(newItem) onAddTag(newItem)
this.props.disableTest()
} }
} }
@ -156,6 +181,7 @@ TagInput.propTypes = {
onDeleteTag: func.isRequired, onDeleteTag: func.isRequired,
tags: arrayOf(string).isRequired, tags: arrayOf(string).isRequired,
title: string.isRequired, title: string.isRequired,
disableTest: func.isRequired,
} }
const Tags = ({tags, onDeleteTag}) => const Tags = ({tags, onDeleteTag}) =>

View File

@ -4,9 +4,12 @@ import RedactedInput from './RedactedInput'
class PagerDutyConfig extends Component { class PagerDutyConfig extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = {
testEnabled: this.props.enabled,
}
} }
handleSaveAlert = e => { handleSubmit = e => {
e.preventDefault() e.preventDefault()
const properties = { const properties = {
@ -15,6 +18,11 @@ class PagerDutyConfig extends Component {
} }
this.props.onSave(properties) this.props.onSave(properties)
this.setState({testEnabled: true})
}
disableTest = () => {
this.setState({testEnabled: false})
} }
render() { render() {
@ -23,13 +31,14 @@ class PagerDutyConfig extends Component {
const serviceKey = options['service-key'] const serviceKey = options['service-key']
const refFunc = r => (this.serviceKey = r) const refFunc = r => (this.serviceKey = r)
return ( return (
<form onSubmit={this.handleSaveAlert}> <form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12"> <div className="form-group col-xs-12">
<label htmlFor="service-key">Service Key</label> <label htmlFor="service-key">Service Key</label>
<RedactedInput <RedactedInput
defaultValue={serviceKey || ''} defaultValue={serviceKey || ''}
id="service-key" id="service-key"
refFunc={refFunc} refFunc={refFunc}
disableTest={this.disableTest}
/> />
</div> </div>
@ -41,12 +50,26 @@ class PagerDutyConfig extends Component {
type="text" type="text"
ref={r => (this.url = r)} ref={r => (this.url = r)}
defaultValue={url || ''} defaultValue={url || ''}
onChange={this.disableTest}
/> />
</div> </div>
<div className="form-group-submit col-xs-12 text-center"> <div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit"> <button
Update PagerDuty Config className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button> </button>
</div> </div>
</form> </form>
@ -64,6 +87,8 @@ PagerDutyConfig.propTypes = {
}).isRequired, }).isRequired,
}).isRequired, }).isRequired,
onSave: func.isRequired, onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
} }
export default PagerDutyConfig export default PagerDutyConfig

View File

@ -8,9 +8,12 @@ import {PUSHOVER_DOCS_LINK} from 'src/kapacitor/copy'
class PushoverConfig extends Component { class PushoverConfig extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = {
testEnabled: this.props.enabled,
}
} }
handleSaveAlert = e => { handleSubmit = e => {
e.preventDefault() e.preventDefault()
const properties = { const properties = {
@ -20,6 +23,11 @@ class PushoverConfig extends Component {
} }
this.props.onSave(properties) this.props.onSave(properties)
this.setState({testEnabled: true})
}
disableTest = () => {
this.setState({testEnabled: false})
} }
handleUserKeyRef = r => (this.userKey = r) handleUserKeyRef = r => (this.userKey = r)
@ -32,7 +40,7 @@ class PushoverConfig extends Component {
const userKey = options['user-key'] const userKey = options['user-key']
return ( return (
<form onSubmit={this.handleSaveAlert}> <form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12"> <div className="form-group col-xs-12">
<label htmlFor="user-key"> <label htmlFor="user-key">
User Key User Key
@ -45,6 +53,7 @@ class PushoverConfig extends Component {
defaultValue={userKey} defaultValue={userKey}
id="user-key" id="user-key"
refFunc={this.handleUserKeyRef} refFunc={this.handleUserKeyRef}
disableTest={this.disableTest}
/> />
</div> </div>
@ -60,6 +69,7 @@ class PushoverConfig extends Component {
defaultValue={token} defaultValue={token}
id="token" id="token"
refFunc={this.handleTokenRef} refFunc={this.handleTokenRef}
disableTest={this.disableTest}
/> />
</div> </div>
@ -71,12 +81,26 @@ class PushoverConfig extends Component {
type="text" type="text"
ref={r => (this.url = r)} ref={r => (this.url = r)}
defaultValue={url || ''} defaultValue={url || ''}
onChange={this.disableTest}
/> />
</div> </div>
<div className="form-group-submit col-xs-12 text-center"> <div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit"> <button
Update Pushover Config className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button> </button>
</div> </div>
</form> </form>
@ -95,6 +119,8 @@ PushoverConfig.propTypes = {
}).isRequired, }).isRequired,
}).isRequired, }).isRequired,
onSave: func.isRequired, onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
} }
export default PushoverConfig export default PushoverConfig

View File

@ -13,7 +13,7 @@ class RedactedInput extends Component {
} }
render() { render() {
const {defaultValue, id, refFunc} = this.props const {defaultValue, id, refFunc, disableTest} = this.props
const {editing} = this.state const {editing} = this.state
if (defaultValue === true && !editing) { if (defaultValue === true && !editing) {
@ -43,6 +43,7 @@ class RedactedInput extends Component {
type="text" type="text"
ref={refFunc} ref={refFunc}
defaultValue={''} defaultValue={''}
onChange={disableTest}
/> />
) )
} }
@ -54,6 +55,7 @@ RedactedInput.propTypes = {
id: string.isRequired, id: string.isRequired,
defaultValue: bool, defaultValue: bool,
refFunc: func.isRequired, refFunc: func.isRequired,
disableTest: func,
} }
export default RedactedInput export default RedactedInput

View File

@ -3,9 +3,12 @@ import React, {PropTypes, Component} from 'react'
class SMTPConfig extends Component { class SMTPConfig extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = {
testEnabled: this.props.enabled,
}
} }
handleSaveAlert = e => { handleSubmit = e => {
e.preventDefault() e.preventDefault()
const properties = { const properties = {
@ -17,13 +20,18 @@ class SMTPConfig extends Component {
} }
this.props.onSave(properties) this.props.onSave(properties)
this.setState({testEnabled: true})
}
disableTest = () => {
this.setState({testEnabled: false})
} }
render() { render() {
const {host, port, from, username, password} = this.props.config.options const {host, port, from, username, password} = this.props.config.options
return ( return (
<form onSubmit={this.handleSaveAlert}> <form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12 col-md-6"> <div className="form-group col-xs-12 col-md-6">
<label htmlFor="smtp-host">SMTP Host</label> <label htmlFor="smtp-host">SMTP Host</label>
<input <input
@ -32,6 +40,7 @@ class SMTPConfig extends Component {
type="text" type="text"
ref={r => (this.host = r)} ref={r => (this.host = r)}
defaultValue={host || ''} defaultValue={host || ''}
onChange={this.disableTest}
/> />
</div> </div>
@ -43,6 +52,7 @@ class SMTPConfig extends Component {
type="text" type="text"
ref={r => (this.port = r)} ref={r => (this.port = r)}
defaultValue={port || ''} defaultValue={port || ''}
onChange={this.disableTest}
/> />
</div> </div>
@ -55,6 +65,7 @@ class SMTPConfig extends Component {
type="text" type="text"
ref={r => (this.from = r)} ref={r => (this.from = r)}
defaultValue={from || ''} defaultValue={from || ''}
onChange={this.disableTest}
/> />
</div> </div>
@ -66,6 +77,7 @@ class SMTPConfig extends Component {
type="text" type="text"
ref={r => (this.username = r)} ref={r => (this.username = r)}
defaultValue={username || ''} defaultValue={username || ''}
onChange={this.disableTest}
/> />
</div> </div>
@ -77,12 +89,26 @@ class SMTPConfig extends Component {
type="password" type="password"
ref={r => (this.password = r)} ref={r => (this.password = r)}
defaultValue={`${password}`} defaultValue={`${password}`}
onChange={this.disableTest}
/> />
</div> </div>
<div className="form-group-submit col-xs-12 text-center"> <div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit"> <button
Update SMTP Config className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button> </button>
</div> </div>
</form> </form>
@ -103,6 +129,8 @@ SMTPConfig.propTypes = {
}).isRequired, }).isRequired,
}).isRequired, }).isRequired,
onSave: func.isRequired, onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
} }
export default SMTPConfig export default SMTPConfig

View File

@ -3,9 +3,12 @@ import React, {PropTypes, Component} from 'react'
class SensuConfig extends Component { class SensuConfig extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = {
testEnabled: this.props.enabled,
}
} }
handleSaveAlert = e => { handleSubmit = e => {
e.preventDefault() e.preventDefault()
const properties = { const properties = {
@ -14,13 +17,18 @@ class SensuConfig extends Component {
} }
this.props.onSave(properties) this.props.onSave(properties)
this.setState({testEnabled: true})
}
disableTest = () => {
this.setState({testEnabled: false})
} }
render() { render() {
const {source, addr} = this.props.config.options const {source, addr} = this.props.config.options
return ( return (
<form onSubmit={this.handleSaveAlert}> <form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12 col-md-6"> <div className="form-group col-xs-12 col-md-6">
<label htmlFor="source">Source</label> <label htmlFor="source">Source</label>
<input <input
@ -29,6 +37,7 @@ class SensuConfig extends Component {
type="text" type="text"
ref={r => (this.source = r)} ref={r => (this.source = r)}
defaultValue={source || ''} defaultValue={source || ''}
onChange={this.disableTest}
/> />
</div> </div>
@ -40,12 +49,26 @@ class SensuConfig extends Component {
type="text" type="text"
ref={r => (this.addr = r)} ref={r => (this.addr = r)}
defaultValue={addr || ''} defaultValue={addr || ''}
onChange={this.disableTest}
/> />
</div> </div>
<div className="form-group-submit col-xs-12 text-center"> <div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit"> <button
Update Sensu Config className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button> </button>
</div> </div>
</form> </form>
@ -53,7 +76,7 @@ class SensuConfig extends Component {
} }
} }
const {func, shape, string} = PropTypes const {bool, func, shape, string} = PropTypes
SensuConfig.propTypes = { SensuConfig.propTypes = {
config: shape({ config: shape({
@ -63,6 +86,8 @@ SensuConfig.propTypes = {
}).isRequired, }).isRequired,
}).isRequired, }).isRequired,
onSave: func.isRequired, onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
} }
export default SensuConfig export default SensuConfig

View File

@ -6,25 +6,21 @@ class SlackConfig extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = { this.state = {
testEnabled: !!this.props.config.options.url, testEnabled: this.props.enabled,
} }
} }
componentWillReceiveProps(nextProps) { handleSubmit = e => {
this.setState({
testEnabled: !!nextProps.config.options.url,
})
}
handleSaveAlert = e => {
e.preventDefault() e.preventDefault()
const properties = { const properties = {
url: this.url.value, url: this.url.value,
channel: this.channel.value, channel: this.channel.value,
} }
this.props.onSave(properties) this.props.onSave(properties)
this.setState({testEnabled: true})
}
disableTest = () => {
this.setState({testEnabled: false})
} }
handleUrlRef = r => (this.url = r) handleUrlRef = r => (this.url = r)
@ -33,7 +29,7 @@ class SlackConfig extends Component {
const {url, channel} = this.props.config.options const {url, channel} = this.props.config.options
return ( return (
<form onSubmit={this.handleSaveAlert}> <form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12"> <div className="form-group col-xs-12">
<label htmlFor="slack-url"> <label htmlFor="slack-url">
Slack Webhook URL ( Slack Webhook URL (
@ -46,6 +42,7 @@ class SlackConfig extends Component {
defaultValue={url} defaultValue={url}
id="url" id="url"
refFunc={this.handleUrlRef} refFunc={this.handleUrlRef}
disableTest={this.disableTest}
/> />
</div> </div>
@ -58,14 +55,30 @@ class SlackConfig extends Component {
placeholder="#alerts" placeholder="#alerts"
ref={r => (this.channel = r)} ref={r => (this.channel = r)}
defaultValue={channel || ''} defaultValue={channel || ''}
onChange={this.disableTest}
/> />
</div> </div>
<div className="form-group-submit col-xs-12 text-center"> <div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit"> <button
Update Slack Config className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button> </button>
</div> </div>
<br />
<br />
</form> </form>
) )
} }
@ -81,6 +94,8 @@ SlackConfig.propTypes = {
}).isRequired, }).isRequired,
}).isRequired, }).isRequired,
onSave: func.isRequired, onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
} }
export default SlackConfig export default SlackConfig

View File

@ -5,9 +5,12 @@ import RedactedInput from './RedactedInput'
class TalkConfig extends Component { class TalkConfig extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = {
testEnabled: this.props.enabled,
}
} }
handleSaveAlert = e => { handleSubmit = e => {
e.preventDefault() e.preventDefault()
const properties = { const properties = {
@ -16,6 +19,11 @@ class TalkConfig extends Component {
} }
this.props.onSave(properties) this.props.onSave(properties)
this.setState({testEnabled: true})
}
disableTest = () => {
this.setState({testEnabled: false})
} }
handleUrlRef = r => (this.url = r) handleUrlRef = r => (this.url = r)
@ -24,13 +32,14 @@ class TalkConfig extends Component {
const {url, author_name: author} = this.props.config.options const {url, author_name: author} = this.props.config.options
return ( return (
<form onSubmit={this.handleSaveAlert}> <form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12"> <div className="form-group col-xs-12">
<label htmlFor="url">URL</label> <label htmlFor="url">URL</label>
<RedactedInput <RedactedInput
defaultValue={url} defaultValue={url}
id="url" id="url"
refFunc={this.handleUrlRef} refFunc={this.handleUrlRef}
disableTest={this.disableTest}
/> />
</div> </div>
@ -42,12 +51,26 @@ class TalkConfig extends Component {
type="text" type="text"
ref={r => (this.author = r)} ref={r => (this.author = r)}
defaultValue={author || ''} defaultValue={author || ''}
onChange={this.disableTest}
/> />
</div> </div>
<div className="form-group-submit col-xs-12 text-center"> <div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit"> <button
Update Talk Config className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button> </button>
</div> </div>
</form> </form>
@ -65,6 +88,8 @@ TalkConfig.propTypes = {
}).isRequired, }).isRequired,
}).isRequired, }).isRequired,
onSave: func.isRequired, onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
} }
export default TalkConfig export default TalkConfig

View File

@ -7,9 +7,12 @@ import RedactedInput from './RedactedInput'
class TelegramConfig extends Component { class TelegramConfig extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = {
testEnabled: this.props.enabled,
}
} }
handleSaveAlert = e => { handleSubmit = e => {
e.preventDefault() e.preventDefault()
let parseMode let parseMode
@ -29,6 +32,11 @@ class TelegramConfig extends Component {
} }
this.props.onSave(properties) this.props.onSave(properties)
this.setState({testEnabled: true})
}
disableTest = () => {
this.setState({testEnabled: false})
} }
handleTokenRef = r => (this.token = r) handleTokenRef = r => (this.token = r)
@ -42,7 +50,7 @@ class TelegramConfig extends Component {
const parseMode = options['parse-mode'] const parseMode = options['parse-mode']
return ( return (
<form onSubmit={this.handleSaveAlert}> <form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12"> <div className="form-group col-xs-12">
<div className="alert alert-warning alert-icon no-user-select"> <div className="alert alert-warning alert-icon no-user-select">
<span className="icon triangle" /> <span className="icon triangle" />
@ -68,6 +76,7 @@ class TelegramConfig extends Component {
defaultValue={token} defaultValue={token}
id="token" id="token"
refFunc={this.handleTokenRef} refFunc={this.handleTokenRef}
disableTest={this.disableTest}
/> />
</div> </div>
@ -86,6 +95,7 @@ class TelegramConfig extends Component {
placeholder="your-telegram-chat-id" placeholder="your-telegram-chat-id"
ref={r => (this.chatID = r)} ref={r => (this.chatID = r)}
defaultValue={chatID || ''} defaultValue={chatID || ''}
onChange={this.disableTest}
/> />
</div> </div>
@ -100,6 +110,7 @@ class TelegramConfig extends Component {
value="markdown" value="markdown"
defaultChecked={parseMode !== 'HTML'} defaultChecked={parseMode !== 'HTML'}
ref={r => (this.parseModeMarkdown = r)} ref={r => (this.parseModeMarkdown = r)}
onChange={this.disableTest}
/> />
<label htmlFor="parseModeMarkdown">Markdown</label> <label htmlFor="parseModeMarkdown">Markdown</label>
</div> </div>
@ -111,6 +122,7 @@ class TelegramConfig extends Component {
value="html" value="html"
defaultChecked={parseMode === 'HTML'} defaultChecked={parseMode === 'HTML'}
ref={r => (this.parseModeHTML = r)} ref={r => (this.parseModeHTML = r)}
onChange={this.disableTest}
/> />
<label htmlFor="parseModeHTML">HTML</label> <label htmlFor="parseModeHTML">HTML</label>
</div> </div>
@ -124,6 +136,7 @@ class TelegramConfig extends Component {
type="checkbox" type="checkbox"
defaultChecked={disableWebPagePreview} defaultChecked={disableWebPagePreview}
ref={r => (this.disableWebPagePreview = r)} ref={r => (this.disableWebPagePreview = r)}
onChange={this.disableTest}
/> />
<label htmlFor="disableWebPagePreview"> <label htmlFor="disableWebPagePreview">
Disable{' '} Disable{' '}
@ -142,6 +155,7 @@ class TelegramConfig extends Component {
type="checkbox" type="checkbox"
defaultChecked={disableNotification} defaultChecked={disableNotification}
ref={r => (this.disableNotification = r)} ref={r => (this.disableNotification = r)}
onChange={this.disableTest}
/> />
<label htmlFor="disableNotification"> <label htmlFor="disableNotification">
Disable notifications on iOS devices and disable sounds on Android Disable notifications on iOS devices and disable sounds on Android
@ -151,8 +165,21 @@ class TelegramConfig extends Component {
</div> </div>
<div className="form-group-submit col-xs-12 text-center"> <div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit"> <button
Update Telegram Config className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button> </button>
</div> </div>
</form> </form>
@ -173,6 +200,8 @@ TelegramConfig.propTypes = {
}).isRequired, }).isRequired,
}).isRequired, }).isRequired,
onSave: func.isRequired, onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
} }
export default TelegramConfig export default TelegramConfig

View File

@ -5,9 +5,12 @@ import RedactedInput from './RedactedInput'
class VictorOpsConfig extends Component { class VictorOpsConfig extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = {
testEnabled: this.props.enabled,
}
} }
handleSaveAlert = e => { handleSubmit = e => {
e.preventDefault() e.preventDefault()
const properties = { const properties = {
@ -17,6 +20,11 @@ class VictorOpsConfig extends Component {
} }
this.props.onSave(properties) this.props.onSave(properties)
this.setState({testEnabled: true})
}
disableTest = () => {
this.setState({testEnabled: false})
} }
handleApiRef = r => (this.apiKey = r) handleApiRef = r => (this.apiKey = r)
@ -28,13 +36,14 @@ class VictorOpsConfig extends Component {
const {url} = options const {url} = options
return ( return (
<form onSubmit={this.handleSaveAlert}> <form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12"> <div className="form-group col-xs-12">
<label htmlFor="api-key">API Key</label> <label htmlFor="api-key">API Key</label>
<RedactedInput <RedactedInput
defaultValue={apiKey} defaultValue={apiKey}
id="api-key" id="api-key"
refFunc={this.handleApiRef} refFunc={this.handleApiRef}
disableTest={this.disableTest}
/> />
</div> </div>
@ -46,6 +55,7 @@ class VictorOpsConfig extends Component {
type="text" type="text"
ref={r => (this.routingKey = r)} ref={r => (this.routingKey = r)}
defaultValue={routingKey || ''} defaultValue={routingKey || ''}
onChange={this.disableTest}
/> />
</div> </div>
@ -57,12 +67,26 @@ class VictorOpsConfig extends Component {
type="text" type="text"
ref={r => (this.url = r)} ref={r => (this.url = r)}
defaultValue={url || ''} defaultValue={url || ''}
onChange={this.disableTest}
/> />
</div> </div>
<div className="form-group-submit col-xs-12 text-center"> <div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit"> <button
Update VictorOps Config className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button> </button>
</div> </div>
</form> </form>
@ -81,6 +105,8 @@ VictorOpsConfig.propTypes = {
}).isRequired, }).isRequired,
}).isRequired, }).isRequired,
onSave: func.isRequired, onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
} }
export default VictorOpsConfig export default VictorOpsConfig

View File

@ -136,9 +136,9 @@ class KapacitorPage extends Component {
} }
render() { render() {
const {source, addFlashMessage} = this.props const {source, addFlashMessage, location, params} = this.props
const hash = (location && location.hash) || (params && params.hash) || ''
const {kapacitor, exists} = this.state const {kapacitor, exists} = this.state
return ( return (
<KapacitorForm <KapacitorForm
onSubmit={this.handleSubmit} onSubmit={this.handleSubmit}
@ -148,6 +148,7 @@ class KapacitorPage extends Component {
source={source} source={source}
addFlashMessage={addFlashMessage} addFlashMessage={addFlashMessage}
exists={exists} exists={exists}
hash={hash}
/> />
) )
} }
@ -168,6 +169,7 @@ KapacitorPage.propTypes = {
url: string.isRequired, url: string.isRequired,
kapacitors: array, kapacitors: array,
}), }),
location: shape({pathname: string, hash: string}).isRequired,
} }
export default withRouter(KapacitorPage) export default withRouter(KapacitorPage)

View File

@ -24,11 +24,12 @@ class TickscriptPage extends Component {
dbrps: [], dbrps: [],
type: 'stream', type: 'stream',
}, },
validation: '', consoleMessage: '',
isEditingID: true, isEditingID: true,
logs: [], logs: [],
areLogsEnabled: false, areLogsEnabled: false,
failStr: '', failStr: '',
unsavedChanges: false,
} }
} }
@ -172,9 +173,10 @@ class TickscriptPage extends Component {
} else { } else {
response = await createTask(kapacitor, task, router, sourceID) response = await createTask(kapacitor, task, router, sourceID)
} }
if (response.code) {
if (response && response.code === 500) { this.setState({unsavedChanges: true, consoleMessage: response.message})
return this.setState({validation: response.message}) } else {
this.setState({unsavedChanges: false, consoleMessage: ''})
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@ -182,37 +184,57 @@ class TickscriptPage extends Component {
} }
} }
handleExit = () => {
const {source: {id: sourceID}, router} = this.props
return router.push(`/sources/${sourceID}/alert-rules`)
}
handleChangeScript = tickscript => { handleChangeScript = tickscript => {
this.setState({task: {...this.state.task, tickscript}}) this.setState({
task: {...this.state.task, tickscript},
unsavedChanges: true,
})
} }
handleSelectDbrps = dbrps => { handleSelectDbrps = dbrps => {
this.setState({task: {...this.state.task, dbrps}}) this.setState({task: {...this.state.task, dbrps}, unsavedChanges: true})
} }
handleChangeType = type => () => { handleChangeType = type => () => {
this.setState({task: {...this.state.task, type}}) this.setState({task: {...this.state.task, type}, unsavedChanges: true})
} }
handleChangeID = e => { handleChangeID = e => {
this.setState({task: {...this.state.task, id: e.target.value}}) this.setState({
task: {...this.state.task, id: e.target.value},
unsavedChanges: true,
})
} }
handleToggleLogsVisbility = () => { handleToggleLogsVisibility = () => {
this.setState({areLogsVisible: !this.state.areLogsVisible}) this.setState({areLogsVisible: !this.state.areLogsVisible})
} }
render() { render() {
const {source} = this.props const {source} = this.props
const {task, validation, logs, areLogsVisible, areLogsEnabled} = this.state const {
task,
logs,
areLogsVisible,
areLogsEnabled,
unsavedChanges,
consoleMessage,
} = this.state
return ( return (
<Tickscript <Tickscript
task={task} task={task}
logs={logs} logs={logs}
source={source} source={source}
validation={validation} consoleMessage={consoleMessage}
onSave={this.handleSave} onSave={this.handleSave}
unsavedChanges={unsavedChanges}
onExit={this.handleExit}
isNewTickscript={!this._isEditing()} isNewTickscript={!this._isEditing()}
onSelectDbrps={this.handleSelectDbrps} onSelectDbrps={this.handleSelectDbrps}
onChangeScript={this.handleChangeScript} onChangeScript={this.handleChangeScript}
@ -220,7 +242,7 @@ class TickscriptPage extends Component {
onChangeID={this.handleChangeID} onChangeID={this.handleChangeID}
areLogsVisible={areLogsVisible} areLogsVisible={areLogsVisible}
areLogsEnabled={areLogsEnabled} areLogsEnabled={areLogsEnabled}
onToggleLogsVisbility={this.handleToggleLogsVisbility} onToggleLogsVisibility={this.handleToggleLogsVisibility}
/> />
) )
} }

View File

@ -152,20 +152,18 @@ export function updateKapacitorConfigSection(kapacitor, section, properties) {
}) })
} }
export function testAlertOutput(kapacitor, outputName, properties) { export const testAlertOutput = async (kapacitor, outputName) => {
return kapacitorProxy( try {
const {data: {services}} = await kapacitorProxy(
kapacitor, kapacitor,
'GET', 'GET',
'/kapacitor/v1/service-tests' '/kapacitor/v1/service-tests'
).then(({data: {services}}) => {
const service = services.find(s => s.name === outputName)
return kapacitorProxy(
kapacitor,
'POST',
service.link.href,
Object.assign({}, service.options, properties)
) )
}) const service = services.find(s => s.name === outputName)
return kapacitorProxy(kapacitor, 'POST', service.link.href, {})
} catch (error) {
console.error(error)
}
} }
export function createKapacitorTask(kapacitor, id, type, dbrps, script) { export function createKapacitorTask(kapacitor, id, type, dbrps, script) {

View File

@ -0,0 +1,110 @@
import React, {Component, PropTypes} from 'react'
import OnClickOutside from 'shared/components/OnClickOutside'
class ConfirmButton extends Component {
constructor(props) {
super(props)
this.state = {
expanded: false,
}
}
handleButtonClick = () => {
if (this.props.disabled) {
return
}
this.setState({expanded: !this.state.expanded})
}
handleConfirmClick = () => {
this.setState({expanded: false})
this.props.confirmAction()
}
handleClickOutside = () => {
this.setState({expanded: false})
}
calculatePosition = () => {
if (!this.buttonDiv || !this.tooltipDiv) {
return ''
}
const windowWidth = window.innerWidth
const buttonRect = this.buttonDiv.getBoundingClientRect()
const tooltipRect = this.tooltipDiv.getBoundingClientRect()
const rightGap = windowWidth - buttonRect.right
if (tooltipRect.width / 2 > rightGap) {
return 'left'
}
return 'bottom'
}
render() {
const {
text,
confirmText,
type,
size,
square,
icon,
disabled,
customClass,
} = this.props
const {expanded} = this.state
const customClassString = customClass ? ` ${customClass}` : ''
const squareString = square ? ' btn-square' : ''
const expandedString = expanded ? ' active' : ''
const disabledString = disabled ? ' disabled' : ''
const classname = `confirm-button btn ${type} ${size}${customClassString}${squareString}${expandedString}${disabledString}`
return (
<div
className={classname}
onClick={this.handleButtonClick}
ref={r => (this.buttonDiv = r)}
>
{icon && <span className={`icon ${icon}`} />}
{text && text}
<div className={`confirm-button--tooltip ${this.calculatePosition()}`}>
<div
className="confirm-button--confirmation"
onClick={this.handleConfirmClick}
ref={r => (this.tooltipDiv = r)}
>
{confirmText}
</div>
</div>
</div>
)
}
}
const {bool, func, string} = PropTypes
ConfirmButton.defaultProps = {
confirmText: 'Confirm',
type: 'btn-default',
size: 'btn-sm',
square: false,
}
ConfirmButton.propTypes = {
text: string,
confirmText: string,
confirmAction: func.isRequired,
type: string,
size: string,
square: bool,
icon: string,
disabled: bool,
customClass: string,
}
export default OnClickOutside(ConfirmButton)

View File

@ -21,7 +21,7 @@ const kapacitorDropdown = (
to={`/sources/${source.id}/kapacitors/new`} to={`/sources/${source.id}/kapacitors/new`}
className="btn btn-xs btn-default" className="btn btn-xs btn-default"
> >
<span className="icon plus" /> Add Config <span className="icon plus" /> Add Kapacitor Connection
</Link> </Link>
</Authorized> </Authorized>
) )
@ -62,7 +62,7 @@ const kapacitorDropdown = (
onChoose={setActiveKapacitor} onChoose={setActiveKapacitor}
addNew={{ addNew={{
url: `/sources/${source.id}/kapacitors/new`, url: `/sources/${source.id}/kapacitors/new`,
text: 'Add Kapacitor', text: 'Add Kapacitor Connection',
}} }}
actions={[ actions={[
{ {
@ -105,16 +105,16 @@ const InfluxTable = ({
<h2 className="panel-title"> <h2 className="panel-title">
{isUsingAuth {isUsingAuth
? <span> ? <span>
InfluxDB Sources for <em>{me.currentOrganization.name}</em> Connections for <em>{me.currentOrganization.name}</em>
</span> </span>
: <span>InfluxDB Sources</span>} : <span>Connections</span>}
</h2> </h2>
<Authorized requiredRole={EDITOR_ROLE}> <Authorized requiredRole={EDITOR_ROLE}>
<Link <Link
to={`/sources/${source.id}/manage-sources/new`} to={`/sources/${source.id}/manage-sources/new`}
className="btn btn-sm btn-primary" className="btn btn-sm btn-primary"
> >
<span className="icon plus" /> Add Source <span className="icon plus" /> Add Connection
</Link> </Link>
</Authorized> </Authorized>
</div> </div>
@ -123,14 +123,14 @@ const InfluxTable = ({
<thead> <thead>
<tr> <tr>
<th className="source-table--connect-col" /> <th className="source-table--connect-col" />
<th>Source Name & Host</th> <th>InfluxDB Connection</th>
<th className="text-right" /> <th className="text-right" />
<th> <th>
Active Kapacitor{' '} Kapacitor Connection{' '}
<QuestionMarkTooltip <QuestionMarkTooltip
tipID="kapacitor-node-helper" tipID="kapacitor-node-helper"
tipContent={ tipContent={
'<p>Kapacitor Configurations are<br/>scoped per InfluxDB Source.<br/>Only one can be active at a time.</p>' '<p>Kapacitor Connections are<br/>scoped per InfluxDB Connection.<br/>Only one can be active at a time.</p>'
} }
/> />
</th> </th>
@ -189,7 +189,7 @@ const InfluxTable = ({
href="#" href="#"
onClick={handleDeleteSource(s)} onClick={handleDeleteSource(s)}
> >
Delete Source Delete Connection
</a> </a>
</Authorized> </Authorized>
</td> </td>

View File

@ -23,13 +23,14 @@ const SourceForm = ({
? <div className="text-center"> ? <div className="text-center">
{me.role === SUPERADMIN_ROLE {me.role === SUPERADMIN_ROLE
? <h3> ? <h3>
<strong>{me.currentOrganization.name}</strong> has no sources <strong>{me.currentOrganization.name}</strong> has no
connections
</h3> </h3>
: <h3> : <h3>
<strong>{me.currentOrganization.name}</strong> has no sources <strong>{me.currentOrganization.name}</strong> has no
available to <em>{me.role}s</em> connections available to <em>{me.role}s</em>
</h3>} </h3>}
<h6>Add a Source below:</h6> <h6>Add a Connection below:</h6>
</div> </div>
: null} : null}
@ -112,13 +113,13 @@ const SourceForm = ({
<div className="form-control-static"> <div className="form-control-static">
<input <input
type="checkbox" type="checkbox"
id="defaultSourceCheckbox" id="defaultConnectionCheckbox"
name="default" name="default"
checked={source.default} checked={source.default}
onChange={onInputChange} onChange={onInputChange}
/> />
<label htmlFor="defaultSourceCheckbox"> <label htmlFor="defaultConnectionCheckbox">
Make this the default source Make this the default connection
</label> </label>
</div> </div>
</div> </div>
@ -148,7 +149,7 @@ const SourceForm = ({
type="submit" type="submit"
> >
<span className={`icon ${editMode ? 'checkmark' : 'plus'}`} /> <span className={`icon ${editMode ? 'checkmark' : 'plus'}`} />
{editMode ? 'Save Changes' : 'Add Source'} {editMode ? 'Save Changes' : 'Add Connection'}
</button> </button>
<br /> <br />

View File

@ -131,7 +131,7 @@ class SourcePage extends Component {
.catch(err => { .catch(err => {
// dont want to flash this until they submit // dont want to flash this until they submit
const error = this._parseError(err) const error = this._parseError(err)
console.error('Error on source creation: ', error) console.error('Error creating InfluxDB connection: ', error)
}) })
} }
@ -142,10 +142,10 @@ class SourcePage extends Component {
.then(({data: sourceFromServer}) => { .then(({data: sourceFromServer}) => {
this.props.addSourceAction(sourceFromServer) this.props.addSourceAction(sourceFromServer)
this._redirect(sourceFromServer) this._redirect(sourceFromServer)
notify('success', `New source ${source.name} added`) notify('success', `InfluxDB ${source.name} available as a connection`)
}) })
.catch(error => { .catch(error => {
this.handleError('Unable to create source', error) this.handleError('Unable to create InfluxDB connection', error)
}) })
} }
@ -156,10 +156,10 @@ class SourcePage extends Component {
.then(({data: sourceFromServer}) => { .then(({data: sourceFromServer}) => {
this.props.updateSourceAction(sourceFromServer) this.props.updateSourceAction(sourceFromServer)
this._redirect(sourceFromServer) this._redirect(sourceFromServer)
notify('success', `Source ${source.name} updated`) notify('success', `InfluxDB connection ${source.name} updated`)
}) })
.catch(error => { .catch(error => {
this.handleError('Unable to update source', error) this.handleError('Unable to update InfluxDB connection', error)
}) })
} }
@ -208,7 +208,9 @@ class SourcePage extends Component {
<div className="page-header__col-md-8"> <div className="page-header__col-md-8">
<div className="page-header__left"> <div className="page-header__left">
<h1 className="page-header__title"> <h1 className="page-header__title">
{editMode ? 'Edit Source' : 'Add a New Source'} {editMode
? 'Configure InfluxDB Connection'
: 'Add a New InfluxDB Connection'}
</h1> </h1>
</div> </div>
{isInitialSource {isInitialSource

View File

@ -28,6 +28,7 @@
// Components // Components
@import 'components/ceo-display-options'; @import 'components/ceo-display-options';
@import 'components/confirm-button';
@import 'components/confirm-buttons'; @import 'components/confirm-buttons';
@import 'components/code-mirror-theme'; @import 'components/code-mirror-theme';
@import 'components/color-dropdown'; @import 'components/color-dropdown';

View File

@ -0,0 +1,79 @@
/*
Confirm Button
----------------------------------------------------------------------------
This button requires a second click to confirm the action
*/
.confirm-button {
.confirm-button--tooltip {
visibility: hidden;
transition: all;
position: absolute;
&.bottom {
top: calc(100% + 4px);
left: 50%;
transform: translateX(-50%);
}
&.left {
top: 50%;
right: calc(100% + 4px);
transform: translateY(-50%);
}
}
}
.confirm-button--confirmation {
border-radius: 3px;
background-color: $c-curacao;
opacity: 0;
padding: 0 7px;
color: $g20-white;
font-size: 13px;
font-weight: 600;
text-align: center;
transition: opacity 0.25s ease, background-color 0.25s ease;
&:after {
content: '';
border: 8px solid transparent;
position: absolute;
transition: border-color 0.25s ease;
z-index: 100;
}
&:hover {
background-color: $c-dreamsicle;
cursor: pointer;
}
}
.confirm-button--tooltip.bottom .confirm-button--confirmation:after {
bottom: 100%;
left: 50%;
border-bottom-color: $c-curacao;
transform: translateX(-50%);
}
.confirm-button--tooltip.bottom .confirm-button--confirmation:hover:after {
border-bottom-color: $c-dreamsicle;
}
.confirm-button--tooltip.left .confirm-button--confirmation:after {
left: 100%;
top: 50%;
border-left-color: $c-curacao;
transform: translateY(-50%);
}
.confirm-button--tooltip.left .confirm-button--confirmation:hover:after {
border-left-color: $c-dreamsicle;
}
.confirm-button.active {
z-index: 999;
.confirm-button--tooltip {
visibility: visible;
}
.confirm-button--confirmation {
opacity: 1;
}
}

View File

@ -3,28 +3,26 @@
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
*/ */
$tickscript-console-height: 60px; $tickscript-controls-height: 60px;
.tickscript { .tickscript {
flex: 1 0 0; flex: 1 0 0;
position: relative;
} }
.tickscript-controls, .tickscript-controls,
.tickscript-console, .tickscript-console,
.tickscript-editor { .tickscript-editor {
padding: 0;
margin: 0;
width: 100%; width: 100%;
position: relative;
} }
.tickscript-controls, .tickscript-console,
.tickscript-console { .tickscript-controls {
height: $tickscript-console-height; padding: 0 60px;
display: flex;
} }
.tickscript-controls { .tickscript-controls {
display: flex;
align-items: center; align-items: center;
height: $tickscript-controls-height;
justify-content: space-between; justify-content: space-between;
padding: 0 60px;
background-color: $g3-castle; background-color: $g3-castle;
} }
.tickscript-controls--name { .tickscript-controls--name {
@ -42,29 +40,42 @@ $tickscript-console-height: 60px;
> * {margin-left: 8px;} > * {margin-left: 8px;}
} }
.tickscript-console--output { .tickscript-console {
padding: 0 60px; align-items: flex-start;
font-family: $code-font; height: $tickscript-controls-height * 2.25;
font-weight: 600; border-top: 2px solid $g3-castle;
display: flex; background-color: $g0-obsidian;
align-items: center; overflow-y: scroll;
background-color: $g2-kevlar; @include custom-scrollbar($g0-obsidian,$g4-onyx);
border-bottom: 2px solid $g3-castle;
position: relative;
height: 100%;
width: 100%;
border-radius: $radius $radius 0 0;
> p { > p {
margin: 0; position: relative;
padding-left: 16px;
font-family: $code-font;
margin: 11px 0;
font-weight: 700;
word-wrap: break-word;
word-break: break-word;
&:before {
content: '>';
position: absolute;
top: 0;
left: 0;
}
} }
} }
.tickscript-console--default { .tickscript-console--default {
color: $g10-wolf; color: $g13-mist;
font-style: italic; }
.tickscript-console--valid {
color: $c-rainforest;
}
.tickscript-console--error {
color: $c-dreamsicle;
} }
.tickscript-editor { .tickscript-editor {
height: calc(100% - #{$tickscript-console-height * 2}); height: calc(100% - #{$tickscript-controls-height * 3.25});
} }
/* /*

View File

@ -6,7 +6,7 @@
margin: 0 0 0 4px; margin: 0 0 0 4px;
} }
.text-center .btn { .text-center .btn {
margin: 0 2px; margin: 0 6px;
} }
.default-source-label { .default-source-label {
display: inline-block; display: inline-block;