From 72322675df0006894792909db5bd3f305cb72742 Mon Sep 17 00:00:00 2001 From: Guillaume Delbergue Date: Sun, 5 Sep 2021 13:17:40 +0200 Subject: [PATCH 1/3] add LibraryPanel features --- client.go | 7 ++ client_test.go | 17 +++ dashboard.go | 15 +++ datasource.go | 136 ++++++++++++++++++++- folder.go | 11 ++ folder_test.go | 17 +++ library_panel.go | 171 +++++++++++++++++++++++++++ library_panel_test.go | 268 ++++++++++++++++++++++++++++++++++++++++++ report.go | 101 ++++++++++++++++ report_test.go | 135 +++++++++++++++++++++ 10 files changed, 873 insertions(+), 5 deletions(-) create mode 100644 library_panel.go create mode 100644 library_panel_test.go create mode 100644 report.go create mode 100644 report_test.go diff --git a/client.go b/client.go index 0cb19f9f..a6fdf882 100644 --- a/client.go +++ b/client.go @@ -30,6 +30,8 @@ type Config struct { APIKey string // BasicAuth is optional basic auth credentials. BasicAuth *url.Userinfo + // HTTPHeaders are optional HTTP headers. + HTTPHeaders map[string]string // Client provides an optional HTTP client, otherwise a default will be used. Client *http.Client // OrgID provides an optional organization ID, ignored when using APIKey, BasicAuth defaults to last used org @@ -144,6 +146,11 @@ func (c *Client) newRequest(method, requestPath string, query url.Values, body i } else if c.config.OrgID != 0 { req.Header.Add("X-Grafana-Org-Id", strconv.FormatInt(c.config.OrgID, 10)) } + if c.config.HTTPHeaders != nil { + for k, v := range c.config.HTTPHeaders { + req.Header.Add(k, v) + } + } if os.Getenv("GF_LOG") != "" { if body == nil { diff --git a/client_test.go b/client_test.go index a79c57ab..b7d77499 100644 --- a/client_test.go +++ b/client_test.go @@ -53,6 +53,23 @@ func TestNew_orgID(t *testing.T) { } } +func TestNew_HTTPHeaders(t *testing.T) { + const key = "foo" + headers := map[string]string{key: "bar"} + c, err := New("http://my-grafana.com", Config{HTTPHeaders: headers}) + if err != nil { + t.Fatalf("expected error to be nil; got: %s", err.Error()) + } + + value, ok := c.config.HTTPHeaders[key] + if !ok { + t.Errorf("expected error: %v; got: %v", headers, c.config.HTTPHeaders) + } + if value != headers[key] { + t.Errorf("expected error: %s; got: %s", headers[key], value) + } +} + func TestNew_invalidURL(t *testing.T) { _, err := New("://my-grafana.com", Config{APIKey: "123"}) diff --git a/dashboard.go b/dashboard.go index b5914432..5eded2d7 100644 --- a/dashboard.go +++ b/dashboard.go @@ -89,6 +89,21 @@ func (c *Client) DashboardByUID(uid string) (*Dashboard, error) { return c.dashboard(fmt.Sprintf("/api/dashboards/uid/%s", uid)) } +// DashboardsByIDs uses the folder and dashboard search endpoint to find +// dashboards by list of dashboard IDs. +func (c *Client) DashboardsByIDs(ids []int64) ([]FolderDashboardSearchResponse, error) { + dashboardIdsJSON, err := json.Marshal(ids) + if err != nil { + return nil, err + } + + params := map[string]string{ + "type": "dash-db", + "dashboardIds": string(dashboardIdsJSON), + } + return c.FolderDashboardSearch(params) +} + func (c *Client) dashboard(path string) (*Dashboard, error) { result := &Dashboard{} err := c.request("GET", path, nil, nil, &result) diff --git a/datasource.go b/datasource.go index cb8a11ce..29838b24 100644 --- a/datasource.go +++ b/datasource.go @@ -3,7 +3,10 @@ package gapi import ( "bytes" "encoding/json" + "errors" "fmt" + "regexp" + "strconv" ) // DataSource represents a Grafana data source. @@ -28,16 +31,61 @@ type DataSource struct { // Deprecated: Use secureJsonData.basicAuthPassword instead. BasicAuthPassword string `json:"basicAuthPassword,omitempty"` + // Helper to read/write http headers + HTTPHeaders map[string]string `json:"-"` + JSONData JSONData `json:"jsonData,omitempty"` SecureJSONData SecureJSONData `json:"secureJsonData,omitempty"` } +// Required to avoid recursion during (un)marshal +type _DataSource DataSource + +// Marshal DataSource +func (ds *DataSource) MarshalJSON() ([]byte, error) { + var index int64 + dataSource := _DataSource(*ds) + dataSource.JSONData.httpHeaderNames = make(map[int64]string, len(ds.HTTPHeaders)) + dataSource.SecureJSONData.httpHeaderValues = make(map[int64]string, len(ds.HTTPHeaders)) + for name, value := range ds.HTTPHeaders { + dataSource.JSONData.httpHeaderNames[index] = name + dataSource.SecureJSONData.httpHeaderValues[index] = value + index++ + } + return json.Marshal(dataSource) +} + +// Unmarshal DataSource +func (ds *DataSource) UnmarshalJSON(b []byte) (err error) { + dataSource := _DataSource(*ds) + if err = json.Unmarshal(b, &dataSource); err == nil { + *ds = DataSource(dataSource) + } + if len(ds.JSONData.httpHeaderNames) != len(ds.SecureJSONData.httpHeaderValues) { + return errors.New("HTTP headers names length doesn't match HTTP header values length") + } + for index, value := range ds.JSONData.httpHeaderNames { + ds.HTTPHeaders[value] = ds.SecureJSONData.httpHeaderValues[index] + } + return err +} + // JSONData is a representation of the datasource `jsonData` property type JSONData struct { // Used by all datasources TLSAuth bool `json:"tlsAuth,omitempty"` TLSAuthWithCACert bool `json:"tlsAuthWithCACert,omitempty"` TLSSkipVerify bool `json:"tlsSkipVerify,omitempty"` + httpHeaderNames map[int64]string + + // Used by Athena + Catalog string `json:"catalog,omitempty"` + Database string `json:"database,omitempty"` + OutputLocation string `json:"outputLocation,omitempty"` + Workgroup string `json:"workgroup,omitempty"` + + // Used by Github + GitHubURL string `json:"githubUrl,omitempty"` // Used by Graphite GraphiteVersion string `json:"graphiteVersion,omitempty"` @@ -55,11 +103,15 @@ type JSONData struct { MaxConcurrentShardRequests int64 `json:"maxConcurrentShardRequests,omitempty"` // Used by Cloudwatch - AuthType string `json:"authType,omitempty"` - AssumeRoleArn string `json:"assumeRoleArn,omitempty"` - DefaultRegion string `json:"defaultRegion,omitempty"` CustomMetricsNamespaces string `json:"customMetricsNamespaces,omitempty"` - Profile string `json:"profile,omitempty"` + + // Used by Cloudwatch, Athena + AuthType string `json:"authType,omitempty"` + AssumeRoleArn string `json:"assumeRoleArn,omitempty"` + DefaultRegion string `json:"defaultRegion,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + ExternalID string `json:"externalId,omitempty"` + Profile string `json:"profile,omitempty"` // Used by OpenTSDB TsdbVersion string `json:"tsdbVersion,omitempty"` @@ -95,6 +147,56 @@ type JSONData struct { SigV4ExternalID string `json:"sigV4ExternalID,omitempty"` SigV4Profile string `json:"sigV4Profile,omitempty"` SigV4Region string `json:"sigV4Region,omitempty"` + + // Used by Prometheus and Loki + ManageAlerts bool `json:"manageAlerts,omitempty"` + AlertmanagerUID string `json:"alertmanagerUid,omitempty"` + + // Used by Alertmanager + Implementation string `json:"implementation,omitempty"` +} + +// Required to avoid recursion during (un)marshal +type _JSONData JSONData + +// Marshal JSONData +func (jd JSONData) MarshalJSON() ([]byte, error) { + jsonData := _JSONData(jd) + b, err := json.Marshal(jsonData) + if err != nil { + return nil, err + } + fields := make(map[string]interface{}) + if err = json.Unmarshal(b, &fields); err != nil { + return nil, err + } + for index, name := range jd.httpHeaderNames { + fields[fmt.Sprintf("httpHeaderName%d", index+1)] = name + } + return json.Marshal(fields) +} + +// Unmarshal JSONData +func (jd *JSONData) UnmarshalJSON(b []byte) (err error) { + jsonData := _JSONData(*jd) + if err = json.Unmarshal(b, &jsonData); err == nil { + *jd = JSONData(jsonData) + } + fields := make(map[string]interface{}) + if err = json.Unmarshal(b, &fields); err == nil { + for name, value := range fields { + re := regexp.MustCompile("httpHeaderName([0-9]+)") + match := re.FindStringSubmatch(name) + if len(match) == 1 { + index, err := strconv.ParseInt(match[0], 10, 64) + if err != nil { + return err + } + jd.httpHeaderNames[index-1] = value.(string) + } + } + } + return err } // SecureJSONData is a representation of the datasource `secureJsonData` property @@ -105,8 +207,9 @@ type SecureJSONData struct { TLSClientKey string `json:"tlsClientKey,omitempty"` Password string `json:"password,omitempty"` BasicAuthPassword string `json:"basicAuthPassword,omitempty"` + httpHeaderValues map[int64]string - // Used by Cloudwatch + // Used by Cloudwatch, Athena AccessKey string `json:"accessKey,omitempty"` SecretKey string `json:"secretKey,omitempty"` @@ -116,6 +219,29 @@ type SecureJSONData struct { // Used by Prometheus and Elasticsearch SigV4AccessKey string `json:"sigV4AccessKey,omitempty"` SigV4SecretKey string `json:"sigV4SecretKey,omitempty"` + + // Used by GitHub + AccessToken string `json:"accessToken,omitempty"` +} + +// Required to avoid recursion during unmarshal +type _SecureJSONData SecureJSONData + +// Marshal SecureJSONData +func (sjd SecureJSONData) MarshalJSON() ([]byte, error) { + secureJSONData := _SecureJSONData(sjd) + b, err := json.Marshal(secureJSONData) + if err != nil { + return nil, err + } + fields := make(map[string]interface{}) + if err = json.Unmarshal(b, &fields); err != nil { + return nil, err + } + for index, value := range sjd.httpHeaderValues { + fields[fmt.Sprintf("httpHeaderValue%d", index+1)] = value + } + return json.Marshal(fields) } // NewDataSource creates a new Grafana data source. diff --git a/folder.go b/folder.go index e13fee93..56a2999b 100644 --- a/folder.go +++ b/folder.go @@ -35,6 +35,17 @@ func (c *Client) Folder(id int64) (*Folder, error) { return folder, err } +// Folder fetches and returns the Grafana folder whose UID it's passed. +func (c *Client) FolderByUID(uid string) (*Folder, error) { + folder := &Folder{} + err := c.request("GET", fmt.Sprintf("/api/folders/%s", uid), nil, nil, folder) + if err != nil { + return folder, err + } + + return folder, err +} + // NewFolder creates a new Grafana folder. func (c *Client) NewFolder(title string) (Folder, error) { folder := Folder{} diff --git a/folder_test.go b/folder_test.go index d3091e85..72abefc5 100644 --- a/folder_test.go +++ b/folder_test.go @@ -120,6 +120,23 @@ func TestFolder(t *testing.T) { } } +func TestFolderByUid(t *testing.T) { + server, client := gapiTestTools(t, 200, getFolderJSON) + defer server.Close() + + folder := "nErXDvCkzz" + resp, err := client.FolderByUID(folder) + if err != nil { + t.Fatal(err) + } + + t.Log(pretty.PrettyFormat(resp)) + + if resp.UID != folder || resp.Title != "Departmenet ABC" { + t.Error("Not correctly parsing returned folder.") + } +} + func TestNewFolder(t *testing.T) { server, client := gapiTestTools(t, 200, createdFolderJSON) defer server.Close() diff --git a/library_panel.go b/library_panel.go new file mode 100644 index 00000000..5ac31b0f --- /dev/null +++ b/library_panel.go @@ -0,0 +1,171 @@ +package gapi + +import ( + "bytes" + "encoding/json" + "fmt" + "time" +) + +// LibraryPanelMetaUser represents the Grafana library panel createdBy and updatedBy fields +type LibraryPanelMetaUser struct { + ID int64 `json:"id"` + Name string `json:"name"` + AvatarURL string `json:"folderId"` +} + +// LibraryPanelMeta represents Grafana library panel metadata. +type LibraryPanelMeta struct { + FolderName string `json:"folderName,,omitempty"` + FolderUID string `json:"folderUid,omitempty"` + ConnectedDashboards int64 `json:"connectedDashboards,omitempty"` + Created time.Time `json:"created,omitempty"` + Updated time.Time `json:"updated,omitempty"` + CreatedBy LibraryPanelMetaUser `json:"createdBy,omitempty"` + UpdatedBy LibraryPanelMetaUser `json:"updatedBy,omitempty"` +} + +// LibraryPanel represents a Grafana library panel. +type LibraryPanel struct { + Folder int64 `json:"folderId,omitempty"` + Name string `json:"name"` + Model map[string]interface{} `json:"model"` + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + ID int64 `json:"id,omitempty"` + Kind int64 `json:"kind,omitempty"` + OrgID int64 `json:"orgId,omitempty"` + UID string `json:"uid,omitempty"` + Version int64 `json:"version,omitempty"` + Meta LibraryPanelMeta `json:"meta,omitempty"` +} + +// LibraryPanelCreateResponse represents the Grafana API response to creating or saving a library panel. +type LibraryPanelCreateResponse struct { + Result LibraryPanel `json:"result"` +} + +// LibraryPanelDeleteResponse represents the Grafana API response to deleting a library panel. +type LibraryPanelDeleteResponse struct { + Message string `json:"message"` + ID int64 `json:"id,omitempty"` +} + +// LibraryPanelConnection represents a Grafana connection between a library panel and a dashboard. +type LibraryPanelConnection struct { + ID int64 `json:"id"` + Kind int64 `json:"kind"` + PanelID int64 `json:"elementId"` + DashboardID int64 `json:"connectionId"` + Created time.Time `json:"created"` + CreatedBy LibraryPanelMetaUser `json:"createdBy"` +} + +// NewLibraryPanel creates a new Grafana library panel. +func (c *Client) NewLibraryPanel(panel LibraryPanel) (*LibraryPanel, error) { + panel.Kind = int64(1) + data, err := json.Marshal(panel) + if err != nil { + return nil, err + } + + resp := &LibraryPanelCreateResponse{} + err = c.request("POST", "/api/library-elements", nil, bytes.NewBuffer(data), &resp) + if err != nil { + return nil, err + } + + return &resp.Result, err +} + +// LibraryPanelByUID gets a library panel by UID. +func (c *Client) LibraryPanelByUID(uid string) (*LibraryPanel, error) { + return c.panel(fmt.Sprintf("/api/library-elements/%s", uid)) +} + +// LibraryPanelByName gets a library panel by name. +func (c *Client) LibraryPanelByName(name string) (*LibraryPanel, error) { + return c.panel(fmt.Sprintf("/api/library-elements/name/%s", name)) +} + +func (c *Client) panel(path string) (*LibraryPanel, error) { + resp := &LibraryPanelCreateResponse{} + err := c.request("GET", path, nil, nil, &resp) + if err != nil { + return nil, err + } + + return &resp.Result, err +} + +// PatchLibraryPanel updates one or more properties of an existing panel that matches the specified UID. +func (c *Client) PatchLibraryPanel(uid string, panel LibraryPanel) (*LibraryPanel, error) { + path := fmt.Sprintf("/api/library-elements/%s", uid) + panel.Kind = int64(1) + + // if Version not specified, get current version from API + if panel.Version == int64(0) { + remotePanel, err := c.LibraryPanelByUID(panel.UID) + if err != nil { + return nil, err + } + panel.Version = remotePanel.Version + } + + data, err := json.Marshal(panel) + if err != nil { + return nil, err + } + + resp := &LibraryPanelCreateResponse{} + err = c.request("PATCH", path, nil, bytes.NewBuffer(data), &resp) + if err != nil { + return nil, err + } + + return &resp.Result, err +} + +// DeleteLibraryPanel deletes a panel by UID. +func (c *Client) DeleteLibraryPanel(uid string) (*LibraryPanelDeleteResponse, error) { + path := fmt.Sprintf("/api/library-elements/%s", uid) + + resp := &LibraryPanelDeleteResponse{} + err := c.request("DELETE", path, nil, bytes.NewBuffer(nil), &resp) + if err != nil { + return nil, err + } + + return resp, err +} + +// LibraryPanelConnections gets library panel connections by UID. +func (c *Client) LibraryPanelConnections(uid string) (*[]LibraryPanelConnection, error) { + path := fmt.Sprintf("/api/library-elements/%s/connections", uid) + + resp := struct { + Result []LibraryPanelConnection `json:"result"` + }{} + + err := c.request("POST", path, nil, bytes.NewBuffer(nil), &resp) + if err != nil { + return nil, err + } + + return &resp.Result, err +} + +// LibraryPanelConnectedDashboards gets Dashboards using this Library Panel. +func (c *Client) LibraryPanelConnectedDashboards(uid string) ([]FolderDashboardSearchResponse, error) { + connections, err := c.LibraryPanelConnections(uid) + if err != nil { + return nil, err + } + + var dashboardIds []int64 + for _, connection := range *connections { + dashboardIds = append(dashboardIds, connection.DashboardID) + } + + return c.DashboardsByIDs(dashboardIds) +} diff --git a/library_panel_test.go b/library_panel_test.go new file mode 100644 index 00000000..e39023d4 --- /dev/null +++ b/library_panel_test.go @@ -0,0 +1,268 @@ +package gapi + +import ( + "testing" + + "github.com/gobs/pretty" +) + +const ( + getLibraryPanelResponse = `{ + "result": { + "id": 25, + "orgId": 1, + "folderId": 0, + "uid": "V--OrYHnz", + "name": "API docs Example", + "kind": 1, + "model": { + "description": "", + "type": "" + }, + "version": 1, + "meta": { + "folderName": "General", + "folderUid": "", + "connectedDashboards": 1, + "created": "2021-09-27T09:56:17+02:00", + "updated": "2021-09-27T09:56:17+02:00", + "createdBy": { + "id": 1, + "name": "admin", + "avatarUrl": "/avatar/46d229b033af06a191ff2267bca9ae56" + }, + "updatedBy": { + "id": 1, + "name": "admin", + "avatarUrl": "/avatar/46d229b033af06a191ff2267bca9ae56" + } + } + } + }` + + patchLibraryPanelResponse = `{ + "result": { + "id": 25, + "orgId": 1, + "folderId": 0, + "uid": "V--OrYHnz", + "name": "Updated library panel name", + "kind": 1, + "model": { + "description": "new description", + "type": "" + }, + "version": 1, + "meta": { + "folderName": "General", + "folderUid": "", + "connectedDashboards": 1, + "created": "2021-09-27T09:56:17+02:00", + "updated": "2021-09-27T09:56:17+02:00", + "createdBy": { + "id": 1, + "name": "admin", + "avatarUrl": "/avatar/46d229b033af06a191ff2267bca9ae56" + }, + "updatedBy": { + "id": 1, + "name": "admin", + "avatarUrl": "/avatar/46d229b033af06a191ff2267bca9ae56" + } + } + } + }` + + deleteLibraryPanelResponse = `{ + "message": "Library element deleted", + "id": 28 + }` + + getLibraryPanelConnectionsResponse = `{ + "result": [ + { + "id": 148, + "kind": 1, + "elementId": 25, + "connectionId": 527, + "created": "2021-09-27T10:00:07+02:00", + "createdBy": { + "id": 1, + "name": "admin", + "avatarUrl": "/avatar/46d229b033af06a191ff2267bca9ae56" + } + } + ] + }` + + getLibraryPanelConnectedDashboardsResponse = `[ + { + "id":1, + "uid": "cIBgcSjkk", + "title":"Production Overview", + "url": "/d/cIBgcSjkk/production-overview", + "type":"dash-db", + "tags":["prod"], + "isStarred":true, + "uri":"db/production-overview" + }, + { + "id":2, + "uid": "SjkkcIBgc", + "title":"Production Overview 2", + "url": "/d/SjkkcIBgc/production-overview-2", + "type":"dash-db", + "tags":["prod"], + "isStarred":true, + "uri":"db/production-overview" + } + ]` +) + +func TestLibraryPanelCreate(t *testing.T) { + server, client := gapiTestTools(t, 200, getLibraryPanelResponse) + defer server.Close() + + panel := LibraryPanel{ + Folder: 0, + Name: "API docs Example", + Model: map[string]interface{}{"description": "", "type": ""}, + } + + resp, err := client.NewLibraryPanel(panel) + if err != nil { + t.Fatal(err) + } + + t.Log(pretty.PrettyFormat(resp)) + + if resp.UID != "V--OrYHnz" { + t.Errorf("Invalid uid - %s, Expected %s", resp.UID, "V--OrYHnz") + } + + for _, code := range []int{400, 401, 403} { + server.code = code + _, err = client.NewLibraryPanel(panel) + if err == nil { + t.Errorf("%d not detected", code) + } + } +} + +func TestLibraryPanelGet(t *testing.T) { + server, client := gapiTestTools(t, 200, getLibraryPanelResponse) + defer server.Close() + + resp, err := client.LibraryPanelByName("API docs Example") + if err != nil { + t.Error(err) + } + if resp.UID != "V--OrYHnz" { + t.Errorf("Invalid uid - %s, Expected %s", resp.UID, "V--OrYHnz") + } + + resp, err = client.LibraryPanelByUID("V--OrYHnz") + if err != nil { + t.Fatal(err) + } + if resp.Name != "API docs Example" { + t.Fatalf("Invalid Name - %s, Expected %s", resp.Name, "API docs Example") + } + + for _, code := range []int{401, 403, 404} { + server.code = code + _, err = client.LibraryPanelByName("test") + if err == nil { + t.Errorf("%d not detected", code) + } + + _, err = client.LibraryPanelByUID("V--OrYHnz") + if err == nil { + t.Errorf("%d not detected", code) + } + } +} + +func TestPatchLibraryPanel(t *testing.T) { + server, client := gapiTestTools(t, 200, patchLibraryPanelResponse) + defer server.Close() + + panel := &LibraryPanel{ + Folder: 1, + Name: "Updated library panel name", + Model: map[string]interface{}{"description": "new description", "type": ""}, + } + resp, err := client.PatchLibraryPanel("V--OrYHnz", panel) + if err != nil { + t.Fatal(err) + } + + if resp.Name != "Updated library panel name" { + t.Fatalf("Invalid Name - %s, Expected %s", resp.Name, "Updated library panel name") + } + if resp.Model["description"] != "new description" { + t.Fatalf("Invalid panel JSON description - %s, Expected %s", resp.Name, "Updated library panel name") + } + + for _, code := range []int{401, 403, 404} { + server.code = code + + _, err := client.LibraryPanelByUID("V--OrYHnz") + if err == nil { + t.Errorf("%d not detected", code) + } + } +} + +func TestLibraryPanelGetConnections(t *testing.T) { + server, client := gapiTestTools(t, 200, getLibraryPanelConnectionsResponse) + defer server.Close() + + resp, err := client.LibraryPanelConnections("V--OrYHnz") + if err != nil { + t.Fatal(err) + } + + if (*resp)[0].ID != int64(148) { + t.Fatalf("Invalid connection id - %d, Expected %d", (*resp)[0].ID, 148) + } +} + +func TestLibraryPanelConnectedDashboards(t *testing.T) { + server, client := gapiTestTools(t, 200, getLibraryPanelConnectionsResponse) + defer server.Close() + + connections, err := client.LibraryPanelConnections("V--OrYHnz") + if err != nil { + t.Fatal(err) + } + + var dashboardIds []int64 + for _, connection := range *connections { + dashboardIds = append(dashboardIds, connection.DashboardID) + } + + _, client = gapiTestTools(t, 200, getLibraryPanelConnectedDashboardsResponse) + dashboards, err := client.DashboardsByIDs(dashboardIds) + if err != nil { + t.Fatal(err) + } + + if dashboards[0].Title != "Production Overview" { + t.Fatalf("Invalid title from connected dashboard 0 - %s, Expected %s", dashboards[0].Title, "Production Overview") + } +} + +func TestLibraryPanelDelete(t *testing.T) { + server, client := gapiTestTools(t, 200, deleteLibraryPanelResponse) + defer server.Close() + + resp, err := client.DeleteLibraryPanel("V--OrYHnz") + if err != nil { + t.Fatal(err) + } + + if resp.Message != "Library element deleted" { + t.Error("Failed to delete. Response should contain the correct response message") + } +} diff --git a/report.go b/report.go new file mode 100644 index 00000000..b4a403e6 --- /dev/null +++ b/report.go @@ -0,0 +1,101 @@ +package gapi + +import ( + "bytes" + "encoding/json" + "fmt" + "time" +) + +// ReportSchedule represents the schedule from a Grafana report. +type ReportSchedule struct { + StartDate *time.Time `json:"startDate,omitempty"` + EndDate *time.Time `json:"endDate,omitempty"` + Frequency string `json:"frequency"` + IntervalFrequency string `json:"intervalFrequency"` + IntervalAmount int64 `json:"intervalAmount"` + WorkdaysOnly bool `json:"workdaysOnly"` + TimeZone string `json:"timeZone"` +} + +// ReportTimeRange represents the time range from a Grafana report. +type ReportTimeRange struct { + From string `json:"from"` + To string `json:"to"` +} + +// ReportOptions represents the options for a Grafana report. +type ReportOptions struct { + Orientation string `json:"orientation"` + Layout string `json:"layout"` + TimeRange ReportTimeRange `json:"timeRange"` +} + +// Report represents a Grafana report. +type Report struct { + // ReadOnly + ID int64 `json:"id,omitempty"` + UserID int64 `json:"userId,omitempty"` + OrgID int64 `json:"orgId,omitempty"` + State string `json:"state,omitempty"` + + DashboardID int64 `json:"dashboardId"` + DashboardUID string `json:"dashboardUid"` + Name string `json:"name"` + Recipients string `json:"recipients"` + ReplyTo string `json:"replyTo"` + Message string `json:"message"` + Schedule ReportSchedule `json:"schedule"` + Options ReportOptions `json:"options"` + EnableDashboardURL bool `json:"enableDashboardUrl"` + EnableCSV bool `json:"enableCsv"` +} + +// Report fetches and returns a Grafana report. +func (c *Client) Report(id int64) (*Report, error) { + path := fmt.Sprintf("/api/reports/%d", id) + report := &Report{} + err := c.request("GET", path, nil, nil, report) + if err != nil { + return nil, err + } + + return report, nil +} + +// NewReport creates a new Grafana report. +func (c *Client) NewReport(report Report) (int64, error) { + data, err := json.Marshal(report) + if err != nil { + return 0, err + } + + result := struct { + ID int64 + }{} + + err = c.request("POST", "/api/reports", nil, bytes.NewBuffer(data), &result) + if err != nil { + return 0, err + } + + return result.ID, nil +} + +// UpdateReport updates a Grafana report. +func (c *Client) UpdateReport(report Report) error { + path := fmt.Sprintf("/api/reports/%d", report.ID) + data, err := json.Marshal(report) + if err != nil { + return err + } + + return c.request("PUT", path, nil, bytes.NewBuffer(data), nil) +} + +// DeleteReport deletes the Grafana report whose ID it's passed. +func (c *Client) DeleteReport(id int64) error { + path := fmt.Sprintf("/api/reports/%d", id) + + return c.request("DELETE", path, nil, nil, nil) +} diff --git a/report_test.go b/report_test.go new file mode 100644 index 00000000..9d4333b0 --- /dev/null +++ b/report_test.go @@ -0,0 +1,135 @@ +package gapi + +import ( + "testing" + "time" + + "github.com/gobs/pretty" +) + +var ( + getReportJSON = ` + { + "id": 4, + "userId": 0, + "orgId": 1, + "dashboardId": 33, + "dashboardName": "Terraform Acceptance Test", + "dashboardUid": "", + "name": "My Report", + "recipients": "test@test.com", + "replyTo": "", + "message": "", + "schedule": { + "startDate": "2020-01-01T00:00:00Z", + "endDate": null, + "frequency": "custom", + "intervalFrequency": "weeks", + "intervalAmount": 2, + "workdaysOnly": true, + "dayOfMonth": "1", + "day": "wednesday", + "hour": 0, + "minute": 0, + "timeZone": "GMT" + }, + "options": { + "orientation": "landscape", + "layout": "grid", + "timeRange": { + "from": "now-1h", + "to": "now" + } + }, + "templateVars": {}, + "enableDashboardUrl": true, + "enableCsv": true, + "state": "", + "created": "2022-01-11T15:09:13Z", + "updated": "2022-01-11T16:18:34Z" + } +` + createReportJSON = ` + { + "id": 4 + } +` + now = time.Now() + testReport = Report{ + DashboardID: 33, + Name: "My Report", + Recipients: "test@test.com", + Schedule: ReportSchedule{ + StartDate: &now, + EndDate: nil, + Frequency: "custom", + IntervalFrequency: "weeks", + IntervalAmount: 2, + WorkdaysOnly: true, + TimeZone: "GMT", + }, + Options: ReportOptions{ + Orientation: "landscape", + Layout: "grid", + TimeRange: ReportTimeRange{ + From: "now-1h", + To: "now", + }, + }, + EnableDashboardURL: true, + EnableCSV: true, + } +) + +func TestReport(t *testing.T) { + server, client := gapiTestTools(t, 200, getReportJSON) + defer server.Close() + + report := int64(4) + resp, err := client.Report(report) + if err != nil { + t.Fatal(err) + } + + t.Log(pretty.PrettyFormat(resp)) + + if resp.ID != report || resp.Name != "My Report" { + t.Error("Not correctly parsing returned report.") + } +} + +func TestNewReport(t *testing.T) { + server, client := gapiTestTools(t, 200, createReportJSON) + defer server.Close() + + resp, err := client.NewReport(testReport) + if err != nil { + t.Fatal(err) + } + + t.Log(pretty.PrettyFormat(resp)) + + if resp != 4 { + t.Error("Not correctly parsing returned creation message.") + } +} + +func TestUpdateReport(t *testing.T) { + server, client := gapiTestTools(t, 200, "") + defer server.Close() + + err := client.UpdateReport(testReport) + if err != nil { + t.Fatal(err) + } +} + +func TestDeleteReport(t *testing.T) { + server, client := gapiTestTools(t, 200, "") + defer server.Close() + + err := client.DeleteReport(4) + if err != nil { + t.Fatal(err) + } +} From 580e51c132fea7b40e358a96d4a4909c728b21e9 Mon Sep 17 00:00:00 2001 From: Justin Mai Date: Wed, 26 Jan 2022 23:21:52 -0800 Subject: [PATCH 2/3] add LibraryPanel features --- library_panel_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library_panel_test.go b/library_panel_test.go index e39023d4..81ecb03a 100644 --- a/library_panel_test.go +++ b/library_panel_test.go @@ -187,7 +187,7 @@ func TestPatchLibraryPanel(t *testing.T) { server, client := gapiTestTools(t, 200, patchLibraryPanelResponse) defer server.Close() - panel := &LibraryPanel{ + panel := LibraryPanel{ Folder: 1, Name: "Updated library panel name", Model: map[string]interface{}{"description": "new description", "type": ""}, From 00c7093295c3b35a31c0d2af0d9b2c5f97797fe2 Mon Sep 17 00:00:00 2001 From: Justin Mai Date: Thu, 27 Jan 2022 19:47:39 -0800 Subject: [PATCH 3/3] add LibraryPanels --- library_panel.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/library_panel.go b/library_panel.go index 5ac31b0f..df3fcd4d 100644 --- a/library_panel.go +++ b/library_panel.go @@ -45,6 +45,14 @@ type LibraryPanelCreateResponse struct { Result LibraryPanel `json:"result"` } +// LibraryPanelGetAllResponse represents the Grafana API response to getting all library panels. +type LibraryPanelGetAllResponse struct { + TotalCount int64 `json:"totalCount"` + Page int64 `json:"page"` + PerPage int64 `json:"perPage"` + Elements []LibraryPanel `json:"elements"` +} + // LibraryPanelDeleteResponse represents the Grafana API response to deleting a library panel. type LibraryPanelDeleteResponse struct { Message string `json:"message"` @@ -78,6 +86,19 @@ func (c *Client) NewLibraryPanel(panel LibraryPanel) (*LibraryPanel, error) { return &resp.Result, err } +// Dashboards fetches and returns all dashboards. +func (c *Client) LibraryPanels() ([]LibraryPanel, error) { + resp := &struct { + Result LibraryPanelGetAllResponse `json:"result"` + }{} + err := c.request("GET", "/api/library-elements", nil, nil, &resp) + if err != nil { + return nil, err + } + + return resp.Result.Elements, err +} + // LibraryPanelByUID gets a library panel by UID. func (c *Client) LibraryPanelByUID(uid string) (*LibraryPanel, error) { return c.panel(fmt.Sprintf("/api/library-elements/%s", uid))