diff --git a/board.go b/board.go index da8edf1f..9b39e97f 100644 --- a/board.go +++ b/board.go @@ -83,7 +83,7 @@ type ( Type string `json:"type"` Auto bool `json:"auto,omitempty"` AutoCount *int `json:"auto_count,omitempty"` - Datasource *string `json:"datasource"` + Datasource interface{} `json:"datasource"` Refresh BoolInt `json:"refresh"` Options []Option `json:"options"` IncludeAll bool `json:"includeAll"` @@ -111,23 +111,23 @@ type ( Value interface{} `json:"value"` // TODO select more precise type } Annotation struct { - Name string `json:"name"` - Datasource *string `json:"datasource"` - ShowLine bool `json:"showLine"` - IconColor string `json:"iconColor"` - LineColor string `json:"lineColor"` - IconSize uint `json:"iconSize"` - Enable bool `json:"enable"` - Query string `json:"query"` - Expr string `json:"expr"` - Step string `json:"step"` - TextField string `json:"textField"` - TextFormat string `json:"textFormat"` - TitleFormat string `json:"titleFormat"` - TagsField string `json:"tagsField"` - Tags []string `json:"tags"` - TagKeys string `json:"tagKeys"` - Type string `json:"type"` + Name string `json:"name"` + Datasource interface{} `json:"datasource"` + ShowLine bool `json:"showLine"` + IconColor string `json:"iconColor"` + LineColor string `json:"lineColor"` + IconSize uint `json:"iconSize"` + Enable bool `json:"enable"` + Query string `json:"query"` + Expr string `json:"expr"` + Step string `json:"step"` + TextField string `json:"textField"` + TextFormat string `json:"textFormat"` + TitleFormat string `json:"titleFormat"` + TagsField string `json:"tagsField"` + Tags []string `json:"tags"` + TagKeys string `json:"tagKeys"` + Type string `json:"type"` } // Link represents link to another dashboard or external weblink Link struct { diff --git a/dashboard-unmarshal_test.go b/dashboard-unmarshal_test.go index c869fa85..2a5f99cc 100644 --- a/dashboard-unmarshal_test.go +++ b/dashboard-unmarshal_test.go @@ -101,8 +101,8 @@ func TestUnmarshal_DashboardWithGraphWithTargets26(t *testing.T) { if panel.OfType != sdk.GraphType { t.Errorf("panel type should be %d (\"graph\") type but got %d", sdk.GraphType, panel.OfType) } - if *panel.Datasource != sdk.MixedSource { - t.Errorf("panel Datasource should be \"%s\" but got \"%s\"", sdk.MixedSource, *panel.Datasource) + if panel.Datasource != sdk.MixedSource { + t.Errorf("panel Datasource should be \"%s\" but got \"%s\"", sdk.MixedSource, panel.Datasource) } if len(panel.GraphPanel.Targets) != 2 { t.Errorf("panel has 2 targets but got %d", len(panel.GraphPanel.Targets)) @@ -188,3 +188,40 @@ func TestUnmarshal_DashboardWithMixedYaxes(t *testing.T) { t.Errorf("panel #1 has wrong max value: %f, expected: %f", max4.Value, 100.0) } } + +func TestUnmarshal_DashboardWithGraphWithTargets83(t *testing.T) { + var board sdk.Board + raw, _ := ioutil.ReadFile("testdata/default-panels-graph-with-target-8.3.json") + + err := json.Unmarshal(raw, &board) + + if err != nil { + t.Fatal(err) + } + if len(board.Panels) != 2 { + t.Fatalf("board should have 2 panels but got %d", len(board.Panels)) + } + rowPanel := board.Panels[0] + if rowPanel.OfType != sdk.RowType { + t.Errorf("panel type should be %d (\"row\") type but got %d", sdk.GraphType, rowPanel.OfType) + } + + panel := board.Panels[1] + if panel.OfType != sdk.GraphType { + t.Errorf("panel type should be %d (\"graph\") type but got %d", sdk.GraphType, panel.OfType) + } + + if len(panel.GraphPanel.Targets) != 1 { + t.Errorf("panel has 1 targets but got %d", len(panel.GraphPanel.Targets)) + } + + target := panel.GraphPanel.Targets[0] + datasource, ok := target.Datasource.(map[string]interface{}) + if !ok { + t.Fatalf("target Datasource should be a map but got %T", panel.Datasource) + } + if datasource["type"] != "prometheus" { + t.Errorf("target datasource should be of type \"prometheus\" but got %s", datasource["type"]) + } + +} diff --git a/panel.go b/panel.go index 76ec071e..ca707e74 100644 --- a/panel.go +++ b/panel.go @@ -23,6 +23,8 @@ import ( "bytes" "encoding/json" "errors" + "fmt" + "sort" ) // Each panel may be one of these types. @@ -66,9 +68,9 @@ type ( } panelType int8 CommonPanel struct { - Datasource *string `json:"datasource,omitempty"` // metrics - Editable bool `json:"editable"` - Error bool `json:"error"` + Datasource interface{} `json:"datasource,omitempty"` // metrics + Editable bool `json:"editable"` + Error bool `json:"error"` GridPos struct { H *int `json:"h,omitempty"` W *int `json:"w,omitempty"` @@ -374,9 +376,9 @@ type ( FieldConfigDefaults struct { Unit string `json:"unit"` Decimals *int `json:"decimals,omitempty"` - Min *int `json:"min,omitempty"` - Max *int `json:"max,omitempty"` - Color FieldConfigColor `json:"color,omitempty"` + Min *float64 `json:"min,omitempty"` + Max *float64 `json:"max,omitempty"` + Color FieldConfigColor `json:"color"` Thresholds Thresholds `json:"thresholds"` Custom FieldConfigCustom `json:"custom"` Links []Link `json:"links,omitempty"` @@ -420,8 +422,8 @@ type ( Steps []ThresholdStep `json:"steps"` } ThresholdStep struct { - Color string `json:"color"` - Value *int `json:"value"` + Color string `json:"color"` + Value *float64 `json:"value"` } FieldConfigColor struct { Mode string `json:"mode"` @@ -550,9 +552,9 @@ type ( // for an any panel type Target struct { - RefID string `json:"refId"` - Datasource string `json:"datasource,omitempty"` - Hide bool `json:"hide,omitempty"` + RefID string `json:"refId"` + Datasource interface{} `json:"datasource,omitempty"` + Hide bool `json:"hide,omitempty"` // For PostgreSQL Table string `json:"table,omitempty"` @@ -1040,78 +1042,85 @@ type probePanel struct { func (p *Panel) UnmarshalJSON(b []byte) (err error) { var probe probePanel - if err = json.Unmarshal(b, &probe); err == nil { - p.CommonPanel = probe.CommonPanel - switch probe.Type { - case "graph": - var graph GraphPanel - p.OfType = GraphType - if err = json.Unmarshal(b, &graph); err == nil { - p.GraphPanel = &graph - } - case "table": - var table TablePanel - p.OfType = TableType - if err = json.Unmarshal(b, &table); err == nil { - p.TablePanel = &table - } - case "text": - var text TextPanel - p.OfType = TextType - if err = json.Unmarshal(b, &text); err == nil { - p.TextPanel = &text - } - case "singlestat": - var singlestat SinglestatPanel - p.OfType = SinglestatType - if err = json.Unmarshal(b, &singlestat); err == nil { - p.SinglestatPanel = &singlestat - } - case "stat": - var stat StatPanel - p.OfType = StatType - if err = json.Unmarshal(b, &stat); err == nil { - p.StatPanel = &stat - } - case "dashlist": - var dashlist DashlistPanel - p.OfType = DashlistType - if err = json.Unmarshal(b, &dashlist); err == nil { - p.DashlistPanel = &dashlist - } - case "bargauge": - var bargauge BarGaugePanel - p.OfType = BarGaugeType - if err = json.Unmarshal(b, &bargauge); err == nil { - p.BarGaugePanel = &bargauge - } - case "heatmap": - var heatmap HeatmapPanel - p.OfType = HeatmapType - if err = json.Unmarshal(b, &heatmap); err == nil { - p.HeatmapPanel = &heatmap - } - case "timeseries": - var timeseries TimeseriesPanel - p.OfType = TimeseriesType - if err = json.Unmarshal(b, ×eries); err == nil { - p.TimeseriesPanel = ×eries - } - case "row": - var rowpanel RowPanel - p.OfType = RowType - if err = json.Unmarshal(b, &rowpanel); err == nil { - p.RowPanel = &rowpanel - } - default: - var custom = make(CustomPanel) - p.OfType = CustomType - if err = json.Unmarshal(b, &custom); err == nil { - p.CustomPanel = &custom - } + if err = json.Unmarshal(b, &probe); err != nil { + return err + } + + p.CommonPanel = probe.CommonPanel + switch probe.Type { + case "graph": + var graph GraphPanel + p.OfType = GraphType + if err = json.Unmarshal(b, &graph); err == nil { + p.GraphPanel = &graph + } + case "table": + var table TablePanel + p.OfType = TableType + if err = json.Unmarshal(b, &table); err == nil { + p.TablePanel = &table + } + case "text": + var text TextPanel + p.OfType = TextType + if err = json.Unmarshal(b, &text); err == nil { + p.TextPanel = &text } + case "singlestat": + var singlestat SinglestatPanel + p.OfType = SinglestatType + if err = json.Unmarshal(b, &singlestat); err == nil { + p.SinglestatPanel = &singlestat + } + case "stat": + var stat StatPanel + p.OfType = StatType + if err = json.Unmarshal(b, &stat); err == nil { + p.StatPanel = &stat + } + case "dashlist": + var dashlist DashlistPanel + p.OfType = DashlistType + if err = json.Unmarshal(b, &dashlist); err == nil { + p.DashlistPanel = &dashlist + } + case "bargauge": + var bargauge BarGaugePanel + p.OfType = BarGaugeType + if err = json.Unmarshal(b, &bargauge); err == nil { + p.BarGaugePanel = &bargauge + } + case "heatmap": + var heatmap HeatmapPanel + p.OfType = HeatmapType + if err = json.Unmarshal(b, &heatmap); err == nil { + p.HeatmapPanel = &heatmap + } + case "timeseries": + var timeseries TimeseriesPanel + p.OfType = TimeseriesType + if err = json.Unmarshal(b, ×eries); err == nil { + p.TimeseriesPanel = ×eries + } + case "row": + var rowpanel RowPanel + p.OfType = RowType + if err = json.Unmarshal(b, &rowpanel); err == nil { + p.RowPanel = &rowpanel + } + default: + var custom = make(CustomPanel) + p.OfType = CustomType + if err = json.Unmarshal(b, &custom); err == nil { + p.CustomPanel = &custom + } + } + + if err != nil && (probe.Title != "" || probe.Type != "") { + err = fmt.Errorf("%w (panel %q of type %q)", err, probe.Title, probe.Type) } - return + + return err } func (p *Panel) MarshalJSON() ([]byte, error) { @@ -1211,11 +1220,18 @@ func (c customPanelOutput) MarshalJSON() ([]byte, error) { // Append custom keys to marshalled CommonPanel. buf := bytes.NewBuffer(b[:len(b)-1]) - for k, v := range c.CustomPanel { + // Sort keys to make output idempotent + keys := make([]string, 0, len(c.CustomPanel)) + for k := range c.CustomPanel { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { buf.WriteString(`,"`) buf.WriteString(k) buf.WriteString(`":`) - b, err := json.Marshal(v) + b, err := json.Marshal(c.CustomPanel[k]) if err != nil { return b, err } diff --git a/panel_test.go b/panel_test.go index ffdb9a5a..fdcdfa21 100644 --- a/panel_test.go +++ b/panel_test.go @@ -721,6 +721,101 @@ func TestPanel_Stackdriver_ParsedTargets(t *testing.T) { } } +func TestPanel_Timeseries(t *testing.T) { + var rawPanel = []byte(`{ + "id": 2, + "gridPos": { + "x": 0, + "y": 0, + "w": 12, + "h": 9 + }, + "type": "timeseries", + "title": "Panel Title", + "options": { + "tooltip": { + "mode": "single" + }, + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "barAlignment": 0, + "lineWidth": 1, + "fillOpacity": 0, + "gradientMode": "none", + "spanNulls": false, + "showPoints": "auto", + "pointSize": 5, + "stacking": { + "mode": "none", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "scaleDistribution": { + "type": "linear" + }, + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "color": { + "mode": "palette-classic" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0.1, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "mappings": [] + }, + "overrides": [] + }, + "targets": [ + { + "expr": "test_expr", + "legendFormat": "", + "interval": "", + "exemplar": true, + "refId": "A", + "datasource": "Sample datasource" + } + ], + "datasource": null + }`) + var timeseries sdk.Panel + err := json.Unmarshal(rawPanel, ×eries) + + if err != nil { + t.Fatalf("%s", err) + } + + if len(timeseries.TimeseriesPanel.Targets) != 1 { + t.Fatalf("should be 1 but %d", len(timeseries.TimeseriesPanel.Targets)) + } +} + // TestCustomPanelOutput_MarshalJSON marshals new custom panel to JSON, // then marshals that json to map[string]interface{},\ // and then checks both custom and non-custom keys are present and correct. diff --git a/rest-admin.go b/rest-admin.go index 89c40b2f..d6d3c158 100644 --- a/rest-admin.go +++ b/rest-admin.go @@ -27,24 +27,27 @@ func (r *Client) CreateUser(ctx context.Context, user User) (StatusMessage, erro return resp, nil } -// DeleteGlobalUser deletes an existing user by ID. -// Reflects DELETE /api/admin/users/:uid API call. -func (r *Client) DeleteGlobalUser(ctx context.Context, uid uint) (StatusMessage, error) { +// DeleteUser deletes a global user +// Requires basic authentication and that the authenticated user ia Grafana Admin +// Reflects DELETE /api/admin/users/:userId API call. +func (r *Client) DeleteUser(ctx context.Context, uid uint) (StatusMessage, error) { var ( - raw []byte - reply StatusMessage - err error + raw []byte + resp StatusMessage + err error ) if raw, _, err = r.delete(ctx, fmt.Sprintf("api/admin/users/%d", uid)); err != nil { return StatusMessage{}, err } - err = json.Unmarshal(raw, &reply) - return reply, err + if err = json.Unmarshal(raw, &resp); err != nil { + return StatusMessage{}, err + } + return resp, nil } // UpdateUserPermissions updates the permissions of a global user. // Requires basic authentication and that the authenticated user is a Grafana Admin. -// Reflects PUT /api/admin/users/:userId/password API call. +// Reflects PUT /api/admin/users/:userId/permissions API call. func (r *Client) UpdateUserPermissions(ctx context.Context, permissions UserPermissions, uid uint) (StatusMessage, error) { var ( raw []byte @@ -79,3 +82,22 @@ func (r *Client) SwitchUserContext(ctx context.Context, uid uint, oid uint) (Sta } return resp, nil } + +// UpdateUserPassword updates the password of a global user. +// Requires basic authentication and that the authenticated user is a Grafana Admin. +// Reflects PUT /api/admin/users/:userId/password API call. +func (r *Client) UpdateUserPassword(ctx context.Context, password UserPassword, uid uint) (StatusMessage, error) { + var ( + raw []byte + reply StatusMessage + err error + ) + if raw, err = json.Marshal(password); err != nil { + return StatusMessage{}, err + } + if raw, _, err = r.put(ctx, fmt.Sprintf("api/admin/users/%d/password", uid), nil, raw); err != nil { + return StatusMessage{}, err + } + err = json.Unmarshal(raw, &reply) + return reply, err +} diff --git a/rest-admin_integration_test.go b/rest-admin_integration_test.go index abc01e0e..cba9df78 100644 --- a/rest-admin_integration_test.go +++ b/rest-admin_integration_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stretchr/testify/assert" + sdk "github.com/kubermatic/grafanasdk" ) @@ -52,4 +54,16 @@ func TestAdminOperations(t *testing.T) { if retrievedUser.IsGrafanaAdmin != true { t.Fatal("user should be an admin but is not") } + statusMessage, err := client.UpdateUserPassword(ctx, sdk.UserPassword{Password: "123456"}, uid) + if err != nil || *statusMessage.Message != "User password updated" { + t.Fatalf("failed to change the user's password") + } + //Delete user + msg, err := client.DeleteUser(ctx, retrievedUser.ID) + assert.Nil(t, err) + assert.Equal(t, "User deleted", *msg.Message) + //attempt to retrieve deleted user + retrievedUser, err = client.GetUser(ctx, retrievedUser.ID) + assert.NotNil(t, err, "Deleted user should not be accessible") + } diff --git a/rest-request.go b/rest-request.go index 79be05c3..ab9d1fac 100644 --- a/rest-request.go +++ b/rest-request.go @@ -100,7 +100,7 @@ func NewClient(apiURL, apiKeyOrBasicAuth string, client *http.Client) (*Client, if !basicAuth { key = fmt.Sprintf("Bearer %s", apiKeyOrBasicAuth) } else { - parts := strings.Split(apiKeyOrBasicAuth, ":") + parts := strings.SplitN(apiKeyOrBasicAuth, ":", 2) baseURL.User = url.UserPassword(parts[0], parts[1]) } } diff --git a/testdata/default-panels-graph-with-target-8.3.json b/testdata/default-panels-graph-with-target-8.3.json new file mode 100644 index 00000000..8da64b06 --- /dev/null +++ b/testdata/default-panels-graph-with-target-8.3.json @@ -0,0 +1,138 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 12, + "links": [], + "liveNow": false, + "panels": [ + { + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 4, + "title": "Basic Row", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 1 + }, + "hiddenSeries": false, + "id": 2, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "8.3.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Ilg0cn17k" + }, + "exemplar": true, + "expr": "go_goroutines{job=\"prometheus\"}", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Panel Title", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + } + ], + "schemaVersion": 33, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Basic Dashboard", + "uid": "t5LxFe17z", + "version": 4, + "weekStart": "" +} diff --git a/user.go b/user.go index f56a1965..1e64e9c1 100644 --- a/user.go +++ b/user.go @@ -49,3 +49,7 @@ type PageUsers struct { Page int `json:"page"` PerPage int `json:"perPage"` } + +type UserPassword struct { + Password string `json:"password"` +}