Browse Source

snapshots: Add support for deleting external snapshots

Victor Cinaglia 7 years ago
parent
commit
411d67cae7

+ 45 - 0
pkg/api/dashboard_snapshot.go

@@ -157,6 +157,37 @@ func GetDashboardSnapshot(c *m.ReqContext) {
 	c.JSON(200, dto)
 	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
 // GET /api/snapshots-delete/:deleteKey
 func DeleteDashboardSnapshotByDeleteKey(c *m.ReqContext) Response {
 func DeleteDashboardSnapshotByDeleteKey(c *m.ReqContext) Response {
 	key := c.Params(":deleteKey")
 	key := c.Params(":deleteKey")
@@ -168,6 +199,13 @@ func DeleteDashboardSnapshotByDeleteKey(c *m.ReqContext) Response {
 		return Error(500, "Failed to get dashboard snapshot", err)
 		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}
 	cmd := &m.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey}
 
 
 	if err := bus.Dispatch(cmd); err != nil {
 	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)
 		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}
 	cmd := &m.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey}
 
 
 	if err := bus.Dispatch(cmd); err != nil {
 	if err := bus.Dispatch(cmd); err != nil {

+ 87 - 0
pkg/api/dashboard_snapshot_test.go

@@ -1,6 +1,9 @@
 package api
 package api
 
 
 import (
 import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
 	"testing"
 	"testing"
 	"time"
 	"time"
 
 
@@ -13,13 +16,17 @@ import (
 
 
 func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 	Convey("Given a single snapshot", t, func() {
 	Convey("Given a single snapshot", t, func() {
+		var externalRequest *http.Request
 		jsonModel, _ := simplejson.NewJson([]byte(`{"id":100}`))
 		jsonModel, _ := simplejson.NewJson([]byte(`{"id":100}`))
 
 
 		mockSnapshotResult := &m.DashboardSnapshot{
 		mockSnapshotResult := &m.DashboardSnapshot{
 			Id:        1,
 			Id:        1,
+			Key:       "12345",
+			DeleteKey: "54321",
 			Dashboard: jsonModel,
 			Dashboard: jsonModel,
 			Expires:   time.Now().Add(time.Duration(1000) * time.Second),
 			Expires:   time.Now().Add(time.Duration(1000) * time.Second),
 			UserId:    999999,
 			UserId:    999999,
+			External:  true,
 		}
 		}
 
 
 		bus.AddHandler("test", func(query *m.GetDashboardSnapshotQuery) error {
 		bus.AddHandler("test", func(query *m.GetDashboardSnapshotQuery) error {
@@ -45,13 +52,25 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 			return nil
 			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("When user has editor role and is not in the ACL", func() {
 			Convey("Should not be able to delete snapshot", 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) {
 				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.handlerFunc = DeleteDashboardSnapshot
 					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
 					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
 
 
 					So(sc.resp.Code, ShouldEqual, 403)
 					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("When user is anonymous", func() {
 			Convey("Should be able to delete snapshot by deleteKey", 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) {
 				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.handlerFunc = DeleteDashboardSnapshotByDeleteKey
 					sc.fakeReqWithParams("GET", sc.url, map[string]string{"deleteKey": "12345"}).exec()
 					sc.fakeReqWithParams("GET", sc.url, map[string]string{"deleteKey": "12345"}).exec()
 
 
@@ -67,6 +92,10 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 					So(err, ShouldBeNil)
 					So(err, ShouldBeNil)
 
 
 					So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted")
 					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() {
 			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) {
 				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.handlerFunc = DeleteDashboardSnapshot
 					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
 					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
 
 
@@ -87,6 +122,8 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 					So(err, ShouldBeNil)
 					So(err, ShouldBeNil)
 
 
 					So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted")
 					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() {
 		Convey("When user is editor and is the creator of the snapshot", func() {
 			aclMockResp = []*m.DashboardAclInfoDTO{}
 			aclMockResp = []*m.DashboardAclInfoDTO{}
 			mockSnapshotResult.UserId = TestUserID
 			mockSnapshotResult.UserId = TestUserID
+			mockSnapshotResult.External = false
 
 
 			Convey("Should be able to delete a snapshot", func() {
 			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) {
 				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)
+				})
+			})
+		})
 	})
 	})
 }
 }

+ 6 - 2
public/app/features/manage-dashboards/SnapshotListCtrl.ts

@@ -5,10 +5,14 @@ export class SnapshotListCtrl {
   snapshots: any;
   snapshots: any;
 
 
   /** @ngInject */
   /** @ngInject */
-  constructor(private $rootScope, private backendSrv, navModelSrv) {
+  constructor(private $rootScope, private backendSrv, navModelSrv, private $location) {
     this.navModel = navModelSrv.getNav('dashboards', 'snapshots', 0);
     this.navModel = navModelSrv.getNav('dashboards', 'snapshots', 0);
     this.backendSrv.get('/api/dashboard/snapshots').then(result => {
     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}`,
+      }));
     });
     });
   }
   }
 
 

+ 7 - 3
public/app/features/manage-dashboards/partials/snapshot_list.html

@@ -6,17 +6,21 @@
       <th><strong>Name</strong></th>
       <th><strong>Name</strong></th>
       <th><strong>Snapshot url</strong></th>
       <th><strong>Snapshot url</strong></th>
       <th style="width: 70px"></th>
       <th style="width: 70px"></th>
+      <th style="width: 30px"></th>
       <th style="width: 25px"></th>
       <th style="width: 25px"></th>
 		</thead>
 		</thead>
 		<tr ng-repeat="snapshot in ctrl.snapshots">
 		<tr ng-repeat="snapshot in ctrl.snapshots">
       <td>
       <td>
-				<a href="dashboard/snapshot/{{snapshot.key}}">{{snapshot.name}}</a>
+        <a href="{{snapshot.url}}">{{snapshot.name}}</a>
       </td>
       </td>
       <td >
       <td >
-        <a href="dashboard/snapshot/{{snapshot.key}}">dashboard/snapshot/{{snapshot.key}}</a>
+        <a href="{{snapshot.url}}">{{snapshot.url}}</a>
+      </td>
+      <td>
+        <span class="query-keyword" ng-if="snapshot.external">External</span>
       </td>
       </td>
       <td class="text-center">
       <td class="text-center">
-        <a href="dashboard/snapshot/{{snapshot.key}}" class="btn btn-inverse btn-mini">
+        <a href="{{snapshot.url}}" class="btn btn-inverse btn-mini">
           <i class="fa fa-eye"></i>
           <i class="fa fa-eye"></i>
           View
           View
         </a>
         </a>