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 <jwt> ``` where `<jwt>` is a JSON web token obtained from the Grafana admin panel. `GET "/api/dashboards/db/:dashboardId/versions?orderBy=<string>&limit=<int>&start=<int>"` 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": <int>, "version": <int> } ``` `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.
This commit is contained in:
committed by
Carlos Rosquillas
parent
59f3cca135
commit
b6e46c9eb8
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 . }}
|
||||
<div class="diff-group">
|
||||
<div class="diff-block">
|
||||
<h2 class="diff-block-title">
|
||||
<i class="diff-circle diff-circle-{{ getChange .Change }} fa fa-circle"></i>
|
||||
<strong class="diff-title">{{ .Title }}</strong> {{ getChange .Change }}
|
||||
</h2>
|
||||
|
||||
|
||||
<!-- Overview -->
|
||||
{{ if .Old }}
|
||||
<div class="change list-change diff-label">{{ .Old }}</div>
|
||||
<i class="diff-arrow fa fa-long-arrow-right"></i>
|
||||
{{ end }}
|
||||
{{ if .New }}
|
||||
<div class="change list-change diff-label">{{ .New }}</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if .LineStart }}
|
||||
<diff-link-json
|
||||
line-link="{{ .LineStart }}"
|
||||
line-display="{{ .LineStart }}{{ if .LineEnd }} - {{ .LineEnd }}{{ end }}"
|
||||
switch-view="ctrl.getDiff('html')"
|
||||
/>
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Basic Changes -->
|
||||
{{ range .Changes }}
|
||||
<ul class="diff-change-container">
|
||||
{{ template "change" . }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
|
||||
<!-- Basic Summary -->
|
||||
{{ range .Summaries }}
|
||||
{{ template "summary" . }}
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}`
|
||||
|
||||
// tplChange is the template for changes
|
||||
tplChange = `{{ define "change" -}}
|
||||
<li class="diff-change-group">
|
||||
<span class="bullet-position-container">
|
||||
<div class="diff-change-item diff-change-title">{{ getChange .Change }} {{ .Key }}</div>
|
||||
|
||||
<div class="diff-change-item">
|
||||
{{ if .Old }}
|
||||
<div class="change list-change diff-label">{{ .Old }}</div>
|
||||
<i class="diff-arrow fa fa-long-arrow-right"></i>
|
||||
{{ end }}
|
||||
{{ if .New }}
|
||||
<div class="change list-change diff-label">{{ .New }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ if .LineStart }}
|
||||
<diff-link-json
|
||||
line-link="{{ .LineStart }}"
|
||||
line-display="{{ .LineStart }}{{ if .LineEnd }} - {{ .LineEnd }}{{ end }}"
|
||||
switch-view="ctrl.getDiff('html')"
|
||||
/>
|
||||
{{ end }}
|
||||
</span>
|
||||
</li>
|
||||
{{ end }}`
|
||||
|
||||
// tplSummary is for basis summaries
|
||||
tplSummary = `{{ define "summary" -}}
|
||||
<div class="diff-group-name">
|
||||
<i class="diff-circle diff-circle-{{ getChange .Change }} fa fa-circle-o diff-list-circle"></i>
|
||||
|
||||
{{ if .Count }}
|
||||
<strong>{{ .Count }}</strong>
|
||||
{{ end }}
|
||||
|
||||
{{ if .Key }}
|
||||
<strong class="diff-summary-key">{{ .Key }}</strong>
|
||||
{{ getChange .Change }}
|
||||
{{ end }}
|
||||
|
||||
{{ if .LineStart }}
|
||||
<diff-link-json
|
||||
line-link="{{ .LineStart }}"
|
||||
line-display="{{ .LineStart }}{{ if .LineEnd }} - {{ .LineEnd }}{{ end }}"
|
||||
switch-view="ctrl.getDiff('html')"
|
||||
/>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}`
|
||||
)
|
||||
@@ -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" -}}
|
||||
<p id="l{{ .LineNum }}" class="diff-line diff-json-{{ cton .Change }}">
|
||||
<span class="diff-line-number">
|
||||
{{if .LeftLine }}{{ .LeftLine }}{{ end }}
|
||||
</span>
|
||||
<span class="diff-line-number">
|
||||
{{if .RightLine }}{{ .RightLine }}{{ end }}
|
||||
</span>
|
||||
<span class="diff-value diff-indent-{{ .Indent }}" title="{{ .Text }}">
|
||||
{{ .Text }}
|
||||
</span>
|
||||
<span class="diff-line-icon">{{ ctos .Change }}</span>
|
||||
</p>
|
||||
{{ 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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -131,6 +131,7 @@ type SaveDashboardCommand struct {
|
||||
OrgId int64 `json:"-"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
PluginId string `json:"-"`
|
||||
Message string `json:"message"`
|
||||
|
||||
Result *Dashboard
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -25,6 +25,7 @@ func AddMigrations(mg *Migrator) {
|
||||
addAlertMigrations(mg)
|
||||
addAnnotationMig(mg)
|
||||
addTestDataMigrations(mg)
|
||||
addDashboardVersionMigration(mg)
|
||||
}
|
||||
|
||||
func addMigrationLogMigrations(mg *Migrator) {
|
||||
|
||||
Reference in New Issue
Block a user