mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into docs-5.1
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[run]
|
||||
init_cmds = [
|
||||
["go", "build", "-o", "./bin/grafana-server", "./pkg/cmd/grafana-server"],
|
||||
["go", "run", "build.go", "build-server"],
|
||||
["./bin/grafana-server", "cfg:app_mode=development"]
|
||||
]
|
||||
watch_all = true
|
||||
@@ -12,6 +12,6 @@ watch_dirs = [
|
||||
watch_exts = [".go", ".ini", ".toml"]
|
||||
build_delay = 1500
|
||||
cmds = [
|
||||
["go", "build", "-o", "./bin/grafana-server", "./pkg/cmd/grafana-server"],
|
||||
["go", "run", "build.go", "build"],
|
||||
["./bin/grafana-server", "cfg:app_mode=development"]
|
||||
]
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* **IE11**: IE 11 compatibility [#11165](https://github.com/grafana/grafana/issues/11165)
|
||||
* **Scrolling**: Better scrolling experience [#11053](https://github.com/grafana/grafana/issues/11053), [#11252](https://github.com/grafana/grafana/issues/11252), [#10836](https://github.com/grafana/grafana/issues/10836), [#11185](https://github.com/grafana/grafana/issues/11185), [#11168](https://github.com/grafana/grafana/issues/11168)
|
||||
* **Docker**: Improved docker image (breaking changes regarding file ownership) [grafana-docker #141](https://github.com/grafana/grafana-docker/issues/141), thx [@Spindel](https://github.com/Spindel), [@ChristianKniep](https://github.com/ChristianKniep), [@brancz](https://github.com/brancz) and [@jangaraj](https://github.com/jangaraj)
|
||||
* **Folders**: A folder admin cannot add user/team permissions for folder/its dashboards [#11173](https://github.com/grafana/grafana/issues/11173)
|
||||
* **Provisioning**: Improved workflow for provisioned dashboards [#10883](https://github.com/grafana/grafana/issues/10883)
|
||||
|
||||
### Minor
|
||||
|
||||
@@ -50,6 +52,9 @@
|
||||
* **Playlist**: Empty playlists cannot be deleted [#11133](https://github.com/grafana/grafana/issues/11133), thx [@kichristensen](https://github.com/kichristensen)
|
||||
* **Switch Orgs**: Alphabetic order in Switch Organization modal [#11556](https://github.com/grafana/grafana/issues/11556)
|
||||
* **Postgres**: improve `$__timeFilter` macro [#11578](https://github.com/grafana/grafana/issues/11578), thx [@svenklemm](https://github.com/svenklemm)
|
||||
* **Permission list**: Improved ux [#10747](https://github.com/grafana/grafana/issues/10747)
|
||||
* **Dashboard**: Sizing and positioning of settings menu icons [#11572](https://github.com/grafana/grafana/pull/11572)
|
||||
* **Folders**: User with org viewer role should not be able to save/move dashboards in/to general folder [#11553](https://github.com/grafana/grafana/issues/11553)
|
||||
|
||||
### Tech
|
||||
* Migrated JavaScript files to TypeScript
|
||||
|
||||
@@ -22,6 +22,7 @@ module.exports = function (grunt) {
|
||||
}
|
||||
}
|
||||
|
||||
config.coverage = grunt.option('coverage');
|
||||
config.phjs = grunt.option('phjsToRelease');
|
||||
config.pkg.version = grunt.option('pkgVer') || config.pkg.version;
|
||||
|
||||
|
||||
11
codecov.yml
Normal file
11
codecov.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
coverage:
|
||||
precision: 2
|
||||
round: down
|
||||
range: "50...100"
|
||||
|
||||
status:
|
||||
project: yes
|
||||
patch: yes
|
||||
changes: no
|
||||
|
||||
comment: off
|
||||
@@ -102,6 +102,7 @@
|
||||
"watch": "webpack --progress --colors --watch --config scripts/webpack/webpack.dev.js",
|
||||
"build": "grunt build",
|
||||
"test": "grunt test",
|
||||
"test:coverage": "grunt test --coverage=true",
|
||||
"lint": "tslint -c tslint.json --project tsconfig.json --type-check",
|
||||
"karma": "grunt karma:dev",
|
||||
"jest": "jest --notify --watch",
|
||||
|
||||
@@ -149,8 +149,6 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
|
||||
// team (admin permission required)
|
||||
apiRoute.Group("/teams", func(teamsRoute RouteRegister) {
|
||||
teamsRoute.Get("/:teamId", wrap(GetTeamByID))
|
||||
teamsRoute.Get("/search", wrap(SearchTeams))
|
||||
teamsRoute.Post("/", bind(m.CreateTeamCommand{}), wrap(CreateTeam))
|
||||
teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), wrap(UpdateTeam))
|
||||
teamsRoute.Delete("/:teamId", wrap(DeleteTeamByID))
|
||||
@@ -159,6 +157,12 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
teamsRoute.Delete("/:teamId/members/:userId", wrap(RemoveTeamMember))
|
||||
}, reqOrgAdmin)
|
||||
|
||||
// team without requirement of user to be org admin
|
||||
apiRoute.Group("/teams", func(teamsRoute RouteRegister) {
|
||||
teamsRoute.Get("/:teamId", wrap(GetTeamByID))
|
||||
teamsRoute.Get("/search", wrap(SearchTeams))
|
||||
})
|
||||
|
||||
// org information available to all users.
|
||||
apiRoute.Group("/org", func(orgRoute RouteRegister) {
|
||||
orgRoute.Get("/", wrap(GetOrgCurrent))
|
||||
@@ -170,7 +174,6 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
orgRoute.Put("/", bind(dtos.UpdateOrgForm{}), wrap(UpdateOrgCurrent))
|
||||
orgRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), wrap(UpdateOrgAddressCurrent))
|
||||
orgRoute.Post("/users", quota("user"), bind(m.AddOrgUserCommand{}), wrap(AddOrgUserToCurrentOrg))
|
||||
orgRoute.Get("/users", wrap(GetOrgUsersForCurrentOrg))
|
||||
orgRoute.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), wrap(UpdateOrgUserForCurrentOrg))
|
||||
orgRoute.Delete("/users/:userId", wrap(RemoveOrgUserForCurrentOrg))
|
||||
|
||||
@@ -184,6 +187,11 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
orgRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), wrap(UpdateOrgPreferences))
|
||||
}, reqOrgAdmin)
|
||||
|
||||
// current org without requirement of user to be org admin
|
||||
apiRoute.Group("/org", func(orgRoute RouteRegister) {
|
||||
orgRoute.Get("/users", wrap(GetOrgUsersForCurrentOrg))
|
||||
})
|
||||
|
||||
// create new org
|
||||
apiRoute.Post("/orgs", quota("org"), bind(m.CreateOrgCommand{}), wrap(CreateOrg))
|
||||
|
||||
|
||||
@@ -102,6 +102,16 @@ func GetDashboard(c *m.ReqContext) Response {
|
||||
meta.FolderUrl = query.Result.GetUrl()
|
||||
}
|
||||
|
||||
isDashboardProvisioned := &m.IsDashboardProvisionedQuery{DashboardId: dash.Id}
|
||||
err = bus.Dispatch(isDashboardProvisioned)
|
||||
if err != nil {
|
||||
return Error(500, "Error while checking if dashboard is provisioned", err)
|
||||
}
|
||||
|
||||
if isDashboardProvisioned.Result {
|
||||
meta.Provisioned = true
|
||||
}
|
||||
|
||||
// make sure db version is in sync with json model version
|
||||
dash.Data.Set("version", dash.Version)
|
||||
|
||||
@@ -228,7 +238,8 @@ func PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) Response {
|
||||
err == m.ErrDashboardWithSameUIDExists ||
|
||||
err == m.ErrFolderNotFound ||
|
||||
err == m.ErrDashboardFolderCannotHaveParent ||
|
||||
err == m.ErrDashboardFolderNameExists {
|
||||
err == m.ErrDashboardFolderNameExists ||
|
||||
err == m.ErrDashboardCannotSaveProvisionedDashboard {
|
||||
return Error(400, err.Error(), nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,11 @@ func GetDashboardPermissionList(c *m.ReqContext) Response {
|
||||
}
|
||||
|
||||
for _, perm := range acl {
|
||||
perm.UserAvatarUrl = dtos.GetGravatarUrl(perm.UserEmail)
|
||||
|
||||
if perm.TeamId > 0 {
|
||||
perm.TeamAvatarUrl = dtos.GetGravatarUrlWithDefault(perm.TeamEmail, perm.Team)
|
||||
}
|
||||
if perm.Slug != "" {
|
||||
perm.Url = m.GetDashboardFolderUrl(perm.IsFolder, perm.Uid, perm.Slug)
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ func TestDashboardPermissionApiEndpoint(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When trying to override inherited permissions with lower presedence", func() {
|
||||
Convey("When trying to override inherited permissions with lower precedence", func() {
|
||||
origNewGuardian := guardian.New
|
||||
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
|
||||
CanAdminValue: true,
|
||||
|
||||
@@ -42,6 +42,11 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(query *m.IsDashboardProvisionedQuery) error {
|
||||
query.Result = false
|
||||
return nil
|
||||
})
|
||||
|
||||
viewerRole := m.ROLE_VIEWER
|
||||
editorRole := m.ROLE_EDITOR
|
||||
|
||||
@@ -192,6 +197,11 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
fakeDash.HasAcl = true
|
||||
setting.ViewersCanEdit = false
|
||||
|
||||
bus.AddHandler("test", func(query *m.IsDashboardProvisionedQuery) error {
|
||||
query.Result = false
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error {
|
||||
dashboards := []*m.Dashboard{fakeDash}
|
||||
query.Result = dashboards
|
||||
@@ -625,6 +635,11 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
dashTwo.FolderId = 3
|
||||
dashTwo.HasAcl = false
|
||||
|
||||
bus.AddHandler("test", func(query *m.IsDashboardProvisionedQuery) error {
|
||||
query.Result = false
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error {
|
||||
dashboards := []*m.Dashboard{dashOne, dashTwo}
|
||||
query.Result = dashboards
|
||||
@@ -720,6 +735,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
{SaveError: m.ErrDashboardUpdateAccessDenied, ExpectedStatusCode: 403},
|
||||
{SaveError: m.ErrDashboardInvalidUid, ExpectedStatusCode: 400},
|
||||
{SaveError: m.ErrDashboardUidToLong, ExpectedStatusCode: 400},
|
||||
{SaveError: m.ErrDashboardCannotSaveProvisionedDashboard, ExpectedStatusCode: 400},
|
||||
{SaveError: m.UpdatePluginDashboardError{PluginId: "plug"}, ExpectedStatusCode: 412},
|
||||
}
|
||||
|
||||
@@ -750,6 +766,11 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(query *m.IsDashboardProvisionedQuery) error {
|
||||
query.Result = false
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardVersionQuery) error {
|
||||
query.Result = &m.DashboardVersion{
|
||||
Data: simplejson.NewFromAny(map[string]interface{}{
|
||||
|
||||
@@ -28,6 +28,7 @@ type DashboardMeta struct {
|
||||
FolderId int64 `json:"folderId"`
|
||||
FolderTitle string `json:"folderTitle"`
|
||||
FolderUrl string `json:"folderUrl"`
|
||||
Provisioned bool `json:"provisioned"`
|
||||
}
|
||||
|
||||
type DashboardFullWithMeta struct {
|
||||
|
||||
@@ -33,6 +33,12 @@ func GetFolderPermissionList(c *m.ReqContext) Response {
|
||||
perm.FolderId = folder.Id
|
||||
perm.DashboardId = 0
|
||||
|
||||
perm.UserAvatarUrl = dtos.GetGravatarUrl(perm.UserEmail)
|
||||
|
||||
if perm.TeamId > 0 {
|
||||
perm.TeamAvatarUrl = dtos.GetGravatarUrlWithDefault(perm.TeamEmail, perm.Team)
|
||||
}
|
||||
|
||||
if perm.Slug != "" {
|
||||
perm.Url = m.GetDashboardFolderUrl(perm.IsFolder, perm.Uid, perm.Slug)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func Init(version string, skipTLSVerify bool) {
|
||||
}
|
||||
|
||||
HttpClient = http.Client{
|
||||
Timeout: time.Duration(10 * time.Second),
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: tr,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func New(orgId int64, name string) KeyGenResult {
|
||||
|
||||
jsonString, _ := json.Marshal(jsonKey)
|
||||
|
||||
result.ClientSecret = base64.StdEncoding.EncodeToString([]byte(jsonString))
|
||||
result.ClientSecret = base64.StdEncoding.EncodeToString(jsonString)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func Decode(keyString string) (*ApiKeyJson, error) {
|
||||
}
|
||||
|
||||
var keyObj ApiKeyJson
|
||||
err = json.Unmarshal([]byte(jsonString), &keyObj)
|
||||
err = json.Unmarshal(jsonString, &keyObj)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidApiKey
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ func (a *Auth) SignRequest(req *http.Request) {
|
||||
)
|
||||
decodedKey, _ := base64.StdEncoding.DecodeString(a.Key)
|
||||
|
||||
sha256 := hmac.New(sha256.New, []byte(decodedKey))
|
||||
sha256 := hmac.New(sha256.New, decodedKey)
|
||||
sha256.Write([]byte(strToSign))
|
||||
|
||||
signature := base64.StdEncoding.EncodeToString(sha256.Sum(nil))
|
||||
|
||||
@@ -50,7 +50,7 @@ func (f *Float) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
f.Float64 = float64(x)
|
||||
f.Float64 = x
|
||||
case map[string]interface{}:
|
||||
err = json.Unmarshal(data, &f.NullFloat64)
|
||||
case nil:
|
||||
|
||||
@@ -54,6 +54,7 @@ var (
|
||||
M_Alerting_Active_Alerts prometheus.Gauge
|
||||
M_StatTotal_Dashboards prometheus.Gauge
|
||||
M_StatTotal_Users prometheus.Gauge
|
||||
M_StatActive_Users prometheus.Gauge
|
||||
M_StatTotal_Orgs prometheus.Gauge
|
||||
M_StatTotal_Playlists prometheus.Gauge
|
||||
M_Grafana_Version *prometheus.GaugeVec
|
||||
@@ -253,6 +254,12 @@ func init() {
|
||||
Namespace: exporterName,
|
||||
})
|
||||
|
||||
M_StatActive_Users = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "stat_active_users",
|
||||
Help: "number of active users",
|
||||
Namespace: exporterName,
|
||||
})
|
||||
|
||||
M_StatTotal_Orgs = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "stat_total_orgs",
|
||||
Help: "total amount of orgs",
|
||||
@@ -270,7 +277,6 @@ func init() {
|
||||
Help: "Information about the Grafana",
|
||||
Namespace: exporterName,
|
||||
}, []string{"version"})
|
||||
|
||||
}
|
||||
|
||||
func initMetricVars(settings *MetricSettings) {
|
||||
@@ -305,6 +311,7 @@ func initMetricVars(settings *MetricSettings) {
|
||||
M_Alerting_Active_Alerts,
|
||||
M_StatTotal_Dashboards,
|
||||
M_StatTotal_Users,
|
||||
M_StatActive_Users,
|
||||
M_StatTotal_Orgs,
|
||||
M_StatTotal_Playlists,
|
||||
M_Grafana_Version)
|
||||
@@ -315,35 +322,36 @@ func initMetricVars(settings *MetricSettings) {
|
||||
func instrumentationLoop(settings *MetricSettings) chan struct{} {
|
||||
M_Instance_Start.Inc()
|
||||
|
||||
// set the total stats gauges before we publishing metrics
|
||||
updateTotalStats()
|
||||
|
||||
onceEveryDayTick := time.NewTicker(time.Hour * 24)
|
||||
secondTicker := time.NewTicker(time.Second * time.Duration(settings.IntervalSeconds))
|
||||
everyMinuteTicker := time.NewTicker(time.Minute)
|
||||
defer onceEveryDayTick.Stop()
|
||||
defer everyMinuteTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-onceEveryDayTick.C:
|
||||
sendUsageStats()
|
||||
case <-secondTicker.C:
|
||||
case <-everyMinuteTicker.C:
|
||||
updateTotalStats()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var metricPublishCounter int64 = 0
|
||||
|
||||
func updateTotalStats() {
|
||||
metricPublishCounter++
|
||||
if metricPublishCounter == 1 || metricPublishCounter%10 == 0 {
|
||||
statsQuery := models.GetSystemStatsQuery{}
|
||||
if err := bus.Dispatch(&statsQuery); err != nil {
|
||||
metricsLogger.Error("Failed to get system stats", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
M_StatTotal_Dashboards.Set(float64(statsQuery.Result.Dashboards))
|
||||
M_StatTotal_Users.Set(float64(statsQuery.Result.Users))
|
||||
M_StatTotal_Playlists.Set(float64(statsQuery.Result.Playlists))
|
||||
M_StatTotal_Orgs.Set(float64(statsQuery.Result.Orgs))
|
||||
statsQuery := models.GetSystemStatsQuery{}
|
||||
if err := bus.Dispatch(&statsQuery); err != nil {
|
||||
metricsLogger.Error("Failed to get system stats", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
M_StatTotal_Dashboards.Set(float64(statsQuery.Result.Dashboards))
|
||||
M_StatTotal_Users.Set(float64(statsQuery.Result.Users))
|
||||
M_StatActive_Users.Set(float64(statsQuery.Result.ActiveUsers))
|
||||
M_StatTotal_Playlists.Set(float64(statsQuery.Result.Playlists))
|
||||
M_StatTotal_Orgs.Set(float64(statsQuery.Result.Orgs))
|
||||
}
|
||||
|
||||
func sendUsageStats() {
|
||||
@@ -403,6 +411,6 @@ func sendUsageStats() {
|
||||
out, _ := json.MarshalIndent(report, "", " ")
|
||||
data := bytes.NewBuffer(out)
|
||||
|
||||
client := http.Client{Timeout: time.Duration(5 * time.Second)}
|
||||
client := http.Client{Timeout: 5 * time.Second}
|
||||
go client.Post("https://stats.grafana.org/grafana-usage-report", "application/json", data)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,10 @@ type DashboardAclInfoDTO struct {
|
||||
UserId int64 `json:"userId"`
|
||||
UserLogin string `json:"userLogin"`
|
||||
UserEmail string `json:"userEmail"`
|
||||
UserAvatarUrl string `json:"userAvatarUrl"`
|
||||
TeamId int64 `json:"teamId"`
|
||||
TeamEmail string `json:"teamEmail"`
|
||||
TeamAvatarUrl string `json:"teamAvatarUrl"`
|
||||
Team string `json:"team"`
|
||||
Role *RoleType `json:"role,omitempty"`
|
||||
Permission PermissionType `json:"permission"`
|
||||
|
||||
@@ -13,26 +13,27 @@ import (
|
||||
|
||||
// Typed errors
|
||||
var (
|
||||
ErrDashboardNotFound = errors.New("Dashboard not found")
|
||||
ErrDashboardFolderNotFound = errors.New("Folder not found")
|
||||
ErrDashboardSnapshotNotFound = errors.New("Dashboard snapshot not found")
|
||||
ErrDashboardWithSameUIDExists = errors.New("A dashboard with the same uid already exists")
|
||||
ErrDashboardWithSameNameInFolderExists = errors.New("A dashboard with the same name in the folder already exists")
|
||||
ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else")
|
||||
ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty")
|
||||
ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder")
|
||||
ErrDashboardContainsInvalidAlertData = errors.New("Invalid alert data. Cannot save dashboard")
|
||||
ErrDashboardFailedToUpdateAlertData = errors.New("Failed to save alert data")
|
||||
ErrDashboardsWithSameSlugExists = errors.New("Multiple dashboards with the same slug exists")
|
||||
ErrDashboardFailedGenerateUniqueUid = errors.New("Failed to generate unique dashboard id")
|
||||
ErrDashboardTypeMismatch = errors.New("Dashboard cannot be changed to a folder")
|
||||
ErrDashboardFolderWithSameNameAsDashboard = errors.New("Folder name cannot be the same as one of its dashboards")
|
||||
ErrDashboardWithSameNameAsFolder = errors.New("Dashboard name cannot be the same as folder")
|
||||
ErrDashboardFolderNameExists = errors.New("A folder with that name already exists")
|
||||
ErrDashboardUpdateAccessDenied = errors.New("Access denied to save dashboard")
|
||||
ErrDashboardInvalidUid = errors.New("uid contains illegal characters")
|
||||
ErrDashboardUidToLong = errors.New("uid to long. max 40 characters")
|
||||
RootFolderName = "General"
|
||||
ErrDashboardNotFound = errors.New("Dashboard not found")
|
||||
ErrDashboardFolderNotFound = errors.New("Folder not found")
|
||||
ErrDashboardSnapshotNotFound = errors.New("Dashboard snapshot not found")
|
||||
ErrDashboardWithSameUIDExists = errors.New("A dashboard with the same uid already exists")
|
||||
ErrDashboardWithSameNameInFolderExists = errors.New("A dashboard with the same name in the folder already exists")
|
||||
ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else")
|
||||
ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty")
|
||||
ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder")
|
||||
ErrDashboardContainsInvalidAlertData = errors.New("Invalid alert data. Cannot save dashboard")
|
||||
ErrDashboardFailedToUpdateAlertData = errors.New("Failed to save alert data")
|
||||
ErrDashboardsWithSameSlugExists = errors.New("Multiple dashboards with the same slug exists")
|
||||
ErrDashboardFailedGenerateUniqueUid = errors.New("Failed to generate unique dashboard id")
|
||||
ErrDashboardTypeMismatch = errors.New("Dashboard cannot be changed to a folder")
|
||||
ErrDashboardFolderWithSameNameAsDashboard = errors.New("Folder name cannot be the same as one of its dashboards")
|
||||
ErrDashboardWithSameNameAsFolder = errors.New("Dashboard name cannot be the same as folder")
|
||||
ErrDashboardFolderNameExists = errors.New("A folder with that name already exists")
|
||||
ErrDashboardUpdateAccessDenied = errors.New("Access denied to save dashboard")
|
||||
ErrDashboardInvalidUid = errors.New("uid contains illegal characters")
|
||||
ErrDashboardUidToLong = errors.New("uid to long. max 40 characters")
|
||||
ErrDashboardCannotSaveProvisionedDashboard = errors.New("Cannot save provisioned dashboard")
|
||||
RootFolderName = "General"
|
||||
)
|
||||
|
||||
type UpdatePluginDashboardError struct {
|
||||
@@ -224,6 +225,10 @@ func GetFolderUrl(folderUid string, slug string) string {
|
||||
return fmt.Sprintf("%s/dashboards/f/%s/%s", setting.AppSubUrl, folderUid, slug)
|
||||
}
|
||||
|
||||
type ValidateDashboardBeforeSaveResult struct {
|
||||
IsParentFolderChanged bool
|
||||
}
|
||||
|
||||
//
|
||||
// COMMANDS
|
||||
//
|
||||
@@ -268,6 +273,7 @@ type ValidateDashboardBeforeSaveCommand struct {
|
||||
OrgId int64
|
||||
Dashboard *Dashboard
|
||||
Overwrite bool
|
||||
Result *ValidateDashboardBeforeSaveResult
|
||||
}
|
||||
|
||||
//
|
||||
@@ -317,6 +323,12 @@ type GetDashboardSlugByIdQuery struct {
|
||||
Result string
|
||||
}
|
||||
|
||||
type IsDashboardProvisionedQuery struct {
|
||||
DashboardId int64
|
||||
|
||||
Result bool
|
||||
}
|
||||
|
||||
type GetProvisionedDashboardDataQuery struct {
|
||||
Name string
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ func (ds *DataSource) GetHttpClient() (*http.Client, error) {
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Timeout: time.Duration(30 * time.Second),
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: transport,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func (pb *PluginBase) registerPlugin(pluginDir string) error {
|
||||
|
||||
for _, include := range pb.Includes {
|
||||
if include.Role == "" {
|
||||
include.Role = m.RoleType(m.ROLE_VIEWER)
|
||||
include.Role = m.ROLE_VIEWER
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
httpClient http.Client = http.Client{Timeout: time.Duration(10 * time.Second)}
|
||||
httpClient http.Client = http.Client{Timeout: 10 * time.Second}
|
||||
)
|
||||
|
||||
type GrafanaNetPlugin struct {
|
||||
|
||||
@@ -90,7 +90,7 @@ func (this *LineNotifier) createAlert(evalContext *alerting.EvalContext) error {
|
||||
}
|
||||
|
||||
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
|
||||
this.log.Error("Failed to send notification to LINE", "error", err, "body", string(body))
|
||||
this.log.Error("Failed to send notification to LINE", "error", err, "body", body)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*mod
|
||||
return cmd.Result, nil
|
||||
}
|
||||
|
||||
func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO, validateAlerts bool) (*models.SaveDashboardCommand, error) {
|
||||
func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO, validateAlerts bool, validateProvisionedDashboard bool) (*models.SaveDashboardCommand, error) {
|
||||
dash := dto.Dashboard
|
||||
|
||||
dash.Title = strings.TrimSpace(dash.Title)
|
||||
@@ -103,6 +103,29 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if validateBeforeSaveCmd.Result.IsParentFolderChanged {
|
||||
folderGuardian := guardian.New(dash.FolderId, dto.OrgId, dto.User)
|
||||
if canSave, err := folderGuardian.CanSave(); err != nil || !canSave {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, models.ErrDashboardUpdateAccessDenied
|
||||
}
|
||||
}
|
||||
|
||||
if validateProvisionedDashboard {
|
||||
isDashboardProvisioned := &models.IsDashboardProvisionedQuery{DashboardId: dash.Id}
|
||||
err := bus.Dispatch(isDashboardProvisioned)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isDashboardProvisioned.Result {
|
||||
return nil, models.ErrDashboardCannotSaveProvisionedDashboard
|
||||
}
|
||||
}
|
||||
|
||||
guard := guardian.New(dash.GetDashboardIdForSavePermissionCheck(), dto.OrgId, dto.User)
|
||||
if canSave, err := guard.CanSave(); err != nil || !canSave {
|
||||
if err != nil {
|
||||
@@ -148,7 +171,7 @@ func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO,
|
||||
UserId: 0,
|
||||
OrgRole: models.ROLE_ADMIN,
|
||||
}
|
||||
cmd, err := dr.buildSaveDashboardCommand(dto, true)
|
||||
cmd, err := dr.buildSaveDashboardCommand(dto, true, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -178,7 +201,7 @@ func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(dto *SaveDash
|
||||
UserId: 0,
|
||||
OrgRole: models.ROLE_ADMIN,
|
||||
}
|
||||
cmd, err := dr.buildSaveDashboardCommand(dto, false)
|
||||
cmd, err := dr.buildSaveDashboardCommand(dto, false, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -197,7 +220,7 @@ func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(dto *SaveDash
|
||||
}
|
||||
|
||||
func (dr *dashboardServiceImpl) SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) {
|
||||
cmd, err := dr.buildSaveDashboardCommand(dto, true)
|
||||
cmd, err := dr.buildSaveDashboardCommand(dto, true, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -216,7 +239,7 @@ func (dr *dashboardServiceImpl) SaveDashboard(dto *SaveDashboardDTO) (*models.Da
|
||||
}
|
||||
|
||||
func (dr *dashboardServiceImpl) ImportDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) {
|
||||
cmd, err := dr.buildSaveDashboardCommand(dto, false)
|
||||
cmd, err := dr.buildSaveDashboardCommand(dto, false, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ import (
|
||||
|
||||
func TestDashboardService(t *testing.T) {
|
||||
Convey("Dashboard service tests", t, func() {
|
||||
service := dashboardServiceImpl{}
|
||||
bus.ClearBusHandlers()
|
||||
|
||||
service := &dashboardServiceImpl{}
|
||||
|
||||
origNewDashboardGuardian := guardian.New
|
||||
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true})
|
||||
@@ -51,6 +53,12 @@ func TestDashboardService(t *testing.T) {
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error {
|
||||
cmd.Result = &models.ValidateDashboardBeforeSaveResult{}
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error {
|
||||
cmd.Result = false
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -72,12 +80,42 @@ func TestDashboardService(t *testing.T) {
|
||||
dto.Dashboard.SetUid(tc.Uid)
|
||||
dto.User = &models.SignedInUser{}
|
||||
|
||||
_, err := service.buildSaveDashboardCommand(dto, true)
|
||||
_, err := service.buildSaveDashboardCommand(dto, true, false)
|
||||
So(err, ShouldEqual, tc.Error)
|
||||
}
|
||||
})
|
||||
|
||||
Convey("Should return validation error if dashboard is provisioned", func() {
|
||||
provisioningValidated := false
|
||||
bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error {
|
||||
provisioningValidated = true
|
||||
cmd.Result = true
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error {
|
||||
cmd.Result = &models.ValidateDashboardBeforeSaveResult{}
|
||||
return nil
|
||||
})
|
||||
|
||||
dto.Dashboard = models.NewDashboard("Dash")
|
||||
dto.Dashboard.SetId(3)
|
||||
dto.User = &models.SignedInUser{UserId: 1}
|
||||
_, err := service.SaveDashboard(dto)
|
||||
So(provisioningValidated, ShouldBeTrue)
|
||||
So(err, ShouldEqual, models.ErrDashboardCannotSaveProvisionedDashboard)
|
||||
})
|
||||
|
||||
Convey("Should return validation error if alert data is invalid", func() {
|
||||
bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error {
|
||||
cmd.Result = false
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
@@ -88,6 +126,80 @@ func TestDashboardService(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Save provisioned dashboard validation", func() {
|
||||
dto := &SaveDashboardDTO{}
|
||||
|
||||
Convey("Should not return validation error if dashboard is provisioned", func() {
|
||||
provisioningValidated := false
|
||||
bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error {
|
||||
provisioningValidated = true
|
||||
cmd.Result = true
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error {
|
||||
cmd.Result = &models.ValidateDashboardBeforeSaveResult{}
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.SaveProvisionedDashboardCommand) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.UpdateDashboardAlertsCommand) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
dto.Dashboard = models.NewDashboard("Dash")
|
||||
dto.Dashboard.SetId(3)
|
||||
dto.User = &models.SignedInUser{UserId: 1}
|
||||
_, err := service.SaveProvisionedDashboard(dto, nil)
|
||||
So(err, ShouldBeNil)
|
||||
So(provisioningValidated, ShouldBeFalse)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Import dashboard validation", func() {
|
||||
dto := &SaveDashboardDTO{}
|
||||
|
||||
Convey("Should return validation error if dashboard is provisioned", func() {
|
||||
provisioningValidated := false
|
||||
bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error {
|
||||
provisioningValidated = true
|
||||
cmd.Result = true
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error {
|
||||
cmd.Result = &models.ValidateDashboardBeforeSaveResult{}
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.SaveProvisionedDashboardCommand) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.UpdateDashboardAlertsCommand) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
dto.Dashboard = models.NewDashboard("Dash")
|
||||
dto.Dashboard.SetId(3)
|
||||
dto.User = &models.SignedInUser{UserId: 1}
|
||||
_, err := service.ImportDashboard(dto)
|
||||
So(provisioningValidated, ShouldBeTrue)
|
||||
So(err, ShouldEqual, models.ErrDashboardCannotSaveProvisionedDashboard)
|
||||
})
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
guardian.New = origNewDashboardGuardian
|
||||
})
|
||||
|
||||
@@ -104,7 +104,7 @@ func (dr *dashboardServiceImpl) CreateFolder(cmd *models.CreateFolderCommand) er
|
||||
User: dr.user,
|
||||
}
|
||||
|
||||
saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false)
|
||||
saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false, false)
|
||||
if err != nil {
|
||||
return toFolderError(err)
|
||||
}
|
||||
@@ -141,7 +141,7 @@ func (dr *dashboardServiceImpl) UpdateFolder(existingUid string, cmd *models.Upd
|
||||
Overwrite: cmd.Overwrite,
|
||||
}
|
||||
|
||||
saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false)
|
||||
saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false, false)
|
||||
if err != nil {
|
||||
return toFolderError(err)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ func TestFolderService(t *testing.T) {
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error {
|
||||
cmd.Result = &models.ValidateDashboardBeforeSaveResult{}
|
||||
return models.ErrDashboardUpdateAccessDenied
|
||||
})
|
||||
|
||||
@@ -92,6 +93,7 @@ func TestFolderService(t *testing.T) {
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error {
|
||||
cmd.Result = &models.ValidateDashboardBeforeSaveResult{}
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -108,11 +110,19 @@ func TestFolderService(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
provisioningValidated := false
|
||||
|
||||
bus.AddHandler("test", func(query *models.IsDashboardProvisionedQuery) error {
|
||||
provisioningValidated = true
|
||||
return nil
|
||||
})
|
||||
|
||||
Convey("When creating folder should not return access denied error", func() {
|
||||
err := service.CreateFolder(&models.CreateFolderCommand{
|
||||
Title: "Folder",
|
||||
})
|
||||
So(err, ShouldBeNil)
|
||||
So(provisioningValidated, ShouldBeFalse)
|
||||
})
|
||||
|
||||
Convey("When updating folder should not return access denied error", func() {
|
||||
@@ -121,6 +131,7 @@ func TestFolderService(t *testing.T) {
|
||||
Title: "Folder",
|
||||
})
|
||||
So(err, ShouldBeNil)
|
||||
So(provisioningValidated, ShouldBeFalse)
|
||||
})
|
||||
|
||||
Convey("When deleting folder by uid should not return access denied error", func() {
|
||||
|
||||
@@ -173,7 +173,7 @@ func (g *dashboardGuardianImpl) CheckPermissionBeforeUpdate(permission m.Permiss
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return g.checkAcl(permission, acl)
|
||||
return g.checkAcl(permission, existingPermissions)
|
||||
}
|
||||
|
||||
// GetAcl returns dashboard acl
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
256
pkg/services/guardian/guardian_util_test.go
Normal file
256
pkg/services/guardian/guardian_util_test.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package guardian
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
type scenarioContext struct {
|
||||
t *testing.T
|
||||
orgRoleScenario string
|
||||
permissionScenario string
|
||||
g DashboardGuardian
|
||||
givenUser *m.SignedInUser
|
||||
givenDashboardID int64
|
||||
givenPermissions []*m.DashboardAclInfoDTO
|
||||
givenTeams []*m.Team
|
||||
updatePermissions []*m.DashboardAcl
|
||||
expectedFlags permissionFlags
|
||||
callerFile string
|
||||
callerLine int
|
||||
}
|
||||
|
||||
type scenarioFunc func(c *scenarioContext)
|
||||
|
||||
func orgRoleScenario(desc string, t *testing.T, role m.RoleType, fn scenarioFunc) {
|
||||
user := &m.SignedInUser{
|
||||
UserId: userID,
|
||||
OrgId: orgID,
|
||||
OrgRole: role,
|
||||
}
|
||||
guard := New(dashboardID, orgID, user)
|
||||
sc := &scenarioContext{
|
||||
t: t,
|
||||
orgRoleScenario: desc,
|
||||
givenUser: user,
|
||||
givenDashboardID: dashboardID,
|
||||
g: guard,
|
||||
}
|
||||
|
||||
Convey(desc, func() {
|
||||
fn(sc)
|
||||
})
|
||||
}
|
||||
|
||||
func permissionScenario(desc string, dashboardID int64, sc *scenarioContext, permissions []*m.DashboardAclInfoDTO, fn scenarioFunc) {
|
||||
bus.ClearBusHandlers()
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
|
||||
if query.OrgId != sc.givenUser.OrgId {
|
||||
sc.reportFailure("Invalid organization id for GetDashboardAclInfoListQuery", sc.givenUser.OrgId, query.OrgId)
|
||||
}
|
||||
if query.DashboardId != sc.givenDashboardID {
|
||||
sc.reportFailure("Invalid dashboard id for GetDashboardAclInfoListQuery", sc.givenDashboardID, query.DashboardId)
|
||||
}
|
||||
|
||||
query.Result = permissions
|
||||
return nil
|
||||
})
|
||||
|
||||
teams := []*m.Team{}
|
||||
|
||||
for _, p := range permissions {
|
||||
if p.TeamId > 0 {
|
||||
teams = append(teams, &m.Team{Id: p.TeamId})
|
||||
}
|
||||
}
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
|
||||
if query.OrgId != sc.givenUser.OrgId {
|
||||
sc.reportFailure("Invalid organization id for GetTeamsByUserQuery", sc.givenUser.OrgId, query.OrgId)
|
||||
}
|
||||
if query.UserId != sc.givenUser.UserId {
|
||||
sc.reportFailure("Invalid user id for GetTeamsByUserQuery", sc.givenUser.UserId, query.UserId)
|
||||
}
|
||||
|
||||
query.Result = teams
|
||||
return nil
|
||||
})
|
||||
|
||||
sc.permissionScenario = desc
|
||||
sc.g = New(dashboardID, sc.givenUser.OrgId, sc.givenUser)
|
||||
sc.givenDashboardID = dashboardID
|
||||
sc.givenPermissions = permissions
|
||||
sc.givenTeams = teams
|
||||
|
||||
Convey(desc, func() {
|
||||
fn(sc)
|
||||
})
|
||||
}
|
||||
|
||||
type permissionType uint8
|
||||
|
||||
const (
|
||||
USER permissionType = 1 << iota
|
||||
TEAM
|
||||
EDITOR
|
||||
VIEWER
|
||||
)
|
||||
|
||||
func (p permissionType) String() string {
|
||||
names := map[uint8]string{
|
||||
uint8(USER): "user",
|
||||
uint8(TEAM): "team",
|
||||
uint8(EDITOR): "editor role",
|
||||
uint8(VIEWER): "viewer role",
|
||||
}
|
||||
return names[uint8(p)]
|
||||
}
|
||||
|
||||
type permissionFlags uint8
|
||||
|
||||
const (
|
||||
NO_ACCESS permissionFlags = 1 << iota
|
||||
CAN_ADMIN
|
||||
CAN_EDIT
|
||||
CAN_SAVE
|
||||
CAN_VIEW
|
||||
FULL_ACCESS = CAN_ADMIN | CAN_EDIT | CAN_SAVE | CAN_VIEW
|
||||
EDITOR_ACCESS = CAN_EDIT | CAN_SAVE | CAN_VIEW
|
||||
VIEWER_ACCESS = CAN_VIEW
|
||||
)
|
||||
|
||||
func (flag permissionFlags) canAdmin() bool {
|
||||
return flag&CAN_ADMIN != 0
|
||||
}
|
||||
|
||||
func (flag permissionFlags) canEdit() bool {
|
||||
return flag&CAN_EDIT != 0
|
||||
}
|
||||
|
||||
func (flag permissionFlags) canSave() bool {
|
||||
return flag&CAN_SAVE != 0
|
||||
}
|
||||
|
||||
func (flag permissionFlags) canView() bool {
|
||||
return flag&CAN_VIEW != 0
|
||||
}
|
||||
|
||||
func (flag permissionFlags) noAccess() bool {
|
||||
return flag&(CAN_ADMIN|CAN_EDIT|CAN_SAVE|CAN_VIEW) == 0
|
||||
}
|
||||
|
||||
func (f permissionFlags) String() string {
|
||||
r := []string{}
|
||||
|
||||
if f.canAdmin() {
|
||||
r = append(r, "admin")
|
||||
}
|
||||
|
||||
if f.canEdit() {
|
||||
r = append(r, "edit")
|
||||
}
|
||||
|
||||
if f.canSave() {
|
||||
r = append(r, "save")
|
||||
}
|
||||
|
||||
if f.canView() {
|
||||
r = append(r, "view")
|
||||
}
|
||||
|
||||
if f.noAccess() {
|
||||
r = append(r, "<no access>")
|
||||
}
|
||||
|
||||
return strings.Join(r[:], ", ")
|
||||
}
|
||||
|
||||
func (sc *scenarioContext) reportSuccess() {
|
||||
So(true, ShouldBeTrue)
|
||||
}
|
||||
|
||||
func (sc *scenarioContext) reportFailure(desc string, expected interface{}, actual interface{}) {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("\n")
|
||||
buf.WriteString(sc.orgRoleScenario)
|
||||
buf.WriteString(" ")
|
||||
buf.WriteString(sc.permissionScenario)
|
||||
buf.WriteString("\n ")
|
||||
buf.WriteString(desc)
|
||||
buf.WriteString("\n")
|
||||
buf.WriteString(fmt.Sprintf("Source test: %s:%d\n", sc.callerFile, sc.callerLine))
|
||||
buf.WriteString(fmt.Sprintf("Expected: %v\n", expected))
|
||||
buf.WriteString(fmt.Sprintf("Actual: %v\n", actual))
|
||||
buf.WriteString("Context:")
|
||||
buf.WriteString(fmt.Sprintf("\n Given user: orgRole=%s, id=%d, orgId=%d", sc.givenUser.OrgRole, sc.givenUser.UserId, sc.givenUser.OrgId))
|
||||
buf.WriteString(fmt.Sprintf("\n Given dashboard id: %d", sc.givenDashboardID))
|
||||
|
||||
for i, p := range sc.givenPermissions {
|
||||
r := "<nil>"
|
||||
if p.Role != nil {
|
||||
r = string(*p.Role)
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("\n Given permission (%d): dashboardId=%d, userId=%d, teamId=%d, role=%v, permission=%s", i, p.DashboardId, p.UserId, p.TeamId, r, p.Permission.String()))
|
||||
}
|
||||
|
||||
for i, t := range sc.givenTeams {
|
||||
buf.WriteString(fmt.Sprintf("\n Given team (%d): id=%d", i, t.Id))
|
||||
}
|
||||
|
||||
for i, p := range sc.updatePermissions {
|
||||
r := "<nil>"
|
||||
if p.Role != nil {
|
||||
r = string(*p.Role)
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("\n Update permission (%d): dashboardId=%d, userId=%d, teamId=%d, role=%v, permission=%s", i, p.DashboardId, p.UserId, p.TeamId, r, p.Permission.String()))
|
||||
}
|
||||
|
||||
sc.t.Fatalf(buf.String())
|
||||
}
|
||||
|
||||
func newCustomUserPermission(dashboardID int64, userID int64, permission m.PermissionType) *m.DashboardAcl {
|
||||
return &m.DashboardAcl{OrgId: orgID, DashboardId: dashboardID, UserId: userID, Permission: permission}
|
||||
}
|
||||
|
||||
func newDefaultUserPermission(dashboardID int64, permission m.PermissionType) *m.DashboardAcl {
|
||||
return newCustomUserPermission(dashboardID, userID, permission)
|
||||
}
|
||||
|
||||
func newCustomTeamPermission(dashboardID int64, teamID int64, permission m.PermissionType) *m.DashboardAcl {
|
||||
return &m.DashboardAcl{OrgId: orgID, DashboardId: dashboardID, TeamId: teamID, Permission: permission}
|
||||
}
|
||||
|
||||
func newDefaultTeamPermission(dashboardID int64, permission m.PermissionType) *m.DashboardAcl {
|
||||
return newCustomTeamPermission(dashboardID, teamID, permission)
|
||||
}
|
||||
|
||||
func newAdminRolePermission(dashboardID int64, permission m.PermissionType) *m.DashboardAcl {
|
||||
return &m.DashboardAcl{OrgId: orgID, DashboardId: dashboardID, Role: &adminRole, Permission: permission}
|
||||
}
|
||||
|
||||
func newEditorRolePermission(dashboardID int64, permission m.PermissionType) *m.DashboardAcl {
|
||||
return &m.DashboardAcl{OrgId: orgID, DashboardId: dashboardID, Role: &editorRole, Permission: permission}
|
||||
}
|
||||
|
||||
func newViewerRolePermission(dashboardID int64, permission m.PermissionType) *m.DashboardAcl {
|
||||
return &m.DashboardAcl{OrgId: orgID, DashboardId: dashboardID, Role: &viewerRole, Permission: permission}
|
||||
}
|
||||
|
||||
func toDto(acl *m.DashboardAcl) *m.DashboardAclInfoDTO {
|
||||
return &m.DashboardAclInfoDTO{
|
||||
OrgId: acl.OrgId,
|
||||
DashboardId: acl.DashboardId,
|
||||
UserId: acl.UserId,
|
||||
TeamId: acl.TeamId,
|
||||
Role: acl.Role,
|
||||
Permission: acl.Permission,
|
||||
PermissionName: acl.Permission.String(),
|
||||
}
|
||||
}
|
||||
@@ -55,9 +55,6 @@ func createDashboardJson(data *simplejson.Json, lastModified time.Time, cfg *Das
|
||||
dash.OrgId = cfg.OrgId
|
||||
dash.Dashboard.OrgId = cfg.OrgId
|
||||
dash.Dashboard.FolderId = folderId
|
||||
if !cfg.Editable {
|
||||
dash.Dashboard.Data.Set("editable", cfg.Editable)
|
||||
}
|
||||
|
||||
if dash.Dashboard.Title == "" {
|
||||
return nil, models.ErrDashboardTitleEmpty
|
||||
|
||||
@@ -544,6 +544,10 @@ func getExistingDashboardByIdOrUidForUpdate(sess *DBSession, cmd *m.ValidateDash
|
||||
dash.SetId(existingByUid.Id)
|
||||
dash.SetUid(existingByUid.Uid)
|
||||
existing = existingByUid
|
||||
|
||||
if !dash.IsFolder {
|
||||
cmd.Result.IsParentFolderChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
if (existing.IsFolder && !dash.IsFolder) ||
|
||||
@@ -551,6 +555,10 @@ func getExistingDashboardByIdOrUidForUpdate(sess *DBSession, cmd *m.ValidateDash
|
||||
return m.ErrDashboardTypeMismatch
|
||||
}
|
||||
|
||||
if !dash.IsFolder && dash.FolderId != existing.FolderId {
|
||||
cmd.Result.IsParentFolderChanged = true
|
||||
}
|
||||
|
||||
// check for is someone else has written in between
|
||||
if dash.Version != existing.Version {
|
||||
if cmd.Overwrite {
|
||||
@@ -586,6 +594,10 @@ func getExistingDashboardByTitleAndFolder(sess *DBSession, cmd *m.ValidateDashbo
|
||||
return m.ErrDashboardFolderWithSameNameAsDashboard
|
||||
}
|
||||
|
||||
if !dash.IsFolder && (dash.FolderId != existing.FolderId || dash.Id == 0) {
|
||||
cmd.Result.IsParentFolderChanged = true
|
||||
}
|
||||
|
||||
if cmd.Overwrite {
|
||||
dash.SetId(existing.Id)
|
||||
dash.SetUid(existing.Uid)
|
||||
@@ -599,6 +611,7 @@ func getExistingDashboardByTitleAndFolder(sess *DBSession, cmd *m.ValidateDashbo
|
||||
}
|
||||
|
||||
func ValidateDashboardBeforeSave(cmd *m.ValidateDashboardBeforeSaveCommand) (err error) {
|
||||
cmd.Result = &m.ValidateDashboardBeforeSaveResult{}
|
||||
return inTransaction(func(sess *DBSession) error {
|
||||
if err = getExistingDashboardByIdOrUidForUpdate(sess, cmd); err != nil {
|
||||
return err
|
||||
|
||||
@@ -92,6 +92,7 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
|
||||
u.login AS user_login,
|
||||
u.email AS user_email,
|
||||
ug.name AS team,
|
||||
ug.email AS team_email,
|
||||
d.title,
|
||||
d.slug,
|
||||
d.uid,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
func init() {
|
||||
bus.AddHandler("sql", GetProvisionedDashboardDataQuery)
|
||||
bus.AddHandler("sql", SaveProvisionedDashboard)
|
||||
bus.AddHandler("sql", GetProvisionedDataByDashboardId)
|
||||
}
|
||||
|
||||
type DashboardExtras struct {
|
||||
@@ -17,6 +18,19 @@ type DashboardExtras struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
func GetProvisionedDataByDashboardId(cmd *models.IsDashboardProvisionedQuery) error {
|
||||
result := &models.DashboardProvisioning{}
|
||||
|
||||
exist, err := x.Where("dashboard_id = ?", cmd.DashboardId).Get(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Result = exist
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SaveProvisionedDashboard(cmd *models.SaveProvisionedDashboardCommand) error {
|
||||
return inTransaction(func(sess *DBSession) error {
|
||||
err := saveDashboard(sess, cmd.DashboardCmd)
|
||||
|
||||
@@ -50,6 +50,23 @@ func TestDashboardProvisioningTest(t *testing.T) {
|
||||
So(query.Result[0].DashboardId, ShouldEqual, dashId)
|
||||
So(query.Result[0].Updated, ShouldEqual, now.Unix())
|
||||
})
|
||||
|
||||
Convey("Can query for one provisioned dashboard", func() {
|
||||
query := &models.IsDashboardProvisionedQuery{DashboardId: cmd.Result.Id}
|
||||
|
||||
err := GetProvisionedDataByDashboardId(query)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(query.Result, ShouldBeTrue)
|
||||
})
|
||||
|
||||
Convey("Can query for none provisioned dashboard", func() {
|
||||
query := &models.IsDashboardProvisionedQuery{DashboardId: 3000}
|
||||
|
||||
err := GetProvisionedDataByDashboardId(query)
|
||||
So(err, ShouldBeNil)
|
||||
So(query.Result, ShouldBeFalse)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
var testOrgId int64 = 1
|
||||
|
||||
Convey("Given saved folders and dashboards in organization A", func() {
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error {
|
||||
return nil
|
||||
})
|
||||
@@ -28,6 +27,11 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(cmd *models.IsDashboardProvisionedQuery) error {
|
||||
cmd.Result = false
|
||||
return nil
|
||||
})
|
||||
|
||||
savedFolder := saveTestFolder("Saved folder", testOrgId)
|
||||
savedDashInFolder := saveTestDashboard("Saved dash in folder", testOrgId, savedFolder.Id)
|
||||
saveTestDashboard("Other saved dash in folder", testOrgId, savedFolder.Id)
|
||||
@@ -74,7 +78,7 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
Convey("Given organization B", func() {
|
||||
var otherOrgId int64 = 2
|
||||
|
||||
Convey("When saving a dashboard with id that are saved in organization A", func() {
|
||||
Convey("When creating a dashboard with same id as dashboard in organization A", func() {
|
||||
cmd := models.SaveDashboardCommand{
|
||||
OrgId: otherOrgId,
|
||||
Dashboard: simplejson.NewFromAny(map[string]interface{}{
|
||||
@@ -93,7 +97,7 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
})
|
||||
|
||||
permissionScenario("Given user has permission to save", true, func(sc *dashboardPermissionScenarioContext) {
|
||||
Convey("When saving a dashboard with uid that are saved in organization A", func() {
|
||||
Convey("When creating a dashboard with same uid as dashboard in organization A", func() {
|
||||
var otherOrgId int64 = 2
|
||||
cmd := models.SaveDashboardCommand{
|
||||
OrgId: otherOrgId,
|
||||
@@ -106,7 +110,7 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
|
||||
res := callSaveWithResult(cmd)
|
||||
|
||||
Convey("It should create dashboard in other organization", func() {
|
||||
Convey("It should create a new dashboard in organization B", func() {
|
||||
So(res, ShouldNotBeNil)
|
||||
|
||||
query := models.GetDashboardQuery{OrgId: otherOrgId, Uid: savedDashInFolder.Uid}
|
||||
@@ -126,7 +130,7 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
|
||||
permissionScenario("Given user has no permission to save", false, func(sc *dashboardPermissionScenarioContext) {
|
||||
|
||||
Convey("When trying to create a new dashboard in the General folder", func() {
|
||||
Convey("When creating a new dashboard in the General folder", func() {
|
||||
cmd := models.SaveDashboardCommand{
|
||||
OrgId: testOrgId,
|
||||
Dashboard: simplejson.NewFromAny(map[string]interface{}{
|
||||
@@ -138,7 +142,7 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
|
||||
err := callSaveWithError(cmd)
|
||||
|
||||
Convey("It should call dashboard guardian with correct arguments and result in access denied error", func() {
|
||||
Convey("It should create dashboard guardian for General Folder with correct arguments and result in access denied error", func() {
|
||||
So(err, ShouldNotBeNil)
|
||||
So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
|
||||
|
||||
@@ -148,7 +152,7 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When trying to create a new dashboard in other folder", func() {
|
||||
Convey("When creating a new dashboard in other folder", func() {
|
||||
cmd := models.SaveDashboardCommand{
|
||||
OrgId: testOrgId,
|
||||
Dashboard: simplejson.NewFromAny(map[string]interface{}{
|
||||
@@ -161,7 +165,7 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
|
||||
err := callSaveWithError(cmd)
|
||||
|
||||
Convey("It should call dashboard guardian with correct arguments and rsult in access denied error", func() {
|
||||
Convey("It should create dashboard guardian for other folder with correct arguments and rsult in access denied error", func() {
|
||||
So(err, ShouldNotBeNil)
|
||||
So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
|
||||
|
||||
@@ -171,7 +175,54 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When trying to update a dashboard by existing id in the General folder", func() {
|
||||
Convey("When creating a new dashboard by existing title in folder", func() {
|
||||
cmd := models.SaveDashboardCommand{
|
||||
OrgId: testOrgId,
|
||||
Dashboard: simplejson.NewFromAny(map[string]interface{}{
|
||||
"title": savedDashInFolder.Title,
|
||||
}),
|
||||
FolderId: savedFolder.Id,
|
||||
UserId: 10000,
|
||||
Overwrite: true,
|
||||
}
|
||||
|
||||
err := callSaveWithError(cmd)
|
||||
|
||||
Convey("It should create dashboard guardian for folder with correct arguments and result in access denied error", func() {
|
||||
So(err, ShouldNotBeNil)
|
||||
So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
|
||||
|
||||
So(sc.dashboardGuardianMock.DashId, ShouldEqual, savedFolder.Id)
|
||||
So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId)
|
||||
So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When creating a new dashboard by existing uid in folder", func() {
|
||||
cmd := models.SaveDashboardCommand{
|
||||
OrgId: testOrgId,
|
||||
Dashboard: simplejson.NewFromAny(map[string]interface{}{
|
||||
"uid": savedDashInFolder.Uid,
|
||||
"title": "New dash",
|
||||
}),
|
||||
FolderId: savedFolder.Id,
|
||||
UserId: 10000,
|
||||
Overwrite: true,
|
||||
}
|
||||
|
||||
err := callSaveWithError(cmd)
|
||||
|
||||
Convey("It should create dashboard guardian for folder with correct arguments and result in access denied error", func() {
|
||||
So(err, ShouldNotBeNil)
|
||||
So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
|
||||
|
||||
So(sc.dashboardGuardianMock.DashId, ShouldEqual, savedFolder.Id)
|
||||
So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId)
|
||||
So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When updating a dashboard by existing id in the General folder", func() {
|
||||
cmd := models.SaveDashboardCommand{
|
||||
OrgId: testOrgId,
|
||||
Dashboard: simplejson.NewFromAny(map[string]interface{}{
|
||||
@@ -185,7 +236,7 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
|
||||
err := callSaveWithError(cmd)
|
||||
|
||||
Convey("It should call dashboard guardian with correct arguments and result in access denied error", func() {
|
||||
Convey("It should create dashboard guardian for dashboard with correct arguments and result in access denied error", func() {
|
||||
So(err, ShouldNotBeNil)
|
||||
So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
|
||||
|
||||
@@ -195,7 +246,7 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When trying to update a dashboard by existing id in other folder", func() {
|
||||
Convey("When updating a dashboard by existing id in other folder", func() {
|
||||
cmd := models.SaveDashboardCommand{
|
||||
OrgId: testOrgId,
|
||||
Dashboard: simplejson.NewFromAny(map[string]interface{}{
|
||||
@@ -209,7 +260,7 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
|
||||
err := callSaveWithError(cmd)
|
||||
|
||||
Convey("It should call dashboard guardian with correct arguments and result in access denied error", func() {
|
||||
Convey("It should create dashboard guardian for dashboard with correct arguments and result in access denied error", func() {
|
||||
So(err, ShouldNotBeNil)
|
||||
So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
|
||||
|
||||
@@ -218,6 +269,102 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When moving a dashboard by existing id to other folder from General folder", func() {
|
||||
cmd := models.SaveDashboardCommand{
|
||||
OrgId: testOrgId,
|
||||
Dashboard: simplejson.NewFromAny(map[string]interface{}{
|
||||
"id": savedDashInGeneralFolder.Id,
|
||||
"title": "Dash",
|
||||
}),
|
||||
FolderId: otherSavedFolder.Id,
|
||||
UserId: 10000,
|
||||
Overwrite: true,
|
||||
}
|
||||
|
||||
err := callSaveWithError(cmd)
|
||||
|
||||
Convey("It should create dashboard guardian for other folder with correct arguments and result in access denied error", func() {
|
||||
So(err, ShouldNotBeNil)
|
||||
So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
|
||||
|
||||
So(sc.dashboardGuardianMock.DashId, ShouldEqual, otherSavedFolder.Id)
|
||||
So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId)
|
||||
So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When moving a dashboard by existing id to the General folder from other folder", func() {
|
||||
cmd := models.SaveDashboardCommand{
|
||||
OrgId: testOrgId,
|
||||
Dashboard: simplejson.NewFromAny(map[string]interface{}{
|
||||
"id": savedDashInFolder.Id,
|
||||
"title": "Dash",
|
||||
}),
|
||||
FolderId: 0,
|
||||
UserId: 10000,
|
||||
Overwrite: true,
|
||||
}
|
||||
|
||||
err := callSaveWithError(cmd)
|
||||
|
||||
Convey("It should create dashboard guardian for General folder with correct arguments and result in access denied error", func() {
|
||||
So(err, ShouldNotBeNil)
|
||||
So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
|
||||
|
||||
So(sc.dashboardGuardianMock.DashId, ShouldEqual, 0)
|
||||
So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId)
|
||||
So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When moving a dashboard by existing uid to other folder from General folder", func() {
|
||||
cmd := models.SaveDashboardCommand{
|
||||
OrgId: testOrgId,
|
||||
Dashboard: simplejson.NewFromAny(map[string]interface{}{
|
||||
"uid": savedDashInGeneralFolder.Uid,
|
||||
"title": "Dash",
|
||||
}),
|
||||
FolderId: otherSavedFolder.Id,
|
||||
UserId: 10000,
|
||||
Overwrite: true,
|
||||
}
|
||||
|
||||
err := callSaveWithError(cmd)
|
||||
|
||||
Convey("It should create dashboard guardian for other folder with correct arguments and result in access denied error", func() {
|
||||
So(err, ShouldNotBeNil)
|
||||
So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
|
||||
|
||||
So(sc.dashboardGuardianMock.DashId, ShouldEqual, otherSavedFolder.Id)
|
||||
So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId)
|
||||
So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When moving a dashboard by existing uid to the General folder from other folder", func() {
|
||||
cmd := models.SaveDashboardCommand{
|
||||
OrgId: testOrgId,
|
||||
Dashboard: simplejson.NewFromAny(map[string]interface{}{
|
||||
"uid": savedDashInFolder.Uid,
|
||||
"title": "Dash",
|
||||
}),
|
||||
FolderId: 0,
|
||||
UserId: 10000,
|
||||
Overwrite: true,
|
||||
}
|
||||
|
||||
err := callSaveWithError(cmd)
|
||||
|
||||
Convey("It should create dashboard guardian for General folder with correct arguments and result in access denied error", func() {
|
||||
So(err, ShouldNotBeNil)
|
||||
So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
|
||||
|
||||
So(sc.dashboardGuardianMock.DashId, ShouldEqual, 0)
|
||||
So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId)
|
||||
So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Given user has permission to save
|
||||
@@ -668,7 +815,7 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When trying to update existing folder to a dashboard using id", func() {
|
||||
Convey("When updating existing folder to a dashboard using id", func() {
|
||||
cmd := models.SaveDashboardCommand{
|
||||
OrgId: 1,
|
||||
Dashboard: simplejson.NewFromAny(map[string]interface{}{
|
||||
@@ -687,7 +834,7 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When trying to update existing dashboard to a folder using id", func() {
|
||||
Convey("When updating existing dashboard to a folder using id", func() {
|
||||
cmd := models.SaveDashboardCommand{
|
||||
OrgId: 1,
|
||||
Dashboard: simplejson.NewFromAny(map[string]interface{}{
|
||||
@@ -706,7 +853,7 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When trying to update existing folder to a dashboard using uid", func() {
|
||||
Convey("When updating existing folder to a dashboard using uid", func() {
|
||||
cmd := models.SaveDashboardCommand{
|
||||
OrgId: 1,
|
||||
Dashboard: simplejson.NewFromAny(map[string]interface{}{
|
||||
@@ -725,7 +872,7 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When trying to update existing dashboard to a folder using uid", func() {
|
||||
Convey("When updating existing dashboard to a folder using uid", func() {
|
||||
cmd := models.SaveDashboardCommand{
|
||||
OrgId: 1,
|
||||
Dashboard: simplejson.NewFromAny(map[string]interface{}{
|
||||
@@ -744,7 +891,7 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When trying to update existing folder to a dashboard using title", func() {
|
||||
Convey("When updating existing folder to a dashboard using title", func() {
|
||||
cmd := models.SaveDashboardCommand{
|
||||
OrgId: 1,
|
||||
Dashboard: simplejson.NewFromAny(map[string]interface{}{
|
||||
@@ -762,7 +909,7 @@ func TestIntegratedDashboardService(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When trying to update existing dashboard to a folder using title", func() {
|
||||
Convey("When updating existing dashboard to a folder using title", func() {
|
||||
cmd := models.SaveDashboardCommand{
|
||||
OrgId: 1,
|
||||
Dashboard: simplejson.NewFromAny(map[string]interface{}{
|
||||
@@ -850,23 +997,6 @@ func callSaveWithError(cmd models.SaveDashboardCommand) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func dashboardServiceScenario(desc string, mock *guardian.FakeDashboardGuardian, fn scenarioFunc) {
|
||||
Convey(desc, func() {
|
||||
origNewDashboardGuardian := guardian.New
|
||||
guardian.MockDashboardGuardian(mock)
|
||||
|
||||
sc := &scenarioContext{
|
||||
dashboardGuardianMock: mock,
|
||||
}
|
||||
|
||||
defer func() {
|
||||
guardian.New = origNewDashboardGuardian
|
||||
}()
|
||||
|
||||
fn(sc)
|
||||
})
|
||||
}
|
||||
|
||||
func saveTestDashboard(title string, orgId int64, folderId int64) *models.Dashboard {
|
||||
cmd := models.SaveDashboardCommand{
|
||||
OrgId: orgId,
|
||||
|
||||
@@ -68,6 +68,7 @@ func GetSystemStats(query *m.GetSystemStatsQuery) error {
|
||||
}
|
||||
|
||||
query.Result = &stats
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ func (e *CloudWatchExecutor) executeAnnotationQuery(ctx context.Context, queryCo
|
||||
MetricName: aws.String(metricName),
|
||||
Dimensions: qd,
|
||||
Statistic: aws.String(s),
|
||||
Period: aws.Int64(int64(period)),
|
||||
Period: aws.Int64(period),
|
||||
}
|
||||
resp, err := svc.DescribeAlarmsForMetric(params)
|
||||
if err != nil {
|
||||
@@ -88,7 +88,7 @@ func (e *CloudWatchExecutor) executeAnnotationQuery(ctx context.Context, queryCo
|
||||
MetricName: aws.String(metricName),
|
||||
Dimensions: qd,
|
||||
ExtendedStatistic: aws.String(s),
|
||||
Period: aws.Int64(int64(period)),
|
||||
Period: aws.Int64(period),
|
||||
}
|
||||
resp, err := svc.DescribeAlarmsForMetric(params)
|
||||
if err != nil {
|
||||
|
||||
@@ -108,8 +108,8 @@ func (e *PrometheusExecutor) Query(ctx context.Context, dsInfo *models.DataSourc
|
||||
|
||||
span, ctx := opentracing.StartSpanFromContext(ctx, "alerting.prometheus")
|
||||
span.SetTag("expr", query.Expr)
|
||||
span.SetTag("start_unixnano", int64(query.Start.UnixNano()))
|
||||
span.SetTag("stop_unixnano", int64(query.End.UnixNano()))
|
||||
span.SetTag("start_unixnano", query.Start.UnixNano())
|
||||
span.SetTag("stop_unixnano", query.End.UnixNano())
|
||||
defer span.Finish()
|
||||
|
||||
value, err := client.QueryRange(ctx, query.Expr, timeRange)
|
||||
|
||||
@@ -39,7 +39,7 @@ class AddPermissions extends Component<IProps, any> {
|
||||
permissions.newItem.setUser(null, null);
|
||||
return;
|
||||
}
|
||||
return permissions.newItem.setUser(user.id, user.login);
|
||||
return permissions.newItem.setUser(user.id, user.login, user.avatarUrl);
|
||||
}
|
||||
|
||||
teamPicked(team: Team) {
|
||||
@@ -48,7 +48,7 @@ class AddPermissions extends Component<IProps, any> {
|
||||
permissions.newItem.setTeam(null, null);
|
||||
return;
|
||||
}
|
||||
return permissions.newItem.setTeam(team.id, team.name);
|
||||
return permissions.newItem.setTeam(team.id, team.name, team.avatarUrl);
|
||||
}
|
||||
|
||||
permissionPicked(permission: OptionWithDescription) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Component } from 'react';
|
||||
import React, { Component } from 'react';
|
||||
import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
|
||||
import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
|
||||
|
||||
@@ -12,9 +12,12 @@ export default class DisabledPermissionListItem extends Component<IProps, any> {
|
||||
|
||||
return (
|
||||
<tr className="gf-form-disabled">
|
||||
<td style={{ width: '100%' }}>
|
||||
<i className={`fa--permissions-list ${item.icon}`} />
|
||||
<span dangerouslySetInnerHTML={{ __html: item.nameHtml }} />
|
||||
<td style={{ width: '1%' }}>
|
||||
<i style={{ width: '25px', height: '25px' }} className="gicon gicon-shield" />
|
||||
</td>
|
||||
<td style={{ width: '90%' }}>
|
||||
{item.name}
|
||||
<span className="filter-table__weak-italic"> (Role)</span>
|
||||
</td>
|
||||
<td />
|
||||
<td className="query-keyword">Can</td>
|
||||
|
||||
@@ -15,9 +15,8 @@ export interface DashboardAcl {
|
||||
permissionName?: string;
|
||||
role?: string;
|
||||
icon?: string;
|
||||
nameHtml?: string;
|
||||
name?: string;
|
||||
inherited?: boolean;
|
||||
sortName?: string;
|
||||
sortRank?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Component } from 'react';
|
||||
import React, { Component } from 'react';
|
||||
import PermissionsListItem from './PermissionsListItem';
|
||||
import DisabledPermissionsListItem from './DisabledPermissionsListItem';
|
||||
import { observer } from 'mobx-react';
|
||||
@@ -23,7 +23,7 @@ class PermissionsList extends Component<IProps, any> {
|
||||
<DisabledPermissionsListItem
|
||||
key={0}
|
||||
item={{
|
||||
nameHtml: 'Everyone with <span class="query-keyword">Admin</span> Role',
|
||||
name: 'Admin',
|
||||
permission: 4,
|
||||
icon: 'fa fa-fw fa-street-view',
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
|
||||
import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
|
||||
@@ -7,6 +7,30 @@ const setClassNameHelper = inherited => {
|
||||
return inherited ? 'gf-form-disabled' : '';
|
||||
};
|
||||
|
||||
function ItemAvatar({ item }) {
|
||||
if (item.userAvatarUrl) {
|
||||
return <img className="filter-table__avatar" src={item.userAvatarUrl} />;
|
||||
}
|
||||
if (item.teamAvatarUrl) {
|
||||
return <img className="filter-table__avatar" src={item.teamAvatarUrl} />;
|
||||
}
|
||||
if (item.role === 'Editor') {
|
||||
return <i style={{ width: '25px', height: '25px' }} className="gicon gicon-editor" />;
|
||||
}
|
||||
|
||||
return <i style={{ width: '25px', height: '25px' }} className="gicon gicon-viewer" />;
|
||||
}
|
||||
|
||||
function ItemDescription({ item }) {
|
||||
if (item.userId) {
|
||||
return <span className="filter-table__weak-italic">(User)</span>;
|
||||
}
|
||||
if (item.teamId) {
|
||||
return <span className="filter-table__weak-italic">(Team)</span>;
|
||||
}
|
||||
return <span className="filter-table__weak-italic">(Role)</span>;
|
||||
}
|
||||
|
||||
export default observer(({ item, removeItem, permissionChanged, itemIndex, folderInfo }) => {
|
||||
const handleRemoveItem = evt => {
|
||||
evt.preventDefault();
|
||||
@@ -21,9 +45,11 @@ export default observer(({ item, removeItem, permissionChanged, itemIndex, folde
|
||||
|
||||
return (
|
||||
<tr className={setClassNameHelper(item.inherited)}>
|
||||
<td style={{ width: '100%' }}>
|
||||
<i className={`fa--permissions-list ${item.icon}`} />
|
||||
<span dangerouslySetInnerHTML={{ __html: item.nameHtml }} />
|
||||
<td style={{ width: '1%' }}>
|
||||
<ItemAvatar item={item} />
|
||||
</td>
|
||||
<td style={{ width: '90%' }}>
|
||||
{item.name} <ItemDescription item={item} />
|
||||
</td>
|
||||
<td>
|
||||
{item.inherited &&
|
||||
|
||||
@@ -6,6 +6,7 @@ import './dashnav/dashnav';
|
||||
import './submenu/submenu';
|
||||
import './save_as_modal';
|
||||
import './save_modal';
|
||||
import './save_provisioned_modal';
|
||||
import './shareModalCtrl';
|
||||
import './share_snapshot_ctrl';
|
||||
import './dashboard_srv';
|
||||
|
||||
186
public/app/features/dashboard/change_tracker.ts
Normal file
186
public/app/features/dashboard/change_tracker.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import angular from 'angular';
|
||||
import _ from 'lodash';
|
||||
import { DashboardModel } from './dashboard_model';
|
||||
|
||||
export class ChangeTracker {
|
||||
current: any;
|
||||
originalPath: any;
|
||||
scope: any;
|
||||
original: any;
|
||||
next: any;
|
||||
$window: any;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(
|
||||
dashboard,
|
||||
scope,
|
||||
originalCopyDelay,
|
||||
private $location,
|
||||
$window,
|
||||
private $timeout,
|
||||
private contextSrv,
|
||||
private $rootScope
|
||||
) {
|
||||
this.$location = $location;
|
||||
this.$window = $window;
|
||||
|
||||
this.current = dashboard;
|
||||
this.originalPath = $location.path();
|
||||
this.scope = scope;
|
||||
|
||||
// register events
|
||||
scope.onAppEvent('dashboard-saved', () => {
|
||||
this.original = this.current.getSaveModelClone();
|
||||
this.originalPath = $location.path();
|
||||
});
|
||||
|
||||
$window.onbeforeunload = () => {
|
||||
if (this.ignoreChanges()) {
|
||||
return undefined;
|
||||
}
|
||||
if (this.hasChanges()) {
|
||||
return 'There are unsaved changes to this dashboard';
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
scope.$on('$locationChangeStart', (event, next) => {
|
||||
// check if we should look for changes
|
||||
if (this.originalPath === $location.path()) {
|
||||
return true;
|
||||
}
|
||||
if (this.ignoreChanges()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.hasChanges()) {
|
||||
event.preventDefault();
|
||||
this.next = next;
|
||||
|
||||
this.$timeout(() => {
|
||||
this.open_modal();
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (originalCopyDelay) {
|
||||
this.$timeout(() => {
|
||||
// wait for different services to patch the dashboard (missing properties)
|
||||
this.original = dashboard.getSaveModelClone();
|
||||
}, originalCopyDelay);
|
||||
} else {
|
||||
this.original = dashboard.getSaveModelClone();
|
||||
}
|
||||
}
|
||||
|
||||
// for some dashboards and users
|
||||
// changes should be ignored
|
||||
ignoreChanges() {
|
||||
if (!this.original) {
|
||||
return true;
|
||||
}
|
||||
if (!this.contextSrv.isEditor) {
|
||||
return true;
|
||||
}
|
||||
if (!this.current || !this.current.meta) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var meta = this.current.meta;
|
||||
return !meta.canSave || meta.fromScript || meta.fromFile;
|
||||
}
|
||||
|
||||
// remove stuff that should not count in diff
|
||||
cleanDashboardFromIgnoredChanges(dashData) {
|
||||
// need to new up the domain model class to get access to expand / collapse row logic
|
||||
let model = new DashboardModel(dashData);
|
||||
|
||||
// Expand all rows before making comparison. This is required because row expand / collapse
|
||||
// change order of panel array and panel positions.
|
||||
model.expandRows();
|
||||
|
||||
let dash = model.getSaveModelClone();
|
||||
|
||||
// ignore time and refresh
|
||||
dash.time = 0;
|
||||
dash.refresh = 0;
|
||||
dash.schemaVersion = 0;
|
||||
|
||||
// ignore iteration property
|
||||
delete dash.iteration;
|
||||
|
||||
dash.panels = _.filter(dash.panels, panel => {
|
||||
if (panel.repeatPanelId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// remove scopedVars
|
||||
panel.scopedVars = null;
|
||||
|
||||
// ignore panel legend sort
|
||||
if (panel.legend) {
|
||||
delete panel.legend.sort;
|
||||
delete panel.legend.sortDesc;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// ignore template variable values
|
||||
_.each(dash.templating.list, function(value) {
|
||||
value.current = null;
|
||||
value.options = null;
|
||||
value.filters = null;
|
||||
});
|
||||
|
||||
return dash;
|
||||
}
|
||||
|
||||
hasChanges() {
|
||||
let current = this.cleanDashboardFromIgnoredChanges(this.current.getSaveModelClone());
|
||||
let original = this.cleanDashboardFromIgnoredChanges(this.original);
|
||||
|
||||
var currentTimepicker = _.find(current.nav, { type: 'timepicker' });
|
||||
var originalTimepicker = _.find(original.nav, { type: 'timepicker' });
|
||||
|
||||
if (currentTimepicker && originalTimepicker) {
|
||||
currentTimepicker.now = originalTimepicker.now;
|
||||
}
|
||||
|
||||
var currentJson = angular.toJson(current, true);
|
||||
var originalJson = angular.toJson(original, true);
|
||||
|
||||
return currentJson !== originalJson;
|
||||
}
|
||||
|
||||
discardChanges() {
|
||||
this.original = null;
|
||||
this.gotoNext();
|
||||
}
|
||||
|
||||
open_modal() {
|
||||
this.$rootScope.appEvent('show-modal', {
|
||||
templateHtml: '<unsaved-changes-modal dismiss="dismiss()"></unsaved-changes-modal>',
|
||||
modalClass: 'modal--narrow confirm-modal',
|
||||
});
|
||||
}
|
||||
|
||||
saveChanges() {
|
||||
var self = this;
|
||||
var cancel = this.$rootScope.$on('dashboard-saved', () => {
|
||||
cancel();
|
||||
this.$timeout(() => {
|
||||
self.gotoNext();
|
||||
});
|
||||
});
|
||||
|
||||
this.$rootScope.appEvent('save-dashboard');
|
||||
}
|
||||
|
||||
gotoNext() {
|
||||
var baseLen = this.$location.absUrl().length - this.$location.url().length;
|
||||
var nextUrl = this.next.substring(baseLen);
|
||||
this.$location.url(nextUrl);
|
||||
}
|
||||
}
|
||||
@@ -649,6 +649,7 @@ export class DashboardModel {
|
||||
|
||||
for (let panel of row.panels) {
|
||||
// make sure y is adjusted (in case row moved while collapsed)
|
||||
// console.log('yDiff', yDiff);
|
||||
panel.gridPos.y -= yDiff;
|
||||
// insert after row
|
||||
this.panels.splice(insertPos, 0, new PanelModel(panel));
|
||||
@@ -657,7 +658,7 @@ export class DashboardModel {
|
||||
yMax = Math.max(yMax, panel.gridPos.y + panel.gridPos.h);
|
||||
}
|
||||
|
||||
const pushDownAmount = yMax - row.gridPos.y;
|
||||
const pushDownAmount = yMax - row.gridPos.y - 1;
|
||||
|
||||
// push panels below down
|
||||
for (let panelIndex = insertPos; panelIndex < this.panels.length; panelIndex++) {
|
||||
|
||||
@@ -105,6 +105,10 @@ export class DashboardSrv {
|
||||
this.setCurrent(this.create(clone, this.dash.meta));
|
||||
}
|
||||
|
||||
if (this.dash.meta.provisioned) {
|
||||
return this.showDashboardProvisionedModal();
|
||||
}
|
||||
|
||||
if (!this.dash.meta.canSave && options.makeEditable !== true) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -120,6 +124,12 @@ export class DashboardSrv {
|
||||
return this.save(this.dash.getSaveModelClone(), options);
|
||||
}
|
||||
|
||||
showDashboardProvisionedModal() {
|
||||
this.$rootScope.appEvent('show-modal', {
|
||||
templateHtml: '<save-provisioned-dashboard-modal dismiss="dismiss()"></save-provisioned-dashboard-modal>',
|
||||
});
|
||||
}
|
||||
|
||||
showSaveAsModal() {
|
||||
this.$rootScope.appEvent('show-modal', {
|
||||
templateHtml: '<save-dashboard-as-modal dismiss="dismiss()"></save-dashboard-as-modal>',
|
||||
|
||||
@@ -19,9 +19,12 @@ export class FolderPickerCtrl {
|
||||
newFolderNameTouched: boolean;
|
||||
hasValidationError: boolean;
|
||||
validationError: any;
|
||||
isEditor: boolean;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private backendSrv, private validationSrv) {
|
||||
constructor(private backendSrv, private validationSrv, private contextSrv) {
|
||||
this.isEditor = this.contextSrv.isEditor;
|
||||
|
||||
if (!this.labelClass) {
|
||||
this.labelClass = 'width-7';
|
||||
}
|
||||
@@ -38,19 +41,20 @@ export class FolderPickerCtrl {
|
||||
|
||||
return this.backendSrv.get('api/search', params).then(result => {
|
||||
if (
|
||||
query === '' ||
|
||||
query.toLowerCase() === 'g' ||
|
||||
query.toLowerCase() === 'ge' ||
|
||||
query.toLowerCase() === 'gen' ||
|
||||
query.toLowerCase() === 'gene' ||
|
||||
query.toLowerCase() === 'gener' ||
|
||||
query.toLowerCase() === 'genera' ||
|
||||
query.toLowerCase() === 'general'
|
||||
this.isEditor &&
|
||||
(query === '' ||
|
||||
query.toLowerCase() === 'g' ||
|
||||
query.toLowerCase() === 'ge' ||
|
||||
query.toLowerCase() === 'gen' ||
|
||||
query.toLowerCase() === 'gene' ||
|
||||
query.toLowerCase() === 'gener' ||
|
||||
query.toLowerCase() === 'genera' ||
|
||||
query.toLowerCase() === 'general')
|
||||
) {
|
||||
result.unshift({ title: this.rootName, id: 0 });
|
||||
}
|
||||
|
||||
if (this.enableCreateNew && query === '') {
|
||||
if (this.isEditor && this.enableCreateNew && query === '') {
|
||||
result.unshift({ title: '-- New Folder --', id: -1 });
|
||||
}
|
||||
|
||||
|
||||
77
public/app/features/dashboard/save_provisioned_modal.ts
Normal file
77
public/app/features/dashboard/save_provisioned_modal.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import angular from 'angular';
|
||||
import { saveAs } from 'file-saver';
|
||||
import coreModule from 'app/core/core_module';
|
||||
|
||||
const template = `
|
||||
<div class="modal-body">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-header-title">
|
||||
<i class="fa fa-save"></i><span class="p-l-1">Cannot save provisioned dashboard</span>
|
||||
</h2>
|
||||
|
||||
<a class="modal-header-close" ng-click="ctrl.dismiss();">
|
||||
<i class="fa fa-remove"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="modal-content">
|
||||
<small>
|
||||
This dashboard cannot be saved from Grafana's UI since it has been provisioned from another source.
|
||||
Copy the JSON or save it to a file below. Then you can update your dashboard in corresponding provisioning source.<br/>
|
||||
<i>See <a class="external-link" href="http://docs.grafana.org/administration/provisioning/#dashboards" target="_blank">
|
||||
documentation</a> for more information about provisioning.</i>
|
||||
</small>
|
||||
<div class="p-t-2">
|
||||
<div class="gf-form">
|
||||
<code-editor content="ctrl.dashboardJson" data-mode="json" data-max-lines=15></code-editor>
|
||||
</div>
|
||||
<div class="gf-form-button-row">
|
||||
<button class="btn btn-success" clipboard-button="ctrl.getJsonForClipboard()">
|
||||
<i class="fa fa-clipboard"></i> Copy JSON to Clipboard
|
||||
</button>
|
||||
<button class="btn btn-secondary" clipboard-button="ctrl.save()">
|
||||
<i class="fa fa-save"></i> Save JSON to file
|
||||
</button>
|
||||
<a class="btn btn-link" ng-click="ctrl.dismiss();">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export class SaveProvisionedDashboardModalCtrl {
|
||||
dash: any;
|
||||
dashboardJson: string;
|
||||
dismiss: () => void;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(dashboardSrv) {
|
||||
this.dash = dashboardSrv.getCurrent().getSaveModelClone();
|
||||
delete this.dash.id;
|
||||
this.dashboardJson = JSON.stringify(this.dash, null, 2);
|
||||
}
|
||||
|
||||
save() {
|
||||
var blob = new Blob([angular.toJson(this.dash, true)], {
|
||||
type: 'application/json;charset=utf-8',
|
||||
});
|
||||
saveAs(blob, this.dash.title + '-' + new Date().getTime() + '.json');
|
||||
}
|
||||
|
||||
getJsonForClipboard() {
|
||||
return this.dashboardJson;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveProvisionedDashboardModalDirective() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: template,
|
||||
controller: SaveProvisionedDashboardModalCtrl,
|
||||
bindToController: true,
|
||||
controllerAs: 'ctrl',
|
||||
scope: { dismiss: '&' },
|
||||
};
|
||||
}
|
||||
|
||||
coreModule.directive('saveProvisionedDashboardModal', saveProvisionedDashboardModalDirective);
|
||||
99
public/app/features/dashboard/specs/change_tracker.jest.ts
Normal file
99
public/app/features/dashboard/specs/change_tracker.jest.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { ChangeTracker } from 'app/features/dashboard/change_tracker';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { DashboardModel } from '../dashboard_model';
|
||||
import { PanelModel } from '../panel_model';
|
||||
|
||||
jest.mock('app/core/services/context_srv', () => ({
|
||||
contextSrv: {
|
||||
user: { orgId: 1 },
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ChangeTracker', () => {
|
||||
let rootScope;
|
||||
let location;
|
||||
let timeout;
|
||||
let tracker: ChangeTracker;
|
||||
let dash;
|
||||
let scope;
|
||||
|
||||
beforeEach(() => {
|
||||
dash = new DashboardModel({
|
||||
refresh: false,
|
||||
panels: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'graph',
|
||||
gridPos: { x: 0, y: 0, w: 24, h: 6 },
|
||||
legend: { sortDesc: false },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'row',
|
||||
gridPos: { x: 0, y: 6, w: 24, h: 2 },
|
||||
collapsed: true,
|
||||
panels: [
|
||||
{ id: 3, type: 'graph', gridPos: { x: 0, y: 6, w: 12, h: 2 } },
|
||||
{ id: 4, type: 'graph', gridPos: { x: 12, y: 6, w: 12, h: 2 } },
|
||||
],
|
||||
},
|
||||
{ id: 5, type: 'row', gridPos: { x: 0, y: 6, w: 1, h: 1 } },
|
||||
],
|
||||
});
|
||||
|
||||
scope = {
|
||||
appEvent: jest.fn(),
|
||||
onAppEvent: jest.fn(),
|
||||
$on: jest.fn(),
|
||||
};
|
||||
|
||||
rootScope = {
|
||||
appEvent: jest.fn(),
|
||||
onAppEvent: jest.fn(),
|
||||
$on: jest.fn(),
|
||||
};
|
||||
|
||||
location = {
|
||||
path: jest.fn(),
|
||||
};
|
||||
|
||||
tracker = new ChangeTracker(dash, scope, undefined, location, window, timeout, contextSrv, rootScope);
|
||||
});
|
||||
|
||||
it('No changes should not have changes', () => {
|
||||
expect(tracker.hasChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('Simple change should be registered', () => {
|
||||
dash.title = 'google';
|
||||
expect(tracker.hasChanges()).toBe(true);
|
||||
});
|
||||
|
||||
it('Should ignore a lot of changes', () => {
|
||||
dash.time = { from: '1h' };
|
||||
dash.refresh = true;
|
||||
dash.schemaVersion = 10;
|
||||
expect(tracker.hasChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('Should ignore .iteration changes', () => {
|
||||
dash.iteration = new Date().getTime() + 1;
|
||||
expect(tracker.hasChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('Should ignore row collapse change', () => {
|
||||
dash.toggleRow(dash.panels[1]);
|
||||
expect(tracker.hasChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('Should ignore panel legend changes', () => {
|
||||
dash.panels[0].legend.sortDesc = true;
|
||||
dash.panels[0].legend.sort = 'avg';
|
||||
expect(tracker.hasChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('Should ignore panel repeats', () => {
|
||||
dash.panels.push(new PanelModel({ repeatPanelId: 10 }));
|
||||
expect(tracker.hasChanges()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -374,14 +374,14 @@ describe('DashboardModel', function() {
|
||||
{
|
||||
id: 2,
|
||||
type: 'row',
|
||||
gridPos: { x: 0, y: 6, w: 24, h: 2 },
|
||||
gridPos: { x: 0, y: 6, w: 24, h: 1 },
|
||||
collapsed: true,
|
||||
panels: [
|
||||
{ id: 3, type: 'graph', gridPos: { x: 0, y: 2, w: 12, h: 2 } },
|
||||
{ id: 4, type: 'graph', gridPos: { x: 12, y: 2, w: 12, h: 2 } },
|
||||
{ id: 3, type: 'graph', gridPos: { x: 0, y: 7, w: 12, h: 2 } },
|
||||
{ id: 4, type: 'graph', gridPos: { x: 12, y: 7, w: 12, h: 2 } },
|
||||
],
|
||||
},
|
||||
{ id: 5, type: 'row', gridPos: { x: 0, y: 6, w: 1, h: 1 } },
|
||||
{ id: 5, type: 'row', gridPos: { x: 0, y: 7, w: 1, h: 1 } },
|
||||
],
|
||||
});
|
||||
dashboard.toggleRow(dashboard.panels[1]);
|
||||
@@ -399,16 +399,16 @@ describe('DashboardModel', function() {
|
||||
it('should position them below row', function() {
|
||||
expect(dashboard.panels[2].gridPos).toMatchObject({
|
||||
x: 0,
|
||||
y: 8,
|
||||
y: 7,
|
||||
w: 12,
|
||||
h: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should move panels below down', function() {
|
||||
it.only('should move panels below down', function() {
|
||||
expect(dashboard.panels[4].gridPos).toMatchObject({
|
||||
x: 0,
|
||||
y: 10,
|
||||
y: 9,
|
||||
w: 1,
|
||||
h: 1,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { SaveProvisionedDashboardModalCtrl } from '../save_provisioned_modal';
|
||||
|
||||
describe('SaveProvisionedDashboardModalCtrl', () => {
|
||||
var json = {
|
||||
title: 'name',
|
||||
id: 5,
|
||||
};
|
||||
|
||||
var mockDashboardSrv = {
|
||||
getCurrent: function() {
|
||||
return {
|
||||
id: 5,
|
||||
meta: {},
|
||||
getSaveModelClone: function() {
|
||||
return json;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
var ctrl = new SaveProvisionedDashboardModalCtrl(mockDashboardSrv);
|
||||
|
||||
it('should remove id from dashboard model', () => {
|
||||
expect(ctrl.dash.id).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should remove id from dashboard model in clipboard json', () => {
|
||||
expect(ctrl.getJsonForClipboard()).toBe(JSON.stringify({ title: 'name' }, null, 2));
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import { describe, beforeEach, it, expect, sinon, angularMocks } from 'test/lib/common';
|
||||
import { Tracker } from 'app/features/dashboard/unsaved_changes_srv';
|
||||
import 'app/features/dashboard/dashboard_srv';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
|
||||
describe('unsavedChangesSrv', function() {
|
||||
var _dashboardSrv;
|
||||
var _contextSrvStub = { isEditor: true };
|
||||
var _rootScope;
|
||||
var _location;
|
||||
var _timeout;
|
||||
var _window;
|
||||
var tracker;
|
||||
var dash;
|
||||
var scope;
|
||||
|
||||
beforeEach(angularMocks.module('grafana.core'));
|
||||
beforeEach(angularMocks.module('grafana.services'));
|
||||
beforeEach(
|
||||
angularMocks.module(function($provide) {
|
||||
$provide.value('contextSrv', _contextSrvStub);
|
||||
$provide.value('$window', {});
|
||||
})
|
||||
);
|
||||
|
||||
beforeEach(
|
||||
angularMocks.inject(function($location, $rootScope, dashboardSrv, $timeout, $window) {
|
||||
_dashboardSrv = dashboardSrv;
|
||||
_rootScope = $rootScope;
|
||||
_location = $location;
|
||||
_timeout = $timeout;
|
||||
_window = $window;
|
||||
})
|
||||
);
|
||||
|
||||
beforeEach(function() {
|
||||
dash = _dashboardSrv.create({
|
||||
refresh: false,
|
||||
panels: [{ test: 'asd', legend: {} }],
|
||||
rows: [
|
||||
{
|
||||
panels: [{ test: 'asd', legend: {} }],
|
||||
},
|
||||
],
|
||||
});
|
||||
scope = _rootScope.$new();
|
||||
scope.appEvent = sinon.spy();
|
||||
scope.onAppEvent = sinon.spy();
|
||||
|
||||
tracker = new Tracker(dash, scope, undefined, _location, _window, _timeout, contextSrv, _rootScope);
|
||||
});
|
||||
|
||||
it('No changes should not have changes', function() {
|
||||
expect(tracker.hasChanges()).to.be(false);
|
||||
});
|
||||
|
||||
it('Simple change should be registered', function() {
|
||||
dash.property = 'google';
|
||||
expect(tracker.hasChanges()).to.be(true);
|
||||
});
|
||||
|
||||
it('Should ignore a lot of changes', function() {
|
||||
dash.time = { from: '1h' };
|
||||
dash.refresh = true;
|
||||
dash.schemaVersion = 10;
|
||||
expect(tracker.hasChanges()).to.be(false);
|
||||
});
|
||||
|
||||
it('Should ignore .iteration changes', () => {
|
||||
dash.iteration = new Date().getTime() + 1;
|
||||
expect(tracker.hasChanges()).to.be(false);
|
||||
});
|
||||
|
||||
it.skip('Should ignore row collapse change', function() {
|
||||
dash.rows[0].collapse = true;
|
||||
expect(tracker.hasChanges()).to.be(false);
|
||||
});
|
||||
|
||||
it('Should ignore panel legend changes', function() {
|
||||
dash.panels[0].legend.sortDesc = true;
|
||||
dash.panels[0].legend.sort = 'avg';
|
||||
expect(tracker.hasChanges()).to.be(false);
|
||||
});
|
||||
|
||||
it.skip('Should ignore panel repeats', function() {
|
||||
dash.rows[0].panels.push({ repeatPanelId: 10 });
|
||||
expect(tracker.hasChanges()).to.be(false);
|
||||
});
|
||||
|
||||
it.skip('Should ignore row repeats', function() {
|
||||
dash.addEmptyRow();
|
||||
dash.rows[1].repeatRowId = 10;
|
||||
expect(tracker.hasChanges()).to.be(false);
|
||||
});
|
||||
});
|
||||
@@ -1,217 +1,10 @@
|
||||
import angular from 'angular';
|
||||
import _ from 'lodash';
|
||||
|
||||
export class Tracker {
|
||||
current: any;
|
||||
originalPath: any;
|
||||
scope: any;
|
||||
original: any;
|
||||
next: any;
|
||||
$window: any;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(
|
||||
dashboard,
|
||||
scope,
|
||||
originalCopyDelay,
|
||||
private $location,
|
||||
$window,
|
||||
private $timeout,
|
||||
private contextSrv,
|
||||
private $rootScope
|
||||
) {
|
||||
this.$location = $location;
|
||||
this.$window = $window;
|
||||
|
||||
this.current = dashboard;
|
||||
this.originalPath = $location.path();
|
||||
this.scope = scope;
|
||||
|
||||
// register events
|
||||
scope.onAppEvent('dashboard-saved', () => {
|
||||
this.original = this.current.getSaveModelClone();
|
||||
this.originalPath = $location.path();
|
||||
});
|
||||
|
||||
$window.onbeforeunload = () => {
|
||||
if (this.ignoreChanges()) {
|
||||
return undefined;
|
||||
}
|
||||
if (this.hasChanges()) {
|
||||
return 'There are unsaved changes to this dashboard';
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
scope.$on('$locationChangeStart', (event, next) => {
|
||||
// check if we should look for changes
|
||||
if (this.originalPath === $location.path()) {
|
||||
return true;
|
||||
}
|
||||
if (this.ignoreChanges()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.hasChanges()) {
|
||||
event.preventDefault();
|
||||
this.next = next;
|
||||
|
||||
this.$timeout(() => {
|
||||
this.open_modal();
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (originalCopyDelay) {
|
||||
this.$timeout(() => {
|
||||
// wait for different services to patch the dashboard (missing properties)
|
||||
this.original = dashboard.getSaveModelClone();
|
||||
}, originalCopyDelay);
|
||||
} else {
|
||||
this.original = dashboard.getSaveModelClone();
|
||||
}
|
||||
}
|
||||
|
||||
// for some dashboards and users
|
||||
// changes should be ignored
|
||||
ignoreChanges() {
|
||||
if (!this.original) {
|
||||
return true;
|
||||
}
|
||||
if (!this.contextSrv.isEditor) {
|
||||
return true;
|
||||
}
|
||||
if (!this.current || !this.current.meta) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var meta = this.current.meta;
|
||||
return !meta.canSave || meta.fromScript || meta.fromFile;
|
||||
}
|
||||
|
||||
// remove stuff that should not count in diff
|
||||
cleanDashboardFromIgnoredChanges(dash) {
|
||||
// ignore time and refresh
|
||||
dash.time = 0;
|
||||
dash.refresh = 0;
|
||||
dash.schemaVersion = 0;
|
||||
|
||||
// ignore iteration property
|
||||
delete dash.iteration;
|
||||
|
||||
// filter row and panels properties that should be ignored
|
||||
dash.rows = _.filter(dash.rows, function(row) {
|
||||
if (row.repeatRowId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
row.panels = _.filter(row.panels, function(panel) {
|
||||
if (panel.repeatPanelId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// remove scopedVars
|
||||
panel.scopedVars = null;
|
||||
|
||||
// ignore span changes
|
||||
panel.span = null;
|
||||
|
||||
// ignore panel legend sort
|
||||
if (panel.legend) {
|
||||
delete panel.legend.sort;
|
||||
delete panel.legend.sortDesc;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// ignore collapse state
|
||||
row.collapse = false;
|
||||
return true;
|
||||
});
|
||||
|
||||
dash.panels = _.filter(dash.panels, panel => {
|
||||
if (panel.repeatPanelId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// remove scopedVars
|
||||
panel.scopedVars = null;
|
||||
|
||||
// ignore panel legend sort
|
||||
if (panel.legend) {
|
||||
delete panel.legend.sort;
|
||||
delete panel.legend.sortDesc;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// ignore template variable values
|
||||
_.each(dash.templating.list, function(value) {
|
||||
value.current = null;
|
||||
value.options = null;
|
||||
value.filters = null;
|
||||
});
|
||||
}
|
||||
|
||||
hasChanges() {
|
||||
var current = this.current.getSaveModelClone();
|
||||
var original = this.original;
|
||||
|
||||
this.cleanDashboardFromIgnoredChanges(current);
|
||||
this.cleanDashboardFromIgnoredChanges(original);
|
||||
|
||||
var currentTimepicker = _.find(current.nav, { type: 'timepicker' });
|
||||
var originalTimepicker = _.find(original.nav, { type: 'timepicker' });
|
||||
|
||||
if (currentTimepicker && originalTimepicker) {
|
||||
currentTimepicker.now = originalTimepicker.now;
|
||||
}
|
||||
|
||||
var currentJson = angular.toJson(current);
|
||||
var originalJson = angular.toJson(original);
|
||||
|
||||
return currentJson !== originalJson;
|
||||
}
|
||||
|
||||
discardChanges() {
|
||||
this.original = null;
|
||||
this.gotoNext();
|
||||
}
|
||||
|
||||
open_modal() {
|
||||
this.$rootScope.appEvent('show-modal', {
|
||||
templateHtml: '<unsaved-changes-modal dismiss="dismiss()"></unsaved-changes-modal>',
|
||||
modalClass: 'modal--narrow confirm-modal',
|
||||
});
|
||||
}
|
||||
|
||||
saveChanges() {
|
||||
var self = this;
|
||||
var cancel = this.$rootScope.$on('dashboard-saved', () => {
|
||||
cancel();
|
||||
this.$timeout(() => {
|
||||
self.gotoNext();
|
||||
});
|
||||
});
|
||||
|
||||
this.$rootScope.appEvent('save-dashboard');
|
||||
}
|
||||
|
||||
gotoNext() {
|
||||
var baseLen = this.$location.absUrl().length - this.$location.url().length;
|
||||
var nextUrl = this.next.substring(baseLen);
|
||||
this.$location.url(nextUrl);
|
||||
}
|
||||
}
|
||||
import { ChangeTracker } from './change_tracker';
|
||||
|
||||
/** @ngInject */
|
||||
export function unsavedChangesSrv($rootScope, $q, $location, $timeout, contextSrv, dashboardSrv, $window) {
|
||||
this.Tracker = Tracker;
|
||||
this.init = function(dashboard, scope) {
|
||||
this.tracker = new Tracker(dashboard, scope, 1000, $location, $window, $timeout, contextSrv, $rootScope);
|
||||
this.tracker = new ChangeTracker(dashboard, scope, 1000, $location, $window, $timeout, contextSrv, $rootScope);
|
||||
return this.tracker;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,7 +91,6 @@ describe('QueryVariable', () => {
|
||||
|
||||
it('should return in same order', () => {
|
||||
var i = 0;
|
||||
console.log(result);
|
||||
expect(result.length).toBe(11);
|
||||
expect(result[i++].text).toBe('');
|
||||
expect(result[i++].text).toBe('0');
|
||||
|
||||
@@ -15,7 +15,23 @@ describe('PermissionsStore', () => {
|
||||
permission: 1,
|
||||
permissionName: 'View',
|
||||
teamId: 1,
|
||||
teamName: 'MyTestTeam',
|
||||
team: 'MyTestTeam',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
dashboardId: 1,
|
||||
permission: 1,
|
||||
permissionName: 'View',
|
||||
userId: 1,
|
||||
userLogin: 'MyTestUser',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
dashboardId: 1,
|
||||
permission: 1,
|
||||
permissionName: 'Edit',
|
||||
teamId: 2,
|
||||
team: 'MyTestTeam2',
|
||||
},
|
||||
])
|
||||
);
|
||||
@@ -48,15 +64,24 @@ describe('PermissionsStore', () => {
|
||||
});
|
||||
|
||||
it('should save removed permissions automatically', async () => {
|
||||
expect(store.items.length).toBe(3);
|
||||
expect(store.items.length).toBe(5);
|
||||
|
||||
await store.removeStoreItem(2);
|
||||
|
||||
expect(store.items.length).toBe(2);
|
||||
expect(store.items.length).toBe(4);
|
||||
expect(backendSrv.post.mock.calls.length).toBe(1);
|
||||
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions');
|
||||
});
|
||||
|
||||
it('should be sorted by sort rank and alphabetically', async () => {
|
||||
expect(store.items[0].name).toBe('MyTestTeam');
|
||||
expect(store.items[0].dashboardId).toBe(10);
|
||||
expect(store.items[1].name).toBe('Editor');
|
||||
expect(store.items[2].name).toBe('Viewer');
|
||||
expect(store.items[3].name).toBe('MyTestTeam2');
|
||||
expect(store.items[4].name).toBe('MyTestUser');
|
||||
});
|
||||
|
||||
describe('when one inherited and one not inherited team permission are added', () => {
|
||||
beforeEach(async () => {
|
||||
const overridingItemForChildDashboard = {
|
||||
@@ -73,7 +98,18 @@ describe('PermissionsStore', () => {
|
||||
});
|
||||
|
||||
it('should add new overriding permission', () => {
|
||||
expect(store.items.length).toBe(4);
|
||||
expect(store.items.length).toBe(6);
|
||||
});
|
||||
|
||||
it('should be sorted by sort rank and alphabetically', async () => {
|
||||
expect(store.items[0].name).toBe('MyTestTeam');
|
||||
expect(store.items[0].dashboardId).toBe(10);
|
||||
expect(store.items[1].name).toBe('Editor');
|
||||
expect(store.items[2].name).toBe('Viewer');
|
||||
expect(store.items[3].name).toBe('MyTestTeam');
|
||||
expect(store.items[3].dashboardId).toBe(1);
|
||||
expect(store.items[4].name).toBe('MyTestTeam2');
|
||||
expect(store.items[5].name).toBe('MyTestUser');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,8 @@ export const NewPermissionsItem = types
|
||||
),
|
||||
userId: types.maybe(types.number),
|
||||
userLogin: types.maybe(types.string),
|
||||
userAvatarUrl: types.maybe(types.string),
|
||||
teamAvatarUrl: types.maybe(types.string),
|
||||
teamId: types.maybe(types.number),
|
||||
team: types.maybe(types.string),
|
||||
permission: types.optional(types.number, 1),
|
||||
@@ -50,17 +52,19 @@ export const NewPermissionsItem = types
|
||||
},
|
||||
}))
|
||||
.actions(self => ({
|
||||
setUser(userId: number, userLogin: string) {
|
||||
setUser(userId: number, userLogin: string, userAvatarUrl: string) {
|
||||
self.userId = userId;
|
||||
self.userLogin = userLogin;
|
||||
self.userAvatarUrl = userAvatarUrl;
|
||||
self.teamId = null;
|
||||
self.team = null;
|
||||
},
|
||||
setTeam(teamId: number, team: string) {
|
||||
setTeam(teamId: number, team: string, teamAvatarUrl: string) {
|
||||
self.userId = null;
|
||||
self.userLogin = null;
|
||||
self.teamId = teamId;
|
||||
self.team = team;
|
||||
self.teamAvatarUrl = teamAvatarUrl;
|
||||
},
|
||||
setPermission(permission: number) {
|
||||
self.permission = permission;
|
||||
@@ -121,16 +125,20 @@ export const PermissionsStore = types
|
||||
teamId: undefined,
|
||||
userLogin: undefined,
|
||||
userId: undefined,
|
||||
userAvatarUrl: undefined,
|
||||
teamAvatarUrl: undefined,
|
||||
role: undefined,
|
||||
};
|
||||
switch (self.newItem.type) {
|
||||
case aclTypeValues.GROUP.value:
|
||||
item.team = self.newItem.team;
|
||||
item.teamId = self.newItem.teamId;
|
||||
item.teamAvatarUrl = self.newItem.teamAvatarUrl;
|
||||
break;
|
||||
case aclTypeValues.USER.value:
|
||||
item.userLogin = self.newItem.userLogin;
|
||||
item.userId = self.newItem.userId;
|
||||
item.userAvatarUrl = self.newItem.userAvatarUrl;
|
||||
break;
|
||||
case aclTypeValues.VIEWER.value:
|
||||
case aclTypeValues.EDITOR.value:
|
||||
@@ -147,6 +155,8 @@ export const PermissionsStore = types
|
||||
try {
|
||||
yield updateItems(self, updatedItems);
|
||||
self.items.push(newItem);
|
||||
let sortedItems = self.items.sort((a, b) => b.sortRank - a.sortRank || a.name.localeCompare(b.name));
|
||||
self.items = sortedItems;
|
||||
resetNewTypeInternal();
|
||||
} catch {}
|
||||
yield Promise.resolve();
|
||||
@@ -206,9 +216,11 @@ const updateItems = (self, items) => {
|
||||
};
|
||||
|
||||
const prepareServerResponse = (response, dashboardId: number, isFolder: boolean, isInRoot: boolean) => {
|
||||
return response.map(item => {
|
||||
return prepareItem(item, dashboardId, isFolder, isInRoot);
|
||||
});
|
||||
return response
|
||||
.map(item => {
|
||||
return prepareItem(item, dashboardId, isFolder, isInRoot);
|
||||
})
|
||||
.sort((a, b) => b.sortRank - a.sortRank || a.name.localeCompare(b.name));
|
||||
};
|
||||
|
||||
const prepareItem = (item, dashboardId: number, isFolder: boolean, isInRoot: boolean) => {
|
||||
@@ -216,21 +228,16 @@ const prepareItem = (item, dashboardId: number, isFolder: boolean, isInRoot: boo
|
||||
|
||||
item.sortRank = 0;
|
||||
if (item.userId > 0) {
|
||||
item.icon = 'fa fa-fw fa-user';
|
||||
item.nameHtml = item.userLogin;
|
||||
item.sortName = item.userLogin;
|
||||
item.name = item.userLogin;
|
||||
item.sortRank = 10;
|
||||
} else if (item.teamId > 0) {
|
||||
item.icon = 'fa fa-fw fa-users';
|
||||
item.nameHtml = item.team;
|
||||
item.sortName = item.team;
|
||||
item.name = item.team;
|
||||
item.sortRank = 20;
|
||||
} else if (item.role) {
|
||||
item.icon = 'fa fa-fw fa-street-view';
|
||||
item.nameHtml = `Everyone with <span class="query-keyword">${item.role}</span> Role`;
|
||||
item.sortName = item.role;
|
||||
item.name = item.role;
|
||||
item.sortRank = 30;
|
||||
if (item.role === 'Viewer') {
|
||||
if (item.role === 'Editor') {
|
||||
item.sortRank += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,9 @@ export const PermissionsStoreItem = types
|
||||
inherited: types.maybe(types.boolean),
|
||||
sortRank: types.maybe(types.number),
|
||||
icon: types.maybe(types.string),
|
||||
nameHtml: types.maybe(types.string),
|
||||
sortName: types.maybe(types.string),
|
||||
name: types.maybe(types.string),
|
||||
teamAvatarUrl: types.maybe(types.string),
|
||||
userAvatarUrl: types.maybe(types.string),
|
||||
})
|
||||
.actions(self => ({
|
||||
updateRole: role => {
|
||||
|
||||
19
public/img/icons_dark_theme/icon_editor.svg
Normal file
19
public/img/icons_dark_theme/icon_editor.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 19.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="64px" height="64px" viewBox="-479 353 64 64" style="enable-background:new -479 353 64 64;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#E3E2E2;}
|
||||
</style>
|
||||
<g>
|
||||
<path class="st0" d="M-470.4,410h34.4c4.7,0,8.6-3.8,8.6-8.6v-17.3l-4.2,4.2v13.1c0,2.4-1.9,4.3-4.3,4.3h-34.4
|
||||
c-2.4,0-4.3-1.9-4.3-4.3V376c0-2.4,1.9-4.3,4.3-4.3h32.1l4.2-4.2h-36.3c-4.7,0-8.6,3.8-8.6,8.6v25.5
|
||||
C-479,406.2-475.2,410-470.4,410z"/>
|
||||
|
||||
<rect x="-438.3" y="364.5" transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 -1008.7032 339.9824)" class="st0" width="8.7" height="28.8"/>
|
||||
<path class="st0" d="M-425.5,364.3l6.2,6.2l1.4-1.4l1.6-1.6c1.7-1.7,1.7-4.5,0-6.2c-1.7-1.7-4.5-1.7-6.2,0l-1.6,1.6L-425.5,364.3z"
|
||||
/>
|
||||
<polygon class="st0" points="-444.8,393.9 -442.3,393.5 -448.5,387.3 -448.9,389.8 -449.8,394.8 "/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
17
public/img/icons_dark_theme/icon_viewer.svg
Normal file
17
public/img/icons_dark_theme/icon_viewer.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 19.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="64px" height="64px" viewBox="-479 353 64 64" style="enable-background:new -479 353 64 64;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#E2E2E2;}
|
||||
</style>
|
||||
<path class="st0" d="M-415.1,384c-0.4-0.7-9.5-16.6-31.6-16.6c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0-0.1,0-0.1,0
|
||||
c-22,0.1-31.3,15.9-31.6,16.6c-0.3,0.6-0.3,1.3,0,1.9c0.4,0.7,9.6,16.5,31.6,16.6c0,0,0.1,0,0.1,0c0,0,0,0,0,0c0,0,0.1,0,0.1,0
|
||||
c22.2,0,31.2-16,31.6-16.6C-414.8,385.3-414.8,384.6-415.1,384z M-446.9,399.3c-7.9,0-14.3-6.4-14.3-14.3c0-7.9,6.4-14.3,14.3-14.3
|
||||
c7.9,0,14.3,6.4,14.3,14.3C-432.6,392.9-439,399.3-446.9,399.3z"/>
|
||||
<g>
|
||||
<path class="st0" d="M-446.9,378.3c-0.9,0-1.8,0.2-2.6,0.5c1.2,0.4,2,1.5,2,2.9c0,1.7-1.4,3-3,3c-1.2,0-2.2-0.7-2.7-1.7
|
||||
c-0.2,0.6-0.3,1.3-0.3,2c0,3.7,3,6.7,6.7,6.7c3.7,0,6.7-3,6.7-6.7S-443.2,378.3-446.9,378.3z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
19
public/img/icons_light_theme/icon_editor.svg
Normal file
19
public/img/icons_light_theme/icon_editor.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 19.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="64px" height="64px" viewBox="-479 353 64 64" style="enable-background:new -479 353 64 64;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#52545C;}
|
||||
</style>
|
||||
<g>
|
||||
<path class="st0" d="M-470.4,410h34.4c4.7,0,8.6-3.8,8.6-8.6v-17.3l-4.2,4.2v13.1c0,2.4-1.9,4.3-4.3,4.3h-34.4
|
||||
c-2.4,0-4.3-1.9-4.3-4.3V376c0-2.4,1.9-4.3,4.3-4.3h32.1l4.2-4.2h-36.3c-4.7,0-8.6,3.8-8.6,8.6v25.5
|
||||
C-479,406.2-475.2,410-470.4,410z"/>
|
||||
|
||||
<rect x="-438.3" y="364.5" transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 -1008.7032 339.9824)" class="st0" width="8.7" height="28.8"/>
|
||||
<path class="st0" d="M-425.5,364.3l6.2,6.2l1.4-1.4l1.6-1.6c1.7-1.7,1.7-4.5,0-6.2c-1.7-1.7-4.5-1.7-6.2,0l-1.6,1.6L-425.5,364.3z"
|
||||
/>
|
||||
<polygon class="st0" points="-444.8,393.9 -442.3,393.5 -448.5,387.3 -448.9,389.8 -449.8,394.8 "/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
17
public/img/icons_light_theme/icon_viewer.svg
Normal file
17
public/img/icons_light_theme/icon_viewer.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 19.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="64px" height="64px" viewBox="-479 353 64 64" style="enable-background:new -479 353 64 64;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#52545C;}
|
||||
</style>
|
||||
<path class="st0" d="M-415.1,384c-0.4-0.7-9.5-16.6-31.6-16.6c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0-0.1,0-0.1,0
|
||||
c-22,0.1-31.3,15.9-31.6,16.6c-0.3,0.6-0.3,1.3,0,1.9c0.4,0.7,9.6,16.5,31.6,16.6c0,0,0.1,0,0.1,0c0,0,0,0,0,0c0,0,0.1,0,0.1,0
|
||||
c22.2,0,31.2-16,31.6-16.6C-414.8,385.3-414.8,384.6-415.1,384z M-446.9,399.3c-7.9,0-14.3-6.4-14.3-14.3c0-7.9,6.4-14.3,14.3-14.3
|
||||
c7.9,0,14.3,6.4,14.3,14.3C-432.6,392.9-439,399.3-446.9,399.3z"/>
|
||||
<g>
|
||||
<path class="st0" d="M-446.9,378.3c-0.9,0-1.8,0.2-2.6,0.5c1.2,0.4,2,1.5,2,2.9c0,1.7-1.4,3-3,3c-1.2,0-2.2-0.7-2.7-1.7
|
||||
c-0.2,0.6-0.3,1.3-0.3,2c0,3.7,3,6.7,6.7,6.7c3.7,0,6.7-3,6.7-6.7S-443.2,378.3-446.9,378.3z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -120,6 +120,10 @@
|
||||
background-image: url('../img/icons_#{$theme-name}_theme/icon_data_sources.svg');
|
||||
}
|
||||
|
||||
.gicon-editor {
|
||||
background-image: url('../img/icons_#{$theme-name}_theme/icon_editor.svg');
|
||||
}
|
||||
|
||||
.gicon-folder-new {
|
||||
background-image: url('../img/icons_#{$theme-name}_theme/icon_add_folder.svg');
|
||||
}
|
||||
@@ -180,6 +184,10 @@
|
||||
background-image: url('../img/icons_#{$theme-name}_theme/icon_variable.svg');
|
||||
}
|
||||
|
||||
.gicon-viewer {
|
||||
background-image: url('../img/icons_#{$theme-name}_theme/icon_viewer.svg');
|
||||
}
|
||||
|
||||
.gicon-zoom-out {
|
||||
background-image: url('../img/icons_#{$theme-name}_theme/icon_zoom_out.svg');
|
||||
}
|
||||
|
||||
@@ -64,8 +64,13 @@
|
||||
background: $page-bg;
|
||||
}
|
||||
|
||||
i {
|
||||
padding-right: 5px;
|
||||
.gicon {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.fa {
|
||||
font-size: 17px;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,3 +85,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.filter-table__weak-italic {
|
||||
font-style: italic;
|
||||
color: $text-color-weak;
|
||||
}
|
||||
|
||||
@@ -20,4 +20,17 @@ echo "building backend with install to cache pkgs"
|
||||
exit_if_fail time go install ./pkg/cmd/grafana-server
|
||||
|
||||
echo "running go test"
|
||||
go test ./pkg/...
|
||||
|
||||
set -e
|
||||
echo "" > coverage.txt
|
||||
|
||||
time for d in $(go list ./pkg/...); do
|
||||
exit_if_fail go test -coverprofile=profile.out -covermode=atomic $d
|
||||
if [ -f profile.out ]; then
|
||||
cat profile.out >> coverage.txt
|
||||
rm profile.out
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Publishing go code coverage"
|
||||
bash <(curl -s https://codecov.io/bash) -cF go
|
||||
|
||||
@@ -10,5 +10,10 @@ function exit_if_fail {
|
||||
fi
|
||||
}
|
||||
|
||||
exit_if_fail npm run test
|
||||
exit_if_fail npm run build
|
||||
exit_if_fail npm run test:coverage
|
||||
exit_if_fail npm run build
|
||||
|
||||
# publish code coverage
|
||||
echo "Publishing javascript code coverage"
|
||||
bash <(curl -s https://codecov.io/bash) -cF javascript
|
||||
rm -rf coverage
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
module.exports = function(config, grunt) {
|
||||
'use strict';
|
||||
|
||||
var coverage = '';
|
||||
if (config.coverage) {
|
||||
coverage = '--coverage --maxWorkers 2';
|
||||
}
|
||||
|
||||
return {
|
||||
tslint: 'node ./node_modules/tslint/lib/tslint-cli.js -c tslint.json --project ./tsconfig.json',
|
||||
jest: 'node ./node_modules/jest-cli/bin/jest.js --maxWorkers 2',
|
||||
jest: 'node ./node_modules/jest-cli/bin/jest.js ' + coverage,
|
||||
webpack: 'node ./node_modules/webpack/bin/webpack.js --config scripts/webpack/webpack.prod.js',
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user