From b6e46c9eb8b15d22b1e9e4e9de8a99aca1522f08 Mon Sep 17 00:00:00 2001 From: Ben Tranter Date: Wed, 24 May 2017 19:14:39 -0400 Subject: [PATCH] History and Version Control for Dashboard Updates A simple version control system for dashboards. Closes #1504. Goals 1. To create a new dashboard version every time a dashboard is saved. 2. To allow users to view all versions of a given dashboard. 3. To allow users to rollback to a previous version of a dashboard. 4. To allow users to compare two versions of a dashboard. Usage Navigate to a dashboard, and click the settings cog. From there, click the "Changelog" button to be brought to the Changelog view. In this view, a table containing each version of a dashboard can be seen. Each entry in the table represents a dashboard version. A selectable checkbox, the version number, date created, name of the user who created that version, and commit message is shown in the table, along with a button that allows a user to restore to a previous version of that dashboard. If a user wants to restore to a previous version of their dashboard, they can do so by clicking the previously mentioned button. If a user wants to compare two different versions of a dashboard, they can do so by clicking the checkbox of two different dashboard versions, then clicking the "Compare versions" button located below the dashboard. From there, the user is brought to a view showing a summary of the dashboard differences. Each summarized change contains a link that can be clicked to take the user a JSON diff highlighting the changes line by line. Overview of Changes Backend Changes - A `dashboard_version` table was created to store each dashboard version, along with a dashboard version model and structs to represent the queries and commands necessary for the dashboard version API methods. - API endpoints were created to support working with dashboard versions. - Methods were added to create, update, read, and destroy dashboard versions in the database. - Logic was added to compute the diff between two versions, and display it to the user. - The dashboard migration logic was updated to save a "Version 1" of each existing dashboard in the database. Frontend Changes - New views - Methods to pull JSON and HTML from endpoints New API Endpoints Each endpoint requires the authorization header to be sent in the format, ``` Authorization: Bearer ``` where `` is a JSON web token obtained from the Grafana admin panel. `GET "/api/dashboards/db/:dashboardId/versions?orderBy=&limit=&start="` Get all dashboard versions for the given dashboard ID. Accepts three URL parameters: - `orderBy` String to order the results by. Possible values are `version`, `created`, `created_by`, `message`. Default is `versions`. Ordering is always in descending order. - `limit` Maximum number of results to return - `start` Position in results to start from `GET "/api/dashboards/db/:dashboardId/versions/:id"` Get an individual dashboard version by ID, for the given dashboard ID. `POST "/api/dashboards/db/:dashboardId/restore"` Restore to the given dashboard version. Post body is of content-type `application/json`, and must contain. ```json { "dashboardId": , "version": } ``` `GET "/api/dashboards/db/:dashboardId/compare/:versionA...:versionB"` Compare two dashboard versions by ID for the given dashboard ID, returning a JSON delta formatted representation of the diff. The URL format follows what GitHub does. For example, visiting [/api/dashboards/db/18/compare/22...33](http://ec2-54-80-139-44.compute-1.amazonaws.com:3000/api/dashboards/db/18/compare/22...33) will return the diff between versions 22 and 33 for the dashboard ID 18. Dependencies Added - The Go package [gojsondiff](https://github.com/yudai/gojsondiff) was added and vendored. --- pkg/api/api.go | 8 + pkg/api/dashboard.go | 261 ++++ pkg/components/formatter/formatter_basic.go | 334 ++++ pkg/components/formatter/formatter_json.go | 477 ++++++ pkg/models/dashboard_version.go | 103 ++ pkg/models/dashboards.go | 1 + pkg/services/sqlstore/dashboard.go | 32 +- pkg/services/sqlstore/dashboard_version.go | 274 ++++ .../sqlstore/dashboard_version_test.go | 188 +++ .../migrations/dashboard_version_mig.go | 54 + .../sqlstore/migrations/migrations.go | 1 + public/app/core/core.ts | 1 + public/app/core/directives/dash_edit_link.js | 1 + public/app/core/directives/diff-view.ts | 76 + public/app/core/directives/misc.js | 14 + public/app/core/services/backend_srv.ts | 3 +- public/app/features/all.js | 1 + public/app/features/dashboard/all.js | 2 + .../features/dashboard/audit/audit_ctrl.ts | 235 +++ .../app/features/dashboard/audit/audit_srv.ts | 32 + public/app/features/dashboard/audit/models.ts | 16 + .../dashboard/audit/partials/audit.html | 161 ++ .../dashboard/audit/partials/link-json.html | 4 + .../app/features/dashboard/dashboard_srv.ts | 81 +- .../features/dashboard/dashnav/dashnav.html | 4 +- .../partials/saveDashboardMessage.html | 49 + .../features/dashboard/saveDashboardAsCtrl.js | 31 +- .../dashboard/saveDashboardMessageCtrl.js | 29 + .../dashboard/specs/audit_ctrl_specs.ts | 416 +++++ .../dashboard/specs/audit_srv_specs.ts | 92 ++ .../features/dashboard/unsavedChangesSrv.js | 22 +- public/app/partials/unsaved-changes.html | 49 +- public/sass/_grafana.scss | 1 + public/sass/_variables.dark.scss | 25 + public/sass/_variables.light.scss | 25 + public/sass/components/_buttons.scss | 11 + public/sass/components/_gf-form.scss | 10 + public/sass/pages/_audit.scss | 268 ++++ public/sass/pages/_dashboard.scss | 1 - public/test/mocks/audit-mocks.js | 197 +++ vendor/github.com/sergi/go-diff/LICENSE | 20 + .../sergi/go-diff/diffmatchpatch/diff.go | 1339 +++++++++++++++++ .../go-diff/diffmatchpatch/diffmatchpatch.go | 46 + .../sergi/go-diff/diffmatchpatch/match.go | 160 ++ .../sergi/go-diff/diffmatchpatch/mathutil.go | 23 + .../sergi/go-diff/diffmatchpatch/patch.go | 556 +++++++ .../go-diff/diffmatchpatch/stringutil.go | 88 ++ vendor/github.com/yudai/gojsondiff/LICENSE | 145 ++ vendor/github.com/yudai/gojsondiff/Makefile | 2 + vendor/github.com/yudai/gojsondiff/README.md | 157 ++ vendor/github.com/yudai/gojsondiff/deltas.go | 461 ++++++ .../yudai/gojsondiff/formatter/ascii.go | 370 +++++ .../yudai/gojsondiff/formatter/delta.go | 124 ++ .../github.com/yudai/gojsondiff/gojsondiff.go | 426 ++++++ .../yudai/gojsondiff/unmarshaler.go | 131 ++ .../github.com/yudai/gojsondiff/wercker.yml | 8 + vendor/github.com/yudai/golcs/LICENSE | 73 + vendor/github.com/yudai/golcs/README.md | 54 + vendor/github.com/yudai/golcs/golcs.go | 130 ++ vendor/vendor.json | 24 + 60 files changed, 7843 insertions(+), 84 deletions(-) create mode 100644 pkg/components/formatter/formatter_basic.go create mode 100644 pkg/components/formatter/formatter_json.go create mode 100644 pkg/models/dashboard_version.go create mode 100644 pkg/services/sqlstore/dashboard_version.go create mode 100644 pkg/services/sqlstore/dashboard_version_test.go create mode 100644 pkg/services/sqlstore/migrations/dashboard_version_mig.go create mode 100644 public/app/core/directives/diff-view.ts create mode 100644 public/app/features/dashboard/audit/audit_ctrl.ts create mode 100644 public/app/features/dashboard/audit/audit_srv.ts create mode 100644 public/app/features/dashboard/audit/models.ts create mode 100644 public/app/features/dashboard/audit/partials/audit.html create mode 100644 public/app/features/dashboard/audit/partials/link-json.html create mode 100644 public/app/features/dashboard/partials/saveDashboardMessage.html create mode 100644 public/app/features/dashboard/saveDashboardMessageCtrl.js create mode 100644 public/app/features/dashboard/specs/audit_ctrl_specs.ts create mode 100644 public/app/features/dashboard/specs/audit_srv_specs.ts create mode 100644 public/sass/pages/_audit.scss create mode 100644 public/test/mocks/audit-mocks.js create mode 100644 vendor/github.com/sergi/go-diff/LICENSE create mode 100644 vendor/github.com/sergi/go-diff/diffmatchpatch/diff.go create mode 100644 vendor/github.com/sergi/go-diff/diffmatchpatch/diffmatchpatch.go create mode 100644 vendor/github.com/sergi/go-diff/diffmatchpatch/match.go create mode 100644 vendor/github.com/sergi/go-diff/diffmatchpatch/mathutil.go create mode 100644 vendor/github.com/sergi/go-diff/diffmatchpatch/patch.go create mode 100644 vendor/github.com/sergi/go-diff/diffmatchpatch/stringutil.go create mode 100644 vendor/github.com/yudai/gojsondiff/LICENSE create mode 100644 vendor/github.com/yudai/gojsondiff/Makefile create mode 100644 vendor/github.com/yudai/gojsondiff/README.md create mode 100644 vendor/github.com/yudai/gojsondiff/deltas.go create mode 100644 vendor/github.com/yudai/gojsondiff/formatter/ascii.go create mode 100644 vendor/github.com/yudai/gojsondiff/formatter/delta.go create mode 100644 vendor/github.com/yudai/gojsondiff/gojsondiff.go create mode 100644 vendor/github.com/yudai/gojsondiff/unmarshaler.go create mode 100644 vendor/github.com/yudai/gojsondiff/wercker.yml create mode 100644 vendor/github.com/yudai/golcs/LICENSE create mode 100644 vendor/github.com/yudai/golcs/README.md create mode 100644 vendor/github.com/yudai/golcs/golcs.go diff --git a/pkg/api/api.go b/pkg/api/api.go index 6dcc900c16f..354bb5bdc5d 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -223,6 +223,14 @@ func (hs *HttpServer) registerRoutes() { // Dashboard r.Group("/dashboards", func() { r.Combo("/db/:slug").Get(GetDashboard).Delete(DeleteDashboard) + + r.Get("/db/:dashboardId/versions", GetDashboardVersions) + r.Get("/db/:dashboardId/versions/:id", GetDashboardVersion) + r.Get("/db/:dashboardId/compare/:versions", CompareDashboardVersions) + r.Get("/db/:dashboardId/compare/:versions/html", CompareDashboardVersionsJSON) + r.Get("/db/:dashboardId/compare/:versions/basic", CompareDashboardVersionsBasic) + r.Post("/db/:dashboardId/restore", reqEditorRole, bind(m.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion)) + r.Post("/db", reqEditorRole, bind(m.SaveDashboardCommand{}), wrap(PostDashboard)) r.Get("/file/:file", GetDashboardFromJsonFile) r.Get("/home", wrap(GetHomeDashboard)) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 55925c4faf6..44d0e52388c 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -2,8 +2,10 @@ package api import ( "encoding/json" + "fmt" "os" "path" + "strconv" "strings" "github.com/grafana/grafana/pkg/api/dtos" @@ -77,6 +79,7 @@ func GetDashboard(c *middleware.Context) { }, } + // TODO(ben): copy this performance metrics logic for the new API endpoints added c.TimeRequest(metrics.M_Api_Dashboard_Get) c.JSON(200, dto) } @@ -255,6 +258,264 @@ func GetDashboardFromJsonFile(c *middleware.Context) { c.JSON(200, &dash) } +// GetDashboardVersions returns all dashboardversions as JSON +func GetDashboardVersions(c *middleware.Context) { + dashboardIdStr := c.Params(":dashboardId") + dashboardId, err := strconv.Atoi(dashboardIdStr) + if err != nil { + c.JsonApiErr(400, err.Error(), err) + return + } + + // TODO(ben) the orderBy arg should be split into snake_case? + orderBy := c.Query("orderBy") + limit := c.QueryInt("limit") + start := c.QueryInt("start") + if orderBy == "" { + orderBy = "version" + } + if limit == 0 { + limit = 1000 + } + + query := m.GetDashboardVersionsCommand{ + DashboardId: int64(dashboardId), + OrderBy: orderBy, + Limit: limit, + Start: start, + } + + if err := bus.Dispatch(&query); err != nil { + c.JsonApiErr(404, fmt.Sprintf("No versions found for dashboardId %d", dashboardId), err) + return + } + + dashboardVersions := make([]*m.DashboardVersionDTO, len(query.Result)) + for i, dashboardVersion := range query.Result { + creator := "Anonymous" + if dashboardVersion.CreatedBy > 0 { + creator = getUserLogin(dashboardVersion.CreatedBy) + } + + dashboardVersions[i] = &m.DashboardVersionDTO{ + Id: dashboardVersion.Id, + DashboardId: dashboardVersion.DashboardId, + ParentVersion: dashboardVersion.ParentVersion, + RestoredFrom: dashboardVersion.RestoredFrom, + Version: dashboardVersion.Version, + Created: dashboardVersion.Created, + CreatedBy: creator, + Message: dashboardVersion.Message, + } + } + + c.JSON(200, dashboardVersions) +} + +// GetDashboardVersion returns the dashboard version with the given ID. +func GetDashboardVersion(c *middleware.Context) { + dashboardIdStr := c.Params(":dashboardId") + dashboardId, err := strconv.Atoi(dashboardIdStr) + if err != nil { + c.JsonApiErr(400, err.Error(), err) + return + } + + versionStr := c.Params(":id") + version, err := strconv.Atoi(versionStr) + if err != nil { + c.JsonApiErr(400, err.Error(), err) + return + } + + query := m.GetDashboardVersionCommand{ + DashboardId: int64(dashboardId), + Version: version, + } + if err := bus.Dispatch(&query); err != nil { + c.JsonApiErr(500, err.Error(), err) + return + } + + creator := "Anonymous" + if query.Result.CreatedBy > 0 { + creator = getUserLogin(query.Result.CreatedBy) + } + + dashVersionMeta := &m.DashboardVersionMeta{ + DashboardVersion: *query.Result, + CreatedBy: creator, + } + + c.JSON(200, dashVersionMeta) +} + +func dashCmd(c *middleware.Context) (m.CompareDashboardVersionsCommand, error) { + cmd := m.CompareDashboardVersionsCommand{} + + dashboardIdStr := c.Params(":dashboardId") + dashboardId, err := strconv.Atoi(dashboardIdStr) + if err != nil { + return cmd, err + } + + versionStrings := strings.Split(c.Params(":versions"), "...") + if len(versionStrings) != 2 { + return cmd, fmt.Errorf("bad format: urls should be in the format /versions/0...1") + } + + originalDash, err := strconv.Atoi(versionStrings[0]) + if err != nil { + return cmd, fmt.Errorf("bad format: first argument is not of type int") + } + + newDash, err := strconv.Atoi(versionStrings[1]) + if err != nil { + return cmd, fmt.Errorf("bad format: second argument is not of type int") + } + + cmd.DashboardId = int64(dashboardId) + cmd.Original = originalDash + cmd.New = newDash + return cmd, nil +} + +// CompareDashboardVersions compares dashboards the way the GitHub API does. +func CompareDashboardVersions(c *middleware.Context) { + cmd, err := dashCmd(c) + if err != nil { + c.JsonApiErr(500, err.Error(), err) + } + cmd.DiffType = m.DiffDelta + + if err := bus.Dispatch(&cmd); err != nil { + c.JsonApiErr(500, "cannot-compute-diff", err) + return + } + // here the output is already JSON, so we need to unmarshal it into a + // map before marshaling the entire response + deltaMap := make(map[string]interface{}) + err = json.Unmarshal(cmd.Delta, &deltaMap) + if err != nil { + c.JsonApiErr(500, err.Error(), err) + return + } + + c.JSON(200, simplejson.NewFromAny(util.DynMap{ + "meta": util.DynMap{ + "original": cmd.Original, + "new": cmd.New, + }, + "delta": deltaMap, + })) +} + +// CompareDashboardVersionsJSON compares dashboards the way the GitHub API does, +// returning a human-readable JSON diff. +func CompareDashboardVersionsJSON(c *middleware.Context) { + cmd, err := dashCmd(c) + if err != nil { + c.JsonApiErr(500, err.Error(), err) + } + cmd.DiffType = m.DiffJSON + + if err := bus.Dispatch(&cmd); err != nil { + c.JsonApiErr(500, err.Error(), err) + return + } + + c.Header().Set("Content-Type", "text/html") + c.WriteHeader(200) + c.Write(cmd.Delta) +} + +// CompareDashboardVersionsBasic compares dashboards the way the GitHub API does, +// returning a human-readable diff. +func CompareDashboardVersionsBasic(c *middleware.Context) { + cmd, err := dashCmd(c) + if err != nil { + c.JsonApiErr(500, err.Error(), err) + } + cmd.DiffType = m.DiffBasic + + if err := bus.Dispatch(&cmd); err != nil { + c.JsonApiErr(500, err.Error(), err) + return + } + + c.Header().Set("Content-Type", "text/html") + c.WriteHeader(200) + c.Write(cmd.Delta) +} + +// RestoreDashboardVersion restores a dashboard to the given version. +func RestoreDashboardVersion(c *middleware.Context, cmd m.RestoreDashboardVersionCommand) Response { + if !c.IsSignedIn { + return Json(401, util.DynMap{ + "message": "Must be signed in to restore a version", + "status": "unauthorized", + }) + } + + cmd.UserId = c.UserId + dashboardIdStr := c.Params(":dashboardId") + dashboardId, err := strconv.Atoi(dashboardIdStr) + if err != nil { + return Json(404, util.DynMap{ + "message": err.Error(), + "status": "cannot-find-dashboard", + }) + } + cmd.DashboardId = int64(dashboardId) + + if err := bus.Dispatch(&cmd); err != nil { + return Json(500, util.DynMap{ + "message": err.Error(), + "status": "cannot-restore-version", + }) + } + + isStarred, err := isDashboardStarredByUser(c, cmd.Result.Id) + if err != nil { + return Json(500, util.DynMap{ + "message": "Error while checking if dashboard was starred by user", + "status": err.Error(), + }) + } + + // Finding creator and last updater of the dashboard + updater, creator := "Anonymous", "Anonymous" + if cmd.Result.UpdatedBy > 0 { + updater = getUserLogin(cmd.Result.UpdatedBy) + } + if cmd.Result.CreatedBy > 0 { + creator = getUserLogin(cmd.Result.CreatedBy) + } + + dto := dtos.DashboardFullWithMeta{ + Dashboard: cmd.Result.Data, + Meta: dtos.DashboardMeta{ + IsStarred: isStarred, + Slug: cmd.Result.Slug, + Type: m.DashTypeDB, + CanStar: c.IsSignedIn, + CanSave: c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR, + CanEdit: canEditDashboard(c.OrgRole), + Created: cmd.Result.Created, + Updated: cmd.Result.Updated, + UpdatedBy: updater, + CreatedBy: creator, + Version: cmd.Result.Version, + }, + } + + return Json(200, util.DynMap{ + "message": fmt.Sprintf("Dashboard restored to version %d", cmd.Result.Version), + "version": cmd.Result.Version, + "dashboard": dto, + }) +} + func GetDashboardTags(c *middleware.Context) { query := m.GetDashboardTagsQuery{OrgId: c.OrgId} err := bus.Dispatch(&query) diff --git a/pkg/components/formatter/formatter_basic.go b/pkg/components/formatter/formatter_basic.go new file mode 100644 index 00000000000..bf460f95f90 --- /dev/null +++ b/pkg/components/formatter/formatter_basic.go @@ -0,0 +1,334 @@ +package formatter + +import ( + "bytes" + "html/template" + + diff "github.com/yudai/gojsondiff" +) + +// A BasicDiff holds the stateful values that are used when generating a basic +// diff from JSON tokens. +type BasicDiff struct { + narrow string + keysIdent int + writing bool + LastIndent int + Block *BasicBlock + Change *BasicChange + Summary *BasicSummary +} + +// A BasicBlock represents a top-level element in a basic diff. +type BasicBlock struct { + Title string + Old interface{} + New interface{} + Change ChangeType + Changes []*BasicChange + Summaries []*BasicSummary + LineStart int + LineEnd int +} + +// A BasicChange represents the change from an old to new value. There are many +// BasicChanges in a BasicBlock. +type BasicChange struct { + Key string + Old interface{} + New interface{} + Change ChangeType + LineStart int + LineEnd int +} + +// A BasicSummary represents the changes within a basic block that're too deep +// or verbose to be represented in the top-level BasicBlock element, or in the +// BasicChange. Instead of showing the values in this case, we simply print +// the key and count how many times the given change was applied to that +// element. +type BasicSummary struct { + Key string + Change ChangeType + Count int + LineStart int + LineEnd int +} + +type BasicFormatter struct { + jsonDiff *JSONFormatter + tpl *template.Template +} + +func NewBasicFormatter(left interface{}) *BasicFormatter { + tpl := template.Must(template.New("block").Funcs(tplFuncMap).Parse(tplBlock)) + tpl = template.Must(tpl.New("change").Funcs(tplFuncMap).Parse(tplChange)) + tpl = template.Must(tpl.New("summary").Funcs(tplFuncMap).Parse(tplSummary)) + + return &BasicFormatter{ + jsonDiff: NewJSONFormatter(left), + tpl: tpl, + } +} + +func (b *BasicFormatter) Format(d diff.Diff) ([]byte, error) { + // calling jsonDiff.Format(d) populates the JSON diff's "Lines" value, + // which we use to compute the basic dif + _, err := b.jsonDiff.Format(d) + if err != nil { + return nil, err + } + + bd := &BasicDiff{} + blocks := bd.Basic(b.jsonDiff.Lines) + buf := &bytes.Buffer{} + + err = b.tpl.ExecuteTemplate(buf, "block", blocks) + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// Basic is V2 of the basic diff +func (b *BasicDiff) Basic(lines []*JSONLine) []*BasicBlock { + // init an array you can append to for the basic "blocks" + blocks := make([]*BasicBlock, 0) + + // iterate through each line + for _, line := range lines { + if b.LastIndent == 3 && line.Indent == 2 && line.Change == ChangeNil { + if b.Block != nil { + blocks = append(blocks, b.Block) + } + } + b.LastIndent = line.Indent + + if line.Indent == 2 { + switch line.Change { + case ChangeNil: + if line.Change == ChangeNil { + if line.Key != "" { + b.Block = &BasicBlock{ + Title: line.Key, + Change: line.Change, + } + } + } + + case ChangeAdded, ChangeDeleted: + blocks = append(blocks, &BasicBlock{ + Title: line.Key, + Change: line.Change, + New: line.Val, + LineStart: line.LineNum, + }) + + case ChangeOld: + b.Block = &BasicBlock{ + Title: line.Key, + Old: line.Val, + Change: line.Change, + LineStart: line.LineNum, + } + + case ChangeNew: + b.Block.New = line.Val + b.Block.LineEnd = line.LineNum + + // then write out the change + blocks = append(blocks, b.Block) + default: + // ok + } + } + + // Other Lines + if line.Indent > 2 { + // Ensure single line change + if line.Key != "" && line.Val != nil && !b.writing { + switch line.Change { + case ChangeAdded, ChangeDeleted: + b.Block.Changes = append(b.Block.Changes, &BasicChange{ + Key: line.Key, + Change: line.Change, + New: line.Val, + LineStart: line.LineNum, + }) + + case ChangeOld: + b.Change = &BasicChange{ + Key: line.Key, + Change: line.Change, + Old: line.Val, + LineStart: line.LineNum, + } + + case ChangeNew: + b.Change.New = line.Val + b.Change.LineEnd = line.LineNum + b.Block.Changes = append(b.Block.Changes, b.Change) + + default: + //ok + } + + } else { + if line.Change != ChangeUnchanged { + if line.Key != "" { + b.narrow = line.Key + b.keysIdent = line.Indent + } + + if line.Change != ChangeNil { + if !b.writing { + b.writing = true + key := b.Block.Title + + if b.narrow != "" { + key = b.narrow + if b.keysIdent > line.Indent { + key = b.Block.Title + } + } + + b.Summary = &BasicSummary{ + Key: key, + Change: line.Change, + LineStart: line.LineNum, + } + } + } + } else { + if b.writing { + b.writing = false + b.Summary.LineEnd = line.LineNum + b.Block.Summaries = append(b.Block.Summaries, b.Summary) + } + } + } + } + } + + return blocks +} + +// encStateMap is used in the template helper +var ( + encStateMap = map[ChangeType]string{ + ChangeAdded: "added", + ChangeDeleted: "deleted", + ChangeOld: "changed", + ChangeNew: "changed", + } + + // tplFuncMap is the function map for each template + tplFuncMap = template.FuncMap{ + "getChange": func(c ChangeType) string { + state, ok := encStateMap[c] + if !ok { + return "changed" + } + return state + }, + } +) + +var ( + // tplBlock is the whole thing + tplBlock = `{{ define "block" -}} +{{ range . }} +
+
+

