diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index 6c3ee7b69c6..af818be99b0 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -157,6 +157,37 @@ func GetDashboardSnapshot(c *m.ReqContext) { c.JSON(200, dto) } +func deleteExternalDashboardSnapshot(externalUrl string) error { + response, err := client.Get(externalUrl) + + if response != nil { + defer response.Body.Close() + } + + if err != nil { + return err + } + + if response.StatusCode == 200 { + return nil + } + + // Gracefully ignore "snapshot not found" errors as they could have already + // been removed either via the cleanup script or by request. + if response.StatusCode == 500 { + var respJson map[string]interface{} + if err := json.NewDecoder(response.Body).Decode(&respJson); err != nil { + return err + } + + if respJson["message"] == "Failed to get dashboard snapshot" { + return nil + } + } + + return fmt.Errorf("Unexpected response when deleting external snapshot. Status code: %d", response.StatusCode) +} + // GET /api/snapshots-delete/:deleteKey func DeleteDashboardSnapshotByDeleteKey(c *m.ReqContext) Response { key := c.Params(":deleteKey") @@ -168,6 +199,13 @@ func DeleteDashboardSnapshotByDeleteKey(c *m.ReqContext) Response { return Error(500, "Failed to get dashboard snapshot", err) } + if query.Result.External { + err := deleteExternalDashboardSnapshot(query.Result.ExternalDeleteUrl) + if err != nil { + return Error(500, "Failed to delete external dashboard", err) + } + } + cmd := &m.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey} if err := bus.Dispatch(cmd); err != nil { @@ -204,6 +242,13 @@ func DeleteDashboardSnapshot(c *m.ReqContext) Response { return Error(403, "Access denied to this snapshot", nil) } + if query.Result.External { + err := deleteExternalDashboardSnapshot(query.Result.ExternalDeleteUrl) + if err != nil { + return Error(500, "Failed to delete external dashboard", err) + } + } + cmd := &m.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey} if err := bus.Dispatch(cmd); err != nil { diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go index e58f2c4712d..a24d0f38d85 100644 --- a/pkg/api/dashboard_snapshot_test.go +++ b/pkg/api/dashboard_snapshot_test.go @@ -1,6 +1,9 @@ package api import ( + "fmt" + "net/http" + "net/http/httptest" "testing" "time" @@ -13,13 +16,17 @@ import ( func TestDashboardSnapshotApiEndpoint(t *testing.T) { Convey("Given a single snapshot", t, func() { + var externalRequest *http.Request jsonModel, _ := simplejson.NewJson([]byte(`{"id":100}`)) mockSnapshotResult := &m.DashboardSnapshot{ Id: 1, + Key: "12345", + DeleteKey: "54321", Dashboard: jsonModel, Expires: time.Now().Add(time.Duration(1000) * time.Second), UserId: 999999, + External: true, } bus.AddHandler("test", func(query *m.GetDashboardSnapshotQuery) error { @@ -45,13 +52,25 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) { return nil }) + setupRemoteServer := func(fn func(http.ResponseWriter, *http.Request)) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + fn(rw, r) + })) + } + Convey("When user has editor role and is not in the ACL", func() { Convey("Should not be able to delete snapshot", func() { loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) { + ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) { + externalRequest = req + }) + + mockSnapshotResult.ExternalDeleteUrl = ts.URL sc.handlerFunc = DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() So(sc.resp.Code, ShouldEqual, 403) + So(externalRequest, ShouldBeNil) }) }) }) @@ -59,6 +78,12 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) { Convey("When user is anonymous", func() { Convey("Should be able to delete snapshot by deleteKey", func() { anonymousUserScenario("When calling GET on", "GET", "/api/snapshots-delete/12345", "/api/snapshots-delete/:deleteKey", func(sc *scenarioContext) { + ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(200) + externalRequest = req + }) + + mockSnapshotResult.ExternalDeleteUrl = ts.URL sc.handlerFunc = DeleteDashboardSnapshotByDeleteKey sc.fakeReqWithParams("GET", sc.url, map[string]string{"deleteKey": "12345"}).exec() @@ -67,6 +92,10 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) { So(err, ShouldBeNil) So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted") + + So(externalRequest.Method, ShouldEqual, http.MethodGet) + So(fmt.Sprintf("http://%s", externalRequest.Host), ShouldEqual, ts.URL) + So(externalRequest.URL.EscapedPath(), ShouldEqual, "/") }) }) }) @@ -79,6 +108,12 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) { Convey("Should be able to delete a snapshot", func() { loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) { + ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(200) + externalRequest = req + }) + + mockSnapshotResult.ExternalDeleteUrl = ts.URL sc.handlerFunc = DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() @@ -87,6 +122,8 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) { So(err, ShouldBeNil) So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted") + So(fmt.Sprintf("http://%s", externalRequest.Host), ShouldEqual, ts.URL) + So(externalRequest.URL.EscapedPath(), ShouldEqual, "/") }) }) }) @@ -94,6 +131,7 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) { Convey("When user is editor and is the creator of the snapshot", func() { aclMockResp = []*m.DashboardAclInfoDTO{} mockSnapshotResult.UserId = TestUserID + mockSnapshotResult.External = false Convey("Should be able to delete a snapshot", func() { loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) { @@ -108,5 +146,54 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) { }) }) }) + + Convey("When deleting an external snapshot", func() { + aclMockResp = []*m.DashboardAclInfoDTO{} + mockSnapshotResult.UserId = TestUserID + + Convey("Should gracefully delete local snapshot when remote snapshot has already been removed", func() { + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) { + ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(`{"message":"Failed to get dashboard snapshot"}`)) + rw.WriteHeader(500) + }) + + mockSnapshotResult.ExternalDeleteUrl = ts.URL + sc.handlerFunc = DeleteDashboardSnapshot + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() + + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + + Convey("Should fail to delete local snapshot when an unexpected 500 error occurs", func() { + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) { + ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(500) + rw.Write([]byte(`{"message":"Unexpected"}`)) + }) + + mockSnapshotResult.ExternalDeleteUrl = ts.URL + sc.handlerFunc = DeleteDashboardSnapshot + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() + + So(sc.resp.Code, ShouldEqual, 500) + }) + }) + + Convey("Should fail to delete local snapshot when an unexpected remote error occurs", func() { + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) { + ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(404) + }) + + mockSnapshotResult.ExternalDeleteUrl = ts.URL + sc.handlerFunc = DeleteDashboardSnapshot + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() + + So(sc.resp.Code, ShouldEqual, 500) + }) + }) + }) }) } diff --git a/public/app/features/manage-dashboards/SnapshotListCtrl.ts b/public/app/features/manage-dashboards/SnapshotListCtrl.ts index 2ff53e7aed5..4d6dc006d47 100644 --- a/public/app/features/manage-dashboards/SnapshotListCtrl.ts +++ b/public/app/features/manage-dashboards/SnapshotListCtrl.ts @@ -5,10 +5,14 @@ export class SnapshotListCtrl { snapshots: any; /** @ngInject */ - constructor(private $rootScope, private backendSrv, navModelSrv) { + constructor(private $rootScope, private backendSrv, navModelSrv, private $location) { this.navModel = navModelSrv.getNav('dashboards', 'snapshots', 0); this.backendSrv.get('/api/dashboard/snapshots').then(result => { - this.snapshots = result; + const baseUrl = this.$location.absUrl().replace($location.url(), ''); + this.snapshots = result.map(snapshot => ({ + ...snapshot, + url: snapshot.externalUrl || `${baseUrl}/dashboard/snapshot/${snapshot.key}`, + })); }); } diff --git a/public/app/features/manage-dashboards/partials/snapshot_list.html b/public/app/features/manage-dashboards/partials/snapshot_list.html index 8775b527ae1..f646194088d 100644 --- a/public/app/features/manage-dashboards/partials/snapshot_list.html +++ b/public/app/features/manage-dashboards/partials/snapshot_list.html @@ -6,17 +6,21 @@