Provisioning: Nested folders for Dashboards (#119852)

* Backend: Provisioning Nested folders for Dashboards

Add support for nested folder hierarchies when using `foldersFromFilesStructure`.

The folder structure on disk (e.g. `level1/level2/dashboard.json`) is now correctly reflected in **Grafana** with proper parent-child relationships.

Changes:

- Refactor `getOrCreateFolder` into `getOrCreateFolderInternal` with unified logic
- Add `parentUID` parameter to `getOrCreateFolderByTitle` for nested folders
- `getOrCreateFolderFullpath` creates folders level by level, passing parent UID
- Use `identity.WithServiceIdentity` for provisioning operations

Backward compatibility:

- Single folder and flat structures unchanged (`parentUID = nil`)
- Explicit FolderUID lookup unchanged
- All existing tests pass

Tests:

- Strengthen "Get nested folders from files structure" to assert `ParentUID` is correctly set when creating child folders (`level2` under `level1`)

# Conflicts:
#	pkg/services/provisioning/dashboards/file_reader_test.go

* Feedbacks from AI-driven review

* Fixed access to `MaxNestedFolderDepth`

# Conflicts:
#	pkg/services/provisioning/dashboards/file_reader_test.go

* Reuse nested folders by title/parent, fix docs and tests

March work: nested dashboard provisioning from filesystem layout, folder reuse by title/parent, docs and foldertest helpers.

* Provisioning: address PR review (usage tracker, nested save assertions)

- Track provisioned usage only after a successful save in storeDashboardsInFoldersFromFileStructure.

- In Get nested folders from files structure, assert FolderUID/FolderID on SaveProvisionedDashboard (per ExternalID).
This commit is contained in:
Victor Ros
2026-04-24 17:45:59 +02:00
committed by GitHub
parent 4ca14a0ce8
commit 4071c4194d
11 changed files with 1067 additions and 410 deletions
@@ -514,7 +514,11 @@ To use `foldersFromFilesStructure`, you must unset the `folder` and `folderUid`
To provision dashboards to the root level, store them in the root of your `path`.
{{< admonition type="note" >}}
This feature doesn't let you create nested folder structures, where you have folders within folders.
Nested folder structures are supported: the folder hierarchy on disk is recreated in Grafana.
For example, `folderTwo/folderThree/dashboard3.json` creates a folder `folderTwo` containing a folder `folderThree` that contains the dashboard.
The folder depth is limited to `4` levels.
{{< /admonition >}}
## Alerting
+36 -1
View File
@@ -66,11 +66,46 @@ func (s *FakeService) Create(ctx context.Context, cmd *folder.CreateFolderComman
}
func (s *FakeService) Get(ctx context.Context, q *folder.GetFolderQuery) (*folder.Folder, error) {
if q.UID != nil && s.foldersByUID != nil {
if q.UID != nil && *q.UID != "" && s.foldersByUID != nil {
if f, exists := s.foldersByUID[*q.UID]; exists {
return f, nil
}
}
if q.Title != nil && *q.Title != "" {
wantParent := ""
if q.ParentUID != nil {
wantParent = *q.ParentUID
}
if f := s.findFolderByTitleAndParent(q.OrgID, *q.Title, wantParent); f != nil {
return f, nil
}
}
return s.ExpectedFolder, s.ExpectedError
}
// findFolderByTitleAndParent returns a folder matching org, title, and parent UID (empty string means root).
func (s *FakeService) findFolderByTitleAndParent(orgID int64, title, parentUID string) *folder.Folder {
byUID := make(map[string]*folder.Folder)
for _, f := range s.ExpectedFolders {
if f != nil {
byUID[f.UID] = f
}
}
for _, f := range s.foldersByUID {
if f != nil {
byUID[f.UID] = f
}
}
for _, f := range byUID {
if f.OrgID == orgID && f.Title == title && f.ParentUID == parentUID {
return f
}
}
return nil
}
func (s *FakeService) GetLegacy(ctx context.Context, q *folder.GetFolderQuery) (*folder.Folder, error) {
return s.ExpectedFolder, s.ExpectedError
}
@@ -58,7 +58,7 @@ func New(ctx context.Context, configDirectory string, provisioner dashboards.Das
return nil, fmt.Errorf("%v: %w", "Failed to read dashboards config", err)
}
fileReaders, err := getFileReaders(configs, logger, provisioner, dashboardStore, folderService)
fileReaders, err := getFileReaders(configs, logger, provisioner, dashboardStore, folderService, cfg)
if err != nil {
return nil, fmt.Errorf("%v: %w", "Failed to initialize file readers", err)
}
@@ -204,6 +204,7 @@ func getFileReaders(
service dashboards.DashboardProvisioningService,
store utils.DashboardStore,
folderService folder.Service,
cfg *setting.Cfg,
) ([]*FileReader, error) {
var readers []*FileReader
@@ -216,6 +217,7 @@ func getFileReaders(
service,
store,
folderService,
cfg,
)
if err != nil {
return nil, fmt.Errorf("failed to create file reader for config %v: %w", config.Name, err)
@@ -20,6 +20,7 @@ import (
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/provisioning/utils"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
@@ -30,6 +31,27 @@ var (
ErrGetOrCreateFolder = errors.New("failed to get or create provisioning folder")
)
// folderPathCacheEntry holds id and uid for a folder path.
// Used by getOrCreateFolderFullpath to avoid redundant Get/Create calls for the same path during a single walkDisk.
// The cache is not thread-safe and is scoped to one provisioning cycle (walkDisk).
type folderPathCacheEntry struct {
id int64
uid string
}
// splitFolderFullpath splits folderFullpath by "/" and returns non-empty segments.
// The path comes from the filesystem (filepath.Rel + ReplaceAll), so no escape handling is needed.
func splitFolderFullpath(folderFullpath string) []string {
parts := strings.Split(folderFullpath, "/")
out := make([]string, 0, len(parts))
for _, part := range parts {
if part != "" {
out = append(out, part)
}
}
return out
}
// FileReader is responsible for reading dashboards from disk and
// insert/update dashboards to the Grafana database using
// `dashboards.DashboardProvisioningService`.
@@ -42,6 +64,7 @@ type FileReader struct {
FoldersFromFilesStructure bool
folderService folder.Service
foldersInUnified bool
settingCfg *setting.Cfg
mux sync.RWMutex
usageTracker *usageTracker
@@ -50,7 +73,7 @@ type FileReader struct {
// NewDashboardFileReader returns a new filereader based on `config`
func NewDashboardFileReader(cfg *config, log log.Logger, service dashboards.DashboardProvisioningService,
dashboardStore utils.DashboardStore, folderService folder.Service) (*FileReader, error) {
dashboardStore utils.DashboardStore, folderService folder.Service, settingCfg *setting.Cfg) (*FileReader, error) {
var path string
path, ok := cfg.Options["path"].(string)
if !ok {
@@ -75,6 +98,7 @@ func NewDashboardFileReader(cfg *config, log log.Logger, service dashboards.Dash
dashboardStore: dashboardStore,
folderService: folderService,
FoldersFromFilesStructure: foldersFromFilesStructure,
settingCfg: settingCfg,
usageTracker: newUsageTracker(),
}, nil
}
@@ -197,7 +221,7 @@ func (fr *FileReader) storeDashboardsInFolder(ctx context.Context, filesFoundOnD
dashboardRefs map[string]*dashboards.DashboardProvisioning, usageTracker *usageTracker) error {
ctx, _ = identity.WithServiceIdentity(ctx, fr.Cfg.OrgID)
folderID, folderUID, err := fr.getOrCreateFolder(ctx, fr.Cfg, fr.dashboardProvisioningService, fr.Cfg.Folder)
folderID, folderUID, err := fr.getOrCreateFolder(ctx, fr.Cfg, fr.Cfg.Folder)
if err != nil && !errors.Is(err, ErrFolderNameMissing) {
return fmt.Errorf("%w with name %q: %w", ErrGetOrCreateFolder, fr.Cfg.Folder, err)
}
@@ -217,27 +241,45 @@ func (fr *FileReader) storeDashboardsInFolder(ctx context.Context, filesFoundOnD
// storeDashboardsInFoldersFromFilesystemStructure saves dashboards from the filesystem on disk to the same folder
// in Grafana as they are in on the filesystem.
// folderPathCache is created per walk and passed to getOrCreateFolderFullpath to avoid redundant Get/Create
// for shared ancestor paths. It is not thread-safe and must not be shared across provisioning cycles.
func (fr *FileReader) storeDashboardsInFoldersFromFileStructure(ctx context.Context, filesFoundOnDisk map[string]os.FileInfo,
dashboardRefs map[string]*dashboards.DashboardProvisioning, resolvedPath string, usageTracker *usageTracker) error {
for path, fileInfo := range filesFoundOnDisk {
folderName := ""
ctx, _ = identity.WithServiceIdentity(ctx, fr.Cfg.OrgID)
folderPathCache := make(map[string]folderPathCacheEntry)
for path, fileInfo := range filesFoundOnDisk {
dashboardsFolder := filepath.Dir(path)
if dashboardsFolder != resolvedPath {
folderName = filepath.Base(dashboardsFolder)
relPath, err := filepath.Rel(resolvedPath, dashboardsFolder)
if err != nil {
return fmt.Errorf("failed to calculate relative path from %q to %q: %w", resolvedPath, dashboardsFolder, err)
}
ctx, _ = identity.WithServiceIdentity(ctx, fr.Cfg.OrgID)
folderID, folderUID, err := fr.getOrCreateFolder(ctx, fr.Cfg, fr.dashboardProvisioningService, folderName)
if err != nil && !errors.Is(err, ErrFolderNameMissing) {
return fmt.Errorf("%w with name %q from file system structure: %w", ErrGetOrCreateFolder, folderName, err)
// Replace the OS separator with a forward slash to get the full path of the folder
folderFullpath := strings.ReplaceAll(relPath, string(filepath.Separator), "/")
if folderFullpath == "." || folderFullpath == "" {
folderFullpath = ""
}
var folderID int64
var folderUID string
if folderFullpath == "" {
folderID = 0
folderUID = ""
} else {
folderID, folderUID, err = fr.getOrCreateFolderFullpath(ctx, folderFullpath, fr.Cfg.OrgID, folderPathCache)
if err != nil {
return fmt.Errorf("%w with full path %q from file system structure: %w", ErrGetOrCreateFolder, folderFullpath, err)
}
}
provisioningMetadata, err := fr.saveDashboard(ctx, path, folderID, folderUID, fileInfo, dashboardRefs)
usageTracker.track(provisioningMetadata)
if err != nil {
fr.log.Error("failed to save dashboard", "file", path, "error", err)
continue
}
usageTracker.track(provisioningMetadata)
}
return nil
}
@@ -369,28 +411,30 @@ func (fr *FileReader) getProvisionedDashboardsByPath(ctx context.Context, servic
return byPath, nil
}
func (fr *FileReader) getOrCreateFolder(ctx context.Context, cfg *config, service dashboards.DashboardProvisioningService, folderName string) (int64, string, error) {
// getOrCreateFolderInternal is the shared logic for getting or creating a folder.
// - explicitUID: if non-nil and non-empty, used for lookup and creation; otherwise lookup by Title+ParentUID and generate UID on create
// - parentUID: optional parent for nested folders
func (fr *FileReader) getOrCreateFolderInternal(ctx context.Context, orgID int64, folderName string, parentUID *string, explicitUID *string) (int64, string, error) {
if folderName == "" {
return 0, "", ErrFolderNameMissing
}
user, err := identity.GetRequester(ctx)
if err != nil {
return 0, "", err
user, reqErr := identity.GetRequester(ctx)
if reqErr != nil {
return 0, "", reqErr
}
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Provisioning).Inc()
cmd := &folder.GetFolderQuery{
OrgID: cfg.OrgID,
OrgID: orgID,
SignedInUser: user,
}
if cfg.FolderUID != "" {
cmd.UID = &cfg.FolderUID
if explicitUID != nil && *explicitUID != "" {
cmd.UID = explicitUID
} else {
// provisioning depends on unique names
//nolint:staticcheck
cmd.Title = &folderName
cmd.ParentUID = parentUID
}
result, err := fr.folderService.Get(ctx, cmd)
@@ -406,7 +450,7 @@ func (fr *FileReader) getOrCreateFolder(ctx context.Context, cfg *config, servic
// When we expect folders in unified storage, they should have a manager indicated.
// NOTE: when everything has been running in mode5 for a while, this check can be removed.
if err == nil && result != nil && result.ManagedBy == "" && fr.foldersInUnified {
result, err = service.UpdateFolderWithManagedByAnnotation(ctx, result, fr.Cfg.Name)
result, err = fr.dashboardProvisioningService.UpdateFolderWithManagedByAnnotation(ctx, result, fr.Cfg.Name)
if err != nil {
return 0, "", fmt.Errorf("unable to update provisioned folder")
}
@@ -414,14 +458,25 @@ func (fr *FileReader) getOrCreateFolder(ctx context.Context, cfg *config, servic
// dashboard folder not found. create one.
if errors.Is(err, dashboards.ErrFolderNotFound) {
// Generate a new UID for the folder if not provided
uid := util.GenerateShortUID()
if explicitUID != nil {
uid = *explicitUID
}
createCmd := &folder.CreateFolderCommand{
OrgID: cfg.OrgID,
UID: cfg.FolderUID,
OrgID: orgID,
UID: uid,
Title: folderName,
SignedInUser: user,
}
f, err := service.SaveFolderForProvisionedDashboards(ctx, createCmd, fr.Cfg.Name)
// If a parent UID is provided, set it as the parent of the new folder
if parentUID != nil {
createCmd.ParentUID = *parentUID
}
f, err := fr.dashboardProvisioningService.SaveFolderForProvisionedDashboards(ctx, createCmd, fr.Cfg.Name)
if err != nil {
return 0, "", err
}
@@ -430,10 +485,66 @@ func (fr *FileReader) getOrCreateFolder(ctx context.Context, cfg *config, servic
return f.ID, f.UID, nil
}
//nolint:staticcheck
// nolint:staticcheck
return result.ID, result.UID, nil
}
func (fr *FileReader) getOrCreateFolder(ctx context.Context, cfg *config, folderName string) (int64, string, error) {
// Ensures Requester for getOrCreateFolderInternal when callers bypass storeDashboardsInFolder (e.g. tests); redundant if parent already wrapped ctx.
ctx, _ = identity.WithServiceIdentity(ctx, cfg.OrgID)
var explicitUID *string
if cfg.FolderUID != "" {
explicitUID = &cfg.FolderUID
}
return fr.getOrCreateFolderInternal(ctx, cfg.OrgID, folderName, nil, explicitUID)
}
// getOrCreateFolderFullpath creates the nested folder hierarchy for folderFullpath (e.g. "level1/level2"),
// reusing cached entries when cache is provided to avoid redundant Get/Create for shared ancestors.
func (fr *FileReader) getOrCreateFolderFullpath(ctx context.Context, folderFullpath string, orgID int64, cache map[string]folderPathCacheEntry) (int64, string, error) {
// Same contract as getOrCreateFolder: direct callers/tests need identity; harmless duplicate when storeDashboardsInFoldersFromFileStructure already wrapped ctx.
ctx, _ = identity.WithServiceIdentity(ctx, orgID)
folderTitles := splitFolderFullpath(folderFullpath)
if len(folderTitles) == 0 {
return 0, "", fmt.Errorf("invalid folder full path: %s", folderFullpath)
}
maxDepth := fr.settingCfg.MaxNestedFolderDepth
if len(folderTitles) > maxDepth {
return 0, "", fmt.Errorf("nested folder depth %d exceeds maximum %d", len(folderTitles), maxDepth)
}
// folderUID: UID of the current folder in the chain (becomes the parent for the next).
// parentForNext: pointer to folderUID, passed to getOrCreateFolderInternal. nil for the first level.
var folderUID string
var parentForNext *string
var folderID int64 // deprecated but still required for compatibility
for i := range folderTitles {
cumulativePath := strings.Join(folderTitles[:i+1], "/")
if cache != nil {
if entry, ok := cache[cumulativePath]; ok {
// Cache hit: reuse folder from a previous file in the same walk.
folderID = entry.id
folderUID = entry.uid
parentForNext = &folderUID
continue
}
}
id, uid, err := fr.getOrCreateFolderInternal(ctx, orgID, folderTitles[i], parentForNext, nil)
if err != nil {
return 0, "", err
}
folderID = id
folderUID = uid
parentForNext = &folderUID
if cache != nil {
cache[cumulativePath] = folderPathCacheEntry{id: id, uid: uid}
}
}
return folderID, folderUID, nil
}
func resolveSymlink(fileinfo os.FileInfo, path string) (os.FileInfo, error) {
checkFilepath, err := filepath.EvalSymlinks(path)
if path != checkFilepath {
@@ -11,6 +11,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/setting"
)
var (
@@ -26,7 +27,7 @@ func TestProvisionedSymlinkedFolder(t *testing.T) {
Options: map[string]any{"path": symlinkedFolder},
}
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil, nil)
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil, nil, setting.NewCfg())
if err != nil {
t.Error("expected err to be nil")
}
@@ -22,8 +22,10 @@ import (
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/folder/folderimpl"
"github.com/grafana/grafana/pkg/services/folder/foldertest"
"github.com/grafana/grafana/pkg/services/search/sort"
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
resourcepb "github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/tests/testsuite"
@@ -41,6 +43,32 @@ const (
configName = "default"
)
// folderServiceForFoldersFromFilesStructure returns a fake folder service for tests using foldersFromFilesStructure.
// The real folderimpl requires Kubernetes REST config (apiserver);
// the test setup uses WithoutRestConfig which causes "rest config will not be available".
// Get falls back to ExpectedError (ErrFolderNotFound) when no folder matches title+parent,
// so folders are created via SaveFolderForProvisionedDashboards—typical for fresh provisioning.
func folderServiceForFoldersFromFilesStructure() folder.Service {
fakeFolder := foldertest.NewFakeService()
fakeFolder.ExpectedFolder = nil
fakeFolder.ExpectedError = dashboards.ErrFolderNotFound
return fakeFolder
}
// folderServiceWithPreexistingFilesStructureHierarchy seeds folders that mirror testdata/folders-from-files-structure
// with UIDs that are not derived from any path hash, so tests can assert reuse by title+parent.
func folderServiceWithPreexistingFilesStructureHierarchy() folder.Service {
fakeFolder := foldertest.NewFakeService()
const orgID int64 = 1
fakeFolder.AddFolder(&folder.Folder{OrgID: orgID, UID: "preexist-folderOne", Title: "folderOne", ParentUID: ""})
fakeFolder.AddFolder(&folder.Folder{OrgID: orgID, UID: "preexist-folderTwo", Title: "folderTwo", ParentUID: ""})
fakeFolder.AddFolder(&folder.Folder{OrgID: orgID, UID: "preexist-folderThree", Title: "folderThree", ParentUID: "preexist-folderTwo"})
fakeFolder.AddFolder(&folder.Folder{OrgID: orgID, UID: "preexist-folderFour", Title: "folderFour", ParentUID: "preexist-folderThree"})
fakeFolder.ExpectedFolder = nil
fakeFolder.ExpectedError = dashboards.ErrFolderNotFound
return fakeFolder
}
func TestMain(m *testing.M) {
testsuite.Run(m)
}
@@ -59,7 +87,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) {
t.Run("using path parameter", func(t *testing.T) {
cfg := setup()
cfg.Options["path"] = defaultDashboards
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil, nil)
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil, nil, setting.NewCfg())
require.NoError(t, err)
require.NotEqual(t, reader.Path, "")
})
@@ -67,7 +95,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) {
t.Run("using folder as options", func(t *testing.T) {
cfg := setup()
cfg.Options["folder"] = defaultDashboards
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil, nil)
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil, nil, setting.NewCfg())
require.NoError(t, err)
require.NotEqual(t, reader.Path, "")
})
@@ -76,7 +104,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) {
cfg := setup()
cfg.Options["path"] = foldersFromFilesStructure
cfg.Options["foldersFromFilesStructure"] = true
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil, nil)
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil, nil, setting.NewCfg())
require.NoError(t, err)
require.NotEqual(t, reader.Path, "")
})
@@ -89,7 +117,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) {
}
cfg.Options["folder"] = fullPath
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil, nil)
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil, nil, setting.NewCfg())
require.NoError(t, err)
require.Equal(t, reader.Path, fullPath)
@@ -99,7 +127,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) {
t.Run("using relative path", func(t *testing.T) {
cfg := setup()
cfg.Options["folder"] = defaultDashboards
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil, nil)
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"), nil, nil, nil, setting.NewCfg())
require.NoError(t, err)
resolvedPath := reader.resolvedPath()
@@ -144,8 +172,7 @@ func TestIntegrationDashboardFileReader(t *testing.T) {
fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(nil, nil).Once()
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything, configName).Return(&folder.Folder{ID: 1}, nil).Once()
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{ID: 2}, nil).Times(2)
reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
reader.dashboardProvisioningService = fakeService
reader, err := NewDashboardFileReader(cfg, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
err = reader.walkDisk(context.Background())
@@ -164,8 +191,7 @@ func TestIntegrationDashboardFileReader(t *testing.T) {
inserted++
})
reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
reader.dashboardProvisioningService = fakeService
reader, err := NewDashboardFileReader(cfg, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
err = reader.walkDisk(context.Background())
@@ -201,8 +227,7 @@ func TestIntegrationDashboardFileReader(t *testing.T) {
fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(provisionedDashboard, nil).Once()
reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
reader.dashboardProvisioningService = fakeService
reader, err := NewDashboardFileReader(cfg, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
err = reader.walkDisk(context.Background())
@@ -229,8 +254,7 @@ func TestIntegrationDashboardFileReader(t *testing.T) {
fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(provisionedDashboard, nil).Once()
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Once()
reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
reader.dashboardProvisioningService = fakeService
reader, err := NewDashboardFileReader(cfg, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
err = reader.walkDisk(context.Background())
@@ -264,8 +288,7 @@ func TestIntegrationDashboardFileReader(t *testing.T) {
fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(provisionedDashboard, nil).Once()
reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
reader.dashboardProvisioningService = fakeService
reader, err := NewDashboardFileReader(cfg, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
err = reader.walkDisk(context.Background())
@@ -292,8 +315,7 @@ func TestIntegrationDashboardFileReader(t *testing.T) {
fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(provisionedDashboard, nil).Once()
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Once()
reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
reader.dashboardProvisioningService = fakeService
reader, err := NewDashboardFileReader(cfg, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
err = reader.walkDisk(context.Background())
@@ -307,8 +329,7 @@ func TestIntegrationDashboardFileReader(t *testing.T) {
fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(nil, nil).Once()
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Once()
reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
reader.dashboardProvisioningService = fakeService
reader, err := NewDashboardFileReader(cfg, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
err = reader.walkDisk(context.Background())
@@ -321,17 +342,142 @@ func TestIntegrationDashboardFileReader(t *testing.T) {
cfg.Options["foldersFromFilesStructure"] = true
fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(nil, nil).Once()
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything, configName).Return(&folder.Folder{}, nil).Times(2)
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Times(3)
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything, configName).Return(&folder.Folder{}, nil).Times(4)
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Times(5)
reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
reader.dashboardProvisioningService = fakeService
reader, err := NewDashboardFileReader(cfg, logger, fakeService, fakeStore, folderServiceForFoldersFromFilesStructure(), cfgT)
require.NoError(t, err)
err = reader.walkDisk(context.Background())
require.NoError(t, err)
})
t.Run("Get nested folders from files structure", func(t *testing.T) {
setup()
cfg.Options["path"] = foldersFromFilesStructure
cfg.Options["foldersFromFilesStructure"] = true
// root.json, folderOne/dashboard1, folderTwo/dashboard2, folderTwo/folderThree/dashboard3, folderTwo/folderThree/folderFour/dashboard4.
// Cache: folderTwo and folderThree reused for dashboard4.
const folderOneUID = "folderOne-uid"
const folderTwoUID = "folderTwo-uid"
const folderThreeUID = "folderThree-uid"
var folderCreateCalls []*folder.CreateFolderCommand
fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(nil, nil).Once()
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.MatchedBy(func(cmd *folder.CreateFolderCommand) bool {
return cmd.Title == "folderOne" && cmd.ParentUID == ""
}), configName).Run(func(args mock.Arguments) {
folderCreateCalls = append(folderCreateCalls, args[1].(*folder.CreateFolderCommand))
}).Return(&folder.Folder{ID: 1, UID: folderOneUID, Title: "folderOne"}, nil).Once()
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.MatchedBy(func(cmd *folder.CreateFolderCommand) bool {
return cmd.Title == "folderTwo" && cmd.ParentUID == ""
}), configName).Run(func(args mock.Arguments) {
folderCreateCalls = append(folderCreateCalls, args[1].(*folder.CreateFolderCommand))
}).Return(&folder.Folder{ID: 2, UID: folderTwoUID, Title: "folderTwo"}, nil).Once()
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.MatchedBy(func(cmd *folder.CreateFolderCommand) bool {
return cmd.Title == "folderThree" && cmd.ParentUID == folderTwoUID
}), configName).Run(func(args mock.Arguments) {
folderCreateCalls = append(folderCreateCalls, args[1].(*folder.CreateFolderCommand))
}).Return(&folder.Folder{ID: 3, UID: folderThreeUID, Title: "folderThree"}, nil).Once()
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.MatchedBy(func(cmd *folder.CreateFolderCommand) bool {
return cmd.Title == "folderFour" && cmd.ParentUID == folderThreeUID
}), configName).Run(func(args mock.Arguments) {
folderCreateCalls = append(folderCreateCalls, args[1].(*folder.CreateFolderCommand))
}).Return(&folder.Folder{ID: 4, UID: "folderFour-uid", Title: "folderFour"}, nil).Once()
// Map iteration order is undefined; record FolderUID/FolderID per provisioned file (ExternalID).
savedFolderByExternalID := make(map[string]struct {
folderUID string
folderID int64
})
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).
Run(func(args mock.Arguments) {
dto := args.Get(1).(*dashboards.SaveDashboardDTO)
dp := args.Get(2).(*dashboards.DashboardProvisioning)
require.NotNil(t, dto)
require.NotNil(t, dto.Dashboard)
require.NotNil(t, dp)
savedFolderByExternalID[dp.ExternalID] = struct {
folderUID string
folderID int64
}{folderUID: dto.Dashboard.FolderUID, folderID: dto.Dashboard.FolderID} // nolint:staticcheck
}).
Return(&dashboards.Dashboard{}, nil).
Times(5)
reader, err := NewDashboardFileReader(cfg, logger, fakeService, fakeStore, folderServiceForFoldersFromFilesStructure(), cfgT)
require.NoError(t, err)
err = reader.walkDisk(context.Background())
require.NoError(t, err)
basePath := reader.resolvedPath()
dashboard3Path := filepath.Join(basePath, "folderTwo", "folderThree", "dashboard3.json")
dashboard4Path := filepath.Join(basePath, "folderTwo", "folderThree", "folderFour", "dashboard4.json")
require.Contains(t, savedFolderByExternalID, dashboard3Path)
require.Equal(t, folderThreeUID, savedFolderByExternalID[dashboard3Path].folderUID)
require.Equal(t, int64(3), savedFolderByExternalID[dashboard3Path].folderID)
require.Contains(t, savedFolderByExternalID, dashboard4Path)
require.Equal(t, "folderFour-uid", savedFolderByExternalID[dashboard4Path].folderUID)
require.Equal(t, int64(4), savedFolderByExternalID[dashboard4Path].folderID)
require.Len(t, folderCreateCalls, 4, "cache: folderOne, folderTwo, folderThree created; folderFour reuses ancestors")
folderByTitle := make(map[string]*folder.CreateFolderCommand)
for _, c := range folderCreateCalls {
folderByTitle[c.Title] = c
}
require.Equal(t, folderTwoUID, folderByTitle["folderThree"].ParentUID, "folderThree must be nested under folderTwo")
require.Equal(t, folderThreeUID, folderByTitle["folderFour"].ParentUID, "folderFour must be nested under folderThree")
})
t.Run("Reuses existing folders by title and parent across walks", func(t *testing.T) {
setup()
cfg.Options["path"] = foldersFromFilesStructure
cfg.Options["foldersFromFilesStructure"] = true
fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(nil, nil).Twice()
// Folders are pre-seeded in the fake with non-hash UIDs; provisioning must not create folders.
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Times(10)
reader, err := NewDashboardFileReader(cfg, logger, fakeService, fakeStore, folderServiceWithPreexistingFilesStructureHierarchy(), cfgT)
require.NoError(t, err)
err = reader.walkDisk(context.Background())
require.NoError(t, err)
err = reader.walkDisk(context.Background())
require.NoError(t, err)
})
t.Run("Single-level and nested folders from files structure", func(t *testing.T) {
setup()
cfg.Options["path"] = foldersFromFilesStructure
cfg.Options["foldersFromFilesStructure"] = true
fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(nil, nil).Once()
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything, configName).Return(&folder.Folder{}, nil).Times(4)
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Times(5)
reader, err := NewDashboardFileReader(cfg, logger, fakeService, fakeStore, folderServiceForFoldersFromFilesStructure(), cfgT)
require.NoError(t, err)
err = reader.walkDisk(context.Background())
require.NoError(t, err)
})
t.Run("Nested folder depth exceeds maximum returns error", func(t *testing.T) {
setup()
cfg.Options["path"] = oneDashboard
cfg.Options["foldersFromFilesStructure"] = true
// We test getOrCreateFolderFullpath directly; depth check runs before folder service is called.
reader, err := NewDashboardFileReader(cfg, logger, fakeService, fakeStore, folderServiceForFoldersFromFilesStructure(), cfgT)
require.NoError(t, err)
_, _, err = reader.getOrCreateFolderFullpath(context.Background(), "a/b/c/d/e", 1, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "exceeds maximum")
})
t.Run("Invalid configuration should return error", func(t *testing.T) {
setup()
cfg := &config{
@@ -341,7 +487,7 @@ func TestIntegrationDashboardFileReader(t *testing.T) {
Folder: "",
}
_, err := NewDashboardFileReader(cfg, logger, nil, nil, folderSvc)
_, err := NewDashboardFileReader(cfg, logger, nil, nil, folderSvc, cfgT)
require.NotNil(t, err)
})
@@ -349,7 +495,7 @@ func TestIntegrationDashboardFileReader(t *testing.T) {
setup()
cfg.Options["path"] = brokenDashboards
_, err := NewDashboardFileReader(cfg, logger, nil, nil, folderSvc)
_, err := NewDashboardFileReader(cfg, logger, nil, nil, folderSvc, cfgT)
require.NoError(t, err)
})
@@ -362,15 +508,13 @@ func TestIntegrationDashboardFileReader(t *testing.T) {
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything, mock.Anything).Return(&folder.Folder{}, nil).Times(2)
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Times(2)
reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore, folderSvc)
reader1.dashboardProvisioningService = fakeService
reader1, err := NewDashboardFileReader(cfg1, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
err = reader1.walkDisk(context.Background())
require.NoError(t, err)
reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore, folderSvc)
reader2.dashboardProvisioningService = fakeService
reader2, err := NewDashboardFileReader(cfg2, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
err = reader2.walkDisk(context.Background())
@@ -389,10 +533,10 @@ func TestIntegrationDashboardFileReader(t *testing.T) {
"folder": defaultDashboards,
},
}
r, err := NewDashboardFileReader(cfg, logger, nil, nil, folderSvc)
r, err := NewDashboardFileReader(cfg, logger, nil, nil, folderSvc, cfgT)
require.NoError(t, err)
_, _, err = r.getOrCreateFolder(context.Background(), cfg, fakeService, cfg.Folder)
_, _, err = r.getOrCreateFolder(context.Background(), cfg, cfg.Folder)
require.Equal(t, err, ErrFolderNameMissing)
})
@@ -409,12 +553,12 @@ func TestIntegrationDashboardFileReader(t *testing.T) {
}
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything, cfg.Name).Return(&folder.Folder{ID: 1}, nil).Once()
r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
r, err := NewDashboardFileReader(cfg, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
ctx := context.Background()
ctx, _ = identity.WithServiceIdentity(ctx, 1)
_, _, err = r.getOrCreateFolder(ctx, cfg, fakeService, cfg.Folder)
_, _, err = r.getOrCreateFolder(ctx, cfg, cfg.Folder)
require.NoError(t, err)
})
@@ -431,12 +575,12 @@ func TestIntegrationDashboardFileReader(t *testing.T) {
},
}
r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
r, err := NewDashboardFileReader(cfg, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
ctx := context.Background()
ctx, _ = identity.WithServiceIdentity(ctx, 1)
_, _, err = r.getOrCreateFolder(ctx, cfg, fakeService, cfg.Folder)
_, _, err = r.getOrCreateFolder(ctx, cfg, cfg.Folder)
require.ErrorIs(t, err, folder.ErrInvalidUID)
})
@@ -466,8 +610,7 @@ func TestIntegrationDashboardFileReader(t *testing.T) {
}
fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(provisionedDashboard, nil).Once()
reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
reader.dashboardProvisioningService = fakeService
reader, err := NewDashboardFileReader(cfg, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
resolvedPath := reader.resolvedPath()
dashboards, err := reader.getProvisionedDashboardsByPath(context.Background(), fakeService, configName)
@@ -503,15 +646,17 @@ func TestIntegrationDashboardFileReader(t *testing.T) {
t.Run("Missing dashboard should be unprovisioned if DisableDeletion = true", func(t *testing.T) {
setupFakeService()
fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(provisionedDashboard, nil).Once()
fakeService.On("UnprovisionDashboard", mock.Anything, mock.Anything).Return(nil).Once()
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Once()
fakeForUnprovision := &dashboards.FakeDashboardProvisioning{}
defer fakeForUnprovision.AssertExpectations(t)
fakeForUnprovision.On("GetProvisionedDashboardData", mock.Anything, configName).Return(provisionedDashboard, nil).Once()
fakeForUnprovision.On("UnprovisionDashboard", mock.Anything, mock.Anything).Return(nil).Once()
fakeForUnprovision.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Once()
cfg.DisableDeletion = true
reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
reader.dashboardProvisioningService = fakeService
reader.dashboardProvisioningService = fakeForUnprovision
err = reader.walkDisk(context.Background())
require.NoError(t, err)
@@ -520,12 +665,13 @@ func TestIntegrationDashboardFileReader(t *testing.T) {
t.Run("Missing dashboard should be deleted if DisableDeletion = false", func(t *testing.T) {
setupFakeService()
fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(provisionedDashboard, nil).Once()
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Once()
fakeService.On("DeleteProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
fakeForDelete := &dashboards.FakeDashboardProvisioning{}
defer fakeForDelete.AssertExpectations(t)
fakeForDelete.On("GetProvisionedDashboardData", mock.Anything, configName).Return(provisionedDashboard, nil).Once()
fakeForDelete.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Once()
fakeForDelete.On("DeleteProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
reader.dashboardProvisioningService = fakeService
reader, err := NewDashboardFileReader(cfg, logger, fakeForDelete, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
err = reader.walkDisk(context.Background())
@@ -534,6 +680,51 @@ func TestIntegrationDashboardFileReader(t *testing.T) {
})
}
func TestSplitFolderFullpath(t *testing.T) {
require.Equal(t, []string{"folderOne", "folderTwo"}, splitFolderFullpath("folderOne/folderTwo"))
require.Equal(t, []string{"folderTwo", "folderThree", "folderFour"}, splitFolderFullpath("folderTwo/folderThree/folderFour"))
require.Equal(t, []string{"folderOne", "folderTwo"}, splitFolderFullpath("folderOne//folderTwo"))
require.Equal(t, []string{"folderOne"}, splitFolderFullpath("folderOne"))
require.Empty(t, splitFolderFullpath(""))
require.Empty(t, splitFolderFullpath("///"))
}
func TestIntegrationFolderPathCacheReuse(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
fakeService := &dashboards.FakeDashboardProvisioning{}
defer fakeService.AssertExpectations(t)
fakeStore := &fakeDashboardStore{}
cfg := &config{
Name: "cache-test",
Type: "file",
OrgID: 1,
Folder: "",
Options: map[string]any{"path": foldersFromFilesStructure},
}
reader, err := NewDashboardFileReader(cfg, log.New("test"), fakeService, fakeStore, folderServiceForFoldersFromFilesStructure(), setting.NewCfg())
require.NoError(t, err)
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything, "cache-test").
Return(&folder.Folder{ID: 1, UID: "folderTwo-uid", Title: "folderTwo"}, nil).Once()
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything, "cache-test").
Return(&folder.Folder{ID: 2, UID: "folderThree-uid", Title: "folderThree"}, nil).Once()
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything, "cache-test").
Return(&folder.Folder{ID: 3, UID: "folderFour-uid", Title: "folderFour"}, nil).Once()
cache := make(map[string]folderPathCacheEntry)
ctx := context.Background()
_, _, err = reader.getOrCreateFolderFullpath(ctx, "folderTwo/folderThree", 1, cache)
require.NoError(t, err)
_, _, err = reader.getOrCreateFolderFullpath(ctx, "folderTwo/folderThree/folderFour", 1, cache)
require.NoError(t, err)
// Second call must only create folderFour (folderTwo and folderThree from cache).
fakeService.AssertExpectations(t)
}
type FakeFileInfo struct {
isDirectory bool
name string
@@ -1,160 +1,160 @@
{
"title": "Grafana1",
"tags": [],
"timezone": "browser",
"editable": true,
"rows": [
{
"title": "New row",
"height": "150px",
"collapse": false,
"editable": true,
"panels": [
{
"id": 1,
"span": 12,
"editable": true,
"type": "text",
"mode": "html",
"content": "<div class=\"text-center\" style=\"padding-top: 15px\">\n<img src=\"img/logo_transparent_200x.png\"> \n</div>",
"style": {},
"title": "Welcome to"
}
]
},
{
"title": "Welcome to Grafana",
"height": "210px",
"collapse": false,
"editable": true,
"panels": [
{
"id": 2,
"span": 6,
"type": "text",
"mode": "html",
"content": "<br/>\n\n<div class=\"row-fluid\">\n <div class=\"span6\">\n <ul>\n <li>\n <a href=\"http://grafana.org/docs#configuration\" target=\"_blank\">Configuration</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/troubleshooting\" target=\"_blank\">Troubleshooting</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/support\" target=\"_blank\">Support</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/intro\" target=\"_blank\">Getting started</a> (Must read!)\n </li>\n </ul>\n </div>\n <div class=\"span6\">\n <ul>\n <li>\n <a href=\"http://grafana.org/docs/features/graphing\" target=\"_blank\">Graphing</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/annotations\" target=\"_blank\">Annotations</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/graphite\" target=\"_blank\">Graphite</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/influxdb\" target=\"_blank\">InfluxDB</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/opentsdb\" target=\"_blank\">OpenTSDB</a>\n </li>\n </ul>\n </div>\n</div>",
"style": {},
"title": "Documentation Links"
"title": "Grafana1",
"tags": [],
"timezone": "browser",
"editable": true,
"rows": [
{
"title": "New row",
"height": "150px",
"collapse": false,
"editable": true,
"panels": [
{
"id": 1,
"span": 12,
"editable": true,
"type": "text",
"mode": "html",
"content": "<div class=\"text-center\" style=\"padding-top: 15px\">\n<img src=\"img/logo_transparent_200x.png\"> \n</div>",
"style": {},
"title": "Welcome to"
}
]
},
{
"title": "Welcome to Grafana",
"height": "210px",
"collapse": false,
"editable": true,
"panels": [
{
"id": 2,
"span": 6,
"type": "text",
"mode": "html",
"content": "<br/>\n\n<div class=\"row-fluid\">\n <div class=\"span6\">\n <ul>\n <li>\n <a href=\"http://grafana.org/docs#configuration\" target=\"_blank\">Configuration</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/troubleshooting\" target=\"_blank\">Troubleshooting</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/support\" target=\"_blank\">Support</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/intro\" target=\"_blank\">Getting started</a> (Must read!)\n </li>\n </ul>\n </div>\n <div class=\"span6\">\n <ul>\n <li>\n <a href=\"http://grafana.org/docs/features/graphing\" target=\"_blank\">Graphing</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/annotations\" target=\"_blank\">Annotations</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/graphite\" target=\"_blank\">Graphite</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/influxdb\" target=\"_blank\">InfluxDB</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/opentsdb\" target=\"_blank\">OpenTSDB</a>\n </li>\n </ul>\n </div>\n</div>",
"style": {},
"title": "Documentation Links"
},
{
"id": 3,
"span": 6,
"type": "text",
"mode": "html",
"content": "<br/>\n\n<div class=\"row-fluid\">\n <div class=\"span12\">\n <ul>\n <li>Ctrl+S saves the current dashboard</li>\n <li>Ctrl+F Opens the dashboard finder</li>\n <li>Ctrl+H Hide/show row controls</li>\n <li>Click and drag graph title to move panel</li>\n <li>Hit Escape to exit graph when in fullscreen or edit mode</li>\n <li>Click the colored icon in the legend to change series color</li>\n <li>Ctrl or Shift + Click legend name to hide other series</li>\n </ul>\n </div>\n</div>\n",
"style": {},
"title": "Tips & Shortcuts"
}
]
},
{
"title": "test",
"height": "250px",
"editable": true,
"collapse": false,
"panels": [
{
"id": 4,
"span": 12,
"type": "graph",
"x-axis": true,
"y-axis": true,
"scale": 1,
"y_formats": [
"short",
"short"
],
"grid": {
"max": null,
"min": null,
"leftMax": null,
"rightMax": null,
"leftMin": null,
"rightMin": null,
"threshold1": null,
"threshold2": null,
"threshold1Color": "rgba(216, 200, 27, 0.27)",
"threshold2Color": "rgba(234, 112, 112, 0.22)"
},
{
"id": 3,
"span": 6,
"type": "text",
"mode": "html",
"content": "<br/>\n\n<div class=\"row-fluid\">\n <div class=\"span12\">\n <ul>\n <li>Ctrl+S saves the current dashboard</li>\n <li>Ctrl+F Opens the dashboard finder</li>\n <li>Ctrl+H Hide/show row controls</li>\n <li>Click and drag graph title to move panel</li>\n <li>Hit Escape to exit graph when in fullscreen or edit mode</li>\n <li>Click the colored icon in the legend to change series color</li>\n <li>Ctrl or Shift + Click legend name to hide other series</li>\n </ul>\n </div>\n</div>\n",
"style": {},
"title": "Tips & Shortcuts"
}
]
},
{
"title": "test",
"height": "250px",
"editable": true,
"collapse": false,
"panels": [
{
"id": 4,
"span": 12,
"type": "graph",
"x-axis": true,
"y-axis": true,
"scale": 1,
"y_formats": [
"short",
"short"
],
"grid": {
"max": null,
"min": null,
"leftMax": null,
"rightMax": null,
"leftMin": null,
"rightMin": null,
"threshold1": null,
"threshold2": null,
"threshold1Color": "rgba(216, 200, 27, 0.27)",
"threshold2Color": "rgba(234, 112, 112, 0.22)"
},
"resolution": 100,
"lines": true,
"fill": 1,
"linewidth": 2,
"dashes": false,
"dashLength": 10,
"spaceLength": 10,
"points": false,
"pointradius": 5,
"bars": false,
"stack": true,
"spyable": true,
"options": false,
"legend": {
"show": true,
"values": false,
"min": false,
"max": false,
"current": false,
"total": false,
"avg": false
},
"interactive": true,
"legend_counts": true,
"timezone": "browser",
"percentage": false,
"nullPointMode": "connected",
"steppedLine": false,
"tooltip": {
"value_type": "cumulative",
"query_as_alias": true
},
"targets": [
{
"target": "randomWalk('random walk')",
"function": "mean",
"column": "value"
}
],
"aliasColors": {},
"aliasYAxis": {},
"title": "First Graph (click title to edit)",
"datasource": "graphite",
"renderer": "flot",
"annotate": {
"enable": false
"resolution": 100,
"lines": true,
"fill": 1,
"linewidth": 2,
"dashes": false,
"dashLength": 10,
"spaceLength": 10,
"points": false,
"pointradius": 5,
"bars": false,
"stack": true,
"spyable": true,
"options": false,
"legend": {
"show": true,
"values": false,
"min": false,
"max": false,
"current": false,
"total": false,
"avg": false
},
"interactive": true,
"legend_counts": true,
"timezone": "browser",
"percentage": false,
"nullPointMode": "connected",
"steppedLine": false,
"tooltip": {
"value_type": "cumulative",
"query_as_alias": true
},
"targets": [
{
"target": "randomWalk('random walk')",
"function": "mean",
"column": "value"
}
],
"aliasColors": {},
"aliasYAxis": {},
"title": "First Graph (click title to edit)",
"datasource": "graphite",
"renderer": "flot",
"annotate": {
"enable": false
}
]
}
],
"nav": [
{
"type": "timepicker",
"collapse": false,
"enable": true,
"status": "Stable",
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"now": true
}
],
"time": {
"from": "now-6h",
"to": "now"
},
"templating": {
"list": []
},
"version": 5
}
}
]
}
],
"nav": [
{
"type": "timepicker",
"collapse": false,
"enable": true,
"status": "Stable",
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"now": true
}
],
"time": {
"from": "now-6h",
"to": "now"
},
"templating": {
"list": []
},
"version": 5
}
@@ -1,160 +1,160 @@
{
"title": "Grafana2",
"tags": [],
"timezone": "browser",
"editable": true,
"rows": [
{
"title": "New row",
"height": "150px",
"collapse": false,
"editable": true,
"panels": [
{
"id": 1,
"span": 12,
"editable": true,
"type": "text",
"mode": "html",
"content": "<div class=\"text-center\" style=\"padding-top: 15px\">\n<img src=\"img/logo_transparent_200x.png\"> \n</div>",
"style": {},
"title": "Welcome to"
}
]
},
{
"title": "Welcome to Grafana",
"height": "210px",
"collapse": false,
"editable": true,
"panels": [
{
"id": 2,
"span": 6,
"type": "text",
"mode": "html",
"content": "<br/>\n\n<div class=\"row-fluid\">\n <div class=\"span6\">\n <ul>\n <li>\n <a href=\"http://grafana.org/docs#configuration\" target=\"_blank\">Configuration</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/troubleshooting\" target=\"_blank\">Troubleshooting</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/support\" target=\"_blank\">Support</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/intro\" target=\"_blank\">Getting started</a> (Must read!)\n </li>\n </ul>\n </div>\n <div class=\"span6\">\n <ul>\n <li>\n <a href=\"http://grafana.org/docs/features/graphing\" target=\"_blank\">Graphing</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/annotations\" target=\"_blank\">Annotations</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/graphite\" target=\"_blank\">Graphite</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/influxdb\" target=\"_blank\">InfluxDB</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/opentsdb\" target=\"_blank\">OpenTSDB</a>\n </li>\n </ul>\n </div>\n</div>",
"style": {},
"title": "Documentation Links"
"title": "Grafana2",
"tags": [],
"timezone": "browser",
"editable": true,
"rows": [
{
"title": "New row",
"height": "150px",
"collapse": false,
"editable": true,
"panels": [
{
"id": 1,
"span": 12,
"editable": true,
"type": "text",
"mode": "html",
"content": "<div class=\"text-center\" style=\"padding-top: 15px\">\n<img src=\"img/logo_transparent_200x.png\"> \n</div>",
"style": {},
"title": "Welcome to"
}
]
},
{
"title": "Welcome to Grafana",
"height": "210px",
"collapse": false,
"editable": true,
"panels": [
{
"id": 2,
"span": 6,
"type": "text",
"mode": "html",
"content": "<br/>\n\n<div class=\"row-fluid\">\n <div class=\"span6\">\n <ul>\n <li>\n <a href=\"http://grafana.org/docs#configuration\" target=\"_blank\">Configuration</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/troubleshooting\" target=\"_blank\">Troubleshooting</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/support\" target=\"_blank\">Support</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/intro\" target=\"_blank\">Getting started</a> (Must read!)\n </li>\n </ul>\n </div>\n <div class=\"span6\">\n <ul>\n <li>\n <a href=\"http://grafana.org/docs/features/graphing\" target=\"_blank\">Graphing</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/annotations\" target=\"_blank\">Annotations</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/graphite\" target=\"_blank\">Graphite</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/influxdb\" target=\"_blank\">InfluxDB</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/opentsdb\" target=\"_blank\">OpenTSDB</a>\n </li>\n </ul>\n </div>\n</div>",
"style": {},
"title": "Documentation Links"
},
{
"id": 3,
"span": 6,
"type": "text",
"mode": "html",
"content": "<br/>\n\n<div class=\"row-fluid\">\n <div class=\"span12\">\n <ul>\n <li>Ctrl+S saves the current dashboard</li>\n <li>Ctrl+F Opens the dashboard finder</li>\n <li>Ctrl+H Hide/show row controls</li>\n <li>Click and drag graph title to move panel</li>\n <li>Hit Escape to exit graph when in fullscreen or edit mode</li>\n <li>Click the colored icon in the legend to change series color</li>\n <li>Ctrl or Shift + Click legend name to hide other series</li>\n </ul>\n </div>\n</div>\n",
"style": {},
"title": "Tips & Shortcuts"
}
]
},
{
"title": "test",
"height": "250px",
"editable": true,
"collapse": false,
"panels": [
{
"id": 4,
"span": 12,
"type": "graph",
"x-axis": true,
"y-axis": true,
"scale": 1,
"y_formats": [
"short",
"short"
],
"grid": {
"max": null,
"min": null,
"leftMax": null,
"rightMax": null,
"leftMin": null,
"rightMin": null,
"threshold1": null,
"threshold2": null,
"threshold1Color": "rgba(216, 200, 27, 0.27)",
"threshold2Color": "rgba(234, 112, 112, 0.22)"
},
{
"id": 3,
"span": 6,
"type": "text",
"mode": "html",
"content": "<br/>\n\n<div class=\"row-fluid\">\n <div class=\"span12\">\n <ul>\n <li>Ctrl+S saves the current dashboard</li>\n <li>Ctrl+F Opens the dashboard finder</li>\n <li>Ctrl+H Hide/show row controls</li>\n <li>Click and drag graph title to move panel</li>\n <li>Hit Escape to exit graph when in fullscreen or edit mode</li>\n <li>Click the colored icon in the legend to change series color</li>\n <li>Ctrl or Shift + Click legend name to hide other series</li>\n </ul>\n </div>\n</div>\n",
"style": {},
"title": "Tips & Shortcuts"
}
]
},
{
"title": "test",
"height": "250px",
"editable": true,
"collapse": false,
"panels": [
{
"id": 4,
"span": 12,
"type": "graph",
"x-axis": true,
"y-axis": true,
"scale": 1,
"y_formats": [
"short",
"short"
],
"grid": {
"max": null,
"min": null,
"leftMax": null,
"rightMax": null,
"leftMin": null,
"rightMin": null,
"threshold1": null,
"threshold2": null,
"threshold1Color": "rgba(216, 200, 27, 0.27)",
"threshold2Color": "rgba(234, 112, 112, 0.22)"
},
"resolution": 100,
"lines": true,
"fill": 1,
"linewidth": 2,
"dashes": false,
"dashLength": 10,
"spaceLength": 10,
"points": false,
"pointradius": 5,
"bars": false,
"stack": true,
"spyable": true,
"options": false,
"legend": {
"show": true,
"values": false,
"min": false,
"max": false,
"current": false,
"total": false,
"avg": false
},
"interactive": true,
"legend_counts": true,
"timezone": "browser",
"percentage": false,
"nullPointMode": "connected",
"steppedLine": false,
"tooltip": {
"value_type": "cumulative",
"query_as_alias": true
},
"targets": [
{
"target": "randomWalk('random walk')",
"function": "mean",
"column": "value"
}
],
"aliasColors": {},
"aliasYAxis": {},
"title": "First Graph (click title to edit)",
"datasource": "graphite",
"renderer": "flot",
"annotate": {
"enable": false
"resolution": 100,
"lines": true,
"fill": 1,
"linewidth": 2,
"dashes": false,
"dashLength": 10,
"spaceLength": 10,
"points": false,
"pointradius": 5,
"bars": false,
"stack": true,
"spyable": true,
"options": false,
"legend": {
"show": true,
"values": false,
"min": false,
"max": false,
"current": false,
"total": false,
"avg": false
},
"interactive": true,
"legend_counts": true,
"timezone": "browser",
"percentage": false,
"nullPointMode": "connected",
"steppedLine": false,
"tooltip": {
"value_type": "cumulative",
"query_as_alias": true
},
"targets": [
{
"target": "randomWalk('random walk')",
"function": "mean",
"column": "value"
}
],
"aliasColors": {},
"aliasYAxis": {},
"title": "First Graph (click title to edit)",
"datasource": "graphite",
"renderer": "flot",
"annotate": {
"enable": false
}
]
}
],
"nav": [
{
"type": "timepicker",
"collapse": false,
"enable": true,
"status": "Stable",
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"now": true
}
],
"time": {
"from": "now-6h",
"to": "now"
},
"templating": {
"list": []
},
"version": 5
}
}
]
}
],
"nav": [
{
"type": "timepicker",
"collapse": false,
"enable": true,
"status": "Stable",
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"now": true
}
],
"time": {
"from": "now-6h",
"to": "now"
},
"templating": {
"list": []
},
"version": 5
}
@@ -0,0 +1,160 @@
{
"title": "Grafana3",
"tags": [],
"timezone": "browser",
"editable": true,
"rows": [
{
"title": "New row",
"height": "150px",
"collapse": false,
"editable": true,
"panels": [
{
"id": 1,
"span": 12,
"editable": true,
"type": "text",
"mode": "html",
"content": "<div class=\"text-center\" style=\"padding-top: 15px\">\n<img src=\"img/logo_transparent_200x.png\"> \n</div>",
"style": {},
"title": "Welcome to"
}
]
},
{
"title": "Welcome to Grafana",
"height": "210px",
"collapse": false,
"editable": true,
"panels": [
{
"id": 2,
"span": 6,
"type": "text",
"mode": "html",
"content": "<br/>\n\n<div class=\"row-fluid\">\n <div class=\"span6\">\n <ul>\n <li>\n <a href=\"http://grafana.org/docs#configuration\" target=\"_blank\">Configuration</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/troubleshooting\" target=\"_blank\">Troubleshooting</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/support\" target=\"_blank\">Support</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/intro\" target=\"_blank\">Getting started</a> (Must read!)\n </li>\n </ul>\n </div>\n <div class=\"span6\">\n <ul>\n <li>\n <a href=\"http://grafana.org/docs/features/graphing\" target=\"_blank\">Graphing</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/annotations\" target=\"_blank\">Annotations</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/graphite\" target=\"_blank\">Graphite</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/influxdb\" target=\"_blank\">InfluxDB</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/opentsdb\" target=\"_blank\">OpenTSDB</a>\n </li>\n </ul>\n </div>\n</div>",
"style": {},
"title": "Documentation Links"
},
{
"id": 3,
"span": 6,
"type": "text",
"mode": "html",
"content": "<br/>\n\n<div class=\"row-fluid\">\n <div class=\"span12\">\n <ul>\n <li>Ctrl+S saves the current dashboard</li>\n <li>Ctrl+F Opens the dashboard finder</li>\n <li>Ctrl+H Hide/show row controls</li>\n <li>Click and drag graph title to move panel</li>\n <li>Hit Escape to exit graph when in fullscreen or edit mode</li>\n <li>Click the colored icon in the legend to change series color</li>\n <li>Ctrl or Shift + Click legend name to hide other series</li>\n </ul>\n </div>\n</div>\n",
"style": {},
"title": "Tips & Shortcuts"
}
]
},
{
"title": "test",
"height": "250px",
"editable": true,
"collapse": false,
"panels": [
{
"id": 4,
"span": 12,
"type": "graph",
"x-axis": true,
"y-axis": true,
"scale": 1,
"y_formats": [
"short",
"short"
],
"grid": {
"max": null,
"min": null,
"leftMax": null,
"rightMax": null,
"leftMin": null,
"rightMin": null,
"threshold1": null,
"threshold2": null,
"threshold1Color": "rgba(216, 200, 27, 0.27)",
"threshold2Color": "rgba(234, 112, 112, 0.22)"
},
"resolution": 100,
"lines": true,
"fill": 1,
"linewidth": 2,
"dashes": false,
"dashLength": 10,
"spaceLength": 10,
"points": false,
"pointradius": 5,
"bars": false,
"stack": true,
"spyable": true,
"options": false,
"legend": {
"show": true,
"values": false,
"min": false,
"max": false,
"current": false,
"total": false,
"avg": false
},
"interactive": true,
"legend_counts": true,
"timezone": "browser",
"percentage": false,
"nullPointMode": "connected",
"steppedLine": false,
"tooltip": {
"value_type": "cumulative",
"query_as_alias": true
},
"targets": [
{
"target": "randomWalk('random walk')",
"function": "mean",
"column": "value"
}
],
"aliasColors": {},
"aliasYAxis": {},
"title": "First Graph (click title to edit)",
"datasource": "graphite",
"renderer": "flot",
"annotate": {
"enable": false
}
}
]
}
],
"nav": [
{
"type": "timepicker",
"collapse": false,
"enable": true,
"status": "Stable",
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"now": true
}
],
"time": {
"from": "now-6h",
"to": "now"
},
"templating": {
"list": []
},
"version": 5
}
@@ -0,0 +1,160 @@
{
"title": "Grafana4",
"tags": [],
"timezone": "browser",
"editable": true,
"rows": [
{
"title": "New row",
"height": "150px",
"collapse": false,
"editable": true,
"panels": [
{
"id": 1,
"span": 12,
"editable": true,
"type": "text",
"mode": "html",
"content": "<div class=\"text-center\" style=\"padding-top: 15px\">\n<img src=\"img/logo_transparent_200x.png\"> \n</div>",
"style": {},
"title": "Welcome to"
}
]
},
{
"title": "Welcome to Grafana",
"height": "210px",
"collapse": false,
"editable": true,
"panels": [
{
"id": 2,
"span": 6,
"type": "text",
"mode": "html",
"content": "<br/>\n\n<div class=\"row-fluid\">\n <div class=\"span6\">\n <ul>\n <li>\n <a href=\"http://grafana.org/docs#configuration\" target=\"_blank\">Configuration</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/troubleshooting\" target=\"_blank\">Troubleshooting</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/support\" target=\"_blank\">Support</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/intro\" target=\"_blank\">Getting started</a> (Must read!)\n </li>\n </ul>\n </div>\n <div class=\"span6\">\n <ul>\n <li>\n <a href=\"http://grafana.org/docs/features/graphing\" target=\"_blank\">Graphing</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/annotations\" target=\"_blank\">Annotations</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/graphite\" target=\"_blank\">Graphite</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/influxdb\" target=\"_blank\">InfluxDB</a>\n </li>\n <li>\n <a href=\"http://grafana.org/docs/features/opentsdb\" target=\"_blank\">OpenTSDB</a>\n </li>\n </ul>\n </div>\n</div>",
"style": {},
"title": "Documentation Links"
},
{
"id": 3,
"span": 6,
"type": "text",
"mode": "html",
"content": "<br/>\n\n<div class=\"row-fluid\">\n <div class=\"span12\">\n <ul>\n <li>Ctrl+S saves the current dashboard</li>\n <li>Ctrl+F Opens the dashboard finder</li>\n <li>Ctrl+H Hide/show row controls</li>\n <li>Click and drag graph title to move panel</li>\n <li>Hit Escape to exit graph when in fullscreen or edit mode</li>\n <li>Click the colored icon in the legend to change series color</li>\n <li>Ctrl or Shift + Click legend name to hide other series</li>\n </ul>\n </div>\n</div>\n",
"style": {},
"title": "Tips & Shortcuts"
}
]
},
{
"title": "test",
"height": "250px",
"editable": true,
"collapse": false,
"panels": [
{
"id": 4,
"span": 12,
"type": "graph",
"x-axis": true,
"y-axis": true,
"scale": 1,
"y_formats": [
"short",
"short"
],
"grid": {
"max": null,
"min": null,
"leftMax": null,
"rightMax": null,
"leftMin": null,
"rightMin": null,
"threshold1": null,
"threshold2": null,
"threshold1Color": "rgba(216, 200, 27, 0.27)",
"threshold2Color": "rgba(234, 112, 112, 0.22)"
},
"resolution": 100,
"lines": true,
"fill": 1,
"linewidth": 2,
"dashes": false,
"dashLength": 10,
"spaceLength": 10,
"points": false,
"pointradius": 5,
"bars": false,
"stack": true,
"spyable": true,
"options": false,
"legend": {
"show": true,
"values": false,
"min": false,
"max": false,
"current": false,
"total": false,
"avg": false
},
"interactive": true,
"legend_counts": true,
"timezone": "browser",
"percentage": false,
"nullPointMode": "connected",
"steppedLine": false,
"tooltip": {
"value_type": "cumulative",
"query_as_alias": true
},
"targets": [
{
"target": "randomWalk('random walk')",
"function": "mean",
"column": "value"
}
],
"aliasColors": {},
"aliasYAxis": {},
"title": "First Graph (click title to edit)",
"datasource": "graphite",
"renderer": "flot",
"annotate": {
"enable": false
}
}
]
}
],
"nav": [
{
"type": "timepicker",
"collapse": false,
"enable": true,
"status": "Stable",
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"now": true
}
],
"time": {
"from": "now-6h",
"to": "now"
},
"templating": {
"list": []
},
"version": 5
}
@@ -62,12 +62,12 @@ func TestIntegrationDuplicatesValidator(t *testing.T) {
ctx, _ = identity.WithServiceIdentity(ctx, 1)
fakeStore := &fakeDashboardStore{}
r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
r, err := NewDashboardFileReader(cfg, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything, mock.Anything).Return(&folder.Folder{}, nil).Times(6)
fakeService.On("GetProvisionedDashboardData", mock.Anything, mock.AnythingOfType("string")).Return([]*dashboards.DashboardProvisioning{}, nil).Times(4)
fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Times(5)
_, folderUID, err := r.getOrCreateFolder(ctx, cfg, fakeService, folderName)
_, folderUID, err := r.getOrCreateFolder(ctx, cfg, folderName)
require.NoError(t, err)
identity := dashboardIdentity{folderUID: folderUID, title: "Grafana"}
@@ -81,12 +81,10 @@ func TestIntegrationDuplicatesValidator(t *testing.T) {
Options: map[string]any{"path": dashboardContainingUID},
}
reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore, folderSvc)
reader1.dashboardProvisioningService = fakeService
reader1, err := NewDashboardFileReader(cfg1, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore, folderSvc)
reader2.dashboardProvisioningService = fakeService
reader2, err := NewDashboardFileReader(cfg2, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
duplicateValidator := newDuplicateValidator(logger, []*FileReader{reader1, reader2})
@@ -121,9 +119,9 @@ func TestIntegrationDuplicatesValidator(t *testing.T) {
ctx, _ = identity.WithServiceIdentity(ctx, 1)
fakeStore := &fakeDashboardStore{}
r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
r, err := NewDashboardFileReader(cfg, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
_, folderUID, err := r.getOrCreateFolder(ctx, cfg, fakeService, folderName)
_, folderUID, err := r.getOrCreateFolder(ctx, cfg, folderName)
require.NoError(t, err)
identity := dashboardIdentity{folderUID: folderUID, title: "Grafana"}
@@ -137,12 +135,10 @@ func TestIntegrationDuplicatesValidator(t *testing.T) {
Options: map[string]any{"path": dashboardContainingUID},
}
reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore, folderSvc)
reader1.dashboardProvisioningService = fakeService
reader1, err := NewDashboardFileReader(cfg1, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore, folderSvc)
reader2.dashboardProvisioningService = fakeService
reader2, err := NewDashboardFileReader(cfg2, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
duplicateValidator := newDuplicateValidator(logger, []*FileReader{reader1, reader2})
@@ -198,16 +194,13 @@ func TestIntegrationDuplicatesValidator(t *testing.T) {
Name: "third", Type: "file", OrgID: 2, Folder: "duplicates-validator-folder",
Options: map[string]any{"path": twoDashboardsWithUID},
}
reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore, folderSvc)
reader1.dashboardProvisioningService = fakeService
reader1, err := NewDashboardFileReader(cfg1, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
reader2, err := NewDashboardFileReader(cfg2, logger, nil, fakeStore, folderSvc)
reader2.dashboardProvisioningService = fakeService
reader2, err := NewDashboardFileReader(cfg2, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
reader3, err := NewDashboardFileReader(cfg3, logger, nil, fakeStore, folderSvc)
reader3.dashboardProvisioningService = fakeService
reader3, err := NewDashboardFileReader(cfg3, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
duplicateValidator := newDuplicateValidator(logger, []*FileReader{reader1, reader2, reader3})
@@ -226,9 +219,9 @@ func TestIntegrationDuplicatesValidator(t *testing.T) {
ctx := context.Background()
ctx, _ = identity.WithServiceIdentity(ctx, 1)
r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
r, err := NewDashboardFileReader(cfg, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
_, folderUID, err := r.getOrCreateFolder(ctx, cfg, fakeService, cfg1.Folder)
_, folderUID, err := r.getOrCreateFolder(ctx, cfg, cfg1.Folder)
require.NoError(t, err)
identity := dashboardIdentity{folderUID: folderUID, title: "Grafana"}
@@ -243,9 +236,9 @@ func TestIntegrationDuplicatesValidator(t *testing.T) {
sort.Strings(titleUsageReaders)
require.Equal(t, []string{"first"}, titleUsageReaders)
r, err = NewDashboardFileReader(cfg3, logger, nil, fakeStore, folderSvc)
r, err = NewDashboardFileReader(cfg3, logger, fakeService, fakeStore, folderSvc, cfgT)
require.NoError(t, err)
_, folderUID, err = r.getOrCreateFolder(ctx, cfg3, fakeService, cfg3.Folder)
_, folderUID, err = r.getOrCreateFolder(ctx, cfg3, cfg3.Folder)
require.NoError(t, err)
identity = dashboardIdentity{folderUID: folderUID, title: "Grafana"}