+ + {{ .Title }} {{ getChange .Change }} +

+ + + + {{ if .Old }} +
{{ .Old }}
+ + {{ end }} + {{ if .New }} +
{{ .New }}
+ {{ end }} + + {{ if .LineStart }} + + {{ end }} + +
+ + + {{ range .Changes }} +
    + {{ template "change" . }} +
+ {{ end }} + + + {{ range .Summaries }} + {{ template "summary" . }} + {{ end }} + +
+{{ end }} +{{ end }}` + + // tplChange is the template for changes + tplChange = `{{ define "change" -}} +
  • + +
    {{ getChange .Change }} {{ .Key }}
    + +
    + {{ if .Old }} +
    {{ .Old }}
    + + {{ end }} + {{ if .New }} +
    {{ .New }}
    + {{ end }} +
    + + {{ if .LineStart }} + + {{ end }} +
    +
  • +{{ end }}` + + // tplSummary is for basis summaries + tplSummary = `{{ define "summary" -}} +
    + + + {{ if .Count }} + {{ .Count }} + {{ end }} + + {{ if .Key }} + {{ .Key }} + {{ getChange .Change }} + {{ end }} + + {{ if .LineStart }} + + {{ end }} +
    +{{ end }}` +) diff --git a/pkg/components/formatter/formatter_json.go b/pkg/components/formatter/formatter_json.go new file mode 100644 index 00000000000..97d9aaf9e42 --- /dev/null +++ b/pkg/components/formatter/formatter_json.go @@ -0,0 +1,477 @@ +package formatter + +import ( + "bytes" + "errors" + "fmt" + "html/template" + "sort" + + diff "github.com/yudai/gojsondiff" +) + +type ChangeType int + +const ( + ChangeNil ChangeType = iota + ChangeAdded + ChangeDeleted + ChangeOld + ChangeNew + ChangeUnchanged +) + +var ( + // changeTypeToSymbol is used for populating the terminating characer in + // the diff + changeTypeToSymbol = map[ChangeType]string{ + ChangeNil: "", + ChangeAdded: "+", + ChangeDeleted: "-", + ChangeOld: "-", + ChangeNew: "+", + } + + // changeTypeToName is used for populating class names in the diff + changeTypeToName = map[ChangeType]string{ + ChangeNil: "same", + ChangeAdded: "added", + ChangeDeleted: "deleted", + ChangeOld: "old", + ChangeNew: "new", + } +) + +var ( + // tplJSONDiffWrapper is the template that wraps a diff + tplJSONDiffWrapper = `{{ define "JSONDiffWrapper" -}} + {{ range $index, $element := . }} + {{ template "JSONDiffLine" $element }} + {{ end }} +{{ end }}` + + // tplJSONDiffLine is the template that prints each line in a diff + tplJSONDiffLine = `{{ define "JSONDiffLine" -}} +

    + + {{if .LeftLine }}{{ .LeftLine }}{{ end }} + + + {{if .RightLine }}{{ .RightLine }}{{ end }} + + + {{ .Text }} + + {{ ctos .Change }} +

    +{{ end }}` +) + +var diffTplFuncs = template.FuncMap{ + "ctos": func(c ChangeType) string { + if symbol, ok := changeTypeToSymbol[c]; ok { + return symbol + } + return "" + }, + "cton": func(c ChangeType) string { + if name, ok := changeTypeToName[c]; ok { + return name + } + return "" + }, +} + +// JSONLine contains the data required to render each line of the JSON diff +// and contains the data required to produce the tokens output in the basic +// diff. +type JSONLine struct { + LineNum int `json:"line"` + LeftLine int `json:"leftLine"` + RightLine int `json:"rightLine"` + Indent int `json:"indent"` + Text string `json:"text"` + Change ChangeType `json:"changeType"` + Key string `json:"key"` + Val interface{} `json:"value"` +} + +func NewJSONFormatter(left interface{}) *JSONFormatter { + tpl := template.Must(template.New("JSONDiffWrapper").Funcs(diffTplFuncs).Parse(tplJSONDiffWrapper)) + tpl = template.Must(tpl.New("JSONDiffLine").Funcs(diffTplFuncs).Parse(tplJSONDiffLine)) + + return &JSONFormatter{ + left: left, + Lines: []*JSONLine{}, + tpl: tpl, + path: []string{}, + size: []int{}, + lineCount: 0, + inArray: []bool{}, + } +} + +type JSONFormatter struct { + left interface{} + path []string + size []int + inArray []bool + lineCount int + leftLine int + rightLine int + line *AsciiLine + Lines []*JSONLine + tpl *template.Template +} + +type AsciiLine struct { + // the type of change + change ChangeType + + // the actual changes - no formatting + key string + val interface{} + + // level of indentation for the current line + indent int + + // buffer containing the fully formatted line + buffer *bytes.Buffer +} + +func (f *JSONFormatter) Format(diff diff.Diff) (result string, err error) { + if v, ok := f.left.(map[string]interface{}); ok { + f.formatObject(v, diff) + } else if v, ok := f.left.([]interface{}); ok { + f.formatArray(v, diff) + } else { + return "", fmt.Errorf("expected map[string]interface{} or []interface{}, got %T", + f.left) + } + + b := &bytes.Buffer{} + err = f.tpl.ExecuteTemplate(b, "JSONDiffWrapper", f.Lines) + if err != nil { + fmt.Printf("%v\n", err) + return "", err + } + return b.String(), nil +} + +func (f *JSONFormatter) formatObject(left map[string]interface{}, df diff.Diff) { + f.addLineWith(ChangeNil, "{") + f.push("ROOT", len(left), false) + f.processObject(left, df.Deltas()) + f.pop() + f.addLineWith(ChangeNil, "}") +} + +func (f *JSONFormatter) formatArray(left []interface{}, df diff.Diff) { + f.addLineWith(ChangeNil, "[") + f.push("ROOT", len(left), true) + f.processArray(left, df.Deltas()) + f.pop() + f.addLineWith(ChangeNil, "]") +} + +func (f *JSONFormatter) processArray(array []interface{}, deltas []diff.Delta) error { + patchedIndex := 0 + for index, value := range array { + f.processItem(value, deltas, diff.Index(index)) + patchedIndex++ + } + + // additional Added + for _, delta := range deltas { + switch delta.(type) { + case *diff.Added: + d := delta.(*diff.Added) + // skip items already processed + if int(d.Position.(diff.Index)) < len(array) { + continue + } + f.printRecursive(d.Position.String(), d.Value, ChangeAdded) + } + } + + return nil +} + +func (f *JSONFormatter) processObject(object map[string]interface{}, deltas []diff.Delta) error { + names := sortKeys(object) + for _, name := range names { + value := object[name] + f.processItem(value, deltas, diff.Name(name)) + } + + // Added + for _, delta := range deltas { + switch delta.(type) { + case *diff.Added: + d := delta.(*diff.Added) + f.printRecursive(d.Position.String(), d.Value, ChangeAdded) + } + } + + return nil +} + +func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, position diff.Position) error { + matchedDeltas := f.searchDeltas(deltas, position) + positionStr := position.String() + if len(matchedDeltas) > 0 { + for _, matchedDelta := range matchedDeltas { + + switch matchedDelta.(type) { + case *diff.Object: + d := matchedDelta.(*diff.Object) + switch value.(type) { + case map[string]interface{}: + //ok + default: + return errors.New("Type mismatch") + } + o := value.(map[string]interface{}) + + f.newLine(ChangeNil) + f.printKey(positionStr) + f.print("{") + f.closeLine() + f.push(positionStr, len(o), false) + f.processObject(o, d.Deltas) + f.pop() + f.newLine(ChangeNil) + f.print("}") + f.printComma() + f.closeLine() + + case *diff.Array: + d := matchedDelta.(*diff.Array) + switch value.(type) { + case []interface{}: + //ok + default: + return errors.New("Type mismatch") + } + a := value.([]interface{}) + + f.newLine(ChangeNil) + f.printKey(positionStr) + f.print("[") + f.closeLine() + f.push(positionStr, len(a), true) + f.processArray(a, d.Deltas) + f.pop() + f.newLine(ChangeNil) + f.print("]") + f.printComma() + f.closeLine() + + case *diff.Added: + d := matchedDelta.(*diff.Added) + f.printRecursive(positionStr, d.Value, ChangeAdded) + f.size[len(f.size)-1]++ + + case *diff.Modified: + d := matchedDelta.(*diff.Modified) + savedSize := f.size[len(f.size)-1] + f.printRecursive(positionStr, d.OldValue, ChangeOld) + f.size[len(f.size)-1] = savedSize + f.printRecursive(positionStr, d.NewValue, ChangeNew) + + case *diff.TextDiff: + savedSize := f.size[len(f.size)-1] + d := matchedDelta.(*diff.TextDiff) + f.printRecursive(positionStr, d.OldValue, ChangeOld) + f.size[len(f.size)-1] = savedSize + f.printRecursive(positionStr, d.NewValue, ChangeNew) + + case *diff.Deleted: + d := matchedDelta.(*diff.Deleted) + f.printRecursive(positionStr, d.Value, ChangeDeleted) + + default: + return errors.New("Unknown Delta type detected") + } + + } + } else { + f.printRecursive(positionStr, value, ChangeUnchanged) + } + + return nil +} + +func (f *JSONFormatter) searchDeltas(deltas []diff.Delta, postion diff.Position) (results []diff.Delta) { + results = make([]diff.Delta, 0) + for _, delta := range deltas { + switch delta.(type) { + case diff.PostDelta: + if delta.(diff.PostDelta).PostPosition() == postion { + results = append(results, delta) + } + case diff.PreDelta: + if delta.(diff.PreDelta).PrePosition() == postion { + results = append(results, delta) + } + default: + panic("heh") + } + } + return +} + +func (f *JSONFormatter) push(name string, size int, array bool) { + f.path = append(f.path, name) + f.size = append(f.size, size) + f.inArray = append(f.inArray, array) +} + +func (f *JSONFormatter) pop() { + f.path = f.path[0 : len(f.path)-1] + f.size = f.size[0 : len(f.size)-1] + f.inArray = f.inArray[0 : len(f.inArray)-1] +} + +func (f *JSONFormatter) addLineWith(change ChangeType, value string) { + f.line = &AsciiLine{ + change: change, + indent: len(f.path), + buffer: bytes.NewBufferString(value), + } + f.closeLine() +} + +func (f *JSONFormatter) newLine(change ChangeType) { + f.line = &AsciiLine{ + change: change, + indent: len(f.path), + buffer: bytes.NewBuffer([]byte{}), + } +} + +func (f *JSONFormatter) closeLine() { + leftLine := 0 + rightLine := 0 + f.lineCount++ + + switch f.line.change { + case ChangeAdded, ChangeNew: + f.rightLine++ + rightLine = f.rightLine + + case ChangeDeleted, ChangeOld: + f.leftLine++ + leftLine = f.leftLine + + case ChangeNil, ChangeUnchanged: + f.rightLine++ + f.leftLine++ + rightLine = f.rightLine + leftLine = f.leftLine + } + + s := f.line.buffer.String() + f.Lines = append(f.Lines, &JSONLine{ + LineNum: f.lineCount, + RightLine: rightLine, + LeftLine: leftLine, + Indent: f.line.indent, + Text: s, + Change: f.line.change, + Key: f.line.key, + Val: f.line.val, + }) +} + +func (f *JSONFormatter) printKey(name string) { + if !f.inArray[len(f.inArray)-1] { + f.line.key = name + fmt.Fprintf(f.line.buffer, `"%s": `, name) + } +} + +func (f *JSONFormatter) printComma() { + f.size[len(f.size)-1]-- + if f.size[len(f.size)-1] > 0 { + f.line.buffer.WriteRune(',') + } +} + +func (f *JSONFormatter) printValue(value interface{}) { + switch value.(type) { + case string: + f.line.val = value + fmt.Fprintf(f.line.buffer, `"%s"`, value) + case nil: + f.line.val = "null" + f.line.buffer.WriteString("null") + default: + f.line.val = value + fmt.Fprintf(f.line.buffer, `%#v`, value) + } +} + +func (f *JSONFormatter) print(a string) { + f.line.buffer.WriteString(a) +} + +func (f *JSONFormatter) printRecursive(name string, value interface{}, change ChangeType) { + switch value.(type) { + case map[string]interface{}: + f.newLine(change) + f.printKey(name) + f.print("{") + f.closeLine() + + m := value.(map[string]interface{}) + size := len(m) + f.push(name, size, false) + + keys := sortKeys(m) + for _, key := range keys { + f.printRecursive(key, m[key], change) + } + f.pop() + + f.newLine(change) + f.print("}") + f.printComma() + f.closeLine() + + case []interface{}: + f.newLine(change) + f.printKey(name) + f.print("[") + f.closeLine() + + s := value.([]interface{}) + size := len(s) + f.push("", size, true) + for _, item := range s { + f.printRecursive("", item, change) + } + f.pop() + + f.newLine(change) + f.print("]") + f.printComma() + f.closeLine() + + default: + f.newLine(change) + f.printKey(name) + f.printValue(value) + f.printComma() + f.closeLine() + } +} + +func sortKeys(m map[string]interface{}) (keys []string) { + keys = make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return +} diff --git a/pkg/models/dashboard_version.go b/pkg/models/dashboard_version.go new file mode 100644 index 00000000000..18a4783a627 --- /dev/null +++ b/pkg/models/dashboard_version.go @@ -0,0 +1,103 @@ +package models + +import ( + "errors" + "time" + + "github.com/grafana/grafana/pkg/components/simplejson" +) + +type DiffType int + +const ( + DiffJSON DiffType = iota + DiffBasic + DiffDelta +) + +var ( + ErrDashboardVersionNotFound = errors.New("Dashboard version not found") + ErrNoVersionsForDashboardId = errors.New("No dashboard versions found for the given DashboardId") +) + +// A DashboardVersion represents the comparable data in a dashboard, allowing +// diffs of the dashboard to be performed. +type DashboardVersion struct { + Id int64 `json:"id"` + DashboardId int64 `json:"dashboardId"` + ParentVersion int `json:"parentVersion"` + RestoredFrom int `json:"restoredFrom"` + Version int `json:"version"` + + Created time.Time `json:"created"` + + CreatedBy int64 `json:"createdBy"` + + Message string `json:"message"` + Data *simplejson.Json `json:"data"` +} + +// DashboardVersionMeta extends the dashboard version model with the names +// associated with the UserIds, overriding the field with the same name from +// the DashboardVersion model. +type DashboardVersionMeta struct { + DashboardVersion + CreatedBy string `json:"createdBy"` +} + +// DashboardVersionDTO represents a dashboard version, without the dashboard +// map. +type DashboardVersionDTO struct { + Id int64 `json:"id"` + DashboardId int64 `json:"dashboardId"` + ParentVersion int `json:"parentVersion"` + RestoredFrom int `json:"restoredFrom"` + Version int `json:"version"` + Created time.Time `json:"created"` + CreatedBy string `json:"createdBy"` + Message string `json:"message"` +} + +// +// COMMANDS +// + +// GetDashboardVersionCommand contains the data required to execute the +// sqlstore.GetDashboardVersionCommand, which returns the DashboardVersion for +// the given Version. +type GetDashboardVersionCommand struct { + DashboardId int64 `json:"dashboardId" binding:"Required"` + Version int `json:"version" binding:"Required"` + + Result *DashboardVersion +} + +// GetDashboardVersionsCommand contains the data required to execute the +// sqlstore.GetDashboardVersionsCommand, which returns all dashboard versions. +type GetDashboardVersionsCommand struct { + DashboardId int64 `json:"dashboardId" binding:"Required"` + OrderBy string `json:"orderBy"` + Limit int `json:"limit"` + Start int `json:"start"` + + Result []*DashboardVersion +} + +// RestoreDashboardVersionCommand creates a new dashboard version. +type RestoreDashboardVersionCommand struct { + DashboardId int64 `json:"dashboardId"` + Version int `json:"version" binding:"Required"` + UserId int64 `json:"-"` + + Result *Dashboard +} + +// CompareDashboardVersionsCommand is used to compare two versions. +type CompareDashboardVersionsCommand struct { + DashboardId int64 `json:"dashboardId"` + Original int `json:"original" binding:"Required"` + New int `json:"new" binding:"Required"` + DiffType DiffType `json:"-"` + + Delta []byte `json:"delta"` +} diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 634b26c3f29..f0d00af380f 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -131,6 +131,7 @@ type SaveDashboardCommand struct { OrgId int64 `json:"-"` Overwrite bool `json:"overwrite"` PluginId string `json:"-"` + Message string `json:"message"` Result *Dashboard } diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index 7bd65ac4da8..bcd09ffc006 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -3,6 +3,7 @@ package sqlstore import ( "bytes" "fmt" + "time" "github.com/go-xorm/xorm" "github.com/grafana/grafana/pkg/bus" @@ -69,17 +70,43 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error { } } - affectedRows := int64(0) + parentVersion := dash.Version + version, err := getMaxVersion(sess, dash.Id) + if err != nil { + return err + } + dash.Version = version + affectedRows := int64(0) if dash.Id == 0 { metrics.M_Models_Dashboard_Insert.Inc(1) + dash.Data.Set("version", dash.Version) affectedRows, err = sess.Insert(dash) } else { - dash.Version += 1 dash.Data.Set("version", dash.Version) affectedRows, err = sess.Id(dash.Id).Update(dash) } + if err != nil { + return err + } + if affectedRows == 0 { + return m.ErrDashboardNotFound + } + dashVersion := &m.DashboardVersion{ + DashboardId: dash.Id, + ParentVersion: parentVersion, + RestoredFrom: -1, + Version: dash.Version, + Created: time.Now(), + CreatedBy: dash.UpdatedBy, + Message: cmd.Message, + Data: dash.Data, + } + affectedRows, err = sess.Insert(dashVersion) + if err != nil { + return err + } if affectedRows == 0 { return m.ErrDashboardNotFound } @@ -234,6 +261,7 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error { "DELETE FROM star WHERE dashboard_id = ? ", "DELETE FROM dashboard WHERE id = ?", "DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?", + "DELETE FROM dashboard_version WHERE dashboard_id = ?", } for _, sql := range deletes { diff --git a/pkg/services/sqlstore/dashboard_version.go b/pkg/services/sqlstore/dashboard_version.go new file mode 100644 index 00000000000..bf0a6212ff7 --- /dev/null +++ b/pkg/services/sqlstore/dashboard_version.go @@ -0,0 +1,274 @@ +package sqlstore + +import ( + "encoding/json" + "errors" + "time" + + "github.com/go-xorm/xorm" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/formatter" + "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" + + diff "github.com/yudai/gojsondiff" + deltaFormatter "github.com/yudai/gojsondiff/formatter" +) + +var ( + // ErrUnsupportedDiffType occurs when an invalid diff type is used. + ErrUnsupportedDiffType = errors.New("sqlstore: unsupported diff type") + + // ErrNilDiff occurs when two compared interfaces are identical. + ErrNilDiff = errors.New("sqlstore: diff is nil") +) + +func init() { + bus.AddHandler("sql", CompareDashboardVersionsCommand) + bus.AddHandler("sql", GetDashboardVersion) + bus.AddHandler("sql", GetDashboardVersions) + bus.AddHandler("sql", RestoreDashboardVersion) +} + +// CompareDashboardVersionsCommand computes the JSON diff of two versions, +// assigning the delta of the diff to the `Delta` field. +func CompareDashboardVersionsCommand(cmd *m.CompareDashboardVersionsCommand) error { + original, err := getDashboardVersion(cmd.DashboardId, cmd.Original) + if err != nil { + return err + } + + newDashboard, err := getDashboardVersion(cmd.DashboardId, cmd.New) + if err != nil { + return err + } + + left, jsonDiff, err := getDiff(original, newDashboard) + if err != nil { + return err + } + + switch cmd.DiffType { + case m.DiffDelta: + + deltaOutput, err := deltaFormatter.NewDeltaFormatter().Format(jsonDiff) + if err != nil { + return err + } + cmd.Delta = []byte(deltaOutput) + + case m.DiffJSON: + jsonOutput, err := formatter.NewJSONFormatter(left).Format(jsonDiff) + if err != nil { + return err + } + cmd.Delta = []byte(jsonOutput) + + case m.DiffBasic: + basicOutput, err := formatter.NewBasicFormatter(left).Format(jsonDiff) + if err != nil { + return err + } + cmd.Delta = basicOutput + + default: + return ErrUnsupportedDiffType + } + + return nil +} + +// GetDashboardVersion gets the dashboard version for the given dashboard ID +// and version number. +func GetDashboardVersion(query *m.GetDashboardVersionCommand) error { + result, err := getDashboardVersion(query.DashboardId, query.Version) + if err != nil { + return err + } + + query.Result = result + return nil +} + +// GetDashboardVersions gets all dashboard versions for the given dashboard ID. +func GetDashboardVersions(query *m.GetDashboardVersionsCommand) error { + order := "" + + // the query builder in xorm doesn't provide a way to set + // a default order, so we perform this check + if query.OrderBy != "" { + order = " desc" + } + err := x.In("dashboard_id", query.DashboardId). + OrderBy(query.OrderBy+order). + Limit(query.Limit, query.Start). + Find(&query.Result) + if err != nil { + return err + } + + if len(query.Result) < 1 { + return m.ErrNoVersionsForDashboardId + } + return nil +} + +// RestoreDashboardVersion restores the dashboard data to the given version. +func RestoreDashboardVersion(cmd *m.RestoreDashboardVersionCommand) error { + return inTransaction(func(sess *xorm.Session) error { + // check if dashboard version exists in dashboard_version table + // + // normally we could use the getDashboardVersion func here, but since + // we're in a transaction, we need to run the queries using the + // session instead of using the global `x`, so we copy those functions + // here, replacing `x` with `sess` + dashboardVersion := m.DashboardVersion{} + has, err := sess.Where( + "dashboard_id=? AND version=?", + cmd.DashboardId, + cmd.Version, + ).Get(&dashboardVersion) + if err != nil { + return err + } + if !has { + return m.ErrDashboardVersionNotFound + } + dashboardVersion.Data.Set("id", dashboardVersion.DashboardId) + + // get the dashboard version + dashboard := m.Dashboard{Id: cmd.DashboardId} + has, err = sess.Get(&dashboard) + if err != nil { + return err + } + if has == false { + return m.ErrDashboardNotFound + } + + version, err := getMaxVersion(sess, dashboard.Id) + if err != nil { + return err + } + + // revert and save to a new dashboard version + dashboard.Data = dashboardVersion.Data + dashboard.Updated = time.Now() + dashboard.UpdatedBy = cmd.UserId + dashboard.Version = version + dashboard.Data.Set("version", dashboard.Version) + affectedRows, err := sess.Id(dashboard.Id).Update(dashboard) + if err != nil { + return err + } + if affectedRows == 0 { + return m.ErrDashboardNotFound + } + + // save that version a new version + dashVersion := &m.DashboardVersion{ + DashboardId: dashboard.Id, + ParentVersion: cmd.Version, + RestoredFrom: cmd.Version, + Version: dashboard.Version, + Created: time.Now(), + CreatedBy: dashboard.UpdatedBy, + Message: "", + Data: dashboard.Data, + } + affectedRows, err = sess.Insert(dashVersion) + if err != nil { + return err + } + if affectedRows == 0 { + return m.ErrDashboardNotFound + } + + cmd.Result = &dashboard + return nil + }) +} + +// getDashboardVersion is a helper function that gets the dashboard version for +// the given dashboard ID and version ID. +func getDashboardVersion(dashboardId int64, version int) (*m.DashboardVersion, error) { + dashboardVersion := m.DashboardVersion{} + has, err := x.Where("dashboard_id=? AND version=?", dashboardId, version).Get(&dashboardVersion) + if err != nil { + return nil, err + } + if !has { + return nil, m.ErrDashboardVersionNotFound + } + + dashboardVersion.Data.Set("id", dashboardVersion.DashboardId) + return &dashboardVersion, nil +} + +// getDashboard gets a dashboard by ID. Used for retrieving the dashboard +// associated with dashboard versions. +func getDashboard(dashboardId int64) (*m.Dashboard, error) { + dashboard := m.Dashboard{Id: dashboardId} + has, err := x.Get(&dashboard) + if err != nil { + return nil, err + } + if has == false { + return nil, m.ErrDashboardNotFound + } + return &dashboard, nil +} + +// getDiff computes the diff of two dashboard versions. +func getDiff(originalDash, newDash *m.DashboardVersion) (interface{}, diff.Diff, error) { + leftBytes, err := simplejson.NewFromAny(originalDash).Encode() + if err != nil { + return nil, nil, err + } + + rightBytes, err := simplejson.NewFromAny(newDash).Encode() + if err != nil { + return nil, nil, err + } + + jsonDiff, err := diff.New().Compare(leftBytes, rightBytes) + if err != nil { + return nil, nil, err + } + + if !jsonDiff.Modified() { + return nil, nil, ErrNilDiff + } + + left := make(map[string]interface{}) + err = json.Unmarshal(leftBytes, &left) + return left, jsonDiff, nil +} + +type version struct { + Max int +} + +// getMaxVersion returns the highest version number in the `dashboard_version` +// table. +// +// This is necessary because sqlite3 doesn't support autoincrement in the same +// way that Postgres or MySQL do, so we use this to get around that. Since it's +// impossible to delete a version in Grafana, this is believed to be a +// safe-enough alternative. +func getMaxVersion(sess *xorm.Session, dashboardId int64) (int, error) { + v := version{} + has, err := sess.Table("dashboard_version"). + Select("MAX(version) AS max"). + Where("dashboard_id = ?", dashboardId). + Get(&v) + if !has { + return 0, m.ErrDashboardNotFound + } + if err != nil { + return 0, err + } + + v.Max++ + return v.Max, nil +} diff --git a/pkg/services/sqlstore/dashboard_version_test.go b/pkg/services/sqlstore/dashboard_version_test.go new file mode 100644 index 00000000000..22d98d7c284 --- /dev/null +++ b/pkg/services/sqlstore/dashboard_version_test.go @@ -0,0 +1,188 @@ +package sqlstore + +import ( + "reflect" + "testing" + + . "github.com/smartystreets/goconvey/convey" + + "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" +) + +func updateTestDashboard(dashboard *m.Dashboard, data map[string]interface{}) { + data["title"] = dashboard.Title + + saveCmd := m.SaveDashboardCommand{ + OrgId: dashboard.OrgId, + Overwrite: true, + Dashboard: simplejson.NewFromAny(data), + } + + err := SaveDashboard(&saveCmd) + So(err, ShouldBeNil) +} + +func TestGetDashboardVersion(t *testing.T) { + Convey("Testing dashboard version retrieval", t, func() { + InitTestDB(t) + + Convey("Get a Dashboard ID and version ID", func() { + savedDash := insertTestDashboard("test dash 26", 1, "diff") + + cmd := m.GetDashboardVersionCommand{ + DashboardId: savedDash.Id, + Version: savedDash.Version, + } + + err := GetDashboardVersion(&cmd) + So(err, ShouldBeNil) + So(savedDash.Id, ShouldEqual, cmd.DashboardId) + So(savedDash.Version, ShouldEqual, cmd.Version) + + dashCmd := m.GetDashboardQuery{ + OrgId: savedDash.OrgId, + Slug: savedDash.Slug, + } + err = GetDashboard(&dashCmd) + So(err, ShouldBeNil) + eq := reflect.DeepEqual(dashCmd.Result.Data, cmd.Result.Data) + So(eq, ShouldEqual, true) + }) + + Convey("Attempt to get a version that doesn't exist", func() { + cmd := m.GetDashboardVersionCommand{ + DashboardId: int64(999), + Version: 123, + } + + err := GetDashboardVersion(&cmd) + So(err, ShouldNotBeNil) + So(err, ShouldEqual, m.ErrDashboardVersionNotFound) + }) + }) +} + +func TestGetDashboardVersions(t *testing.T) { + Convey("Testing dashboard versions retrieval", t, func() { + InitTestDB(t) + savedDash := insertTestDashboard("test dash 43", 1, "diff-all") + + Convey("Get all versions for a given Dashboard ID", func() { + cmd := m.GetDashboardVersionsCommand{ + DashboardId: savedDash.Id, + } + + err := GetDashboardVersions(&cmd) + So(err, ShouldBeNil) + So(len(cmd.Result), ShouldEqual, 1) + }) + + Convey("Attempt to get the versions for a non-existent Dashboard ID", func() { + cmd := m.GetDashboardVersionsCommand{ + DashboardId: int64(999), + } + + err := GetDashboardVersions(&cmd) + So(err, ShouldNotBeNil) + So(err, ShouldEqual, m.ErrNoVersionsForDashboardId) + So(len(cmd.Result), ShouldEqual, 0) + }) + + Convey("Get all versions for an updated dashboard", func() { + updateTestDashboard(savedDash, map[string]interface{}{ + "tags": "different-tag", + }) + + cmd := m.GetDashboardVersionsCommand{ + DashboardId: savedDash.Id, + } + err := GetDashboardVersions(&cmd) + So(err, ShouldBeNil) + So(len(cmd.Result), ShouldEqual, 2) + }) + }) +} + +func TestCompareDashboardVersions(t *testing.T) { + Convey("Testing dashboard version comparison", t, func() { + InitTestDB(t) + + savedDash := insertTestDashboard("test dash 43", 1, "x") + updateTestDashboard(savedDash, map[string]interface{}{ + "tags": "y", + }) + + Convey("Compare two versions that are different", func() { + getVersionCmd := m.GetDashboardVersionsCommand{ + DashboardId: savedDash.Id, + } + err := GetDashboardVersions(&getVersionCmd) + So(err, ShouldBeNil) + So(len(getVersionCmd.Result), ShouldEqual, 2) + + cmd := m.CompareDashboardVersionsCommand{ + DashboardId: savedDash.Id, + Original: getVersionCmd.Result[0].Version, + New: getVersionCmd.Result[1].Version, + DiffType: m.DiffDelta, + } + err = CompareDashboardVersionsCommand(&cmd) + So(err, ShouldBeNil) + So(cmd.Delta, ShouldNotBeNil) + }) + + Convey("Compare two versions that are the same", func() { + cmd := m.CompareDashboardVersionsCommand{ + DashboardId: savedDash.Id, + Original: savedDash.Version, + New: savedDash.Version, + DiffType: m.DiffDelta, + } + + err := CompareDashboardVersionsCommand(&cmd) + So(err, ShouldNotBeNil) + So(cmd.Delta, ShouldBeNil) + }) + + Convey("Compare two versions that don't exist", func() { + cmd := m.CompareDashboardVersionsCommand{ + DashboardId: savedDash.Id, + Original: 123, + New: 456, + DiffType: m.DiffDelta, + } + + err := CompareDashboardVersionsCommand(&cmd) + So(err, ShouldNotBeNil) + So(cmd.Delta, ShouldBeNil) + }) + }) +} + +func TestRestoreDashboardVersion(t *testing.T) { + Convey("Testing dashboard version restoration", t, func() { + InitTestDB(t) + savedDash := insertTestDashboard("test dash 26", 1, "restore") + updateTestDashboard(savedDash, map[string]interface{}{ + "tags": "not restore", + }) + + Convey("Restore dashboard to a previous version", func() { + versionsCmd := m.GetDashboardVersionsCommand{ + DashboardId: savedDash.Id, + } + err := GetDashboardVersions(&versionsCmd) + So(err, ShouldBeNil) + + cmd := m.RestoreDashboardVersionCommand{ + DashboardId: savedDash.Id, + Version: savedDash.Version, + UserId: 0, + } + + err = RestoreDashboardVersion(&cmd) + So(err, ShouldBeNil) + }) + }) +} diff --git a/pkg/services/sqlstore/migrations/dashboard_version_mig.go b/pkg/services/sqlstore/migrations/dashboard_version_mig.go new file mode 100644 index 00000000000..dbc3dc46007 --- /dev/null +++ b/pkg/services/sqlstore/migrations/dashboard_version_mig.go @@ -0,0 +1,54 @@ +package migrations + +import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +func addDashboardVersionMigration(mg *Migrator) { + dashboardVersionV1 := Table{ + Name: "dashboard_version", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "dashboard_id", Type: DB_BigInt}, + {Name: "parent_version", Type: DB_Int, Nullable: false}, + {Name: "restored_from", Type: DB_Int, Nullable: false}, + {Name: "version", Type: DB_Int, Nullable: false}, + {Name: "created", Type: DB_DateTime, Nullable: false}, + {Name: "created_by", Type: DB_BigInt, Nullable: false}, + {Name: "message", Type: DB_Text, Nullable: false}, + {Name: "data", Type: DB_Text, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"dashboard_id"}}, + {Cols: []string{"dashboard_id", "version"}, Type: UniqueIndex}, + }, + } + + mg.AddMigration("create dashboard_version table v1", NewAddTableMigration(dashboardVersionV1)) + mg.AddMigration("add index dashboard_version.dashboard_id", NewAddIndexMigration(dashboardVersionV1, dashboardVersionV1.Indices[0])) + mg.AddMigration("add unique index dashboard_version.dashboard_id and dashboard_version.version", NewAddIndexMigration(dashboardVersionV1, dashboardVersionV1.Indices[1])) + + const rawSQL = `INSERT INTO dashboard_version +( + dashboard_id, + version, + parent_version, + restored_from, + created, + created_by, + message, + data +) +SELECT + dashboard.id, + dashboard.version + 1, + dashboard.version, + dashboard.version, + dashboard.updated, + dashboard.updated_by, + '', + dashboard.data +FROM dashboard;` + mg.AddMigration("save existing dashboard data in dashboard_version table v1", new(RawSqlMigration). + Sqlite(rawSQL). + Postgres(rawSQL). + Mysql(rawSQL)) +} diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index bf334d57bb0..38072fe88e4 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -25,6 +25,7 @@ func AddMigrations(mg *Migrator) { addAlertMigrations(mg) addAnnotationMig(mg) addTestDataMigrations(mg) + addDashboardVersionMigration(mg) } func addMigrationLogMigrations(mg *Migrator) { diff --git a/public/app/core/core.ts b/public/app/core/core.ts index 4aa2e7eb64a..555e8b2df9a 100644 --- a/public/app/core/core.ts +++ b/public/app/core/core.ts @@ -16,6 +16,7 @@ import "./directives/value_select_dropdown"; import "./directives/plugin_component"; import "./directives/rebuild_on_change"; import "./directives/give_focus"; +import "./directives/diff-view"; import './jquery_extended'; import './partials'; import './components/jsontree/jsontree'; diff --git a/public/app/core/directives/dash_edit_link.js b/public/app/core/directives/dash_edit_link.js index 7486d0ada18..45b2760ea57 100644 --- a/public/app/core/directives/dash_edit_link.js +++ b/public/app/core/directives/dash_edit_link.js @@ -8,6 +8,7 @@ function ($, coreModule) { var editViewMap = { 'settings': { src: 'public/app/features/dashboard/partials/settings.html'}, 'annotations': { src: 'public/app/features/annotations/partials/editor.html'}, + 'audit': { src: 'public/app/features/dashboard/audit/partials/audit.html'}, 'templating': { src: 'public/app/features/templating/partials/editor.html'}, 'import': { src: '' } }; diff --git a/public/app/core/directives/diff-view.ts b/public/app/core/directives/diff-view.ts new file mode 100644 index 00000000000..05415273702 --- /dev/null +++ b/public/app/core/directives/diff-view.ts @@ -0,0 +1,76 @@ +/// + +import angular from 'angular'; +import coreModule from '../core_module'; + +export class DeltaCtrl { + observer: any; + + constructor(private $rootScope) { + const waitForCompile = function(mutations) { + if (mutations.length === 1) { + this.$rootScope.appEvent('json-diff-ready'); + } + }; + + this.observer = new MutationObserver(waitForCompile.bind(this)); + + const observerConfig = { + attributes: true, + attributeFilter: ['class'], + characterData: false, + childList: true, + subtree: false, + }; + + this.observer.observe(angular.element('.delta-html')[0], observerConfig); + } + + $onDestroy() { + this.observer.disconnect(); + } +} + +export function delta() { + return { + controller: DeltaCtrl, + replace: false, + restrict: 'A', + }; +} +coreModule.directive('diffDelta', delta); + +// Link to JSON line number +export class LinkJSONCtrl { + /** @ngInject */ + constructor(private $scope, private $rootScope, private $anchorScroll) {} + + goToLine(line: number) { + let unbind; + + const scroll = () => { + this.$anchorScroll(`l${line}`); + unbind(); + }; + + this.$scope.switchView().then(() => { + unbind = this.$rootScope.$on('json-diff-ready', scroll.bind(this)); + }); + } +} + +export function linkJson() { + return { + controller: LinkJSONCtrl, + controllerAs: 'ctrl', + replace: true, + restrict: 'E', + scope: { + line: '@lineDisplay', + link: '@lineLink', + switchView: '&', + }, + templateUrl: 'public/app/features/dashboard/audit/partials/link-json.html', + }; +} +coreModule.directive('diffLinkJson', linkJson); diff --git a/public/app/core/directives/misc.js b/public/app/core/directives/misc.js index 8e1791af46c..8f6f1fc52a7 100644 --- a/public/app/core/directives/misc.js +++ b/public/app/core/directives/misc.js @@ -18,6 +18,20 @@ function (angular, coreModule, kbn) { }; }); + coreModule.default.directive('compile', function($compile) { + return { + restrict: 'A', + link: function(scope, element, attrs) { + scope.$watch(function(scope) { + return scope.$eval(attrs.compile); + }, function(value) { + element.html(value); + $compile(element.contents())(scope); + }); + } + }; + }); + coreModule.default.directive('watchChange', function() { return { scope: { onchange: '&watchChange' }, diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index 16edc364340..2c3102f1811 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -202,7 +202,8 @@ export class BackendSrv { saveDashboard(dash, options) { options = (options || {}); - return this.post('/api/dashboards/db/', {dashboard: dash, overwrite: options.overwrite === true}); + const message = options.message || ''; + return this.post('/api/dashboards/db/', {dashboard: dash, overwrite: options.overwrite === true, message}); } } diff --git a/public/app/features/all.js b/public/app/features/all.js index 96c28288e8e..ef88fbf6ea2 100644 --- a/public/app/features/all.js +++ b/public/app/features/all.js @@ -2,6 +2,7 @@ define([ './panellinks/module', './dashlinks/module', './annotations/all', + './annotations/annotations_srv', './templating/all', './dashboard/all', './playlist/all', diff --git a/public/app/features/dashboard/all.js b/public/app/features/dashboard/all.js index c362f9cd032..69ed18fe98b 100644 --- a/public/app/features/dashboard/all.js +++ b/public/app/features/dashboard/all.js @@ -1,10 +1,12 @@ define([ './dashboard_ctrl', './alerting_srv', + './audit/audit_srv', './dashboardLoaderSrv', './dashnav/dashnav', './submenu/submenu', './saveDashboardAsCtrl', + './saveDashboardMessageCtrl', './shareModalCtrl', './shareSnapshotCtrl', './dashboard_srv', diff --git a/public/app/features/dashboard/audit/audit_ctrl.ts b/public/app/features/dashboard/audit/audit_ctrl.ts new file mode 100644 index 00000000000..489959f2842 --- /dev/null +++ b/public/app/features/dashboard/audit/audit_ctrl.ts @@ -0,0 +1,235 @@ +/// + +import _ from 'lodash'; +import angular from 'angular'; +import moment from 'moment'; + +import coreModule from 'app/core/core_module'; + +import {DashboardModel} from '../model'; +import {AuditLogOpts, RevisionsModel} from './models'; + +export class AuditLogCtrl { + appending: boolean; + dashboard: DashboardModel; + delta: { basic: string; html: string; }; + diff: string; + limit: number; + loading: boolean; + max: number; + mode: string; + orderBy: string; + revisions: RevisionsModel[]; + selected: number[]; + start: number; + + /** @ngInject */ + constructor(private $scope, + private $rootScope, + private $window, + private $q, + private contextSrv, + private auditSrv) { + $scope.ctrl = this; + + this.appending = false; + this.dashboard = $scope.dashboard; + this.diff = 'basic'; + this.limit = 10; + this.loading = false; + this.max = 2; + this.mode = 'list'; + this.orderBy = 'version'; + this.selected = []; + this.start = 0; + + this.resetFromSource(); + + $scope.$watch('ctrl.mode', newVal => { + $window.scrollTo(0, 0); + if (newVal === 'list') { + this.reset(); + } + }); + + $rootScope.onAppEvent('dashboard-saved', this.onDashboardSaved.bind(this)); + } + + addToLog() { + this.start = this.start + this.limit; + this.getLog(true); + } + + compareRevisionStateChanged(revision: any) { + if (revision.checked) { + this.selected.push(revision.version); + } else { + _.remove(this.selected, version => version === revision.version); + } + this.selected = _.sortBy(this.selected); + } + + compareRevisionDisabled(checked: boolean) { + return (this.selected.length === this.max && !checked) || this.revisions.length === 1; + } + + formatDate(date) { + date = moment.isMoment(date) ? date : moment(date); + const format = 'YYYY-MM-DD HH:mm:ss'; + + return this.dashboard.timezone === 'browser' ? + moment(date).format(format) : + moment.utc(date).format(format); + } + + formatBasicDate(date) { + const now = this.dashboard.timezone === 'browser' ? moment() : moment.utc(); + const then = this.dashboard.timezone === 'browser' ? moment(date) : moment.utc(date); + return then.from(now); + } + + getDiff(diff: string) { + if (!this.isComparable()) { return; } // disable button but not tooltip + + this.diff = diff; + this.mode = 'compare'; + this.loading = true; + + // instead of using lodash to find min/max we use the index + // due to the array being sorted in ascending order + const compare = { + new: this.selected[1], + original: this.selected[0], + }; + + if (this.delta[this.diff]) { + this.loading = false; + return this.$q.when(this.delta[this.diff]); + } else { + return this.auditSrv.compareVersions(this.dashboard, compare, diff).then(response => { + this.delta[this.diff] = response; + }).catch(err => { + this.mode = 'list'; + this.$rootScope.appEvent('alert-error', ['There was an error fetching the diff', (err.message || err)]); + }).finally(() => { this.loading = false; }); + } + } + + getLog(append = false) { + this.loading = !append; + this.appending = append; + const options: AuditLogOpts = { + limit: this.limit, + start: this.start, + orderBy: this.orderBy, + }; + return this.auditSrv.getAuditLog(this.dashboard, options).then(revisions => { + const formattedRevisions = _.flow( + _.partialRight(_.map, rev => _.extend({}, rev, { + checked: false, + message: (revision => { + if (revision.message === '') { + if (revision.version === 1) { + return 'Dashboard\'s initial save'; + } + + if (revision.restoredFrom > 0) { + return `Restored from version ${revision.restoredFrom}`; + } + + if (revision.parentVersion === 0) { + return 'Dashboard overwritten'; + } + + return 'Dashboard saved'; + } + return revision.message; + })(rev), + })))(revisions); + + this.revisions = append ? this.revisions.concat(formattedRevisions) : formattedRevisions; + }).catch(err => { + this.$rootScope.appEvent('alert-error', ['There was an error fetching the audit log', (err.message || err)]); + }).finally(() => { + this.loading = false; + this.appending = false; + }); + } + + getMeta(version: number, property: string) { + const revision = _.find(this.revisions, rev => rev.version === version); + return revision[property]; + } + + isOriginalCurrent() { + return this.selected[1] === this.dashboard.version; + } + + isComparable() { + const isParamLength = this.selected.length === 2; + const areNumbers = this.selected.every(version => _.isNumber(version)); + const areValidVersions = _.filter(this.revisions, revision => { + return revision.version === this.selected[0] || revision.version === this.selected[1]; + }).length === 2; + return isParamLength && areNumbers && areValidVersions; + } + + isLastPage() { + return _.find(this.revisions, rev => rev.version === 1); + } + + onDashboardSaved() { + this.$rootScope.appEvent('hide-dash-editor'); + } + + reset() { + this.delta = { basic: '', html: '' }; + this.diff = 'basic'; + this.mode = 'list'; + this.revisions = _.map(this.revisions, rev => _.extend({}, rev, { checked: false })); + this.selected = []; + this.start = 0; + } + + resetFromSource() { + this.revisions = []; + return this.getLog().then(this.reset.bind(this)); + } + + restore(version: number) { + this.$rootScope.appEvent('confirm-modal', { + title: 'Restore version', + text: '', + text2: `Are you sure you want to restore the dashboard to version ${version}? All unsaved changes will be lost.`, + icon: 'fa-rotate-right', + yesText: `Yes, restore to version ${version}`, + onConfirm: this.restoreConfirm.bind(this, version), + }); + } + + restoreConfirm(version: number) { + this.loading = true; + return this.auditSrv.restoreDashboard(this.dashboard, version).then(response => { + this.revisions.unshift({ + id: this.revisions[0].id + 1, + checked: false, + dashboardId: this.dashboard.id, + parentVersion: version, + version: this.revisions[0].version + 1, + created: new Date(), + createdBy: this.contextSrv.user.name, + message: `Restored from version ${version}`, + }); + + this.reset(); + const restoredData = response.dashboard; + this.dashboard = restoredData.dashboard; + this.dashboard.meta = restoredData.meta; + this.$scope.setupDashboard(restoredData); + }).catch(err => { + this.$rootScope.appEvent('alert-error', ['There was an error restoring the dashboard', (err.message || err)]); + }).finally(() => { this.loading = false; }); + } +} + +coreModule.controller('AuditLogCtrl', AuditLogCtrl); diff --git a/public/app/features/dashboard/audit/audit_srv.ts b/public/app/features/dashboard/audit/audit_srv.ts new file mode 100644 index 00000000000..7c6e7134e2e --- /dev/null +++ b/public/app/features/dashboard/audit/audit_srv.ts @@ -0,0 +1,32 @@ +/// + +import './audit_ctrl'; + +import _ from 'lodash'; +import coreModule from 'app/core/core_module'; +import {DashboardModel} from '../model'; +import {AuditLogOpts} from './models'; + +export class AuditSrv { + /** @ngInject */ + constructor(private backendSrv, private $q) {} + + getAuditLog(dashboard: DashboardModel, options: AuditLogOpts) { + const id = dashboard && dashboard.id ? dashboard.id : void 0; + return id ? this.backendSrv.get(`api/dashboards/db/${id}/versions`, options) : this.$q.when([]); + } + + compareVersions(dashboard: DashboardModel, compare: { new: number, original: number }, view = 'html') { + const id = dashboard && dashboard.id ? dashboard.id : void 0; + const url = `api/dashboards/db/${id}/compare/${compare.original}...${compare.new}/${view}`; + return id ? this.backendSrv.get(url) : this.$q.when({}); + } + + restoreDashboard(dashboard: DashboardModel, version: number) { + const id = dashboard && dashboard.id ? dashboard.id : void 0; + const url = `api/dashboards/db/${id}/restore`; + return id && _.isNumber(version) ? this.backendSrv.post(url, { version }) : this.$q.when({}); + } +} + +coreModule.service('auditSrv', AuditSrv); diff --git a/public/app/features/dashboard/audit/models.ts b/public/app/features/dashboard/audit/models.ts new file mode 100644 index 00000000000..c32eb11bef3 --- /dev/null +++ b/public/app/features/dashboard/audit/models.ts @@ -0,0 +1,16 @@ +export interface AuditLogOpts { + limit: number; + start: number; + orderBy: string; +} + +export interface RevisionsModel { + id: number; + checked: boolean; + dashboardId: number; + parentVersion: number; + version: number; + created: Date; + createdBy: string; + message: string; +} diff --git a/public/app/features/dashboard/audit/partials/audit.html b/public/app/features/dashboard/audit/partials/audit.html new file mode 100644 index 00000000000..3fcac20db66 --- /dev/null +++ b/public/app/features/dashboard/audit/partials/audit.html @@ -0,0 +1,161 @@ +
    +
    +

    + Changelog +

    + +
      +
    • + + List + +
    • +
    • + + Version {{ctrl.selected[0]}} Current + + + Version {{ctrl.selected[0]}} Version {{ctrl.selected[1]}} + +
    • +
    + + +
    + +
    + +
    +
    + + Fetching audit log… +
    + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + +
    VersionDateUpdated ByNotes
    + + + {{revision.version}}{{ctrl.formatDate(revision.created)}}{{revision.createdBy}}{{revision.message}} + +   Restore + + +   Current + +
    + +
    + + Fetching more entries… +
    + + +
    +
    +
    +
    + +
    +
    +
    + + +
    +
    + + Fetching changes… +
    + +
    + +   Restore to version {{new}} + +

    + Comparing Version {{ctrl.selected[0]}} + + Version {{ctrl.selected[1]}} + (Current) +

    +
    +

    + Version {{new}} updated by + {{ctrl.getMeta(new, 'createdBy')}} + {{ctrl.formatBasicDate(ctrl.getMeta(new, 'created'))}} + - {{ctrl.getMeta(new, 'message')}} +

    +

    + Version {{original}} updated by + {{ctrl.getMeta(original, 'createdBy')}} + {{ctrl.formatBasicDate(ctrl.getMeta(original, 'created'))}} + - {{ctrl.getMeta(original, 'message')}} +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    diff --git a/public/app/features/dashboard/audit/partials/link-json.html b/public/app/features/dashboard/audit/partials/link-json.html new file mode 100644 index 00000000000..0ad398486c8 --- /dev/null +++ b/public/app/features/dashboard/audit/partials/link-json.html @@ -0,0 +1,4 @@ + + Line {{ line }} + + diff --git a/public/app/features/dashboard/dashboard_srv.ts b/public/app/features/dashboard/dashboard_srv.ts index 5939ff83a15..b075af7352e 100644 --- a/public/app/features/dashboard/dashboard_srv.ts +++ b/public/app/features/dashboard/dashboard_srv.ts @@ -23,32 +23,7 @@ export class DashboardSrv { return this.dash; } - saveDashboard(options) { - if (!this.dash.meta.canSave && options.makeEditable !== true) { - return Promise.resolve(); - } - - if (this.dash.title === 'New dashboard') { - return this.saveDashboardAs(); - } - - var clone = this.dash.getSaveModelClone(); - - return this.backendSrv.saveDashboard(clone, options).then(data => { - this.dash.version = data.version; - - this.$rootScope.appEvent('dashboard-saved', this.dash); - - var dashboardUrl = '/dashboard/db/' + data.slug; - if (dashboardUrl !== this.$location.path()) { - this.$location.url(dashboardUrl); - } - - this.$rootScope.appEvent('alert-success', ['Dashboard saved', 'Saved as ' + clone.title]); - }).catch(this.handleSaveDashboardError.bind(this)); - } - - handleSaveDashboardError(err) { + handleSaveDashboardError(clone, err) { if (err.data && err.data.status === "version-mismatch") { err.isHandled = true; @@ -59,7 +34,7 @@ export class DashboardSrv { yesText: "Save & Overwrite", icon: "fa-warning", onConfirm: () => { - this.saveDashboard({overwrite: true}); + this.saveDashboard({overwrite: true}, clone); } }); } @@ -74,7 +49,7 @@ export class DashboardSrv { yesText: "Save & Overwrite", icon: "fa-warning", onConfirm: () => { - this.saveDashboard({overwrite: true}); + this.saveDashboard({overwrite: true}, clone); } }); } @@ -93,12 +68,50 @@ export class DashboardSrv { this.saveDashboardAs(); }, onConfirm: () => { - this.saveDashboard({overwrite: true}); + this.saveDashboard({overwrite: true}, clone); } }); } } + postSave(clone, data) { + this.dash.version = data.version; + + var dashboardUrl = '/dashboard/db/' + data.slug; + if (dashboardUrl !== this.$location.path()) { + this.$location.url(dashboardUrl); + } + + this.$rootScope.appEvent('dashboard-saved', this.dash); + this.$rootScope.appEvent('alert-success', ['Dashboard saved', 'Saved as ' + clone.title]); + } + + save(clone, options) { + return this.backendSrv.saveDashboard(clone, options) + .then(this.postSave.bind(this, clone)) + .catch(this.handleSaveDashboardError.bind(this, clone)); + } + + saveDashboard(options, clone) { + if (clone) { + this.setCurrent(this.create(clone, this.dash.meta)); + } + + if (!this.dash.meta.canSave && options.makeEditable !== true) { + return Promise.resolve(); + } + + if (this.dash.title === 'New dashboard') { + return this.saveDashboardAs(); + } + + if (this.dash.version > 0) { + return this.saveDashboardMessage(); + } + + return this.save(this.dash.getSaveModelClone(), options); + } + saveDashboardAs() { var newScope = this.$rootScope.$new(); newScope.clone = this.dash.getSaveModelClone(); @@ -112,6 +125,16 @@ export class DashboardSrv { }); } + saveDashboardMessage() { + var newScope = this.$rootScope.$new(); + newScope.clone = this.dash.getSaveModelClone(); + + this.$rootScope.appEvent('show-modal', { + src: 'public/app/features/dashboard/partials/saveDashboardMessage.html', + scope: newScope, + modalClass: 'modal--narrow' + }); + } } coreModule.service('dashboardSrv', DashboardSrv); diff --git a/public/app/features/dashboard/dashnav/dashnav.html b/public/app/features/dashboard/dashnav/dashnav.html index 7deb46931c2..36e523cd189 100644 --- a/public/app/features/dashboard/dashnav/dashnav.html +++ b/public/app/features/dashboard/dashnav/dashnav.html @@ -56,7 +56,8 @@
  • - + +
  • @@ -66,6 +67,7 @@