Commit e6616cc5 by sanchitraizada Committed by GitHub

Merge pull request #10 from walmartlabs/version-control

History and Version Control for Dashboard Updates
parents d1d47b56 b6e46c9e
......@@ -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)
......
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 }}`
)
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/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/metrics"
......@@ -68,17 +69,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
}
......@@ -233,6 +260,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 {
......
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
}
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)
})
})
}
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) {
......
......@@ -15,6 +15,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';
......
......@@ -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: '<dash-import></dash-import>' }
};
......
///<reference path="../../headers/common.d.ts" />
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);
......@@ -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' },
......
......@@ -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});
}
}
......
......@@ -2,6 +2,7 @@ define([
'./panellinks/module',
'./dashlinks/module',
'./annotations/all',
'./annotations/annotations_srv',
'./templating/all',
'./dashboard/all',
'./playlist/all',
......
define([
'./dashboard_ctrl',
'./alerting_srv',
'./audit/audit_srv',
'./dashboardLoaderSrv',
'./dashnav/dashnav',
'./submenu/submenu',
'./saveDashboardAsCtrl',
'./saveDashboardMessageCtrl',
'./shareModalCtrl',
'./shareSnapshotCtrl',
'./dashboard_srv',
......
///<reference path="../../../headers/common.d.ts" />
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);
///<reference path="../../../headers/common.d.ts" />
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);
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;
}
<div ng-controller="AuditLogCtrl">
<div class="tabbed-view-header">
<h2 class="tabbed-view-title">
Changelog
</h2>
<ul class="gf-tabs">
<li class="gf-tabs-item" >
<a class="gf-tabs-link" ng-click="ctrl.mode = 'list';" ng-class="{active: ctrl.mode === 'list'}">
List
</a>
</li>
<li class="gf-tabs-item" ng-show="ctrl.mode === 'compare'">
<span ng-if="ctrl.isOriginalCurrent()" class="active gf-tabs-link">
Version {{ctrl.selected[0]}} <i class="fa fa-arrows-h" /> Current
</span>
<span ng-if="!ctrl.isOriginalCurrent()" class="active gf-tabs-link">
Version {{ctrl.selected[0]}} <i class="fa fa-arrows-h" /> Version {{ctrl.selected[1]}}
</span>
</li>
</ul>
<button class="tabbed-view-close-btn" ng-click="dismiss();">
<i class="fa fa-remove"></i>
</button>
</div>
<div class="tabbed-view-body">
<div ng-if="ctrl.mode === 'list'">
<div ng-if="ctrl.loading">
<i class="fa fa-spinner fa-spin"></i>
<em>Fetching audit log&hellip;</em>
</div>
<div ng-if="!ctrl.loading">
<div class="audit-table gf-form">
<div class="gf-form-group">
<table class="filter-table">
<thead>
<tr>
<th class="width-4"></th>
<th class="width-4">Version</th>
<th class="width-14">Date</th>
<th class="width-10">Updated By</th>
<th class="width-30">Notes</th>
<th></th>
</tr>
</thead>
<tbody>
<tr ng-repeat="revision in ctrl.revisions">
<td bs-tooltip="ctrl.compareRevisionDisabled(revision.checked) ? 'You can only compare 2 versions at a time' : ''">
<gf-form-switch
checked="revision.checked"
on-change="ctrl.compareRevisionStateChanged(revision)"
ng-disabled="ctrl.compareRevisionDisabled(revision.checked)">
</gf-form-switch>
</td>
<td>{{revision.version}}</td>
<td>{{ctrl.formatDate(revision.created)}}</td>
<td>{{revision.createdBy}}</td>
<td>{{revision.message}}</td>
<td class="text-right">
<a class="btn btn-inverse btn-small" ng-show="revision.version !== ctrl.dashboard.version" ng-click="ctrl.restore(revision.version)">
<i class="fa fa-rotate-right"></i>&nbsp;&nbsp;Restore
</a>
<a class="btn btn-outline-disabled btn-small" ng-show="revision.version === ctrl.dashboard.version">
<i class="fa fa-check"></i>&nbsp;&nbsp;Current
</a>
</td>
</tr>
</tbody>
</table>
<div ng-if="ctrl.appending">
<i class="fa fa-spinner fa-spin"></i>
<em>Fetching more entries&hellip;</em>
</div>
<div class="gf-form-group" ng-show="ctrl.mode === 'list'">
<div class="gf-form-button-row">
<a type="button"
class="btn gf-form-button btn-primary"
ng-if="ctrl.revisions.length > 1"
ng-class="{disabled: !ctrl.isComparable()}"
ng-click="ctrl.getDiff(ctrl.diff)"
bs-tooltip="ctrl.isComparable() ? '' : 'Select 2 versions to start comparing'">
<i class="fa fa-code-fork" ></i>&nbsp;&nbsp;Compare versions
</a>
<a type="button"
class="btn gf-form-button btn-inverse"
ng-if="ctrl.revisions.length >= ctrl.limit"
ng-click="ctrl.addToLog()"
ng-class="{disabled: ctrl.isLastPage()}"
ng-disabled="ctrl.isLastPage()">
Show more versions
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="audit-log" ng-if="ctrl.mode === 'compare'">
<div class="page-container">
<div class="page-body">
<aside class="page-sidebar">
<section class="page-sidebar-section">
<ul class="ui-list">
<li><a ng-class="{active: ctrl.diff === 'basic'}" ng-click="ctrl.getDiff('basic')" href="">Change Summary</a></li>
<li><a ng-class="{active: ctrl.diff === 'html'}" ng-click="ctrl.getDiff('html')" href="">JSON Code View</a></li>
</ul>
</section>
</aside>
<div class="tab-content page-content-with-sidebar">
<div ng-if="ctrl.loading">
<i class="fa fa-spinner fa-spin"></i>
<em>Fetching changes&hellip;</em>
</div>
<div ng-if="!ctrl.loading" ng-init="new = ctrl.selected[0]; original = ctrl.selected[1]">
<a type="button"
class="btn gf-form-button btn-primary diff-restore-btn"
ng-click="ctrl.restore(new)"
ng-if="ctrl.isOriginalCurrent()">
<i class="fa fa-rotate-right" ></i>&nbsp;&nbsp;Restore to version {{new}}
</a>
<h4>
Comparing Version {{ctrl.selected[0]}}
<i class="fa fa-arrows-h" />
Version {{ctrl.selected[1]}}
<cite class="muted" ng-if="ctrl.isOriginalCurrent()">(Current)</cite>
</h4>
<section ng-if="ctrl.diff === 'basic'">
<p class="small muted">
<strong>Version {{new}}</strong> updated by
<span>{{ctrl.getMeta(new, 'createdBy')}} </span>
<span>{{ctrl.formatBasicDate(ctrl.getMeta(new, 'created'))}}</span>
<span> - {{ctrl.getMeta(new, 'message')}}</span>
</p>
<p class="small muted">
<strong>Version {{original}}</strong> updated by
<span>{{ctrl.getMeta(original, 'createdBy')}} </span>
<span>{{ctrl.formatBasicDate(ctrl.getMeta(original, 'created'))}}</span>
<span> - {{ctrl.getMeta(original, 'message')}}</span>
</p>
</section>
<div id="delta" diff-delta>
<div class="delta-basic" ng-show="ctrl.diff === 'basic'" compile="ctrl.delta.basic" />
<div class="delta-html" ng-show="ctrl.diff === 'html'" compile="ctrl.delta.html" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<a class="change list-linenum diff-linenum btn btn-inverse btn-small" ng-click="ctrl.goToLine(link)">
Line {{ line }}
</a>
......@@ -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);
......
......@@ -56,7 +56,8 @@
</ul>
</li>
<li ng-show="::dashboardMeta.canSave">
<a ng-click="saveDashboard()" bs-tooltip="'Save dashboard <br> CTRL+S'" data-placement="bottom"><i class="fa fa-save"></i></a>
<a ng-show="dashboard.version === 0" ng-click="saveDashboard()" bs-tooltip="'Save dashboard <br> CTRL+S'" data-placement="bottom"><i class="fa fa-save"></i></a>
<a ng-show="dashboard.version > 0" ng-click="saveDashboard()" bs-tooltip="'Save to changelog <br> CTRL+S'" data-placement="bottom"><i class="fa fa-save"></i></a>
</li>
<li ng-if="dashboard.snapshot.originalUrl">
<a ng-href="{{dashboard.snapshot.originalUrl}}" bs-tooltip="'Open original dashboard'" data-placement="bottom"><i class="fa fa-link"></i></a>
......@@ -66,6 +67,7 @@
<ul class="dropdown-menu">
<li ng-if="dashboardMeta.canEdit"><a class="pointer" ng-click="openEditView('settings');">Settings</a></li>
<li ng-if="dashboardMeta.canEdit"><a class="pointer" ng-click="openEditView('annotations');">Annotations</a></li>
<li ng-if="dashboardMeta.canEdit && dashboard.version > 0 && !dashboardMeta.isHome"><a class="pointer" ng-click="openEditView('audit');">Changelog</a></li>
<li ng-if="dashboardMeta.canEdit"><a class="pointer" ng-click="openEditView('templating');">Templating</a></li>
<li ng-if="dashboardMeta.canEdit"><a class="pointer" ng-click="viewJson();">View JSON</a></li>
<li ng-if="contextSrv.isEditor && !dashboard.editable"><a class="pointer" ng-click="makeEditable();">Make Editable</a></li>
......
<div class="modal-body" ng-controller="SaveDashboardMessageCtrl" ng-init="init();">
<div class="modal-header">
<h2 class="modal-header-title">
<i class="fa fa-save"></i>
<span class="p-l-1">Save to changelog</span>
</h2>
<a class="modal-header-close" ng-click="dismiss();">
<i class="fa fa-remove"></i>
</a>
</div>
<form name="saveMessage" ng-submit="saveVersion(saveMessage.$valid)" class="modal-content" novalidate>
<h6 class="text-center">Add a note to describe the changes in this version</h6>
<div class="p-t-2">
<div class="gf-form">
<label class="gf-form-hint">
<input
type="text"
name="message"
class="gf-form-input"
placeholder="Updates to &hellip;"
give-focus="true"
ng-model="clone.message"
ng-model-options="{allowInvalid: true}"
ng-keydown="keyDown($event)"
ng-maxlength="clone.max"
autocomplete="off"
required />
<small class="gf-form-hint-text muted" ng-cloak>
<span ng-class="{'text-error': saveMessage.message.$invalid && saveMessage.message.$dirty }">
{{clone.message.length || 0}}
</span>
/ {{clone.max}} characters
</small>
</label>
</div>
</div>
<div class="gf-form-button-row text-center">
<button type="submit" class="btn btn-success" ng-disabled="saveMessage.$invalid">
Save to changelog
</button>
<button class="btn btn-inverse" ng-click="dismiss();">Cancel</button>
</div>
</form>
</div>
......@@ -6,7 +6,7 @@ function (angular) {
var module = angular.module('grafana.controllers');
module.controller('SaveDashboardAsCtrl', function($scope, backendSrv, $location) {
module.controller('SaveDashboardAsCtrl', function($scope, dashboardSrv) {
$scope.init = function() {
$scope.clone.id = null;
......@@ -24,17 +24,6 @@ function (angular) {
delete $scope.clone.autoUpdate;
};
function saveDashboard(options) {
return backendSrv.saveDashboard($scope.clone, options).then(function(result) {
$scope.appEvent('alert-success', ['Dashboard saved', 'Saved as ' + $scope.clone.title]);
$location.url('/dashboard/db/' + result.slug);
$scope.appEvent('dashboard-saved', $scope.clone);
$scope.dismiss();
});
}
$scope.keyDown = function (evt) {
if (evt.keyCode === 13) {
$scope.saveClone();
......@@ -42,22 +31,8 @@ function (angular) {
};
$scope.saveClone = function() {
saveDashboard({overwrite: false}).then(null, function(err) {
if (err.data && err.data.status === "name-exists") {
err.isHandled = true;
$scope.appEvent('confirm-modal', {
title: 'Conflict',
text: 'Dashboard with the same name exists.',
text2: 'Would you still like to save this dashboard?',
yesText: "Save & Overwrite",
icon: "fa-warning",
onConfirm: function() {
saveDashboard({overwrite: true});
}
});
}
});
return dashboardSrv.save($scope.clone, {overwrite: false})
.then(function() { $scope.dismiss(); });
};
});
......
define([
'angular',
],
function (angular) {
'use strict';
var module = angular.module('grafana.controllers');
module.controller('SaveDashboardMessageCtrl', function($scope, dashboardSrv) {
$scope.init = function() {
$scope.clone.message = '';
$scope.clone.max = 64;
};
function saveDashboard(options) {
options.message = $scope.clone.message;
return dashboardSrv.save($scope.clone, options)
.then(function() { $scope.dismiss(); });
}
$scope.saveVersion = function(isValid) {
if (!isValid) { return; }
saveDashboard({overwrite: false});
};
});
});
import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common';
import helpers from 'test/specs/helpers';
import AuditSrv from '../audit/audit_srv';
import { versions, compare, restore } from 'test/mocks/audit-mocks';
describe('auditSrv', function() {
var ctx = new helpers.ServiceTestContext();
var versionsResponse = versions();
var compareResponse = compare();
var restoreResponse = restore;
beforeEach(angularMocks.module('grafana.core'));
beforeEach(angularMocks.module('grafana.services'));
beforeEach(angularMocks.inject(function($httpBackend) {
ctx.$httpBackend = $httpBackend;
$httpBackend.whenRoute('GET', 'api/dashboards/db/:id/versions').respond(versionsResponse);
$httpBackend.whenRoute('GET', 'api/dashboards/db/:id/compare/:original...:new').respond(compareResponse);
$httpBackend.whenRoute('POST', 'api/dashboards/db/:id/restore')
.respond(function(method, url, data, headers, params) {
const parsedData = JSON.parse(data);
return [200, restoreResponse(parsedData.version)];
});
}));
beforeEach(ctx.createService('auditSrv'));
describe('getAuditLog', function() {
it('should return a versions array for the given dashboard id', function(done) {
ctx.service.getAuditLog({ id: 1 }).then(function(versions) {
expect(versions).to.eql(versionsResponse);
done();
});
ctx.$httpBackend.flush();
});
it('should return an empty array when not given an id', function(done) {
ctx.service.getAuditLog({ }).then(function(versions) {
expect(versions).to.eql([]);
done();
});
ctx.$httpBackend.flush();
});
it('should return an empty array when not given a dashboard', function(done) {
ctx.service.getAuditLog().then(function(versions) {
expect(versions).to.eql([]);
done();
});
ctx.$httpBackend.flush();
});
});
describe('compareVersions', function() {
it('should return a diff object for the given dashboard revisions', function(done) {
var compare = { original: 6, new: 4 };
ctx.service.compareVersions({ id: 1 }, compare).then(function(response) {
expect(response).to.eql(compareResponse);
done();
});
ctx.$httpBackend.flush();
});
it('should return an empty object when not given an id', function(done) {
var compare = { original: 6, new: 4 };
ctx.service.compareVersions({ }, compare).then(function(response) {
expect(response).to.eql({});
done();
});
ctx.$httpBackend.flush();
});
});
describe('restoreDashboard', function() {
it('should return a success response given valid parameters', function(done) {
var version = 6;
ctx.service.restoreDashboard({ id: 1 }, version).then(function(response) {
expect(response).to.eql(restoreResponse(version));
done();
});
ctx.$httpBackend.flush();
});
it('should return an empty object when not given an id', function(done) {
ctx.service.restoreDashboard({}, 6).then(function(response) {
expect(response).to.eql({});
done();
});
ctx.$httpBackend.flush();
});
});
});
......@@ -7,7 +7,7 @@ function(angular, _) {
var module = angular.module('grafana.services');
module.service('unsavedChangesSrv', function($rootScope, $q, $location, $timeout, contextSrv, $window) {
module.service('unsavedChangesSrv', function($rootScope, $q, $location, $timeout, contextSrv, dashboardSrv, $window) {
function Tracker(dashboard, scope, originalCopyDelay) {
var self = this;
......@@ -136,28 +136,28 @@ function(angular, _) {
p.open_modal = function() {
var tracker = this;
var dashboard = this.current;
var modalScope = this.scope.$new();
var clone = dashboard.getSaveModelClone();
modalScope.clone = clone;
modalScope.ignore = function() {
tracker.original = null;
tracker.goto_next();
};
modalScope.save = function() {
var cancel = $rootScope.$on('dashboard-saved', function() {
cancel();
$timeout(function() {
tracker.goto_next();
});
var cancel = $rootScope.$on('dashboard-saved', function() {
cancel();
$timeout(function() {
tracker.goto_next();
});
$rootScope.$emit('save-dashboard');
};
});
$rootScope.appEvent('show-modal', {
src: 'public/app/partials/unsaved-changes.html',
modalClass: 'confirm-modal',
scope: modalScope,
modalClass: 'modal--narrow'
});
};
......
<div class="modal-body">
<div class="modal-body" ng-controller="SaveDashboardMessageCtrl" ng-init="init();">
<div class="modal-header">
<h2 class="modal-header-title">
<i class="fa fa-exclamation"></i>
<i class="fa fa-exclamation"></i>
<span class="p-l-1">Unsaved changes</span>
</h2>
......@@ -10,18 +10,45 @@
</a>
</div>
<div class="modal-content text-center">
<div class="confirm-modal-text">
What do you want to do?
<form name="saveMessage" ng-submit="saveVersion(saveMessage.$valid)" class="modal-content" novalidate>
<h6 class="text-center">
You're leaving without saving your changes, are you sure you want to leave? To save, add a small note to describe the changes in this version.
</h6>
<div class="p-t-2">
<div class="gf-form">
<label class="gf-form-hint">
<input
type="text"
name="message"
class="gf-form-input"
placeholder="Updates to &hellip;"
give-focus="true"
ng-model="clone.message"
ng-model-options="{allowInvalid: true}"
ng-keydown="keyDown($event)"
ng-maxlength="clone.max"
autocomplete="off"
required />
<small class="gf-form-hint-text muted" ng-cloak>
<span ng-class="{'text-error': saveMessage.message.$invalid && saveMessage.message.$dirty }">
{{clone.message.length || 0}}
</span>
/ {{clone.max}} characters
</small>
</label>
</div>
</div>
<div class="confirm-modal-buttons">
<button type="button" class="btn btn-inverse" ng-click="dismiss()">Cancel</button>
<button type="button" class="btn btn-danger" ng-click="ignore();dismiss()">Ignore</button>
<button type="button" class="btn btn-success" ng-click="save();dismiss();">Save</button>
<div class="gf-form-button-row text-center">
<button type="submit" class="btn btn-success" ng-disabled="saveMessage.$invalid">
Save changes
</button>
<button type="button" class="btn btn-danger" ng-click="ignore();dismiss()">
Discard changes and leave
</button>
<button class="btn btn-inverse" ng-click="dismiss();">Cancel</button>
</div>
</div>
</form>
</div>
......@@ -82,6 +82,7 @@
@import "pages/playlist";
@import "pages/admin";
@import "pages/alerting";
@import "pages/audit";
@import "pages/plugins";
@import "pages/signup";
@import "pages/styleguide";
......
......@@ -280,3 +280,28 @@ $card-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, .1), 1px 1px 0 0 rgba(0, 0, 0, .3)
$footer-link-color: $gray-1;
$footer-link-hover: $gray-4;
// Changelog and diff
// -------------------------
$diff-label-bg: $dark-2;
$diff-label-fg: $white;
$diff-switch-bg: $dark-5;
$diff-switch-disabled: $gray-1;
$diff-group-bg: $dark-4;
$diff-arrow-color: $white;
$diff-json-bg: $dark-4;
$diff-json-fg: $gray-5;
$diff-json-added: #2f5f40;
$diff-json-deleted: #862d2d;
$diff-json-old: #5a372a;
$diff-json-new: #664e33;
$diff-json-changed-fg: $gray-5;
$diff-json-changed-num: $text-muted;
$diff-json-icon: $gray-7;
......@@ -303,3 +303,28 @@ $card-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, .1), 1px 1px 0 0 rgba(0, 0, 0, .1)
// footer
$footer-link-color: $gray-3;
$footer-link-hover: $dark-5;
// Changelog and diff
// -------------------------
$diff-label-bg: $gray-5;
$diff-label-fg: $gray-2;
$diff-switch-bg: $gray-7;
$diff-switch-disabled: $gray-5;
$diff-arrow-color: $dark-3;
$diff-group-bg: $gray-7;
$diff-json-bg: $gray-5;
$diff-json-fg: $gray-2;
$diff-json-added: lighten(desaturate($green, 30%), 10%);
$diff-json-deleted: desaturate($red, 35%);
$diff-json-old: #5a372a;
$diff-json-new: #664e33;
$diff-json-changed-fg: $gray-6;
$diff-json-changed-num: $gray-4;
$diff-json-icon: $gray-4;
......@@ -118,5 +118,16 @@
.btn-outline-danger {
@include button-outline-variant($btn-danger-bg);
}
.btn-outline-disabled {
@include button-outline-variant($gray-1);
@include box-shadow(none);
cursor: default;
&:hover, &:active, &:active:hover, &:focus {
color: $gray-1;
background-color: transparent;
border-color: $gray-1;
}
}
......@@ -163,6 +163,16 @@ $gf-form-margin: 0.25rem;
}
}
.gf-form-hint {
width: 100%;
}
.gf-form-hint-text {
display: block;
text-align: right;
padding-top: 0.5em;
}
.gf-form-select-wrapper {
margin-right: $gf-form-margin;
position: relative;
......
// Audit Table
.audit-table {
// .gf-form overrides
.gf-form-label { display: none; }
.gf-form-switch {
margin-bottom: 0;
input + label {
height: 3.6rem;
width: 110%;
}
input + label::before, input + label::after {
background-color: $diff-switch-bg;
background-image: none;
border: 0;
height: 50px;
line-height: 3.7rem;
}
}
gf-form-switch[disabled] {
.gf-form-switch input + label {
&::before {
color: $diff-switch-disabled;
text-shadow: none;
}
}
}
// .filter-table overrides
.filter-table {
tr {
border-bottom: 3px solid $page-bg;
}
thead tr {
border-width: 3px;
}
$date-padding: 1em;
td {
padding: 0;
&:nth-child(2) {
border-left: 5px solid $page-bg;
padding-left: 1.5em;
}
&:nth-child(3) { padding-left: $date-padding; }
&:last-child { padding-right: 1.5em; }
}
th:nth-child(2) { padding-left: 0.5em; }
th:nth-child(3) { padding-left: $date-padding; }
}
}
// Diff View
.audit-log {
h4 {
margin-bottom: 0.75em;
}
.page-container {
padding: 0;
background: none;
}
.page-sidebar {
margin-left: 0;
margin-right: 3em;
}
.small.muted { margin-bottom: 0.25em; }
.ui-list > li {
margin-bottom: 1.5em;
& > a { padding-left: 15px; }
& > a.active { @include left-brand-border-gradient(); }
}
}
// Actual Diff
#delta {
margin: 2em 0;
}
// JSON
@for $i from 0 through 16 {
.diff-indent-#{$i} {
padding-left: $i * 1.5rem;
margin-left: 10px;
}
}
.delta-html {
background: $diff-json-bg;
padding-top: 5px;
padding-bottom: 5px;
user-select: none;
}
.diff-line {
color: $diff-json-fg;
font-family: $font-family-monospace;
font-size: $font-size-sm;
line-height: 2;
margin-bottom: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
position: relative;
&:before, &:after {
}
&:after { left: -40px; }
}
.diff-line-number {
color: $text-muted;
display: inline-block;
font-size: $font-size-xs;
line-height: 2.3;
text-align: right;
width: 30px;
}
.diff-line-number-hide { visibility: hidden; }
.diff-line-icon {
color: $diff-json-icon;
font-size: $font-size-xs;
float: right;
position: relative;
top: 2px;
right: 10px;
}
.diff-json-new, .diff-json-old {
color: $diff-json-changed-fg;
& .diff-line-number { color: $diff-json-changed-num; }
}
.diff-json-new { background-color: $diff-json-new; }
.diff-json-old { background-color: $diff-json-old; }
.diff-json-added { background-color: $diff-json-added; }
.diff-json-deleted { background-color: $diff-json-deleted; }
.diff-value {
user-select: all;
}
// Basic
.diff-circle { margin-right: .5em; }
.diff-circle-changed { color: #f59433; }
.diff-circle-added { color: #29D761; }
.diff-circle-deleted { color: #fd474a; }
.diff-item-added, .diff-item-deleted { list-style: none; }
.diff-restore-btn {
float: right;
}
.diff-group {
background: $diff-group-bg;
font-size: 16px;
font-style: normal;
padding: 10px 15px;
margin: 1rem 0;
& .diff-group { padding: 0 5px; }
}
.diff-group-name {
display: inline-block;
width: 100%;
font-size: 16px;
padding-left: 1.75em;
margin: 0 0 14px 0;
}
.diff-summary-key {
padding-left: .25em;
}
.diff-list {
padding-left: 40px;
& .diff-list { padding-left: 0; }
}
.diff-item {
color: $gray-2;
line-height: 2.5;
& > div { display: inline; }
}
.diff-item-changeset {
list-style: none;
}
.diff-label {
background-color: $diff-label-bg;
border-radius: 3px;
color: $diff-label-fg;
display: inline;
font-size: .95rem;
margin: 0 5px;
padding: 3px 8px;
}
.diff-linenum {
float: right;
}
.diff-arrow {
color: $diff-arrow-color;
}
.diff-block {
width: 100%;
display: inline-block;
}
.diff-block-title {
font-size: 16px;
display: inline-block;
}
.diff-title {
font-size: 16px;
}
.diff-change-container {
margin: 0 0;
padding-left: 3em;
padding-right: 0;
}
.diff-change-group {
width: 100%;
color: rgba(223,224,225, .6);
margin-bottom: 14px;
}
.diff-change-item {
display: inline-block;
}
.diff-change-title {
font-size: 16px;
}
.bullet-position-container {
position: relative;
left: -6px;
}
.diff-list-circle {
font-size: 10px;
}
......@@ -255,4 +255,3 @@ div.flot-text {
padding: 0.5rem .5rem .2rem .5rem;
}
}
define([],
function() {
'use strict';
return {
versions: function() {
return [{
id: 4,
dashboardId: 1,
parentVersion: 3,
restoredFrom: 0,
version: 4,
created: '2017-02-22T17:43:01-08:00',
createdBy: 'admin',
message: '',
},
{
id: 3,
dashboardId: 1,
parentVersion: 1,
restoredFrom: 1,
version: 3,
created: '2017-02-22T17:43:01-08:00',
createdBy: 'admin',
message: '',
},
{
id: 2,
dashboardId: 1,
parentVersion: 0,
restoredFrom: -1,
version: 2,
created: '2017-02-22T17:29:52-08:00',
createdBy: 'admin',
message: '',
},
{
id: 1,
dashboardId: 1,
parentVersion: 0,
restoredFrom: -1,
slug: 'audit-dashboard',
version: 1,
created: '2017-02-22T17:06:37-08:00',
createdBy: 'admin',
message: '',
}];
},
compare: function(type) {
return type === 'basic' ? '<div></div>' : '<pre><code></code></pre>';
},
restore: function(version, restoredFrom) {
return {
dashboard: {
meta: {
type: 'db',
canSave: true,
canEdit: true,
canStar: true,
slug: 'audit-dashboard',
expires: '0001-01-01T00:00:00Z',
created: '2017-02-21T18:40:45-08:00',
updated: '2017-04-11T21:31:22.59219665-07:00',
updatedBy: 'admin',
createdBy: 'admin',
version: version,
},
dashboard: {
annotations: {
list: []
},
description: 'A random dashboard for implementing the audit log',
editable: true,
gnetId: null,
graphTooltip: 0,
hideControls: false,
id: 1,
links: [],
restoredFrom: restoredFrom,
rows: [{
collapse: false,
height: '250px',
panels: [{
aliasColors: {},
bars: false,
datasource: null,
fill: 1,
id: 1,
legend: {
avg: false,
current: false,
max: false,
min: false,
show: true,
total: false,
values: false
},
lines: true,
linewidth: 1,
nullPointMode: "null",
percentage: false,
pointradius: 5,
points: false,
renderer: 'flot',
seriesOverrides: [],
span: 12,
stack: false,
steppedLine: false,
targets: [{}],
thresholds: [],
timeFrom: null,
timeShift: null,
title: 'Panel Title',
tooltip: {
shared: true,
sort: 0,
value_type: 'individual'
},
type: 'graph',
xaxis: {
mode: 'time',
name: null,
show: true,
values: []
},
yaxes: [{
format: 'short',
label: null,
logBase: 1,
max: null,
min: null,
show: true
}, {
format: 'short',
label: null,
logBase: 1,
max: null,
min: null,
show: true
}]
}],
repeat: null,
repeatIteration: null,
repeatRowId: null,
showTitle: false,
title: 'Dashboard Row',
titleSize: 'h6'
}
],
schemaVersion: 14,
style: 'dark',
tags: [
'development'
],
templating: {
'list': []
},
time: {
from: 'now-6h',
to: 'now'
},
timepicker: {
refresh_intervals: [
'5s',
'10s',
'30s',
'1m',
'5m',
'15m',
'30m',
'1h',
'2h',
'1d',
],
time_options: [
'5m',
'15m',
'1h',
'6h',
'12h',
'24h',
'2d',
'7d',
'30d'
]
},
timezone: 'utc',
title: 'Audit Dashboard',
version: version,
}
},
message: 'Dashboard restored to version ' + version,
version: version
};
},
};
});
Copyright (c) 2012-2016 The go-diff Authors. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
// Copyright (c) 2012-2016 The go-diff authors. All rights reserved.
// https://github.com/sergi/go-diff
// See the included LICENSE file for license details.
//
// go-diff is a Go implementation of Google's Diff, Match, and Patch library
// Original library is Copyright (c) 2006 Google Inc.
// http://code.google.com/p/google-diff-match-patch/
// Package diffmatchpatch offers robust algorithms to perform the operations required for synchronizing plain text.
package diffmatchpatch
import (
"time"
)
// DiffMatchPatch holds the configuration for diff-match-patch operations.
type DiffMatchPatch struct {
// Number of seconds to map a diff before giving up (0 for infinity).
DiffTimeout time.Duration
// Cost of an empty edit operation in terms of edit characters.
DiffEditCost int
// How far to search for a match (0 = exact location, 1000+ = broad match). A match this many characters away from the expected location will add 1.0 to the score (0.0 is a perfect match).
MatchDistance int
// When deleting a large block of text (over ~64 characters), how close do the contents have to be to match the expected contents. (0.0 = perfection, 1.0 = very loose). Note that MatchThreshold controls how closely the end points of a delete need to match.
PatchDeleteThreshold float64
// Chunk size for context length.
PatchMargin int
// The number of bits in an int.
MatchMaxBits int
// At what point is no match declared (0.0 = perfection, 1.0 = very loose).
MatchThreshold float64
}
// New creates a new DiffMatchPatch object with default parameters.
func New() *DiffMatchPatch {
// Defaults.
return &DiffMatchPatch{
DiffTimeout: time.Second,
DiffEditCost: 4,
MatchThreshold: 0.5,
MatchDistance: 1000,
PatchDeleteThreshold: 0.5,
PatchMargin: 4,
MatchMaxBits: 32,
}
}
// Copyright (c) 2012-2016 The go-diff authors. All rights reserved.
// https://github.com/sergi/go-diff
// See the included LICENSE file for license details.
//
// go-diff is a Go implementation of Google's Diff, Match, and Patch library
// Original library is Copyright (c) 2006 Google Inc.
// http://code.google.com/p/google-diff-match-patch/
package diffmatchpatch
import (
"math"
)
// MatchMain locates the best instance of 'pattern' in 'text' near 'loc'.
// Returns -1 if no match found.
func (dmp *DiffMatchPatch) MatchMain(text, pattern string, loc int) int {
// Check for null inputs not needed since null can't be passed in C#.
loc = int(math.Max(0, math.Min(float64(loc), float64(len(text)))))
if text == pattern {
// Shortcut (potentially not guaranteed by the algorithm)
return 0
} else if len(text) == 0 {
// Nothing to match.
return -1
} else if loc+len(pattern) <= len(text) && text[loc:loc+len(pattern)] == pattern {
// Perfect match at the perfect spot! (Includes case of null pattern)
return loc
}
// Do a fuzzy compare.
return dmp.MatchBitap(text, pattern, loc)
}
// MatchBitap locates the best instance of 'pattern' in 'text' near 'loc' using the Bitap algorithm.
// Returns -1 if no match was found.
func (dmp *DiffMatchPatch) MatchBitap(text, pattern string, loc int) int {
// Initialise the alphabet.
s := dmp.MatchAlphabet(pattern)
// Highest score beyond which we give up.
scoreThreshold := dmp.MatchThreshold
// Is there a nearby exact match? (speedup)
bestLoc := indexOf(text, pattern, loc)
if bestLoc != -1 {
scoreThreshold = math.Min(dmp.matchBitapScore(0, bestLoc, loc,
pattern), scoreThreshold)
// What about in the other direction? (speedup)
bestLoc = lastIndexOf(text, pattern, loc+len(pattern))
if bestLoc != -1 {
scoreThreshold = math.Min(dmp.matchBitapScore(0, bestLoc, loc,
pattern), scoreThreshold)
}
}
// Initialise the bit arrays.
matchmask := 1 << uint((len(pattern) - 1))
bestLoc = -1
var binMin, binMid int
binMax := len(pattern) + len(text)
lastRd := []int{}
for d := 0; d < len(pattern); d++ {
// Scan for the best match; each iteration allows for one more error. Run a binary search to determine how far from 'loc' we can stray at this error level.
binMin = 0
binMid = binMax
for binMin < binMid {
if dmp.matchBitapScore(d, loc+binMid, loc, pattern) <= scoreThreshold {
binMin = binMid
} else {
binMax = binMid
}
binMid = (binMax-binMin)/2 + binMin
}
// Use the result from this iteration as the maximum for the next.
binMax = binMid
start := int(math.Max(1, float64(loc-binMid+1)))
finish := int(math.Min(float64(loc+binMid), float64(len(text))) + float64(len(pattern)))
rd := make([]int, finish+2)
rd[finish+1] = (1 << uint(d)) - 1
for j := finish; j >= start; j-- {
var charMatch int
if len(text) <= j-1 {
// Out of range.
charMatch = 0
} else if _, ok := s[text[j-1]]; !ok {
charMatch = 0
} else {
charMatch = s[text[j-1]]
}
if d == 0 {
// First pass: exact match.
rd[j] = ((rd[j+1] << 1) | 1) & charMatch
} else {
// Subsequent passes: fuzzy match.
rd[j] = ((rd[j+1]<<1)|1)&charMatch | (((lastRd[j+1] | lastRd[j]) << 1) | 1) | lastRd[j+1]
}
if (rd[j] & matchmask) != 0 {
score := dmp.matchBitapScore(d, j-1, loc, pattern)
// This match will almost certainly be better than any existing match. But check anyway.
if score <= scoreThreshold {
// Told you so.
scoreThreshold = score
bestLoc = j - 1
if bestLoc > loc {
// When passing loc, don't exceed our current distance from loc.
start = int(math.Max(1, float64(2*loc-bestLoc)))
} else {
// Already passed loc, downhill from here on in.
break
}
}
}
}
if dmp.matchBitapScore(d+1, loc, loc, pattern) > scoreThreshold {
// No hope for a (better) match at greater error levels.
break
}
lastRd = rd
}
return bestLoc
}
// matchBitapScore computes and returns the score for a match with e errors and x location.
func (dmp *DiffMatchPatch) matchBitapScore(e, x, loc int, pattern string) float64 {
accuracy := float64(e) / float64(len(pattern))
proximity := math.Abs(float64(loc - x))
if dmp.MatchDistance == 0 {
// Dodge divide by zero error.
if proximity == 0 {
return accuracy
}
return 1.0
}
return accuracy + (proximity / float64(dmp.MatchDistance))
}
// MatchAlphabet initialises the alphabet for the Bitap algorithm.
func (dmp *DiffMatchPatch) MatchAlphabet(pattern string) map[byte]int {
s := map[byte]int{}
charPattern := []byte(pattern)
for _, c := range charPattern {
_, ok := s[c]
if !ok {
s[c] = 0
}
}
i := 0
for _, c := range charPattern {
value := s[c] | int(uint(1)<<uint((len(pattern)-i-1)))
s[c] = value
i++
}
return s
}
// Copyright (c) 2012-2016 The go-diff authors. All rights reserved.
// https://github.com/sergi/go-diff
// See the included LICENSE file for license details.
//
// go-diff is a Go implementation of Google's Diff, Match, and Patch library
// Original library is Copyright (c) 2006 Google Inc.
// http://code.google.com/p/google-diff-match-patch/
package diffmatchpatch
func min(x, y int) int {
if x < y {
return x
}
return y
}
func max(x, y int) int {
if x > y {
return x
}
return y
}
// Copyright (c) 2012-2016 The go-diff authors. All rights reserved.
// https://github.com/sergi/go-diff
// See the included LICENSE file for license details.
//
// go-diff is a Go implementation of Google's Diff, Match, and Patch library
// Original library is Copyright (c) 2006 Google Inc.
// http://code.google.com/p/google-diff-match-patch/
package diffmatchpatch
import (
"strings"
"unicode/utf8"
)
// unescaper unescapes selected chars for compatibility with JavaScript's encodeURI.
// In speed critical applications this could be dropped since the receiving application will certainly decode these fine. Note that this function is case-sensitive. Thus "%3F" would not be unescaped. But this is ok because it is only called with the output of HttpUtility.UrlEncode which returns lowercase hex. Example: "%3f" -> "?", "%24" -> "$", etc.
var unescaper = strings.NewReplacer(
"%21", "!", "%7E", "~", "%27", "'",
"%28", "(", "%29", ")", "%3B", ";",
"%2F", "/", "%3F", "?", "%3A", ":",
"%40", "@", "%26", "&", "%3D", "=",
"%2B", "+", "%24", "$", "%2C", ",", "%23", "#", "%2A", "*")
// indexOf returns the first index of pattern in str, starting at str[i].
func indexOf(str string, pattern string, i int) int {
if i > len(str)-1 {
return -1
}
if i <= 0 {
return strings.Index(str, pattern)
}
ind := strings.Index(str[i:], pattern)
if ind == -1 {
return -1
}
return ind + i
}
// lastIndexOf returns the last index of pattern in str, starting at str[i].
func lastIndexOf(str string, pattern string, i int) int {
if i < 0 {
return -1
}
if i >= len(str) {
return strings.LastIndex(str, pattern)
}
_, size := utf8.DecodeRuneInString(str[i:])
return strings.LastIndex(str[:i+size], pattern)
}
// runesIndexOf returns the index of pattern in target, starting at target[i].
func runesIndexOf(target, pattern []rune, i int) int {
if i > len(target)-1 {
return -1
}
if i <= 0 {
return runesIndex(target, pattern)
}
ind := runesIndex(target[i:], pattern)
if ind == -1 {
return -1
}
return ind + i
}
func runesEqual(r1, r2 []rune) bool {
if len(r1) != len(r2) {
return false
}
for i, c := range r1 {
if c != r2[i] {
return false
}
}
return true
}
// runesIndex is the equivalent of strings.Index for rune slices.
func runesIndex(r1, r2 []rune) int {
last := len(r1) - len(r2)
for i := 0; i <= last; i++ {
if runesEqual(r1[i:i+len(r2)], r2) {
return i
}
}
return -1
}
The MIT License (MIT)
Copyright (c) 2015 Iwasaki Yudai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
============================================================================
This repository is build with following third party libraries. Thank you!
## go-diff - https://github.com/sergi/go-diff
Copyright (c) 2012 Sergi Mansilla <sergi.mansilla@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
## golcs - https://github.com/yudai/golcs
The MIT License (MIT)
Copyright (c) 2015 Iwasaki Yudai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
## cli.go - https://github.com/urfave/cli
Copyright (C) 2013 Jeremy Saenz
All Rights Reserved.
MIT LICENSE
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## ginkgo - https://github.com/onsi/ginkgo
Copyright (c) 2013-2014 Onsi Fakhouri
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
# gomega - https://github.com/onsi/gomega
Copyright (c) 2013-2014 Onsi Fakhouri
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
test:
if [ `go fmt $(go list ./... | grep -v /vendor/) | wc -l` -gt 0 ]; then echo "go fmt error"; exit 1; fi
# Go JSON Diff (and Patch)
[![Wercker](https://app.wercker.com/status/00d70daaf40ce277fd4f10290f097b9d/s/master)][wercker]
[![GoDoc](https://godoc.org/github.com/yudai/gojsondiff?status.svg)][godoc]
[![MIT License](http://img.shields.io/badge/license-MIT-blue.svg)][license]
[wercker]: https://app.wercker.com/project/bykey/00d70daaf40ce277fd4f10290f097b9d
[godoc]: https://godoc.org/github.com/yudai/gojsondiff
[license]: https://github.com/yudai/gojsondiff/blob/master/LICENSE
## How to use
### Installation
```sh
go get github.com/yudai/gojsondiff
```
### Comparing two JSON strings
See `jd/main.go` for how to use this library.
## CLI tool
This repository contains a package that you can use as a CLI tool.
### Installation
```sh
go get github.com/yudai/gojsondiff/jd
```
### Usage
#### Diff
Just give two json files to the `jd` command:
```sh
jd one.json another.json
```
Outputs would be something like:
```diff
{
"arr": [
0: "arr0",
1: 21,
2: {
"num": 1,
- "str": "pek3f"
+ "str": "changed"
},
3: [
0: 0,
- 1: "1"
+ 1: "changed"
]
],
"bool": true,
"num_float": 39.39,
"num_int": 13,
"obj": {
"arr": [
0: 17,
1: "str",
2: {
- "str": "eafeb"
+ "str": "changed"
}
],
+ "new": "added",
- "num": 19,
"obj": {
- "num": 14,
+ "num": 9999
- "str": "efj3"
+ "str": "changed"
},
"str": "bcded"
},
"str": "abcde"
}
```
When you prefer the delta foramt of [jsondiffpatch](https://github.com/benjamine/jsondiffpatch), add the `-f delta` option.
```sh
jd -f delta one.json another.json
```
This command shows:
```json
{
"arr": {
"2": {
"str": [
"pek3f",
"changed"
]
},
"3": {
"1": [
"1",
"changed"
],
"_t": "a"
},
"_t": "a"
},
"obj": {
"arr": {
"2": {
"str": [
"eafeb",
"changed"
]
},
"_t": "a"
},
"new": [
"added"
],
"num": [
19,
0,
0
],
"obj": {
"num": [
14,
9999
],
"str": [
"efj3",
"changed"
]
}
}
}
```
#### Patch
Give a diff file in the delta format and the JSON file to the `jp` command.
```sh
jp diff.delta one.json
```
## License
MIT License (see `LICENSE` for detail)
package formatter
import (
"bytes"
"errors"
"fmt"
"sort"
diff "github.com/yudai/gojsondiff"
)
func NewAsciiFormatter(left interface{}, config AsciiFormatterConfig) *AsciiFormatter {
return &AsciiFormatter{
left: left,
config: config,
}
}
type AsciiFormatter struct {
left interface{}
config AsciiFormatterConfig
buffer *bytes.Buffer
path []string
size []int
inArray []bool
line *AsciiLine
}
type AsciiFormatterConfig struct {
ShowArrayIndex bool
Coloring bool
}
var AsciiFormatterDefaultConfig = AsciiFormatterConfig{}
type AsciiLine struct {
marker string
indent int
buffer *bytes.Buffer
}
func (f *AsciiFormatter) Format(diff diff.Diff) (result string, err error) {
f.buffer = bytes.NewBuffer([]byte{})
f.path = []string{}
f.size = []int{}
f.inArray = []bool{}
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)
}
return f.buffer.String(), nil
}
func (f *AsciiFormatter) formatObject(left map[string]interface{}, df diff.Diff) {
f.addLineWith(AsciiSame, "{")
f.push("ROOT", len(left), false)
f.processObject(left, df.Deltas())
f.pop()
f.addLineWith(AsciiSame, "}")
}
func (f *AsciiFormatter) formatArray(left []interface{}, df diff.Diff) {
f.addLineWith(AsciiSame, "[")
f.push("ROOT", len(left), true)
f.processArray(left, df.Deltas())
f.pop()
f.addLineWith(AsciiSame, "]")
}
func (f *AsciiFormatter) 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, AsciiAdded)
}
}
return nil
}
func (f *AsciiFormatter) processObject(object map[string]interface{}, deltas []diff.Delta) error {
names := sortedKeys(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, AsciiAdded)
}
}
return nil
}
func (f *AsciiFormatter) 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(AsciiSame)
f.printKey(positionStr)
f.print("{")
f.closeLine()
f.push(positionStr, len(o), false)
f.processObject(o, d.Deltas)
f.pop()
f.newLine(AsciiSame)
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(AsciiSame)
f.printKey(positionStr)
f.print("[")
f.closeLine()
f.push(positionStr, len(a), true)
f.processArray(a, d.Deltas)
f.pop()
f.newLine(AsciiSame)
f.print("]")
f.printComma()
f.closeLine()
case *diff.Added:
d := matchedDelta.(*diff.Added)
f.printRecursive(positionStr, d.Value, AsciiAdded)
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, AsciiDeleted)
f.size[len(f.size)-1] = savedSize
f.printRecursive(positionStr, d.NewValue, AsciiAdded)
case *diff.TextDiff:
savedSize := f.size[len(f.size)-1]
d := matchedDelta.(*diff.TextDiff)
f.printRecursive(positionStr, d.OldValue, AsciiDeleted)
f.size[len(f.size)-1] = savedSize
f.printRecursive(positionStr, d.NewValue, AsciiAdded)
case *diff.Deleted:
d := matchedDelta.(*diff.Deleted)
f.printRecursive(positionStr, d.Value, AsciiDeleted)
default:
return errors.New("Unknown Delta type detected")
}
}
} else {
f.printRecursive(positionStr, value, AsciiSame)
}
return nil
}
func (f *AsciiFormatter) 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
}
const (
AsciiSame = " "
AsciiAdded = "+"
AsciiDeleted = "-"
)
var AsciiStyles = map[string]string{
AsciiAdded: "30;42",
AsciiDeleted: "30;41",
}
func (f *AsciiFormatter) 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 *AsciiFormatter) 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 *AsciiFormatter) addLineWith(marker string, value string) {
f.line = &AsciiLine{
marker: marker,
indent: len(f.path),
buffer: bytes.NewBufferString(value),
}
f.closeLine()
}
func (f *AsciiFormatter) newLine(marker string) {
f.line = &AsciiLine{
marker: marker,
indent: len(f.path),
buffer: bytes.NewBuffer([]byte{}),
}
}
func (f *AsciiFormatter) closeLine() {
style, ok := AsciiStyles[f.line.marker]
if f.config.Coloring && ok {
f.buffer.WriteString("\x1b[" + style + "m")
}
f.buffer.WriteString(f.line.marker)
for n := 0; n < f.line.indent; n++ {
f.buffer.WriteString(" ")
}
f.buffer.Write(f.line.buffer.Bytes())
if f.config.Coloring && ok {
f.buffer.WriteString("\x1b[0m")
}
f.buffer.WriteRune('\n')
}
func (f *AsciiFormatter) printKey(name string) {
if !f.inArray[len(f.inArray)-1] {
fmt.Fprintf(f.line.buffer, `"%s": `, name)
} else if f.config.ShowArrayIndex {
fmt.Fprintf(f.line.buffer, `%s: `, name)
}
}
func (f *AsciiFormatter) printComma() {
f.size[len(f.size)-1]--
if f.size[len(f.size)-1] > 0 {
f.line.buffer.WriteRune(',')
}
}
func (f *AsciiFormatter) printValue(value interface{}) {
switch value.(type) {
case string:
fmt.Fprintf(f.line.buffer, `"%s"`, value)
case nil:
f.line.buffer.WriteString("null")
default:
fmt.Fprintf(f.line.buffer, `%#v`, value)
}
}
func (f *AsciiFormatter) print(a string) {
f.line.buffer.WriteString(a)
}
func (f *AsciiFormatter) printRecursive(name string, value interface{}, marker string) {
switch value.(type) {
case map[string]interface{}:
f.newLine(marker)
f.printKey(name)
f.print("{")
f.closeLine()
m := value.(map[string]interface{})
size := len(m)
f.push(name, size, false)
keys := sortedKeys(m)
for _, key := range keys {
f.printRecursive(key, m[key], marker)
}
f.pop()
f.newLine(marker)
f.print("}")
f.printComma()
f.closeLine()
case []interface{}:
f.newLine(marker)
f.printKey(name)
f.print("[")
f.closeLine()
s := value.([]interface{})
size := len(s)
f.push("", size, true)
for _, item := range s {
f.printRecursive("", item, marker)
}
f.pop()
f.newLine(marker)
f.print("]")
f.printComma()
f.closeLine()
default:
f.newLine(marker)
f.printKey(name)
f.printValue(value)
f.printComma()
f.closeLine()
}
}
func sortedKeys(m map[string]interface{}) (keys []string) {
keys = make([]string, 0, len(m))
for key, _ := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return
}
package formatter
import (
"encoding/json"
"errors"
"fmt"
diff "github.com/yudai/gojsondiff"
)
const (
DeltaDelete = 0
DeltaTextDiff = 2
DeltaMove = 3
)
func NewDeltaFormatter() *DeltaFormatter {
return &DeltaFormatter{
PrintIndent: true,
}
}
type DeltaFormatter struct {
PrintIndent bool
}
func (f *DeltaFormatter) Format(diff diff.Diff) (result string, err error) {
jsonObject, err := f.formatObject(diff.Deltas())
if err != nil {
return "", err
}
var resultBytes []byte
if f.PrintIndent {
resultBytes, err = json.MarshalIndent(jsonObject, "", " ")
} else {
resultBytes, err = json.Marshal(jsonObject)
}
if err != nil {
return "", err
}
return string(resultBytes) + "\n", nil
}
func (f *DeltaFormatter) FormatAsJson(diff diff.Diff) (json map[string]interface{}, err error) {
return f.formatObject(diff.Deltas())
}
func (f *DeltaFormatter) formatObject(deltas []diff.Delta) (deltaJson map[string]interface{}, err error) {
deltaJson = map[string]interface{}{}
for _, delta := range deltas {
switch delta.(type) {
case *diff.Object:
d := delta.(*diff.Object)
deltaJson[d.Position.String()], err = f.formatObject(d.Deltas)
if err != nil {
return nil, err
}
case *diff.Array:
d := delta.(*diff.Array)
deltaJson[d.Position.String()], err = f.formatArray(d.Deltas)
if err != nil {
return nil, err
}
case *diff.Added:
d := delta.(*diff.Added)
deltaJson[d.PostPosition().String()] = []interface{}{d.Value}
case *diff.Modified:
d := delta.(*diff.Modified)
deltaJson[d.PostPosition().String()] = []interface{}{d.OldValue, d.NewValue}
case *diff.TextDiff:
d := delta.(*diff.TextDiff)
deltaJson[d.PostPosition().String()] = []interface{}{d.DiffString(), 0, DeltaTextDiff}
case *diff.Deleted:
d := delta.(*diff.Deleted)
deltaJson[d.PrePosition().String()] = []interface{}{d.Value, 0, DeltaDelete}
case *diff.Moved:
return nil, errors.New("Delta type 'Move' is not supported in objects")
default:
return nil, errors.New(fmt.Sprintf("Unknown Delta type detected: %#v", delta))
}
}
return
}
func (f *DeltaFormatter) formatArray(deltas []diff.Delta) (deltaJson map[string]interface{}, err error) {
deltaJson = map[string]interface{}{
"_t": "a",
}
for _, delta := range deltas {
switch delta.(type) {
case *diff.Object:
d := delta.(*diff.Object)
deltaJson[d.Position.String()], err = f.formatObject(d.Deltas)
if err != nil {
return nil, err
}
case *diff.Array:
d := delta.(*diff.Array)
deltaJson[d.Position.String()], err = f.formatArray(d.Deltas)
if err != nil {
return nil, err
}
case *diff.Added:
d := delta.(*diff.Added)
deltaJson[d.PostPosition().String()] = []interface{}{d.Value}
case *diff.Modified:
d := delta.(*diff.Modified)
deltaJson[d.PostPosition().String()] = []interface{}{d.OldValue, d.NewValue}
case *diff.TextDiff:
d := delta.(*diff.TextDiff)
deltaJson[d.PostPosition().String()] = []interface{}{d.DiffString(), 0, DeltaTextDiff}
case *diff.Deleted:
d := delta.(*diff.Deleted)
deltaJson["_"+d.PrePosition().String()] = []interface{}{d.Value, 0, DeltaDelete}
case *diff.Moved:
d := delta.(*diff.Moved)
deltaJson["_"+d.PrePosition().String()] = []interface{}{"", d.PostPosition(), DeltaMove}
default:
return nil, errors.New(fmt.Sprintf("Unknown Delta type detected: %#v", delta))
}
}
return
}
package gojsondiff
import (
"encoding/json"
"errors"
dmp "github.com/sergi/go-diff/diffmatchpatch"
"io"
"strconv"
)
type Unmarshaller struct {
}
func NewUnmarshaller() *Unmarshaller {
return &Unmarshaller{}
}
func (um *Unmarshaller) UnmarshalBytes(diffBytes []byte) (Diff, error) {
var diffObj map[string]interface{}
json.Unmarshal(diffBytes, &diffObj)
return um.UnmarshalObject(diffObj)
}
func (um *Unmarshaller) UnmarshalString(diffString string) (Diff, error) {
return um.UnmarshalBytes([]byte(diffString))
}
func (um *Unmarshaller) UnmarshalReader(diffReader io.Reader) (Diff, error) {
var diffBytes []byte
io.ReadFull(diffReader, diffBytes)
return um.UnmarshalBytes(diffBytes)
}
func (um *Unmarshaller) UnmarshalObject(diffObj map[string]interface{}) (Diff, error) {
result, err := process(Name(""), diffObj)
if err != nil {
return nil, err
}
return &diff{deltas: result.(*Object).Deltas}, nil
}
func process(position Position, object interface{}) (Delta, error) {
var delta Delta
switch object.(type) {
case map[string]interface{}:
o := object.(map[string]interface{})
if isArray, typed := o["_t"]; typed && isArray == "a" {
deltas := make([]Delta, 0, len(o))
for name, value := range o {
if name == "_t" {
continue
}
normalizedName := name
if normalizedName[0] == '_' {
normalizedName = name[1:]
}
index, err := strconv.Atoi(normalizedName)
if err != nil {
return nil, err
}
childDelta, err := process(Index(index), value)
if err != nil {
return nil, err
}
deltas = append(deltas, childDelta)
}
for _, d := range deltas {
switch d.(type) {
case *Moved:
moved := d.(*Moved)
var dd interface{}
var i int
for i, dd = range deltas {
switch dd.(type) {
case *Moved:
case PostDelta:
pd := dd.(PostDelta)
if moved.PostPosition() == pd.PostPosition() {
moved.Delta = pd
deltas = append(deltas[:i], deltas[i+1:]...)
}
}
}
}
}
delta = NewArray(position, deltas)
} else {
deltas := make([]Delta, 0, len(o))
for name, value := range o {
childDelta, err := process(Name(name), value)
if err != nil {
return nil, err
}
deltas = append(deltas, childDelta)
}
delta = NewObject(position, deltas)
}
case []interface{}:
o := object.([]interface{})
switch len(o) {
case 1:
delta = NewAdded(position, o[0])
case 2:
delta = NewModified(position, o[0], o[1])
case 3:
switch o[2] {
case float64(0):
delta = NewDeleted(position, o[0])
case float64(2):
dmp := dmp.New()
patches, err := dmp.PatchFromText(o[0].(string))
if err != nil {
return nil, err
}
delta = NewTextDiff(position, patches, nil, nil)
case float64(3):
delta = NewMoved(position, Index(int(o[1].(float64))), nil, nil)
default:
return nil, errors.New("Unknown delta type")
}
}
}
return delta, nil
}
box: golang:1.6.3
build:
steps:
- setup-go-workspace
- script:
name: test
code: make test
The MIT License (MIT)
Copyright (c) 2015 Iwasaki Yudai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
============================================================================
This repository is build with following third party libraries. Thank you!
## ginkgo - https://github.com/onsi/ginkgo
Copyright (c) 2013-2014 Onsi Fakhouri
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
# gomega - https://github.com/onsi/gomega
Copyright (c) 2013-2014 Onsi Fakhouri
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
# Go Longest Common Subsequence (LCS)
A package to calculate [LCS](http://en.wikipedia.org/wiki/Longest_common_subsequence_problem) of slices.
## Usage
```sh
go get github.com/yudai/golcs
```
```go
import " github.com/yudai/golcs"
left = []interface{}{1, 2, 5, 3, 1, 1, 5, 8, 3}
right = []interface{}{1, 2, 3, 3, 4, 4, 5, 1, 6}
lcs := golcs.New(left, right)
lcs.Values() // LCS values => []interface{}{1, 2, 5, 1}
lcs.IndexPairs() // Matched indices => [{Left: 0, Right: 0}, {Left: 1, Right: 1}, {Left: 2, Right: 6}, {Left: 4, Right: 7}]
lcs.Length() // Matched length => 4
lcs.Table() // Memo table
```
All the methods of `Lcs` cache their return values. For example, the memo table is calculated only once and reused when `Values()`, `Length()` and other methods are called.
## FAQ
### How can I give `[]byte` values to `Lcs()` as its arguments?
As `[]interface{}` is incompatible with `[]othertype` like `[]byte`, you need to create a `[]interface{}` slice and copy the values in your `[]byte` slice into it. Unfortunately, Go doesn't provide any mesure to cast a slice into `[]interface{}` with zero cost. Your copy costs O(n).
```go
leftBytes := []byte("TGAGTA")
left = make([]interface{}, len(leftBytes))
for i, v := range leftBytes {
left[i] = v
}
rightBytes := []byte("GATA")
right = make([]interface{}, len(rightBytes))
for i, v := range rightBytes {
right[i] = v
}
lcs.New(left, right)
```
## LICENSE
The MIT license (See `LICENSE` for detail)
package lcs
import (
"reflect"
)
type Lcs interface {
Values() (values []interface{})
IndexPairs() (pairs []IndexPair)
Length() (length int)
Left() (leftValues []interface{})
Right() (righttValues []interface{})
}
type IndexPair struct {
Left int
Right int
}
type lcs struct {
left []interface{}
right []interface{}
/* for caching */
table [][]int
indexPairs []IndexPair
values []interface{}
}
func New(left, right []interface{}) Lcs {
return &lcs{
left: left,
right: right,
table: nil,
indexPairs: nil,
values: nil,
}
}
func (lcs *lcs) Table() (table [][]int) {
if lcs.table != nil {
return lcs.table
}
sizeX := len(lcs.left) + 1
sizeY := len(lcs.right) + 1
table = make([][]int, sizeX)
for x := 0; x < sizeX; x++ {
table[x] = make([]int, sizeY)
}
for y := 1; y < sizeY; y++ {
for x := 1; x < sizeX; x++ {
increment := 0
if reflect.DeepEqual(lcs.left[x-1], lcs.right[y-1]) {
increment = 1
}
table[x][y] = max(table[x-1][y-1]+increment, table[x-1][y], table[x][y-1])
}
}
lcs.table = table
return
}
func (lcs *lcs) Length() (length int) {
length = lcs.Table()[len(lcs.left)][len(lcs.right)]
return
}
func (lcs *lcs) IndexPairs() (pairs []IndexPair) {
if lcs.indexPairs != nil {
return lcs.indexPairs
}
table := lcs.Table()
pairs = make([]IndexPair, table[len(table)-1][len(table[0])-1])
for x, y := len(lcs.left), len(lcs.right); x > 0 && y > 0; {
if reflect.DeepEqual(lcs.left[x-1], lcs.right[y-1]) {
pairs[table[x][y]-1] = IndexPair{Left: x - 1, Right: y - 1}
x--
y--
} else {
if table[x-1][y] >= table[x][y-1] {
x--
} else {
y--
}
}
}
lcs.indexPairs = pairs
return
}
func (lcs *lcs) Values() (values []interface{}) {
if lcs.values != nil {
return lcs.values
}
pairs := lcs.IndexPairs()
values = make([]interface{}, len(pairs))
for i, pair := range pairs {
values[i] = lcs.left[pair.Left]
}
lcs.values = values
return
}
func (lcs *lcs) Left() (leftValues []interface{}) {
leftValues = lcs.left
return
}
func (lcs *lcs) Right() (rightValues []interface{}) {
rightValues = lcs.right
return
}
func max(first int, rest ...int) (max int) {
max = first
for _, value := range rest {
if value > max {
max = value
}
}
return
}
......@@ -459,6 +459,12 @@
"revisionTime": "2016-09-17T18:44:01Z"
},
{
"checksumSHA1": "iWCtyR1TkJ22Bi/ygzfKDvOQdQY=",
"path": "github.com/sergi/go-diff/diffmatchpatch",
"revision": "24e2351369ec4949b2ed0dc5c477afdd4c4034e8",
"revisionTime": "2017-01-18T13:12:30Z"
},
{
"checksumSHA1": "6AYg4fjEvFuAVN3wHakGApjhZAM=",
"path": "github.com/smartystreets/assertions",
"revision": "2063fd1cc7c975db70502811a34b06ad034ccdf2",
......@@ -501,6 +507,24 @@
"revisionTime": "2017-04-18T16:44:36Z"
},
{
"checksumSHA1": "r7o16T0WQ/XSe2mlQuioMi8gxbw=",
"path": "github.com/yudai/gojsondiff",
"revision": "9209d1532c51cabe0439993586a71c207b09a0ac",
"revisionTime": "2017-02-27T22:09:00Z"
},
{
"checksumSHA1": "7/V6fDOOfkmSHQahCK+J5G4Y1uk=",
"path": "github.com/yudai/gojsondiff/formatter",
"revision": "9209d1532c51cabe0439993586a71c207b09a0ac",
"revisionTime": "2017-02-27T22:09:00Z"
},
{
"checksumSHA1": "OCkp7qxxdxjpoM3T6Q3CTiMP5kM=",
"path": "github.com/yudai/golcs",
"revision": "d1c525dea8ce39ea9a783d33cf08932305373f2c",
"revisionTime": "2015-04-05T16:34:35Z"
},
{
"checksumSHA1": "WHc3uByvGaMcnSoI21fhzYgbOgg=",
"path": "golang.org/x/net/context/ctxhttp",
"revision": "71a035914f99bb58fe82eac0f1289f10963d876c",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment