diff --git a/server/cells.go b/server/cells.go index 99ce5c07c..b6a79290b 100644 --- a/server/cells.go +++ b/server/cells.go @@ -10,6 +10,39 @@ import ( "github.com/influxdata/chronograf/uuid" ) +const ( + // DefaultWidth is used if not specified + DefaultWidth = 4 + // DefaultHeight is used if not specified + DefaultHeight = 4 +) + +type dashboardCellLinks struct { + Self string `json:"self"` // Self link mapping to this resource +} + +type dashboardCellResponse struct { + chronograf.DashboardCell + Links dashboardCellLinks `json:"links"` +} + +func newCellResponses(dID chronograf.DashboardID, dcells []chronograf.DashboardCell) []dashboardCellResponse { + base := "/chronograf/v1/dashboards" + cells := make([]dashboardCellResponse, len(dcells)) + for i, cell := range dcells { + if len(cell.Queries) == 0 { + cell.Queries = make([]chronograf.DashboardQuery, 0) + } + cells[i] = dashboardCellResponse{ + DashboardCell: cell, + Links: dashboardCellLinks{ + Self: fmt.Sprintf("%s/%d/cells/%s", base, dID, cell.ID), + }, + } + } + return cells +} + // ValidDashboardCellRequest verifies that the dashboard cells have a query func ValidDashboardCellRequest(c *chronograf.DashboardCell) error { CorrectWidthHeight(c) @@ -108,7 +141,7 @@ func (s *Service) NewDashboardCell(w http.ResponseWriter, r *http.Request) { } } -// DashboardCellID adds a cell to an existing dashboard +// DashboardCellID gets a specific cell from an existing dashboard func (s *Service) DashboardCellID(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id, err := paramID("id", r) @@ -134,7 +167,7 @@ func (s *Service) DashboardCellID(w http.ResponseWriter, r *http.Request) { notFound(w, id, s.Logger) } -// RemoveDashboardCell adds a cell to an existing dashboard +// RemoveDashboardCell removes a specific cell from an existing dashboard func (s *Service) RemoveDashboardCell(w http.ResponseWriter, r *http.Request) { id, err := paramID("id", r) if err != nil { @@ -171,7 +204,7 @@ func (s *Service) RemoveDashboardCell(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// ReplaceDashboardCell adds a cell to an existing dashboard +// ReplaceDashboardCell replaces a cell entirely within an existing dashboard func (s *Service) ReplaceDashboardCell(w http.ResponseWriter, r *http.Request) { id, err := paramID("id", r) if err != nil { diff --git a/server/dashboards.go b/server/dashboards.go index 33e065b60..6448d91ef 100644 --- a/server/dashboards.go +++ b/server/dashboards.go @@ -8,32 +8,18 @@ import ( "github.com/influxdata/chronograf" ) -const ( - // DefaultWidth is used if not specified - DefaultWidth = 4 - // DefaultHeight is used if not specified - DefaultHeight = 4 -) - type dashboardLinks struct { - Self string `json:"self"` // Self link mapping to this resource - Cells string `json:"cells"` // Cells link to the cells endpoint -} - -type dashboardCellLinks struct { - Self string `json:"self"` // Self link mapping to this resource -} - -type dashboardCellResponse struct { - chronograf.DashboardCell - Links dashboardCellLinks `json:"links"` + Self string `json:"self"` // Self link mapping to this resource + Cells string `json:"cells"` // Cells link to the cells endpoint + Templates string `json:"templates"` // Templates link to the templates endpoint } type dashboardResponse struct { - ID chronograf.DashboardID `json:"id"` - Cells []dashboardCellResponse `json:"cells"` - Name string `json:"name"` - Links dashboardLinks `json:"links"` + ID chronograf.DashboardID `json:"id"` + Cells []dashboardCellResponse `json:"cells"` + Templates []templateResponse `json:"templates"` + Name string `json:"name"` + Links dashboardLinks `json:"links"` } type getDashboardsResponse struct { @@ -44,25 +30,18 @@ func newDashboardResponse(d chronograf.Dashboard) *dashboardResponse { base := "/chronograf/v1/dashboards" DashboardDefaults(&d) AddQueryConfigs(&d) - cells := make([]dashboardCellResponse, len(d.Cells)) - for i, cell := range d.Cells { - if len(cell.Queries) == 0 { - cell.Queries = make([]chronograf.DashboardQuery, 0) - } - cells[i] = dashboardCellResponse{ - DashboardCell: cell, - Links: dashboardCellLinks{ - Self: fmt.Sprintf("%s/%d/cells/%s", base, d.ID, cell.ID), - }, - } - } + cells := newCellResponses(d.ID, d.Cells) + templates := newTemplateResponses(d.ID, d.Templates) + return &dashboardResponse{ - ID: d.ID, - Name: d.Name, - Cells: cells, + ID: d.ID, + Name: d.Name, + Cells: cells, + Templates: templates, Links: dashboardLinks{ - Self: fmt.Sprintf("%s/%d", base, d.ID), - Cells: fmt.Sprintf("%s/%d/cells", base, d.ID), + Self: fmt.Sprintf("%s/%d", base, d.ID), + Cells: fmt.Sprintf("%s/%d/cells", base, d.ID), + Templates: fmt.Sprintf("%s/%d/templates", base, d.ID), }, } } @@ -83,7 +62,6 @@ func (s *Service) Dashboards(w http.ResponseWriter, r *http.Request) { for _, dashboard := range dashboards { res.Dashboards = append(res.Dashboards, newDashboardResponse(dashboard)) } - encodeJSON(w, http.StatusOK, res, s.Logger) } diff --git a/server/dashboards_test.go b/server/dashboards_test.go index 2c7c1c490..672d0fa5b 100644 --- a/server/dashboards_test.go +++ b/server/dashboards_test.go @@ -1,6 +1,8 @@ package server import ( + "encoding/json" + "log" "reflect" "testing" @@ -233,6 +235,7 @@ func Test_newDashboardResponse(t *testing.T) { }, }, want: &dashboardResponse{ + Templates: []templateResponse{}, Cells: []dashboardCellResponse{ dashboardCellResponse{ Links: dashboardCellLinks{ @@ -289,8 +292,9 @@ func Test_newDashboardResponse(t *testing.T) { }, }, Links: dashboardLinks{ - Self: "/chronograf/v1/dashboards/0", - Cells: "/chronograf/v1/dashboards/0/cells", + Self: "/chronograf/v1/dashboards/0", + Cells: "/chronograf/v1/dashboards/0/cells", + Templates: "/chronograf/v1/dashboards/0/templates", }, }, }, @@ -298,6 +302,10 @@ func Test_newDashboardResponse(t *testing.T) { for _, tt := range tests { if got := newDashboardResponse(tt.d); !reflect.DeepEqual(got, tt.want) { t.Errorf("%q. newDashboardResponse() = \n%+v\n\n, want\n\n%+v", tt.name, got, tt.want) + g, _ := json.MarshalIndent(got, "", " ") + w, _ := json.MarshalIndent(tt.want, "", " ") + log.Printf(string(g)) + log.Printf(string(w)) } } } diff --git a/server/mux.go b/server/mux.go index 9e05756c1..0024cf221 100644 --- a/server/mux.go +++ b/server/mux.go @@ -155,6 +155,13 @@ func NewMux(opts MuxOpts, service Service) http.Handler { router.GET("/chronograf/v1/dashboards/:id/cells/:cid", service.DashboardCellID) router.DELETE("/chronograf/v1/dashboards/:id/cells/:cid", service.RemoveDashboardCell) router.PUT("/chronograf/v1/dashboards/:id/cells/:cid", service.ReplaceDashboardCell) + // Dashboard Templates + router.GET("/chronograf/v1/dashboards/:id/templates", service.Templates) + router.POST("/chronograf/v1/dashboards/:id/templates", service.NewTemplate) + + router.GET("/chronograf/v1/dashboards/:id/templates/:tid", service.TemplateID) + router.DELETE("/chronograf/v1/dashboards/:id/templates/:tid", service.RemoveTemplate) + router.PUT("/chronograf/v1/dashboards/:id/templates/:tid", service.ReplaceTemplate) // Databases router.GET("/chronograf/v1/sources/:id/dbs", service.GetDatabases) diff --git a/server/templates.go b/server/templates.go new file mode 100644 index 000000000..f13cfbb87 --- /dev/null +++ b/server/templates.go @@ -0,0 +1,241 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/bouk/httprouter" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/uuid" +) + +// ValidTemplateRequest checks if the request sent to the server is the correct format. +func ValidTemplateRequest(template *chronograf.Template) error { + switch template.Type { + default: + return fmt.Errorf("Unknown template type %s", template.Type) + case "query", "constant", "csv", "fieldKeys", "tagKeys", "tagValues": + } + + for _, v := range template.Values { + switch v.Type { + default: + return fmt.Errorf("Unknown template variable type %s", v.Type) + case "csv", "fieldKey", "tagKey", "tagValue": + } + } + + if template.Type == "query" && template.Query == nil { + return fmt.Errorf("No query set for template of type 'query'") + } + return nil +} + +type templateLinks struct { + Self string `json:"self"` // Self link mapping to this resource +} + +type templateResponse struct { + chronograf.Template + Links templateLinks `json:"links"` +} + +func newTemplateResponses(dID chronograf.DashboardID, tmps []chronograf.Template) []templateResponse { + res := make([]templateResponse, len(tmps)) + for i, t := range tmps { + res[i] = newTemplateResponse(dID, t) + } + return res +} + +func newTemplateResponse(dID chronograf.DashboardID, tmp chronograf.Template) templateResponse { + base := "/chronograf/v1/dashboards" + return templateResponse{ + Template: tmp, + Links: templateLinks{ + Self: fmt.Sprintf("%s/%d/templates/%s", base, dID, tmp.ID), + }, + } +} + +// Templates returns all templates from a dashboard within the store +func (s *Service) Templates(w http.ResponseWriter, r *http.Request) { + id, err := paramID("id", r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) + return + } + + ctx := r.Context() + e, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + if err != nil { + notFound(w, id, s.Logger) + return + } + + boards := newDashboardResponse(e) + templates := boards.Templates + encodeJSON(w, http.StatusOK, templates, s.Logger) +} + +// NewTemplate adds a template to an existing dashboard +func (s *Service) NewTemplate(w http.ResponseWriter, r *http.Request) { + id, err := paramID("id", r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) + return + } + + ctx := r.Context() + dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + if err != nil { + notFound(w, id, s.Logger) + return + } + + var template chronograf.Template + if err := json.NewDecoder(r.Body).Decode(&template); err != nil { + invalidJSON(w, s.Logger) + return + } + + if err := ValidTemplateRequest(&template); err != nil { + invalidData(w, err, s.Logger) + return + } + + ids := uuid.V4{} + tid, err := ids.Generate() + if err != nil { + msg := fmt.Sprintf("Error creating template ID for dashboard %d: %v", id, err) + Error(w, http.StatusInternalServerError, msg, s.Logger) + return + } + template.ID = chronograf.TemplateID(tid) + + dash.Templates = append(dash.Templates, template) + if err := s.DashboardsStore.Update(ctx, dash); err != nil { + msg := fmt.Sprintf("Error adding template %s to dashboard %d: %v", tid, id, err) + Error(w, http.StatusInternalServerError, msg, s.Logger) + return + } + + res := newTemplateResponse(dash.ID, template) + encodeJSON(w, http.StatusOK, res, s.Logger) +} + +// TemplateID retrieves a specific template from a dashboard +func (s *Service) TemplateID(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id, err := paramID("id", r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) + return + } + + dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + if err != nil { + notFound(w, id, s.Logger) + return + } + + tid := httprouter.GetParamFromContext(ctx, "tid") + for _, t := range dash.Templates { + if t.ID == chronograf.TemplateID(tid) { + res := newTemplateResponse(chronograf.DashboardID(id), t) + encodeJSON(w, http.StatusOK, res, s.Logger) + } + } + + notFound(w, id, s.Logger) +} + +// RemoveTemplate removes a specific template from an existing dashboard +func (s *Service) RemoveTemplate(w http.ResponseWriter, r *http.Request) { + id, err := paramID("id", r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) + return + } + + ctx := r.Context() + dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + if err != nil { + notFound(w, id, s.Logger) + return + } + + tid := httprouter.GetParamFromContext(ctx, "tid") + pos := -1 + for i, t := range dash.Templates { + if t.ID == chronograf.TemplateID(tid) { + pos = i + break + } + } + if pos == -1 { + notFound(w, id, s.Logger) + return + } + + dash.Templates = append(dash.Templates[:pos], dash.Templates[pos+1:]...) + if err := s.DashboardsStore.Update(ctx, dash); err != nil { + msg := fmt.Sprintf("Error removing template %s from dashboard %d: %v", tid, id, err) + Error(w, http.StatusInternalServerError, msg, s.Logger) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// ReplaceTemplate replaces a template entirely within an existing dashboard +func (s *Service) ReplaceTemplate(w http.ResponseWriter, r *http.Request) { + id, err := paramID("id", r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) + return + } + + ctx := r.Context() + dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + if err != nil { + notFound(w, id, s.Logger) + return + } + + tid := httprouter.GetParamFromContext(ctx, "tid") + pos := -1 + for i, t := range dash.Templates { + if t.ID == chronograf.TemplateID(tid) { + pos = i + break + } + } + if pos == -1 { + notFound(w, id, s.Logger) + return + } + + var template chronograf.Template + if err := json.NewDecoder(r.Body).Decode(&template); err != nil { + invalidJSON(w, s.Logger) + return + } + + if err := ValidTemplateRequest(&template); err != nil { + invalidData(w, err, s.Logger) + return + } + template.ID = chronograf.TemplateID(tid) + + dash.Templates[pos] = template + if err := s.DashboardsStore.Update(ctx, dash); err != nil { + msg := fmt.Sprintf("Error updating template %s in dashboard %d: %v", tid, id, err) + Error(w, http.StatusInternalServerError, msg, s.Logger) + return + } + + res := newTemplateResponse(chronograf.DashboardID(id), template) + encodeJSON(w, http.StatusOK, res, s.Logger) +} diff --git a/server/templates_test.go b/server/templates_test.go new file mode 100644 index 000000000..8a9bec46f --- /dev/null +++ b/server/templates_test.go @@ -0,0 +1,71 @@ +package server + +import ( + "testing" + + "github.com/influxdata/chronograf" +) + +func TestValidTemplateRequest(t *testing.T) { + tests := []struct { + name string + template *chronograf.Template + wantErr bool + }{ + { + name: "Valid Template", + template: &chronograf.Template{ + Type: "fieldKeys", + TemplateVar: chronograf.TemplateVar{ + Values: []chronograf.TemplateValue{ + { + Type: "fieldKey", + }, + }, + }, + }, + }, + { + name: "Invalid Template Type", + wantErr: true, + template: &chronograf.Template{ + Type: "Unknown Type", + TemplateVar: chronograf.TemplateVar{ + Values: []chronograf.TemplateValue{ + { + Type: "fieldKey", + }, + }, + }, + }, + }, + { + name: "Invalid Template Variable Type", + wantErr: true, + template: &chronograf.Template{ + Type: "csv", + TemplateVar: chronograf.TemplateVar{ + Values: []chronograf.TemplateValue{ + { + Type: "unknown value", + }, + }, + }, + }, + }, + { + name: "No query set", + wantErr: true, + template: &chronograf.Template{ + Type: "query", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := ValidTemplateRequest(tt.template); (err != nil) != tt.wantErr { + t.Errorf("ValidTemplateRequest() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +}