mirror of https://github.com/grafana/grafana.git
				
				
				
			chore: remove export service POC from main (#63945)
* chore: remove export service POC from main This is a POC and we'll see it, or something like it, again! * remove frontend changes --------- Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
		
							parent
							
								
									030f6c948f
								
							
						
					
					
						commit
						157c270ad3
					
				|  | @ -259,7 +259,6 @@ | ||||||
| /pkg/services/searchV2/ @grafana/multitenancy-squad | /pkg/services/searchV2/ @grafana/multitenancy-squad | ||||||
| /pkg/services/store/ @grafana/multitenancy-squad | /pkg/services/store/ @grafana/multitenancy-squad | ||||||
| /pkg/services/querylibrary/ @grafana/multitenancy-squad | /pkg/services/querylibrary/ @grafana/multitenancy-squad | ||||||
| /pkg/services/export/ @grafana/multitenancy-squad |  | ||||||
| /pkg/infra/filestorage/ @grafana/multitenancy-squad | /pkg/infra/filestorage/ @grafana/multitenancy-squad | ||||||
| /pkg/util/converter/ @grafana/multitenancy-squad | /pkg/util/converter/ @grafana/multitenancy-squad | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -101,7 +101,6 @@ The following toggles require explicitly setting Grafana's [app mode]({{< relref | ||||||
| | `publicDashboardsEmailSharing` | Allows public dashboard sharing to be restricted to only allowed emails | | | `publicDashboardsEmailSharing` | Allows public dashboard sharing to be restricted to only allowed emails | | ||||||
| | `k8s`                          | Explore native k8s integrations                                         | | | `k8s`                          | Explore native k8s integrations                                         | | ||||||
| | `dashboardsFromStorage`        | Load dashboards from the generic storage interface                      | | | `dashboardsFromStorage`        | Load dashboards from the generic storage interface                      | | ||||||
| | `export`                       | Export grafana instance (to git, etc)                                   | |  | ||||||
| | `grpcServer`                   | Run GRPC server                                                         | | | `grpcServer`                   | Run GRPC server                                                         | | ||||||
| | `entityStore`                  | SQL-based entity store (requires storage flag also)                     | | | `entityStore`                  | SQL-based entity store (requires storage flag also)                     | | ||||||
| | `queryLibrary`                 | Reusable query library                                                  | | | `queryLibrary`                 | Reusable query library                                                  | | ||||||
|  |  | ||||||
|  | @ -37,7 +37,6 @@ export interface FeatureToggles { | ||||||
|   storage?: boolean; |   storage?: boolean; | ||||||
|   k8s?: boolean; |   k8s?: boolean; | ||||||
|   dashboardsFromStorage?: boolean; |   dashboardsFromStorage?: boolean; | ||||||
|   export?: boolean; |  | ||||||
|   exploreMixedDatasource?: boolean; |   exploreMixedDatasource?: boolean; | ||||||
|   tracing?: boolean; |   tracing?: boolean; | ||||||
|   newTraceView?: boolean; |   newTraceView?: boolean; | ||||||
|  |  | ||||||
|  | @ -630,13 +630,6 @@ func (hs *HTTPServer) registerRoutes() { | ||||||
| 		adminRoute.Get("/stats", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)), routing.Wrap(hs.AdminGetStats)) | 		adminRoute.Get("/stats", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)), routing.Wrap(hs.AdminGetStats)) | ||||||
| 		adminRoute.Post("/pause-all-alerts", reqGrafanaAdmin, routing.Wrap(hs.PauseAllAlerts(setting.AlertingEnabled))) | 		adminRoute.Post("/pause-all-alerts", reqGrafanaAdmin, routing.Wrap(hs.PauseAllAlerts(setting.AlertingEnabled))) | ||||||
| 
 | 
 | ||||||
| 		if hs.Features.IsEnabled(featuremgmt.FlagExport) { |  | ||||||
| 			adminRoute.Get("/export", reqGrafanaAdmin, routing.Wrap(hs.ExportService.HandleGetStatus)) |  | ||||||
| 			adminRoute.Post("/export", reqGrafanaAdmin, routing.Wrap(hs.ExportService.HandleRequestExport)) |  | ||||||
| 			adminRoute.Post("/export/stop", reqGrafanaAdmin, routing.Wrap(hs.ExportService.HandleRequestStop)) |  | ||||||
| 			adminRoute.Get("/export/options", reqGrafanaAdmin, routing.Wrap(hs.ExportService.HandleGetOptions)) |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		adminRoute.Post("/encryption/rotate-data-keys", reqGrafanaAdmin, routing.Wrap(hs.AdminRotateDataEncryptionKeys)) | 		adminRoute.Post("/encryption/rotate-data-keys", reqGrafanaAdmin, routing.Wrap(hs.AdminRotateDataEncryptionKeys)) | ||||||
| 		adminRoute.Post("/encryption/reencrypt-data-keys", reqGrafanaAdmin, routing.Wrap(hs.AdminReEncryptEncryptionKeys)) | 		adminRoute.Post("/encryption/reencrypt-data-keys", reqGrafanaAdmin, routing.Wrap(hs.AdminReEncryptEncryptionKeys)) | ||||||
| 		adminRoute.Post("/encryption/reencrypt-secrets", reqGrafanaAdmin, routing.Wrap(hs.AdminReEncryptSecrets)) | 		adminRoute.Post("/encryption/reencrypt-secrets", reqGrafanaAdmin, routing.Wrap(hs.AdminReEncryptSecrets)) | ||||||
|  |  | ||||||
|  | @ -52,7 +52,6 @@ import ( | ||||||
| 	"github.com/grafana/grafana/pkg/services/datasources" | 	"github.com/grafana/grafana/pkg/services/datasources" | ||||||
| 	"github.com/grafana/grafana/pkg/services/datasources/permissions" | 	"github.com/grafana/grafana/pkg/services/datasources/permissions" | ||||||
| 	"github.com/grafana/grafana/pkg/services/encryption" | 	"github.com/grafana/grafana/pkg/services/encryption" | ||||||
| 	"github.com/grafana/grafana/pkg/services/export" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/featuremgmt" | 	"github.com/grafana/grafana/pkg/services/featuremgmt" | ||||||
| 	"github.com/grafana/grafana/pkg/services/folder" | 	"github.com/grafana/grafana/pkg/services/folder" | ||||||
| 	"github.com/grafana/grafana/pkg/services/hooks" | 	"github.com/grafana/grafana/pkg/services/hooks" | ||||||
|  | @ -146,7 +145,6 @@ type HTTPServer struct { | ||||||
| 	Live                         *live.GrafanaLive | 	Live                         *live.GrafanaLive | ||||||
| 	LivePushGateway              *pushhttp.Gateway | 	LivePushGateway              *pushhttp.Gateway | ||||||
| 	ThumbService                 thumbs.Service | 	ThumbService                 thumbs.Service | ||||||
| 	ExportService                export.ExportService |  | ||||||
| 	StorageService               store.StorageService | 	StorageService               store.StorageService | ||||||
| 	httpEntityStore              httpentitystore.HTTPEntityStore | 	httpEntityStore              httpentitystore.HTTPEntityStore | ||||||
| 	SearchV2HTTPService          searchV2.SearchHTTPService | 	SearchV2HTTPService          searchV2.SearchHTTPService | ||||||
|  | @ -234,7 +232,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi | ||||||
| 	live *live.GrafanaLive, livePushGateway *pushhttp.Gateway, plugCtxProvider *plugincontext.Provider, | 	live *live.GrafanaLive, livePushGateway *pushhttp.Gateway, plugCtxProvider *plugincontext.Provider, | ||||||
| 	contextHandler *contexthandler.ContextHandler, features *featuremgmt.FeatureManager, | 	contextHandler *contexthandler.ContextHandler, features *featuremgmt.FeatureManager, | ||||||
| 	alertNG *ngalert.AlertNG, libraryPanelService librarypanels.Service, libraryElementService libraryelements.Service, | 	alertNG *ngalert.AlertNG, libraryPanelService librarypanels.Service, libraryElementService libraryelements.Service, | ||||||
| 	quotaService quota.Service, socialService social.Service, tracer tracing.Tracer, exportService export.ExportService, | 	quotaService quota.Service, socialService social.Service, tracer tracing.Tracer, | ||||||
| 	encryptionService encryption.Internal, grafanaUpdateChecker *updatechecker.GrafanaService, | 	encryptionService encryption.Internal, grafanaUpdateChecker *updatechecker.GrafanaService, | ||||||
| 	pluginsUpdateChecker *updatechecker.PluginsService, searchUsersService searchusers.Service, | 	pluginsUpdateChecker *updatechecker.PluginsService, searchUsersService searchusers.Service, | ||||||
| 	dataSourcesService datasources.DataSourceService, queryDataService *query.Service, | 	dataSourcesService datasources.DataSourceService, queryDataService *query.Service, | ||||||
|  | @ -298,7 +296,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi | ||||||
| 		DataProxy:                    dataSourceProxy, | 		DataProxy:                    dataSourceProxy, | ||||||
| 		SearchV2HTTPService:          searchv2HTTPService, | 		SearchV2HTTPService:          searchv2HTTPService, | ||||||
| 		SearchService:                searchService, | 		SearchService:                searchService, | ||||||
| 		ExportService:                exportService, |  | ||||||
| 		Live:                         live, | 		Live:                         live, | ||||||
| 		LivePushGateway:              livePushGateway, | 		LivePushGateway:              livePushGateway, | ||||||
| 		PluginContextProvider:        plugCtxProvider, | 		PluginContextProvider:        plugCtxProvider, | ||||||
|  |  | ||||||
|  | @ -5,6 +5,7 @@ package runner | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
|  | 
 | ||||||
| 	"github.com/grafana/grafana/pkg/services/folder" | 	"github.com/grafana/grafana/pkg/services/folder" | ||||||
| 	"github.com/grafana/grafana/pkg/services/folder/folderimpl" | 	"github.com/grafana/grafana/pkg/services/folder/folderimpl" | ||||||
| 
 | 
 | ||||||
|  | @ -57,7 +58,6 @@ import ( | ||||||
| 	datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" | 	datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" | ||||||
| 	"github.com/grafana/grafana/pkg/services/encryption" | 	"github.com/grafana/grafana/pkg/services/encryption" | ||||||
| 	encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" | 	encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" | ||||||
| 	"github.com/grafana/grafana/pkg/services/export" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/featuremgmt" | 	"github.com/grafana/grafana/pkg/services/featuremgmt" | ||||||
| 	"github.com/grafana/grafana/pkg/services/guardian" | 	"github.com/grafana/grafana/pkg/services/guardian" | ||||||
| 	"github.com/grafana/grafana/pkg/services/hooks" | 	"github.com/grafana/grafana/pkg/services/hooks" | ||||||
|  | @ -209,7 +209,6 @@ var wireSet = wire.NewSet( | ||||||
| 	search.ProvideService, | 	search.ProvideService, | ||||||
| 	searchV2.ProvideService, | 	searchV2.ProvideService, | ||||||
| 	store.ProvideService, | 	store.ProvideService, | ||||||
| 	export.ProvideService, |  | ||||||
| 	live.ProvideService, | 	live.ProvideService, | ||||||
| 	pushhttp.ProvideService, | 	pushhttp.ProvideService, | ||||||
| 	contexthandler.ProvideService, | 	contexthandler.ProvideService, | ||||||
|  |  | ||||||
|  | @ -6,6 +6,7 @@ package server | ||||||
| import ( | import ( | ||||||
| 	"github.com/google/wire" | 	"github.com/google/wire" | ||||||
| 	sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" | 	sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" | ||||||
|  | 
 | ||||||
| 	"github.com/grafana/grafana/pkg/services/folder" | 	"github.com/grafana/grafana/pkg/services/folder" | ||||||
| 
 | 
 | ||||||
| 	"github.com/grafana/grafana/pkg/api" | 	"github.com/grafana/grafana/pkg/api" | ||||||
|  | @ -59,7 +60,6 @@ import ( | ||||||
| 	datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" | 	datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" | ||||||
| 	"github.com/grafana/grafana/pkg/services/encryption" | 	"github.com/grafana/grafana/pkg/services/encryption" | ||||||
| 	encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" | 	encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" | ||||||
| 	"github.com/grafana/grafana/pkg/services/export" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/featuremgmt" | 	"github.com/grafana/grafana/pkg/services/featuremgmt" | ||||||
| 	"github.com/grafana/grafana/pkg/services/folder/folderimpl" | 	"github.com/grafana/grafana/pkg/services/folder/folderimpl" | ||||||
| 	"github.com/grafana/grafana/pkg/services/grpcserver" | 	"github.com/grafana/grafana/pkg/services/grpcserver" | ||||||
|  | @ -231,7 +231,6 @@ var wireBasicSet = wire.NewSet( | ||||||
| 	searchV2.ProvideSearchHTTPService, | 	searchV2.ProvideSearchHTTPService, | ||||||
| 	store.ProvideService, | 	store.ProvideService, | ||||||
| 	store.ProvideSystemUsersService, | 	store.ProvideSystemUsersService, | ||||||
| 	export.ProvideService, |  | ||||||
| 	live.ProvideService, | 	live.ProvideService, | ||||||
| 	pushhttp.ProvideService, | 	pushhttp.ProvideService, | ||||||
| 	contexthandler.ProvideService, | 	contexthandler.ProvideService, | ||||||
|  |  | ||||||
|  | @ -1,196 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"context" |  | ||||||
| 	"fmt" |  | ||||||
| 	"os" |  | ||||||
| 	"path" |  | ||||||
| 	"strings" |  | ||||||
| 	"time" |  | ||||||
| 
 |  | ||||||
| 	"github.com/go-git/go-git/v5" |  | ||||||
| 	"github.com/go-git/go-git/v5/plumbing/object" |  | ||||||
| 	"github.com/grafana/grafana-plugin-sdk-go/data" |  | ||||||
| 	jsoniter "github.com/json-iterator/go" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/appcontext" |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/db" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/user" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| type commitHelper struct { |  | ||||||
| 	ctx           context.Context |  | ||||||
| 	repo          *git.Repository |  | ||||||
| 	work          *git.Worktree |  | ||||||
| 	orgDir        string // includes the orgID
 |  | ||||||
| 	workDir       string // same as the worktree root
 |  | ||||||
| 	orgID         int64 |  | ||||||
| 	users         map[int64]*userInfo |  | ||||||
| 	stopRequested bool |  | ||||||
| 	broadcast     func(path string) |  | ||||||
| 	exporter      string // key for the current exporter
 |  | ||||||
| 
 |  | ||||||
| 	counter int |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| type commitBody struct { |  | ||||||
| 	fpath string // absolute
 |  | ||||||
| 	body  []byte |  | ||||||
| 	frame *data.Frame |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| type commitOptions struct { |  | ||||||
| 	body    []commitBody |  | ||||||
| 	when    time.Time |  | ||||||
| 	userID  int64 |  | ||||||
| 	comment string |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (ch *commitHelper) initOrg(ctx context.Context, sql db.DB, orgID int64) error { |  | ||||||
| 	return sql.WithDbSession(ch.ctx, func(sess *db.Session) error { |  | ||||||
| 		userprefix := "user" |  | ||||||
| 		if isPostgreSQL(sql) { |  | ||||||
| 			userprefix = `"user"` // postgres has special needs
 |  | ||||||
| 		} |  | ||||||
| 		sess.Table("user"). |  | ||||||
| 			Join("inner", "org_user", userprefix+`.id = org_user.user_id`). |  | ||||||
| 			Cols(userprefix+`.*`, "org_user.role"). |  | ||||||
| 			Where("org_user.org_id = ?", orgID). |  | ||||||
| 			Asc(userprefix + `.id`) |  | ||||||
| 
 |  | ||||||
| 		rows := make([]*userInfo, 0) |  | ||||||
| 		err := sess.Find(&rows) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		lookup := make(map[int64]*userInfo, len(rows)) |  | ||||||
| 		for _, row := range rows { |  | ||||||
| 			lookup[row.ID] = row |  | ||||||
| 		} |  | ||||||
| 		ch.users = lookup |  | ||||||
| 		ch.orgID = orgID |  | ||||||
| 
 |  | ||||||
| 		// Set an admin user with the
 |  | ||||||
| 		rowUser := &user.SignedInUser{ |  | ||||||
| 			Login:  "", |  | ||||||
| 			OrgID:  orgID, // gets filled in from each row
 |  | ||||||
| 			UserID: 0, |  | ||||||
| 		} |  | ||||||
| 		ch.ctx = appcontext.WithUser(context.Background(), rowUser) |  | ||||||
| 		return err |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (ch *commitHelper) add(opts commitOptions) error { |  | ||||||
| 	if ch.stopRequested { |  | ||||||
| 		return fmt.Errorf("stop requested") |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	if len(opts.body) < 1 { |  | ||||||
| 		return nil // nothing to commit
 |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	user, ok := ch.users[opts.userID] |  | ||||||
| 	if !ok { |  | ||||||
| 		user = &userInfo{ |  | ||||||
| 			Name:  "admin", |  | ||||||
| 			Email: "admin@unknown.org", |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	sig := user.getAuthor() |  | ||||||
| 	if opts.when.Unix() > 100 { |  | ||||||
| 		sig.When = opts.when |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	for _, b := range opts.body { |  | ||||||
| 		if !strings.HasPrefix(b.fpath, ch.orgDir) { |  | ||||||
| 			return fmt.Errorf("invalid path, must be within the root folder") |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		// make sure the parent exists
 |  | ||||||
| 		err := os.MkdirAll(path.Dir(b.fpath), 0750) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		body := b.body |  | ||||||
| 		if b.frame != nil { |  | ||||||
| 			body, err = jsoniter.ConfigCompatibleWithStandardLibrary.MarshalIndent(b.frame, "", "  ") |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		err = os.WriteFile(b.fpath, body, 0644) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 		err = os.Chtimes(b.fpath, sig.When, sig.When) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		sub := b.fpath[len(ch.workDir)+1:] |  | ||||||
| 		_, err = ch.work.Add(sub) |  | ||||||
| 		if err != nil { |  | ||||||
| 			status, e2 := ch.work.Status() |  | ||||||
| 			if e2 != nil { |  | ||||||
| 				return fmt.Errorf("error adding: %s (invalud work status: %s)", sub, e2.Error()) |  | ||||||
| 			} |  | ||||||
| 			fmt.Printf("STATUS: %+v\n", status) |  | ||||||
| 			return fmt.Errorf("unable to add file: %s (%d)", sub, len(b.body)) |  | ||||||
| 		} |  | ||||||
| 		ch.counter++ |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	copts := &git.CommitOptions{ |  | ||||||
| 		Author: &sig, |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	ch.broadcast(opts.body[0].fpath) |  | ||||||
| 	_, err := ch.work.Commit(opts.comment, copts) |  | ||||||
| 	return err |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| type userInfo struct { |  | ||||||
| 	ID               int64     `json:"-" db:"id"` |  | ||||||
| 	Login            string    `json:"login"` |  | ||||||
| 	Email            string    `json:"email"` |  | ||||||
| 	Name             string    `json:"name"` |  | ||||||
| 	Password         string    `json:"password"` |  | ||||||
| 	Salt             string    `json:"salt"` |  | ||||||
| 	Company          string    `json:"company,omitempty"` |  | ||||||
| 	Rands            string    `json:"-"` |  | ||||||
| 	Role             string    `json:"org_role"` // org role
 |  | ||||||
| 	Theme            string    `json:"-"`        // managed in preferences
 |  | ||||||
| 	Created          time.Time `json:"-"`        // managed in git or external source
 |  | ||||||
| 	Updated          time.Time `json:"-"`        // managed in git or external source
 |  | ||||||
| 	IsDisabled       bool      `json:"disabled" db:"is_disabled"` |  | ||||||
| 	IsServiceAccount bool      `json:"serviceAccount" db:"is_service_account"` |  | ||||||
| 	LastSeenAt       time.Time `json:"-" db:"last_seen_at"` |  | ||||||
| 
 |  | ||||||
| 	// Added to make sqlx happy
 |  | ||||||
| 	Version       int   `json:"-"` |  | ||||||
| 	HelpFlags1    int   `json:"-" db:"help_flags1"` |  | ||||||
| 	OrgID         int64 `json:"-" db:"org_id"` |  | ||||||
| 	EmailVerified bool  `json:"-" db:"email_verified"` |  | ||||||
| 	IsAdmin       bool  `json:"-" db:"is_admin"` |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (u *userInfo) getAuthor() object.Signature { |  | ||||||
| 	return object.Signature{ |  | ||||||
| 		Name:  firstRealStringX(u.Name, u.Login, u.Email, "?"), |  | ||||||
| 		Email: firstRealStringX(u.Email, u.Login, u.Name, "?"), |  | ||||||
| 		When:  time.Now(), |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func firstRealStringX(vals ...string) string { |  | ||||||
| 	for _, v := range vals { |  | ||||||
| 		if v != "" { |  | ||||||
| 			return v |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return "?" |  | ||||||
| } |  | ||||||
|  | @ -1,105 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"fmt" |  | ||||||
| 	"math" |  | ||||||
| 	"math/rand" |  | ||||||
| 	"sync" |  | ||||||
| 	"time" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/log" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| var _ Job = new(dummyExportJob) |  | ||||||
| 
 |  | ||||||
| type dummyExportJob struct { |  | ||||||
| 	logger log.Logger |  | ||||||
| 
 |  | ||||||
| 	statusMu      sync.Mutex |  | ||||||
| 	status        ExportStatus |  | ||||||
| 	cfg           ExportConfig |  | ||||||
| 	broadcaster   statusBroadcaster |  | ||||||
| 	stopRequested bool |  | ||||||
| 	total         int |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func startDummyExportJob(cfg ExportConfig, broadcaster statusBroadcaster) (Job, error) { |  | ||||||
| 	job := &dummyExportJob{ |  | ||||||
| 		logger:      log.New("dummy_export_job"), |  | ||||||
| 		cfg:         cfg, |  | ||||||
| 		broadcaster: broadcaster, |  | ||||||
| 		status: ExportStatus{ |  | ||||||
| 			Running: true, |  | ||||||
| 			Target:  "dummy export", |  | ||||||
| 			Started: time.Now().UnixMilli(), |  | ||||||
| 			Count:   make(map[string]int, 10), |  | ||||||
| 			Index:   0, |  | ||||||
| 		}, |  | ||||||
| 		total: int(math.Round(10 + rand.Float64()*20)), |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	broadcaster(job.status) |  | ||||||
| 	go job.start() |  | ||||||
| 	return job, nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (e *dummyExportJob) requestStop() { |  | ||||||
| 	e.stopRequested = true |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (e *dummyExportJob) start() { |  | ||||||
| 	defer func() { |  | ||||||
| 		e.logger.Info("Finished dummy export job") |  | ||||||
| 
 |  | ||||||
| 		e.statusMu.Lock() |  | ||||||
| 		defer e.statusMu.Unlock() |  | ||||||
| 		s := e.status |  | ||||||
| 		if err := recover(); err != nil { |  | ||||||
| 			e.logger.Error("export panic", "error", err) |  | ||||||
| 			s.Status = fmt.Sprintf("ERROR: %v", err) |  | ||||||
| 		} |  | ||||||
| 		// Make sure it finishes OK
 |  | ||||||
| 		if s.Finished < 10 { |  | ||||||
| 			s.Finished = time.Now().UnixMilli() |  | ||||||
| 		} |  | ||||||
| 		s.Running = false |  | ||||||
| 		if s.Status == "" { |  | ||||||
| 			s.Status = "done" |  | ||||||
| 		} |  | ||||||
| 		e.status = s |  | ||||||
| 		e.broadcaster(s) |  | ||||||
| 	}() |  | ||||||
| 
 |  | ||||||
| 	e.logger.Info("Starting dummy export job") |  | ||||||
| 
 |  | ||||||
| 	ticker := time.NewTicker(1 * time.Second) |  | ||||||
| 	for t := range ticker.C { |  | ||||||
| 		e.statusMu.Lock() |  | ||||||
| 		e.status.Changed = t.UnixMilli() |  | ||||||
| 		e.status.Index++ |  | ||||||
| 		e.status.Last = fmt.Sprintf("ITEM: %d", e.status.Index) |  | ||||||
| 		e.statusMu.Unlock() |  | ||||||
| 
 |  | ||||||
| 		// Wait till we are done
 |  | ||||||
| 		shouldStop := e.stopRequested || e.status.Index >= e.total |  | ||||||
| 		e.broadcaster(e.status) |  | ||||||
| 
 |  | ||||||
| 		if shouldStop { |  | ||||||
| 			break |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (e *dummyExportJob) getStatus() ExportStatus { |  | ||||||
| 	e.statusMu.Lock() |  | ||||||
| 	defer e.statusMu.Unlock() |  | ||||||
| 
 |  | ||||||
| 	return e.status |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (e *dummyExportJob) getConfig() ExportConfig { |  | ||||||
| 	e.statusMu.Lock() |  | ||||||
| 	defer e.statusMu.Unlock() |  | ||||||
| 
 |  | ||||||
| 	return e.cfg |  | ||||||
| } |  | ||||||
|  | @ -1,374 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"context" |  | ||||||
| 	"encoding/json" |  | ||||||
| 	"fmt" |  | ||||||
| 	"sync" |  | ||||||
| 	"time" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/appcontext" |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/db" |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/log" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/dashboardsnapshots" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/playlist" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/sqlstore/session" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/store/entity" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/store/kind/folder" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/store/kind/snapshot" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/user" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| var _ Job = new(entityStoreJob) |  | ||||||
| 
 |  | ||||||
| type entityStoreJob struct { |  | ||||||
| 	logger log.Logger |  | ||||||
| 
 |  | ||||||
| 	statusMu      sync.Mutex |  | ||||||
| 	status        ExportStatus |  | ||||||
| 	cfg           ExportConfig |  | ||||||
| 	broadcaster   statusBroadcaster |  | ||||||
| 	stopRequested bool |  | ||||||
| 	ctx           context.Context |  | ||||||
| 
 |  | ||||||
| 	sess               *session.SessionDB |  | ||||||
| 	playlistService    playlist.Service |  | ||||||
| 	store              entity.EntityStoreServer |  | ||||||
| 	dashboardsnapshots dashboardsnapshots.Service |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func startEntityStoreJob(ctx context.Context, |  | ||||||
| 	cfg ExportConfig, |  | ||||||
| 	broadcaster statusBroadcaster, |  | ||||||
| 	db db.DB, |  | ||||||
| 	playlistService playlist.Service, |  | ||||||
| 	store entity.EntityStoreServer, |  | ||||||
| 	dashboardsnapshots dashboardsnapshots.Service, |  | ||||||
| ) (Job, error) { |  | ||||||
| 	job := &entityStoreJob{ |  | ||||||
| 		logger:      log.New("export_to_object_store_job"), |  | ||||||
| 		cfg:         cfg, |  | ||||||
| 		ctx:         ctx, |  | ||||||
| 		broadcaster: broadcaster, |  | ||||||
| 		status: ExportStatus{ |  | ||||||
| 			Running: true, |  | ||||||
| 			Target:  "object store export", |  | ||||||
| 			Started: time.Now().UnixMilli(), |  | ||||||
| 			Count:   make(map[string]int, 10), |  | ||||||
| 			Index:   0, |  | ||||||
| 		}, |  | ||||||
| 		sess:               db.GetSqlxSession(), |  | ||||||
| 		playlistService:    playlistService, |  | ||||||
| 		store:              store, |  | ||||||
| 		dashboardsnapshots: dashboardsnapshots, |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	broadcaster(job.status) |  | ||||||
| 	go job.start(ctx) |  | ||||||
| 	return job, nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (e *entityStoreJob) requestStop() { |  | ||||||
| 	e.stopRequested = true |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (e *entityStoreJob) start(ctx context.Context) { |  | ||||||
| 	defer func() { |  | ||||||
| 		e.logger.Info("Finished dummy export job") |  | ||||||
| 
 |  | ||||||
| 		e.statusMu.Lock() |  | ||||||
| 		defer e.statusMu.Unlock() |  | ||||||
| 		s := e.status |  | ||||||
| 		if err := recover(); err != nil { |  | ||||||
| 			e.logger.Error("export panic", "error", err) |  | ||||||
| 			s.Status = fmt.Sprintf("ERROR: %v", err) |  | ||||||
| 		} |  | ||||||
| 		// Make sure it finishes OK
 |  | ||||||
| 		if s.Finished < 10 { |  | ||||||
| 			s.Finished = time.Now().UnixMilli() |  | ||||||
| 		} |  | ||||||
| 		s.Running = false |  | ||||||
| 		if s.Status == "" { |  | ||||||
| 			s.Status = "done" |  | ||||||
| 		} |  | ||||||
| 		e.status = s |  | ||||||
| 		e.broadcaster(s) |  | ||||||
| 	}() |  | ||||||
| 
 |  | ||||||
| 	e.logger.Info("Starting dummy export job") |  | ||||||
| 	// Select all dashboards
 |  | ||||||
| 	rowUser := &user.SignedInUser{ |  | ||||||
| 		Login:  "", |  | ||||||
| 		OrgID:  0, // gets filled in from each row
 |  | ||||||
| 		UserID: 0, |  | ||||||
| 	} |  | ||||||
| 	ctx = appcontext.WithUser(ctx, rowUser) |  | ||||||
| 
 |  | ||||||
| 	what := entity.StandardKindFolder |  | ||||||
| 	e.status.Count[what] = 0 |  | ||||||
| 
 |  | ||||||
| 	folders := make(map[int64]string) |  | ||||||
| 	folderInfo, err := e.getFolders(ctx) |  | ||||||
| 	if err != nil { |  | ||||||
| 		e.status.Status = "error: " + err.Error() |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
| 	e.status.Last = fmt.Sprintf("export %d folders", len(folderInfo)) |  | ||||||
| 	e.broadcaster(e.status) |  | ||||||
| 
 |  | ||||||
| 	for _, dash := range folderInfo { |  | ||||||
| 		folders[dash.ID] = dash.UID |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	for _, dash := range folderInfo { |  | ||||||
| 		rowUser.OrgID = dash.OrgID |  | ||||||
| 		rowUser.UserID = dash.UpdatedBy |  | ||||||
| 		if dash.UpdatedBy < 0 { |  | ||||||
| 			rowUser.UserID = 0 // avoid Uint64Val issue????
 |  | ||||||
| 		} |  | ||||||
| 		f := folder.Model{Name: dash.Title} |  | ||||||
| 		d, _ := json.Marshal(f) |  | ||||||
| 
 |  | ||||||
| 		_, err = e.store.AdminWrite(ctx, &entity.AdminWriteEntityRequest{ |  | ||||||
| 			GRN: &entity.GRN{ |  | ||||||
| 				UID:  dash.UID, |  | ||||||
| 				Kind: entity.StandardKindFolder, |  | ||||||
| 			}, |  | ||||||
| 			ClearHistory: true, |  | ||||||
| 			CreatedAt:    dash.Created.UnixMilli(), |  | ||||||
| 			UpdatedAt:    dash.Updated.UnixMilli(), |  | ||||||
| 			UpdatedBy:    fmt.Sprintf("user:%d", dash.UpdatedBy), |  | ||||||
| 			CreatedBy:    fmt.Sprintf("user:%d", dash.CreatedBy), |  | ||||||
| 			Body:         d, |  | ||||||
| 			Folder:       folders[dash.FolderID], |  | ||||||
| 			Comment:      "(exported from SQL)", |  | ||||||
| 			Origin: &entity.EntityOriginInfo{ |  | ||||||
| 				Source: "export-from-sql", |  | ||||||
| 			}, |  | ||||||
| 		}) |  | ||||||
| 		if err != nil { |  | ||||||
| 			e.status.Status = "error: " + err.Error() |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
| 		e.status.Changed = time.Now().UnixMilli() |  | ||||||
| 		e.status.Index++ |  | ||||||
| 		e.status.Count[what] += 1 |  | ||||||
| 		e.status.Last = fmt.Sprintf("ITEM: %s", dash.UID) |  | ||||||
| 		e.broadcaster(e.status) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	what = entity.StandardKindDashboard |  | ||||||
| 	e.status.Count[what] = 0 |  | ||||||
| 
 |  | ||||||
| 	// TODO paging etc
 |  | ||||||
| 	// NOTE: doing work inside rows.Next() leads to database locked
 |  | ||||||
| 	dashInfo, err := e.getDashboards(ctx) |  | ||||||
| 	if err != nil { |  | ||||||
| 		e.status.Status = "error: " + err.Error() |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
| 	e.status.Last = fmt.Sprintf("export %d dashboards", len(dashInfo)) |  | ||||||
| 	e.broadcaster(e.status) |  | ||||||
| 
 |  | ||||||
| 	for _, dash := range dashInfo { |  | ||||||
| 		rowUser.OrgID = dash.OrgID |  | ||||||
| 		rowUser.UserID = dash.UpdatedBy |  | ||||||
| 		if dash.UpdatedBy < 0 { |  | ||||||
| 			rowUser.UserID = 0 // avoid Uint64Val issue????
 |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		_, err = e.store.AdminWrite(ctx, &entity.AdminWriteEntityRequest{ |  | ||||||
| 			GRN: &entity.GRN{ |  | ||||||
| 				UID:  dash.UID, |  | ||||||
| 				Kind: entity.StandardKindDashboard, |  | ||||||
| 			}, |  | ||||||
| 			ClearHistory: true, |  | ||||||
| 			Version:      fmt.Sprintf("%d", dash.Version), |  | ||||||
| 			CreatedAt:    dash.Created.UnixMilli(), |  | ||||||
| 			UpdatedAt:    dash.Updated.UnixMilli(), |  | ||||||
| 			UpdatedBy:    fmt.Sprintf("user:%d", dash.UpdatedBy), |  | ||||||
| 			CreatedBy:    fmt.Sprintf("user:%d", dash.CreatedBy), |  | ||||||
| 			Body:         dash.Data, |  | ||||||
| 			Folder:       folders[dash.FolderID], |  | ||||||
| 			Comment:      "(exported from SQL)", |  | ||||||
| 			Origin: &entity.EntityOriginInfo{ |  | ||||||
| 				Source: "export-from-sql", |  | ||||||
| 			}, |  | ||||||
| 		}) |  | ||||||
| 		if err != nil { |  | ||||||
| 			e.status.Status = "error: " + err.Error() |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
| 		e.status.Changed = time.Now().UnixMilli() |  | ||||||
| 		e.status.Index++ |  | ||||||
| 		e.status.Count[what] += 1 |  | ||||||
| 		e.status.Last = fmt.Sprintf("ITEM: %s", dash.UID) |  | ||||||
| 		e.broadcaster(e.status) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Playlists
 |  | ||||||
| 	what = entity.StandardKindPlaylist |  | ||||||
| 	e.status.Count[what] = 0 |  | ||||||
| 	rowUser.OrgID = 1 |  | ||||||
| 	rowUser.UserID = 1 |  | ||||||
| 	res, err := e.playlistService.Search(ctx, &playlist.GetPlaylistsQuery{ |  | ||||||
| 		OrgId: rowUser.OrgID, // TODO... all or orgs
 |  | ||||||
| 		Limit: 5000, |  | ||||||
| 	}) |  | ||||||
| 	if err != nil { |  | ||||||
| 		e.status.Status = "error: " + err.Error() |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
| 	for _, item := range res { |  | ||||||
| 		playlist, err := e.playlistService.Get(ctx, &playlist.GetPlaylistByUidQuery{ |  | ||||||
| 			UID:   item.UID, |  | ||||||
| 			OrgId: rowUser.OrgID, |  | ||||||
| 		}) |  | ||||||
| 		if err != nil { |  | ||||||
| 			e.status.Status = "error: " + err.Error() |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		_, err = e.store.Write(ctx, &entity.WriteEntityRequest{ |  | ||||||
| 			GRN: &entity.GRN{ |  | ||||||
| 				UID:  playlist.Uid, |  | ||||||
| 				Kind: entity.StandardKindPlaylist, |  | ||||||
| 			}, |  | ||||||
| 			Body:    prettyJSON(playlist), |  | ||||||
| 			Comment: "export from playlists", |  | ||||||
| 		}) |  | ||||||
| 		if err != nil { |  | ||||||
| 			e.status.Status = "error: " + err.Error() |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
| 		e.status.Changed = time.Now().UnixMilli() |  | ||||||
| 		e.status.Index++ |  | ||||||
| 		e.status.Count[what] += 1 |  | ||||||
| 		e.status.Last = fmt.Sprintf("ITEM: %s", playlist.Uid) |  | ||||||
| 		e.broadcaster(e.status) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// TODO.. query lookup
 |  | ||||||
| 	orgIDs := []int64{1} |  | ||||||
| 	what = "snapshot" |  | ||||||
| 	for _, orgId := range orgIDs { |  | ||||||
| 		rowUser.OrgID = orgId |  | ||||||
| 		rowUser.UserID = 1 |  | ||||||
| 		cmd := &dashboardsnapshots.GetDashboardSnapshotsQuery{ |  | ||||||
| 			OrgID:        orgId, |  | ||||||
| 			Limit:        500000, |  | ||||||
| 			SignedInUser: rowUser, |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		result, err := e.dashboardsnapshots.SearchDashboardSnapshots(ctx, cmd) |  | ||||||
| 		if err != nil { |  | ||||||
| 			e.status.Status = "error: " + err.Error() |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		for _, dto := range result { |  | ||||||
| 			m := snapshot.Model{ |  | ||||||
| 				Name:        dto.Name, |  | ||||||
| 				ExternalURL: dto.ExternalURL, |  | ||||||
| 				Expires:     dto.Expires.UnixMilli(), |  | ||||||
| 			} |  | ||||||
| 			rowUser.OrgID = dto.OrgID |  | ||||||
| 			rowUser.UserID = dto.UserID |  | ||||||
| 
 |  | ||||||
| 			snapcmd := &dashboardsnapshots.GetDashboardSnapshotQuery{ |  | ||||||
| 				Key: dto.Key, |  | ||||||
| 			} |  | ||||||
| 			snapcmdResult, err := e.dashboardsnapshots.GetDashboardSnapshot(ctx, snapcmd) |  | ||||||
| 			if err == nil { |  | ||||||
| 				res := snapcmdResult |  | ||||||
| 				m.DeleteKey = res.DeleteKey |  | ||||||
| 				m.ExternalURL = res.ExternalURL |  | ||||||
| 
 |  | ||||||
| 				snap := res.Dashboard |  | ||||||
| 				m.DashboardUID = snap.Get("uid").MustString("") |  | ||||||
| 				snap.Del("uid") |  | ||||||
| 				snap.Del("id") |  | ||||||
| 
 |  | ||||||
| 				b, _ := snap.MarshalJSON() |  | ||||||
| 				m.Snapshot = b |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			_, err = e.store.Write(ctx, &entity.WriteEntityRequest{ |  | ||||||
| 				GRN: &entity.GRN{ |  | ||||||
| 					UID:  dto.Key, |  | ||||||
| 					Kind: entity.StandardKindSnapshot, |  | ||||||
| 				}, |  | ||||||
| 				Body:    prettyJSON(m), |  | ||||||
| 				Comment: "export from snapshtts", |  | ||||||
| 			}) |  | ||||||
| 			if err != nil { |  | ||||||
| 				e.status.Status = "error: " + err.Error() |  | ||||||
| 				return |  | ||||||
| 			} |  | ||||||
| 			e.status.Changed = time.Now().UnixMilli() |  | ||||||
| 			e.status.Index++ |  | ||||||
| 			e.status.Count[what] += 1 |  | ||||||
| 			e.status.Last = fmt.Sprintf("ITEM: %s", dto.Name) |  | ||||||
| 			e.broadcaster(e.status) |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| type dashInfo struct { |  | ||||||
| 	OrgID     int64 `db:"org_id"` |  | ||||||
| 	UID       string |  | ||||||
| 	Version   int64 |  | ||||||
| 	Slug      string |  | ||||||
| 	Data      []byte |  | ||||||
| 	Created   time.Time |  | ||||||
| 	Updated   time.Time |  | ||||||
| 	CreatedBy int64 `db:"created_by"` |  | ||||||
| 	UpdatedBy int64 `db:"updated_by"` |  | ||||||
| 	FolderID  int64 `db:"folder_id"` |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| type folderInfo struct { |  | ||||||
| 	ID        int64 `db:"id"` |  | ||||||
| 	OrgID     int64 `db:"org_id"` |  | ||||||
| 	UID       string |  | ||||||
| 	Title     string |  | ||||||
| 	Created   time.Time |  | ||||||
| 	Updated   time.Time |  | ||||||
| 	CreatedBy int64 `db:"created_by"` |  | ||||||
| 	UpdatedBy int64 `db:"updated_by"` |  | ||||||
| 	FolderID  int64 `db:"folder_id"` |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // TODO, paging etc
 |  | ||||||
| func (e *entityStoreJob) getDashboards(ctx context.Context) ([]dashInfo, error) { |  | ||||||
| 	e.status.Last = "find dashbaords...." |  | ||||||
| 	e.broadcaster(e.status) |  | ||||||
| 
 |  | ||||||
| 	dash := make([]dashInfo, 0) |  | ||||||
| 	err := e.sess.Select(ctx, &dash, "SELECT org_id,uid,version,slug,data,folder_id,created,updated,created_by,updated_by FROM dashboard WHERE is_folder=false") |  | ||||||
| 	return dash, err |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // TODO, paging etc
 |  | ||||||
| func (e *entityStoreJob) getFolders(ctx context.Context) ([]folderInfo, error) { |  | ||||||
| 	e.status.Last = "find dashbaords...." |  | ||||||
| 	e.broadcaster(e.status) |  | ||||||
| 
 |  | ||||||
| 	dash := make([]folderInfo, 0) |  | ||||||
| 	err := e.sess.Select(ctx, &dash, "SELECT id,org_id,uid,title,folder_id,created,updated,created_by,updated_by FROM dashboard WHERE is_folder=true") |  | ||||||
| 	return dash, err |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (e *entityStoreJob) getStatus() ExportStatus { |  | ||||||
| 	e.statusMu.Lock() |  | ||||||
| 	defer e.statusMu.Unlock() |  | ||||||
| 
 |  | ||||||
| 	return e.status |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (e *entityStoreJob) getConfig() ExportConfig { |  | ||||||
| 	e.statusMu.Lock() |  | ||||||
| 	defer e.statusMu.Unlock() |  | ||||||
| 
 |  | ||||||
| 	return e.cfg |  | ||||||
| } |  | ||||||
|  | @ -1,51 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"encoding/json" |  | ||||||
| 	"fmt" |  | ||||||
| 	"path" |  | ||||||
| 	"time" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/db" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| func exportAlerts(helper *commitHelper, job *gitExportJob) error { |  | ||||||
| 	alertDir := path.Join(helper.orgDir, "alerts") |  | ||||||
| 
 |  | ||||||
| 	return job.sql.WithDbSession(helper.ctx, func(sess *db.Session) error { |  | ||||||
| 		type ruleResult struct { |  | ||||||
| 			Title        string          `xorm:"title"` |  | ||||||
| 			UID          string          `xorm:"uid"` |  | ||||||
| 			NamespaceUID string          `xorm:"namespace_uid"` |  | ||||||
| 			RuleGroup    string          `xorm:"rule_group"` |  | ||||||
| 			Condition    json.RawMessage `xorm:"data"` |  | ||||||
| 			DashboardUID string          `xorm:"dashboard_uid"` |  | ||||||
| 			PanelID      int64           `xorm:"panel_id"` |  | ||||||
| 			Updated      time.Time       `xorm:"updated" json:"-"` |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		rows := make([]*ruleResult, 0) |  | ||||||
| 
 |  | ||||||
| 		sess.Table("alert_rule").Where("org_id = ?", helper.orgID) |  | ||||||
| 
 |  | ||||||
| 		err := sess.Find(&rows) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		for _, row := range rows { |  | ||||||
| 			err = helper.add(commitOptions{ |  | ||||||
| 				body: []commitBody{{ |  | ||||||
| 					body:  prettyJSON(row), |  | ||||||
| 					fpath: path.Join(alertDir, row.UID) + ".json", // must be JSON files
 |  | ||||||
| 				}}, |  | ||||||
| 				comment: fmt.Sprintf("Alert: %s", row.Title), |  | ||||||
| 				when:    row.Updated, |  | ||||||
| 			}) |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 		return err |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  | @ -1,132 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"encoding/json" |  | ||||||
| 	"fmt" |  | ||||||
| 	"path/filepath" |  | ||||||
| 	"time" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana-plugin-sdk-go/data" |  | ||||||
| 	jsoniter "github.com/json-iterator/go" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/db" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| func exportAnnotations(helper *commitHelper, job *gitExportJob) error { |  | ||||||
| 	return job.sql.WithDbSession(helper.ctx, func(sess *db.Session) error { |  | ||||||
| 		type annoResult struct { |  | ||||||
| 			ID          int64  `xorm:"id"` |  | ||||||
| 			DashboardID int64  `xorm:"dashboard_id"` |  | ||||||
| 			PanelID     int64  `xorm:"panel_id"` |  | ||||||
| 			UserID      int64  `xorm:"user_id"` |  | ||||||
| 			Text        string `xorm:"text"` |  | ||||||
| 			Epoch       int64  `xorm:"epoch"` |  | ||||||
| 			EpochEnd    int64  `xorm:"epoch_end"` |  | ||||||
| 			Created     int64  `xorm:"created"` // not used
 |  | ||||||
| 			Tags        string `xorm:"tags"`    // JSON Array
 |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		type annoEvent struct { |  | ||||||
| 			PanelID  int64  `json:"panel"` |  | ||||||
| 			Text     string `json:"text"` |  | ||||||
| 			Epoch    int64  `json:"epoch"` // dashboard/start+end is really the UID
 |  | ||||||
| 			EpochEnd int64  `json:"epoch_end,omitempty"` |  | ||||||
| 			Tags     []string |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		rows := make([]*annoResult, 0) |  | ||||||
| 
 |  | ||||||
| 		sess.Table("annotation"). |  | ||||||
| 			Where("org_id = ? AND alert_id = 0", helper.orgID).Asc("epoch") |  | ||||||
| 
 |  | ||||||
| 		err := sess.Find(&rows) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		count := len(rows) |  | ||||||
| 		f_ID := data.NewFieldFromFieldType(data.FieldTypeInt64, count) |  | ||||||
| 		f_DashboardID := data.NewFieldFromFieldType(data.FieldTypeInt64, count) |  | ||||||
| 		f_PanelID := data.NewFieldFromFieldType(data.FieldTypeInt64, count) |  | ||||||
| 		f_Epoch := data.NewFieldFromFieldType(data.FieldTypeTime, count) |  | ||||||
| 		f_EpochEnd := data.NewFieldFromFieldType(data.FieldTypeNullableTime, count) |  | ||||||
| 		f_Text := data.NewFieldFromFieldType(data.FieldTypeString, count) |  | ||||||
| 		f_Tags := data.NewFieldFromFieldType(data.FieldTypeJSON, count) |  | ||||||
| 
 |  | ||||||
| 		f_ID.Name = "ID" |  | ||||||
| 		f_DashboardID.Name = "DashboardID" |  | ||||||
| 		f_PanelID.Name = "PanelID" |  | ||||||
| 		f_Epoch.Name = "Epoch" |  | ||||||
| 		f_EpochEnd.Name = "EpochEnd" |  | ||||||
| 		f_Text.Name = "Text" |  | ||||||
| 		f_Tags.Name = "Tags" |  | ||||||
| 
 |  | ||||||
| 		for id, row := range rows { |  | ||||||
| 			f_ID.Set(id, row.ID) |  | ||||||
| 			f_DashboardID.Set(id, row.DashboardID) |  | ||||||
| 			f_PanelID.Set(id, row.PanelID) |  | ||||||
| 			f_Epoch.Set(id, time.UnixMilli(row.Epoch)) |  | ||||||
| 			if row.Epoch != row.EpochEnd { |  | ||||||
| 				f_EpochEnd.SetConcrete(id, time.UnixMilli(row.EpochEnd)) |  | ||||||
| 			} |  | ||||||
| 			f_Text.Set(id, row.Text) |  | ||||||
| 			f_Tags.Set(id, json.RawMessage(row.Tags)) |  | ||||||
| 
 |  | ||||||
| 			// Save a file for each
 |  | ||||||
| 			event := &annoEvent{ |  | ||||||
| 				PanelID: row.PanelID, |  | ||||||
| 				Text:    row.Text, |  | ||||||
| 			} |  | ||||||
| 			err = json.Unmarshal([]byte(row.Tags), &event.Tags) |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 			fname := fmt.Sprintf("%d", row.Epoch) |  | ||||||
| 			if row.Epoch != row.EpochEnd { |  | ||||||
| 				fname += "-" + fmt.Sprintf("%d", row.EpochEnd) |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			err = helper.add(commitOptions{ |  | ||||||
| 				body: []commitBody{ |  | ||||||
| 					{ |  | ||||||
| 						fpath: filepath.Join(helper.orgDir, |  | ||||||
| 							"annotations", |  | ||||||
| 							"dashboard", |  | ||||||
| 							fmt.Sprintf("id-%d", row.DashboardID), |  | ||||||
| 							fname+".json"), |  | ||||||
| 						body: prettyJSON(event), |  | ||||||
| 					}, |  | ||||||
| 				}, |  | ||||||
| 				when:    time.UnixMilli(row.Epoch), |  | ||||||
| 				comment: fmt.Sprintf("Added annotation (%d)", row.ID), |  | ||||||
| 				userID:  row.UserID, |  | ||||||
| 			}) |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		if f_ID.Len() > 0 { |  | ||||||
| 			frame := data.NewFrame("", f_ID, f_DashboardID, f_PanelID, f_Epoch, f_EpochEnd, f_Text, f_Tags) |  | ||||||
| 			js, err := jsoniter.ConfigCompatibleWithStandardLibrary.MarshalIndent(frame, "", "  ") |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			err = helper.add(commitOptions{ |  | ||||||
| 				body: []commitBody{ |  | ||||||
| 					{ |  | ||||||
| 						fpath: filepath.Join(helper.orgDir, "annotations", "annotations.json"), |  | ||||||
| 						body:  js, // TODO, pretty?
 |  | ||||||
| 					}, |  | ||||||
| 				}, |  | ||||||
| 				when:    time.Now(), |  | ||||||
| 				comment: "Exported annotations", |  | ||||||
| 			}) |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 		return err |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  | @ -1,138 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"path" |  | ||||||
| 	"strconv" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana-plugin-sdk-go/data" |  | ||||||
| 	"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/db" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| func dumpAuthTables(helper *commitHelper, job *gitExportJob) error { |  | ||||||
| 	isMySQL := isMySQLEngine(job.sql) |  | ||||||
| 
 |  | ||||||
| 	return job.sql.WithDbSession(helper.ctx, func(sess *db.Session) error { |  | ||||||
| 		commit := commitOptions{ |  | ||||||
| 			comment: "auth tables dump", |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		type statsTables struct { |  | ||||||
| 			table      string |  | ||||||
| 			sql        string |  | ||||||
| 			converters []sqlutil.Converter |  | ||||||
| 			drop       []string |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		dump := []statsTables{ |  | ||||||
| 			{ |  | ||||||
| 				table: "user", |  | ||||||
| 				sql: removeQuotesFromQuery(` |  | ||||||
| 					SELECT "user".*, org_user.role  |  | ||||||
| 					  FROM "user"  |  | ||||||
| 					  JOIN org_user ON "user".id = org_user.user_id |  | ||||||
| 					 WHERE org_user.org_id =`+strconv.FormatInt(helper.orgID, 10), isMySQL), |  | ||||||
| 				converters: []sqlutil.Converter{{Dynamic: true}}, |  | ||||||
| 				drop: []string{ |  | ||||||
| 					"id", "version", |  | ||||||
| 					"password", // UMMMMM... for now
 |  | ||||||
| 					"org_id", |  | ||||||
| 				}, |  | ||||||
| 			}, |  | ||||||
| 			{ |  | ||||||
| 				table: "user_role", |  | ||||||
| 				sql: ` |  | ||||||
| 					SELECT * FROM user_role  |  | ||||||
| 					 WHERE org_id =` + strconv.FormatInt(helper.orgID, 10), |  | ||||||
| 			}, |  | ||||||
| 			{ |  | ||||||
| 				table: "builtin_role", |  | ||||||
| 				sql: ` |  | ||||||
| 					SELECT * FROM builtin_role  |  | ||||||
| 					 WHERE org_id =` + strconv.FormatInt(helper.orgID, 10), |  | ||||||
| 			}, |  | ||||||
| 			{ |  | ||||||
| 				table: "api_key", |  | ||||||
| 				sql: ` |  | ||||||
| 					SELECT * FROM api_key  |  | ||||||
| 					 WHERE org_id =` + strconv.FormatInt(helper.orgID, 10), |  | ||||||
| 			}, |  | ||||||
| 			{ |  | ||||||
| 				table: "permission", |  | ||||||
| 				sql: ` |  | ||||||
| 					SELECT permission.*  |  | ||||||
| 					  FROM permission  |  | ||||||
| 					  JOIN role ON permission.role_id = role.id |  | ||||||
| 					 WHERE org_id =` + strconv.FormatInt(helper.orgID, 10), |  | ||||||
| 			}, |  | ||||||
| 			{ |  | ||||||
| 				table: "user_auth_token", |  | ||||||
| 				sql: ` |  | ||||||
| 					SELECT user_auth_token.*  |  | ||||||
| 					  FROM user_auth_token  |  | ||||||
| 					  JOIN org_user ON user_auth_token.id = org_user.user_id |  | ||||||
| 					 WHERE org_user.org_id =` + strconv.FormatInt(helper.orgID, 10), |  | ||||||
| 			}, |  | ||||||
| 			{table: "team"}, |  | ||||||
| 			{table: "team_role"}, |  | ||||||
| 			{table: "team_member"}, |  | ||||||
| 			{table: "temp_user"}, |  | ||||||
| 			{table: "role"}, |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		for _, auth := range dump { |  | ||||||
| 			if auth.sql == "" { |  | ||||||
| 				auth.sql = ` |  | ||||||
| 					SELECT * FROM ` + auth.table + `  |  | ||||||
| 					 WHERE org_id =` + strconv.FormatInt(helper.orgID, 10) |  | ||||||
| 			} |  | ||||||
| 			if auth.converters == nil { |  | ||||||
| 				auth.converters = []sqlutil.Converter{{Dynamic: true}} |  | ||||||
| 			} |  | ||||||
| 			if auth.drop == nil { |  | ||||||
| 				auth.drop = []string{ |  | ||||||
| 					"id", |  | ||||||
| 					"org_id", |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			rows, err := sess.DB().QueryContext(helper.ctx, auth.sql) |  | ||||||
| 			if err != nil { |  | ||||||
| 				if isTableNotExistsError(err) { |  | ||||||
| 					continue |  | ||||||
| 				} |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			frame, err := sqlutil.FrameFromRows(rows.Rows, -1, auth.converters...) |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 			if frame.Fields[0].Len() < 1 { |  | ||||||
| 				continue // do not write empty structures
 |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			if len(auth.drop) > 0 { |  | ||||||
| 				lookup := make(map[string]bool, len(auth.drop)) |  | ||||||
| 				for _, v := range auth.drop { |  | ||||||
| 					lookup[v] = true |  | ||||||
| 				} |  | ||||||
| 				fields := make([]*data.Field, 0, len(frame.Fields)) |  | ||||||
| 				for _, f := range frame.Fields { |  | ||||||
| 					if lookup[f.Name] { |  | ||||||
| 						continue |  | ||||||
| 					} |  | ||||||
| 					fields = append(fields, f) |  | ||||||
| 				} |  | ||||||
| 				frame.Fields = fields |  | ||||||
| 			} |  | ||||||
| 			frame.Name = auth.table |  | ||||||
| 			commit.body = append(commit.body, commitBody{ |  | ||||||
| 				fpath: path.Join(helper.orgDir, "auth", "sql.dump", auth.table+".json"), |  | ||||||
| 				frame: frame, |  | ||||||
| 			}) |  | ||||||
| 		} |  | ||||||
| 		return helper.add(commit) |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  | @ -1,241 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"encoding/json" |  | ||||||
| 	"fmt" |  | ||||||
| 	"path" |  | ||||||
| 	"path/filepath" |  | ||||||
| 	"strings" |  | ||||||
| 	"time" |  | ||||||
| 
 |  | ||||||
| 	"github.com/google/uuid" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/db" |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/filestorage" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/store/kind/dashboard" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| func exportDashboards(helper *commitHelper, job *gitExportJob) error { |  | ||||||
| 	alias := make(map[string]string, 100) |  | ||||||
| 	ids := make(map[int64]string, 100) |  | ||||||
| 	folders := make(map[int64]string, 100) |  | ||||||
| 
 |  | ||||||
| 	// Should root files be at the root or in a subfolder called "general"?
 |  | ||||||
| 	if len(job.cfg.GeneralFolderPath) > 0 { |  | ||||||
| 		folders[0] = job.cfg.GeneralFolderPath // "general"
 |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	lookup, err := dashboard.LoadDatasourceLookup(helper.ctx, helper.orgID, job.sql) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	rootDir := path.Join(helper.orgDir, "drive") |  | ||||||
| 	folderStructure := commitOptions{ |  | ||||||
| 		when:    time.Now(), |  | ||||||
| 		comment: "Exported folder structure", |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	err = job.sql.WithDbSession(helper.ctx, func(sess *db.Session) error { |  | ||||||
| 		type dashDataQueryResult struct { |  | ||||||
| 			Id       int64 |  | ||||||
| 			UID      string `xorm:"uid"` |  | ||||||
| 			IsFolder bool   `xorm:"is_folder"` |  | ||||||
| 			FolderID int64  `xorm:"folder_id"` |  | ||||||
| 			Slug     string `xorm:"slug"` |  | ||||||
| 			Data     []byte |  | ||||||
| 			Created  time.Time |  | ||||||
| 			Updated  time.Time |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		rows := make([]*dashDataQueryResult, 0) |  | ||||||
| 
 |  | ||||||
| 		sess.Table("dashboard"). |  | ||||||
| 			Where("org_id = ?", helper.orgID). |  | ||||||
| 			Cols("id", "is_folder", "folder_id", "data", "slug", "created", "updated", "uid") |  | ||||||
| 
 |  | ||||||
| 		err := sess.Find(&rows) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		reader := dashboard.NewStaticDashboardSummaryBuilder(lookup, false) |  | ||||||
| 
 |  | ||||||
| 		// Process all folders
 |  | ||||||
| 		for _, row := range rows { |  | ||||||
| 			if !row.IsFolder { |  | ||||||
| 				continue |  | ||||||
| 			} |  | ||||||
| 			dash, _, err := reader(helper.ctx, row.UID, row.Data) |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			dash.UID = row.UID |  | ||||||
| 			slug := cleanFileName(dash.Name) |  | ||||||
| 			folder := map[string]string{ |  | ||||||
| 				"title": dash.Name, |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			folderStructure.body = append(folderStructure.body, commitBody{ |  | ||||||
| 				fpath: path.Join(rootDir, slug, "__folder.json"), |  | ||||||
| 				body:  prettyJSON(folder), |  | ||||||
| 			}) |  | ||||||
| 
 |  | ||||||
| 			alias[dash.UID] = slug |  | ||||||
| 			folders[row.Id] = slug |  | ||||||
| 
 |  | ||||||
| 			if row.Created.Before(folderStructure.when) { |  | ||||||
| 				folderStructure.when = row.Created |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		// Now process the dashboards in each folder
 |  | ||||||
| 		for _, row := range rows { |  | ||||||
| 			if row.IsFolder { |  | ||||||
| 				continue |  | ||||||
| 			} |  | ||||||
| 			fname := row.Slug + "-dashboard.json" |  | ||||||
| 			fpath, ok := folders[row.FolderID] |  | ||||||
| 			if ok { |  | ||||||
| 				fpath = path.Join(fpath, fname) |  | ||||||
| 			} else { |  | ||||||
| 				fpath = fname |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			alias[row.UID] = fpath |  | ||||||
| 			ids[row.Id] = fpath |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		return err |  | ||||||
| 	}) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	err = helper.add(folderStructure) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	err = helper.add(commitOptions{ |  | ||||||
| 		body: []commitBody{ |  | ||||||
| 			{ |  | ||||||
| 				fpath: filepath.Join(helper.orgDir, "root-alias.json"), |  | ||||||
| 				body:  prettyJSON(alias), |  | ||||||
| 			}, |  | ||||||
| 			{ |  | ||||||
| 				fpath: filepath.Join(helper.orgDir, "root-ids.json"), |  | ||||||
| 				body:  prettyJSON(ids), |  | ||||||
| 			}, |  | ||||||
| 		}, |  | ||||||
| 		when:    folderStructure.when, |  | ||||||
| 		comment: "adding UID alias structure", |  | ||||||
| 	}) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Now walk the history
 |  | ||||||
| 	err = job.sql.WithDbSession(helper.ctx, func(sess *db.Session) error { |  | ||||||
| 		type dashVersionResult struct { |  | ||||||
| 			DashId    int64     `xorm:"id"` |  | ||||||
| 			Version   int64     `xorm:"version"` |  | ||||||
| 			Created   time.Time `xorm:"created"` |  | ||||||
| 			CreatedBy int64     `xorm:"created_by"` |  | ||||||
| 			Message   string    `xorm:"message"` |  | ||||||
| 			Data      []byte |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		rows := make([]*dashVersionResult, 0, len(ids)) |  | ||||||
| 
 |  | ||||||
| 		if job.cfg.KeepHistory { |  | ||||||
| 			sess.Table("dashboard_version"). |  | ||||||
| 				Join("INNER", "dashboard", "dashboard.id = dashboard_version.dashboard_id"). |  | ||||||
| 				Where("org_id = ?", helper.orgID). |  | ||||||
| 				Cols("dashboard.id", |  | ||||||
| 					"dashboard_version.version", |  | ||||||
| 					"dashboard_version.created", |  | ||||||
| 					"dashboard_version.created_by", |  | ||||||
| 					"dashboard_version.message", |  | ||||||
| 					"dashboard_version.data"). |  | ||||||
| 				Asc("dashboard_version.created") |  | ||||||
| 		} else { |  | ||||||
| 			sess.Table("dashboard"). |  | ||||||
| 				Where("org_id = ?", helper.orgID). |  | ||||||
| 				Cols("id", |  | ||||||
| 					"version", |  | ||||||
| 					"created", |  | ||||||
| 					"created_by", |  | ||||||
| 					"data"). |  | ||||||
| 				Asc("created") |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		err := sess.Find(&rows) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		count := int64(0) |  | ||||||
| 
 |  | ||||||
| 		// Process all folders (only one level deep!!!)
 |  | ||||||
| 		for _, row := range rows { |  | ||||||
| 			fpath, ok := ids[row.DashId] |  | ||||||
| 			if !ok { |  | ||||||
| 				continue |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			msg := row.Message |  | ||||||
| 			if msg == "" { |  | ||||||
| 				msg = fmt.Sprintf("Version: %d", row.Version) |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			err = helper.add(commitOptions{ |  | ||||||
| 				body: []commitBody{ |  | ||||||
| 					{ |  | ||||||
| 						fpath: filepath.Join(rootDir, fpath), |  | ||||||
| 						body:  cleanDashboardJSON(row.Data), |  | ||||||
| 					}, |  | ||||||
| 				}, |  | ||||||
| 				userID:  row.CreatedBy, |  | ||||||
| 				when:    row.Created, |  | ||||||
| 				comment: msg, |  | ||||||
| 			}) |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 			count++ |  | ||||||
| 			fmt.Printf("COMMIT: %d // %s (%d)\n", count, fpath, row.Version) |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		return nil |  | ||||||
| 	}) |  | ||||||
| 
 |  | ||||||
| 	return err |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func cleanDashboardJSON(data []byte) []byte { |  | ||||||
| 	var dash map[string]interface{} |  | ||||||
| 	err := json.Unmarshal(data, &dash) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil |  | ||||||
| 	} |  | ||||||
| 	delete(dash, "id") |  | ||||||
| 	delete(dash, "uid") |  | ||||||
| 	delete(dash, "version") |  | ||||||
| 
 |  | ||||||
| 	clean, _ := json.MarshalIndent(dash, "", "  ") |  | ||||||
| 	return clean |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // replace any unsafe file name characters... TODO, but be a standard way to do this cleanly!!!
 |  | ||||||
| func cleanFileName(name string) string { |  | ||||||
| 	name = strings.ReplaceAll(name, "/", "-") |  | ||||||
| 	name = strings.ReplaceAll(name, "\\", "-") |  | ||||||
| 	name = strings.ReplaceAll(name, ":", "-") |  | ||||||
| 	if err := filestorage.ValidatePath(filestorage.Delimiter + name); err != nil { |  | ||||||
| 		randomName, _ := uuid.NewRandom() |  | ||||||
| 		return randomName.String() |  | ||||||
| 	} |  | ||||||
| 	return name |  | ||||||
| } |  | ||||||
|  | @ -1,80 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"encoding/json" |  | ||||||
| 	"fmt" |  | ||||||
| 	"os" |  | ||||||
| 	"path/filepath" |  | ||||||
| 	"strings" |  | ||||||
| 	"time" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/db" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| func exportDashboardThumbnails(helper *commitHelper, job *gitExportJob) error { |  | ||||||
| 	alias := make(map[string]string, 100) |  | ||||||
| 	aliasLookup, err := os.ReadFile(filepath.Join(helper.orgDir, "root-alias.json")) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return fmt.Errorf("missing dashboard alias files (must export dashboards first)") |  | ||||||
| 	} |  | ||||||
| 	err = json.Unmarshal(aliasLookup, &alias) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return job.sql.WithDbSession(helper.ctx, func(sess *db.Session) error { |  | ||||||
| 		type dashboardThumb struct { |  | ||||||
| 			UID      string `xorm:"uid"` |  | ||||||
| 			Image    []byte `xorm:"image"` |  | ||||||
| 			Theme    string `xorm:"theme"` |  | ||||||
| 			Kind     string `xorm:"kind"` |  | ||||||
| 			MimeType string `xorm:"mime_type"` |  | ||||||
| 			Updated  time.Time |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		rows := make([]*dashboardThumb, 0) |  | ||||||
| 
 |  | ||||||
| 		// SELECT uid,image,theme,kind,mime_type,dashboard_thumbnail.updated
 |  | ||||||
| 		// FROM dashboard_thumbnail
 |  | ||||||
| 		//  JOIN dashboard ON dashboard.id = dashboard_thumbnail.dashboard_id
 |  | ||||||
| 		// WHERE org_id = 2; //dashboard.uid = '2VVbg06nz';
 |  | ||||||
| 
 |  | ||||||
| 		sess.Table("dashboard_thumbnail"). |  | ||||||
| 			Join("INNER", "dashboard", "dashboard.id = dashboard_thumbnail.dashboard_id"). |  | ||||||
| 			Cols("uid", "image", "theme", "kind", "mime_type", "dashboard_thumbnail.updated"). |  | ||||||
| 			Where("dashboard.org_id = ?", helper.orgID) |  | ||||||
| 
 |  | ||||||
| 		err := sess.Find(&rows) |  | ||||||
| 		if err != nil { |  | ||||||
| 			if isTableNotExistsError(err) { |  | ||||||
| 				return nil |  | ||||||
| 			} |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		// Process all folders
 |  | ||||||
| 		for _, row := range rows { |  | ||||||
| 			p, ok := alias[row.UID] |  | ||||||
| 			if !ok { |  | ||||||
| 				p = "uid/" + row.UID |  | ||||||
| 			} else { |  | ||||||
| 				p = strings.TrimSuffix(p, "-dash.json") |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			err := helper.add(commitOptions{ |  | ||||||
| 				body: []commitBody{ |  | ||||||
| 					{ |  | ||||||
| 						fpath: filepath.Join(helper.orgDir, "thumbs", fmt.Sprintf("%s.thumb-%s.png", p, row.Theme)), |  | ||||||
| 						body:  row.Image, |  | ||||||
| 					}, |  | ||||||
| 				}, |  | ||||||
| 				when:    row.Updated, |  | ||||||
| 				comment: "Thumbnail", |  | ||||||
| 			}) |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 		return nil |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  | @ -1,47 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"fmt" |  | ||||||
| 	"path/filepath" |  | ||||||
| 	"sort" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/datasources" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| func exportDataSources(helper *commitHelper, job *gitExportJob) error { |  | ||||||
| 	cmd := &datasources.GetDataSourcesQuery{ |  | ||||||
| 		OrgID: helper.orgID, |  | ||||||
| 	} |  | ||||||
| 	dataSources, err := job.datasourceService.GetDataSources(helper.ctx, cmd) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	sort.SliceStable(dataSources, func(i, j int) bool { |  | ||||||
| 		return dataSources[i].Created.After(dataSources[j].Created) |  | ||||||
| 	}) |  | ||||||
| 
 |  | ||||||
| 	for _, ds := range dataSources { |  | ||||||
| 		ds.OrgID = 0 |  | ||||||
| 		ds.Version = 0 |  | ||||||
| 		ds.SecureJsonData = map[string][]byte{ |  | ||||||
| 			"TODO": []byte("XXX"), |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		err := helper.add(commitOptions{ |  | ||||||
| 			body: []commitBody{ |  | ||||||
| 				{ |  | ||||||
| 					fpath: filepath.Join(helper.orgDir, "datasources", fmt.Sprintf("%s-ds.json", ds.UID)), |  | ||||||
| 					body:  prettyJSON(ds), |  | ||||||
| 				}, |  | ||||||
| 			}, |  | ||||||
| 			when:    ds.Created, |  | ||||||
| 			comment: fmt.Sprintf("Add datasource: %s", ds.Name), |  | ||||||
| 		}) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
|  | @ -1,48 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"fmt" |  | ||||||
| 	"path" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/filestorage" |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/log" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| func exportFiles(helper *commitHelper, job *gitExportJob) error { |  | ||||||
| 	fs := filestorage.NewDbStorage(log.New("grafanaStorageLogger"), job.sql, nil, fmt.Sprintf("/%d/", helper.orgID)) |  | ||||||
| 
 |  | ||||||
| 	paging := &filestorage.Paging{} |  | ||||||
| 	for { |  | ||||||
| 		rsp, err := fs.List(helper.ctx, "/resources", paging, &filestorage.ListOptions{ |  | ||||||
| 			WithFolders:  false, // ????
 |  | ||||||
| 			Recursive:    true, |  | ||||||
| 			WithContents: true, |  | ||||||
| 		}) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		for _, f := range rsp.Files { |  | ||||||
| 			if f.Size < 1 { |  | ||||||
| 				continue |  | ||||||
| 			} |  | ||||||
| 			err = helper.add(commitOptions{ |  | ||||||
| 				body: []commitBody{{ |  | ||||||
| 					body:  f.Contents, |  | ||||||
| 					fpath: path.Join(helper.orgDir, f.FullPath), |  | ||||||
| 				}}, |  | ||||||
| 				comment: fmt.Sprintf("Adding: %s", path.Base(f.FullPath)), |  | ||||||
| 				when:    f.Created, |  | ||||||
| 			}) |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		paging.After = rsp.LastPath |  | ||||||
| 		if !rsp.HasMore { |  | ||||||
| 			break |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
|  | @ -1,46 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"fmt" |  | ||||||
| 	"path" |  | ||||||
| 	"time" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/db" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| func exportKVStore(helper *commitHelper, job *gitExportJob) error { |  | ||||||
| 	kvdir := path.Join(helper.orgDir, "system", "kv_store") |  | ||||||
| 
 |  | ||||||
| 	return job.sql.WithDbSession(helper.ctx, func(sess *db.Session) error { |  | ||||||
| 		type kvResult struct { |  | ||||||
| 			Namespace string    `xorm:"namespace"` |  | ||||||
| 			Key       string    `xorm:"key"` |  | ||||||
| 			Value     string    `xorm:"value"` |  | ||||||
| 			Updated   time.Time `xorm:"updated"` |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		rows := make([]*kvResult, 0) |  | ||||||
| 
 |  | ||||||
| 		sess.Table("kv_store").Where("org_id = ? OR org_id = 0", helper.orgID) |  | ||||||
| 
 |  | ||||||
| 		err := sess.Find(&rows) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		for _, row := range rows { |  | ||||||
| 			err = helper.add(commitOptions{ |  | ||||||
| 				body: []commitBody{{ |  | ||||||
| 					body:  []byte(row.Value), |  | ||||||
| 					fpath: path.Join(kvdir, row.Namespace, row.Key), |  | ||||||
| 				}}, |  | ||||||
| 				comment: fmt.Sprintf("Exporting: %s/%s", row.Namespace, row.Key), |  | ||||||
| 				when:    row.Updated, |  | ||||||
| 			}) |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 		return err |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  | @ -1,52 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"encoding/json" |  | ||||||
| 	"fmt" |  | ||||||
| 	"path" |  | ||||||
| 	"time" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/db" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| func exportPlugins(helper *commitHelper, job *gitExportJob) error { |  | ||||||
| 	return job.sql.WithDbSession(helper.ctx, func(sess *db.Session) error { |  | ||||||
| 		type pResult struct { |  | ||||||
| 			PluginID string          `xorm:"plugin_id" json:"-"` |  | ||||||
| 			Enabled  string          `xorm:"enabled" json:"enabled"` |  | ||||||
| 			Pinned   string          `xorm:"pinned" json:"pinned"` |  | ||||||
| 			JSONData json.RawMessage `xorm:"json_data" json:"json_data,omitempty"` |  | ||||||
| 			// TODO: secure!!!!
 |  | ||||||
| 			PluginVersion string    `xorm:"plugin_version" json:"version"` |  | ||||||
| 			Created       time.Time `xorm:"created" json:"created"` |  | ||||||
| 			Updated       time.Time `xorm:"updated" json:"updated"` |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		rows := make([]*pResult, 0) |  | ||||||
| 
 |  | ||||||
| 		sess.Table("plugin_setting").Where("org_id = ?", helper.orgID) |  | ||||||
| 
 |  | ||||||
| 		err := sess.Find(&rows) |  | ||||||
| 		if err != nil { |  | ||||||
| 			if isTableNotExistsError(err) { |  | ||||||
| 				return nil |  | ||||||
| 			} |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		for _, row := range rows { |  | ||||||
| 			err = helper.add(commitOptions{ |  | ||||||
| 				body: []commitBody{{ |  | ||||||
| 					body:  prettyJSON(row), |  | ||||||
| 					fpath: path.Join(helper.orgDir, "plugins", row.PluginID, "settings.json"), |  | ||||||
| 				}}, |  | ||||||
| 				comment: fmt.Sprintf("Plugin: %s", row.PluginID), |  | ||||||
| 				when:    row.Updated, |  | ||||||
| 			}) |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 		return err |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  | @ -1,43 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"fmt" |  | ||||||
| 	"path/filepath" |  | ||||||
| 	"time" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/dashboardsnapshots" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| func exportSnapshots(helper *commitHelper, job *gitExportJob) error { |  | ||||||
| 	cmd := &dashboardsnapshots.GetDashboardSnapshotsQuery{ |  | ||||||
| 		OrgID:        helper.orgID, |  | ||||||
| 		Limit:        500000, |  | ||||||
| 		SignedInUser: nil, |  | ||||||
| 	} |  | ||||||
| 	if cmd.SignedInUser == nil { |  | ||||||
| 		return fmt.Errorf("snapshots requires an admin user") |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	result, err := job.dashboardsnapshotsService.SearchDashboardSnapshots(helper.ctx, cmd) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	if len(result) < 1 { |  | ||||||
| 		return nil // nothing
 |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	gitcmd := commitOptions{ |  | ||||||
| 		when:    time.Now(), |  | ||||||
| 		comment: "Export snapshots", |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	for _, snapshot := range result { |  | ||||||
| 		gitcmd.body = append(gitcmd.body, commitBody{ |  | ||||||
| 			fpath: filepath.Join(helper.orgDir, "snapshot", fmt.Sprintf("%d-snapshot.json", snapshot.ID)), |  | ||||||
| 			body:  prettyJSON(snapshot), |  | ||||||
| 		}) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return helper.add(gitcmd) |  | ||||||
| } |  | ||||||
|  | @ -1,51 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"fmt" |  | ||||||
| 	"path/filepath" |  | ||||||
| 	"time" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/playlist" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/store/entity" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| func exportSystemPlaylists(helper *commitHelper, job *gitExportJob) error { |  | ||||||
| 	cmd := &playlist.GetPlaylistsQuery{ |  | ||||||
| 		OrgId: helper.orgID, |  | ||||||
| 		Limit: 500000, |  | ||||||
| 	} |  | ||||||
| 	res, err := job.playlistService.Search(helper.ctx, cmd) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	if len(res) < 1 { |  | ||||||
| 		return nil // nothing
 |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	gitcmd := commitOptions{ |  | ||||||
| 		when:    time.Now(), |  | ||||||
| 		comment: "Export playlists", |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	for _, item := range res { |  | ||||||
| 		playlist, err := job.playlistService.Get(helper.ctx, &playlist.GetPlaylistByUidQuery{ |  | ||||||
| 			UID:   item.UID, |  | ||||||
| 			OrgId: helper.orgID, |  | ||||||
| 		}) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		gitcmd.body = append(gitcmd.body, commitBody{ |  | ||||||
| 			fpath: filepath.Join( |  | ||||||
| 				helper.orgDir, |  | ||||||
| 				"entity", |  | ||||||
| 				entity.StandardKindPlaylist, |  | ||||||
| 				fmt.Sprintf("%s.json", playlist.Uid)), |  | ||||||
| 			body: prettyJSON(playlist), |  | ||||||
| 		}) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return helper.add(gitcmd) |  | ||||||
| } |  | ||||||
|  | @ -1,136 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"fmt" |  | ||||||
| 	"path" |  | ||||||
| 	"path/filepath" |  | ||||||
| 	"time" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/db" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| func exportSystemPreferences(helper *commitHelper, job *gitExportJob) error { |  | ||||||
| 	type preferences struct { |  | ||||||
| 		UserID          int64                  `json:"-" xorm:"user_id"` |  | ||||||
| 		TeamID          int64                  `json:"-" xorm:"team_id"` |  | ||||||
| 		HomeDashboardID int64                  `json:"-" xorm:"home_dashboard_id"` |  | ||||||
| 		Updated         time.Time              `json:"-" xorm:"updated"` |  | ||||||
| 		JSONData        map[string]interface{} `json:"-" xorm:"json_data"` |  | ||||||
| 
 |  | ||||||
| 		Theme         string      `json:"theme"` |  | ||||||
| 		Locale        string      `json:"locale"` |  | ||||||
| 		Timezone      string      `json:"timezone"` |  | ||||||
| 		WeekStart     string      `json:"week_start,omitempty"` |  | ||||||
| 		HomeDashboard string      `json:"home,omitempty" xorm:"uid"` // dashboard
 |  | ||||||
| 		NavBar        interface{} `json:"navbar,omitempty"` |  | ||||||
| 		QueryHistory  interface{} `json:"queryHistory,omitempty"` |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	prefsDir := path.Join(helper.orgDir, "system", "preferences") |  | ||||||
| 	users := make(map[int64]*userInfo, len(helper.users)) |  | ||||||
| 	for _, user := range helper.users { |  | ||||||
| 		users[user.ID] = user |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return job.sql.WithDbSession(helper.ctx, func(sess *db.Session) error { |  | ||||||
| 		rows := make([]*preferences, 0) |  | ||||||
| 
 |  | ||||||
| 		sess.Table("preferences"). |  | ||||||
| 			Join("LEFT", "dashboard", "dashboard.id = preferences.home_dashboard_id"). |  | ||||||
| 			Cols("preferences.*", "dashboard.uid"). |  | ||||||
| 			Where("preferences.org_id = ?", helper.orgID) |  | ||||||
| 
 |  | ||||||
| 		err := sess.Find(&rows) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		var comment string |  | ||||||
| 		var fpath string |  | ||||||
| 		for _, row := range rows { |  | ||||||
| 			if row.TeamID > 0 { |  | ||||||
| 				fpath = filepath.Join(prefsDir, "team", fmt.Sprintf("%d.json", row.TeamID)) |  | ||||||
| 				comment = fmt.Sprintf("Team preferences: %d", row.TeamID) |  | ||||||
| 			} else if row.UserID == 0 { |  | ||||||
| 				fpath = filepath.Join(prefsDir, "default.json") |  | ||||||
| 				comment = "Default preferences" |  | ||||||
| 			} else { |  | ||||||
| 				user, ok := users[row.UserID] |  | ||||||
| 				if ok { |  | ||||||
| 					delete(users, row.UserID) |  | ||||||
| 					if user.IsServiceAccount { |  | ||||||
| 						continue // don't write preferences for service account
 |  | ||||||
| 					} |  | ||||||
| 				} else { |  | ||||||
| 					user = &userInfo{ |  | ||||||
| 						Login: fmt.Sprintf("__%d__", row.UserID), |  | ||||||
| 					} |  | ||||||
| 				} |  | ||||||
| 
 |  | ||||||
| 				fpath = filepath.Join(prefsDir, "user", fmt.Sprintf("%s.json", user.Login)) |  | ||||||
| 				comment = fmt.Sprintf("User preferences: %s", user.getAuthor().Name) |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			if row.JSONData != nil { |  | ||||||
| 				v, ok := row.JSONData["locale"] |  | ||||||
| 				if ok && row.Locale == "" { |  | ||||||
| 					s, ok := v.(string) |  | ||||||
| 					if ok { |  | ||||||
| 						row.Locale = s |  | ||||||
| 					} |  | ||||||
| 				} |  | ||||||
| 
 |  | ||||||
| 				v, ok = row.JSONData["navbar"] |  | ||||||
| 				if ok && row.NavBar == nil { |  | ||||||
| 					row.NavBar = v |  | ||||||
| 				} |  | ||||||
| 
 |  | ||||||
| 				v, ok = row.JSONData["queryHistory"] |  | ||||||
| 				if ok && row.QueryHistory == nil { |  | ||||||
| 					row.QueryHistory = v |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			err := helper.add(commitOptions{ |  | ||||||
| 				body: []commitBody{ |  | ||||||
| 					{ |  | ||||||
| 						fpath: fpath, |  | ||||||
| 						body:  prettyJSON(row), |  | ||||||
| 					}, |  | ||||||
| 				}, |  | ||||||
| 				when:    row.Updated, |  | ||||||
| 				comment: comment, |  | ||||||
| 				userID:  row.UserID, |  | ||||||
| 			}) |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		// add a file for all useres that may not be in the system
 |  | ||||||
| 		for _, user := range users { |  | ||||||
| 			if user.IsServiceAccount { |  | ||||||
| 				continue |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			row := preferences{ |  | ||||||
| 				Theme: user.Theme, // never set?
 |  | ||||||
| 			} |  | ||||||
| 			err := helper.add(commitOptions{ |  | ||||||
| 				body: []commitBody{ |  | ||||||
| 					{ |  | ||||||
| 						fpath: filepath.Join(prefsDir, "user", fmt.Sprintf("%s.json", user.Login)), |  | ||||||
| 						body:  prettyJSON(row), |  | ||||||
| 					}, |  | ||||||
| 				}, |  | ||||||
| 				when:    user.Updated, |  | ||||||
| 				comment: "user preferences", |  | ||||||
| 				userID:  row.UserID, |  | ||||||
| 			}) |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 		return err |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  | @ -1,72 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"fmt" |  | ||||||
| 	"path/filepath" |  | ||||||
| 	"time" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/db" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| func exportSystemShortURL(helper *commitHelper, job *gitExportJob) error { |  | ||||||
| 	mostRecent := int64(0) |  | ||||||
| 	lastSeen := make(map[string]int64, 50) |  | ||||||
| 	dir := filepath.Join(helper.orgDir, "system", "short_url") |  | ||||||
| 
 |  | ||||||
| 	err := job.sql.WithDbSession(helper.ctx, func(sess *db.Session) error { |  | ||||||
| 		type urlResult struct { |  | ||||||
| 			UID        string    `xorm:"uid" json:"-"` |  | ||||||
| 			Path       string    `xorm:"path" json:"path"` |  | ||||||
| 			CreatedBy  int64     `xorm:"created_by" json:"-"` |  | ||||||
| 			CreatedAt  time.Time `xorm:"created_at" json:"-"` |  | ||||||
| 			LastSeenAt int64     `xorm:"last_seen_at" json:"-"` |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		rows := make([]*urlResult, 0) |  | ||||||
| 
 |  | ||||||
| 		sess.Table("short_url").Where("org_id = ?", helper.orgID) |  | ||||||
| 
 |  | ||||||
| 		err := sess.Find(&rows) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		for _, row := range rows { |  | ||||||
| 			if row.LastSeenAt > 0 { |  | ||||||
| 				lastSeen[row.UID] = row.LastSeenAt |  | ||||||
| 				if mostRecent < row.LastSeenAt { |  | ||||||
| 					mostRecent = row.LastSeenAt |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 			err := helper.add(commitOptions{ |  | ||||||
| 				body: []commitBody{ |  | ||||||
| 					{ |  | ||||||
| 						fpath: filepath.Join(dir, "uid", fmt.Sprintf("%s.json", row.UID)), |  | ||||||
| 						body:  prettyJSON(row), |  | ||||||
| 					}, |  | ||||||
| 				}, |  | ||||||
| 				when:    row.CreatedAt, |  | ||||||
| 				comment: "short URL", |  | ||||||
| 				userID:  row.CreatedBy, |  | ||||||
| 			}) |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 		return err |  | ||||||
| 	}) |  | ||||||
| 	if err != nil || len(lastSeen) < 1 { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return helper.add(commitOptions{ |  | ||||||
| 		body: []commitBody{ |  | ||||||
| 			{ |  | ||||||
| 				fpath: filepath.Join(dir, "last_seen_at.json"), |  | ||||||
| 				body:  prettyJSON(lastSeen), |  | ||||||
| 			}, |  | ||||||
| 		}, |  | ||||||
| 		when:    time.UnixMilli(mostRecent), |  | ||||||
| 		comment: "short URL", |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  | @ -1,65 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"fmt" |  | ||||||
| 	"path/filepath" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/db" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| func exportSystemStars(helper *commitHelper, job *gitExportJob) error { |  | ||||||
| 	byUser := make(map[int64][]string, 50) |  | ||||||
| 
 |  | ||||||
| 	err := job.sql.WithDbSession(helper.ctx, func(sess *db.Session) error { |  | ||||||
| 		type starResult struct { |  | ||||||
| 			User int64  `xorm:"user_id"` |  | ||||||
| 			UID  string `xorm:"uid"` |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		rows := make([]*starResult, 0) |  | ||||||
| 
 |  | ||||||
| 		sess.Table("star"). |  | ||||||
| 			Join("INNER", "dashboard", "dashboard.id = star.dashboard_id"). |  | ||||||
| 			Cols("star.user_id", "dashboard.uid"). |  | ||||||
| 			Where("dashboard.org_id = ?", helper.orgID) |  | ||||||
| 
 |  | ||||||
| 		err := sess.Find(&rows) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		for _, row := range rows { |  | ||||||
| 			stars := append(byUser[row.User], fmt.Sprintf("dashboard/%s", row.UID)) |  | ||||||
| 			byUser[row.User] = stars |  | ||||||
| 		} |  | ||||||
| 		return err |  | ||||||
| 	}) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	for userID, stars := range byUser { |  | ||||||
| 		user, ok := helper.users[userID] |  | ||||||
| 		if !ok { |  | ||||||
| 			user = &userInfo{ |  | ||||||
| 				Login: fmt.Sprintf("__unknown_%d", userID), |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		err := helper.add(commitOptions{ |  | ||||||
| 			body: []commitBody{ |  | ||||||
| 				{ |  | ||||||
| 					fpath: filepath.Join(helper.orgDir, "system", "stars", fmt.Sprintf("%s.json", user.Login)), |  | ||||||
| 					body:  prettyJSON(stars), |  | ||||||
| 				}, |  | ||||||
| 			}, |  | ||||||
| 			when:    user.Updated, |  | ||||||
| 			comment: "user preferences", |  | ||||||
| 			userID:  userID, |  | ||||||
| 		}) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
|  | @ -1,85 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"path" |  | ||||||
| 	"strconv" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/db" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| func exportUsage(helper *commitHelper, job *gitExportJob) error { |  | ||||||
| 	return job.sql.WithDbSession(helper.ctx, func(sess *db.Session) error { |  | ||||||
| 		commit := commitOptions{ |  | ||||||
| 			comment: "usage stats", |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		type statsTables struct { |  | ||||||
| 			table      string |  | ||||||
| 			sql        string |  | ||||||
| 			converters []sqlutil.Converter |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		dump := []statsTables{ |  | ||||||
| 			{ |  | ||||||
| 				table: "data_source_usage_by_day", |  | ||||||
| 				sql: `SELECT day,uid,queries,errors,load_duration_ms  |  | ||||||
| 					FROM data_source_usage_by_day  |  | ||||||
| 					JOIN data_source ON data_source.id = data_source_usage_by_day.data_source_id |  | ||||||
| 					WHERE org_id =` + strconv.FormatInt(helper.orgID, 10), |  | ||||||
| 				converters: []sqlutil.Converter{{Dynamic: true}}, |  | ||||||
| 			}, |  | ||||||
| 			{ |  | ||||||
| 				table: "dashboard_usage_by_day", |  | ||||||
| 				sql: `SELECT uid,day,views,queries,errors,load_duration  |  | ||||||
| 					FROM dashboard_usage_by_day |  | ||||||
| 					JOIN dashboard ON dashboard_usage_by_day.dashboard_id = dashboard.id |  | ||||||
| 					WHERE org_id =` + strconv.FormatInt(helper.orgID, 10), |  | ||||||
| 				converters: []sqlutil.Converter{{Dynamic: true}}, |  | ||||||
| 			}, |  | ||||||
| 			{ |  | ||||||
| 				table: "dashboard_usage_sums", |  | ||||||
| 				sql: `SELECT uid, |  | ||||||
| 					views_last_1_days, |  | ||||||
| 					views_last_7_days, |  | ||||||
| 					views_last_30_days, |  | ||||||
| 					views_total, |  | ||||||
| 					queries_last_1_days, |  | ||||||
| 					queries_last_7_days, |  | ||||||
| 					queries_last_30_days, |  | ||||||
| 					queries_total, |  | ||||||
| 					errors_last_1_days, |  | ||||||
| 					errors_last_7_days, |  | ||||||
| 					errors_last_30_days, |  | ||||||
| 					errors_total |  | ||||||
| 					FROM dashboard_usage_sums |  | ||||||
| 					JOIN dashboard ON dashboard_usage_sums.dashboard_id = dashboard.id |  | ||||||
| 					WHERE org_id =` + strconv.FormatInt(helper.orgID, 10), |  | ||||||
| 				converters: []sqlutil.Converter{{Dynamic: true}}, |  | ||||||
| 			}, |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		for _, usage := range dump { |  | ||||||
| 			rows, err := sess.DB().QueryContext(helper.ctx, usage.sql) |  | ||||||
| 			if err != nil { |  | ||||||
| 				if isTableNotExistsError(err) { |  | ||||||
| 					continue |  | ||||||
| 				} |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			frame, err := sqlutil.FrameFromRows(rows.Rows, -1, usage.converters...) |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 			frame.Name = usage.table |  | ||||||
| 			commit.body = append(commit.body, commitBody{ |  | ||||||
| 				fpath: path.Join(helper.orgDir, "usage", usage.table+".json"), |  | ||||||
| 				frame: frame, |  | ||||||
| 			}) |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		return helper.add(commit) |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  | @ -1,233 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"context" |  | ||||||
| 	"encoding/json" |  | ||||||
| 	"fmt" |  | ||||||
| 	"path" |  | ||||||
| 	"runtime/debug" |  | ||||||
| 	"sync" |  | ||||||
| 	"time" |  | ||||||
| 
 |  | ||||||
| 	"github.com/go-git/go-git/v5" |  | ||||||
| 	"github.com/go-git/go-git/v5/plumbing" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/db" |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/log" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/dashboardsnapshots" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/datasources" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/org" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/playlist" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| var _ Job = new(gitExportJob) |  | ||||||
| 
 |  | ||||||
| type gitExportJob struct { |  | ||||||
| 	logger                    log.Logger |  | ||||||
| 	sql                       db.DB |  | ||||||
| 	dashboardsnapshotsService dashboardsnapshots.Service |  | ||||||
| 	datasourceService         datasources.DataSourceService |  | ||||||
| 	playlistService           playlist.Service |  | ||||||
| 	orgService                org.Service |  | ||||||
| 	rootDir                   string |  | ||||||
| 
 |  | ||||||
| 	statusMu    sync.Mutex |  | ||||||
| 	status      ExportStatus |  | ||||||
| 	cfg         ExportConfig |  | ||||||
| 	broadcaster statusBroadcaster |  | ||||||
| 	helper      *commitHelper |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func startGitExportJob(ctx context.Context, cfg ExportConfig, sql db.DB, |  | ||||||
| 	dashboardsnapshotsService dashboardsnapshots.Service, rootDir string, orgID int64, |  | ||||||
| 	broadcaster statusBroadcaster, playlistService playlist.Service, orgService org.Service, |  | ||||||
| 	datasourceService datasources.DataSourceService) (Job, error) { |  | ||||||
| 	job := &gitExportJob{ |  | ||||||
| 		logger:                    log.New("git_export_job"), |  | ||||||
| 		cfg:                       cfg, |  | ||||||
| 		sql:                       sql, |  | ||||||
| 		dashboardsnapshotsService: dashboardsnapshotsService, |  | ||||||
| 		playlistService:           playlistService, |  | ||||||
| 		orgService:                orgService, |  | ||||||
| 		datasourceService:         datasourceService, |  | ||||||
| 		rootDir:                   rootDir, |  | ||||||
| 		broadcaster:               broadcaster, |  | ||||||
| 		status: ExportStatus{ |  | ||||||
| 			Running: true, |  | ||||||
| 			Target:  "git export", |  | ||||||
| 			Started: time.Now().UnixMilli(), |  | ||||||
| 			Count:   make(map[string]int, len(exporters)*2), |  | ||||||
| 		}, |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	broadcaster(job.status) |  | ||||||
| 	go job.start(ctx) |  | ||||||
| 	return job, nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (e *gitExportJob) getStatus() ExportStatus { |  | ||||||
| 	e.statusMu.Lock() |  | ||||||
| 	defer e.statusMu.Unlock() |  | ||||||
| 
 |  | ||||||
| 	return e.status |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (e *gitExportJob) getConfig() ExportConfig { |  | ||||||
| 	e.statusMu.Lock() |  | ||||||
| 	defer e.statusMu.Unlock() |  | ||||||
| 
 |  | ||||||
| 	return e.cfg |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (e *gitExportJob) requestStop() { |  | ||||||
| 	e.helper.stopRequested = true // will error on the next write
 |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // Utility function to export dashboards
 |  | ||||||
| func (e *gitExportJob) start(ctx context.Context) { |  | ||||||
| 	defer func() { |  | ||||||
| 		e.logger.Info("Finished git export job") |  | ||||||
| 		e.statusMu.Lock() |  | ||||||
| 		defer e.statusMu.Unlock() |  | ||||||
| 		s := e.status |  | ||||||
| 		if err := recover(); err != nil { |  | ||||||
| 			e.logger.Error("export panic", "error", err) |  | ||||||
| 			e.logger.Error("trace", "error", string(debug.Stack())) |  | ||||||
| 			s.Status = fmt.Sprintf("ERROR: %v", err) |  | ||||||
| 		} |  | ||||||
| 		// Make sure it finishes OK
 |  | ||||||
| 		if s.Finished < 10 { |  | ||||||
| 			s.Finished = time.Now().UnixMilli() |  | ||||||
| 		} |  | ||||||
| 		s.Running = false |  | ||||||
| 		if s.Status == "" { |  | ||||||
| 			s.Status = "done" |  | ||||||
| 		} |  | ||||||
| 		s.Target = e.rootDir |  | ||||||
| 		e.status = s |  | ||||||
| 		e.broadcaster(s) |  | ||||||
| 	}() |  | ||||||
| 
 |  | ||||||
| 	err := e.doExportWithHistory(ctx) |  | ||||||
| 	if err != nil { |  | ||||||
| 		e.logger.Error("ERROR", "e", err) |  | ||||||
| 		e.status.Status = "ERROR" |  | ||||||
| 		e.status.Last = err.Error() |  | ||||||
| 		e.broadcaster(e.status) |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (e *gitExportJob) doExportWithHistory(ctx context.Context) error { |  | ||||||
| 	r, err := git.PlainInit(e.rootDir, false) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	// default to "main" branch
 |  | ||||||
| 	h := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.ReferenceName("refs/heads/main")) |  | ||||||
| 	err = r.Storer.SetReference(h) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	w, err := r.Worktree() |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	e.helper = &commitHelper{ |  | ||||||
| 		repo:    r, |  | ||||||
| 		work:    w, |  | ||||||
| 		ctx:     ctx, |  | ||||||
| 		workDir: e.rootDir, |  | ||||||
| 		orgDir:  e.rootDir, |  | ||||||
| 		broadcast: func(p string) { |  | ||||||
| 			e.status.Index++ |  | ||||||
| 			e.status.Last = p[len(e.rootDir):] |  | ||||||
| 			e.status.Changed = time.Now().UnixMilli() |  | ||||||
| 			e.broadcaster(e.status) |  | ||||||
| 		}, |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	cmd := &org.SearchOrgsQuery{} |  | ||||||
| 	result, err := e.orgService.Search(e.helper.ctx, cmd) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Export each org
 |  | ||||||
| 	for _, org := range result { |  | ||||||
| 		if len(result) > 1 { |  | ||||||
| 			e.helper.orgDir = path.Join(e.rootDir, fmt.Sprintf("org_%d", org.ID)) |  | ||||||
| 			e.status.Count["orgs"] += 1 |  | ||||||
| 		} |  | ||||||
| 		err = e.helper.initOrg(ctx, e.sql, org.ID) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		err := e.process(exporters) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// cleanup the folder
 |  | ||||||
| 	e.status.Target = "pruning..." |  | ||||||
| 	e.broadcaster(e.status) |  | ||||||
| 	err = r.Prune(git.PruneOptions{}) |  | ||||||
| 
 |  | ||||||
| 	// TODO
 |  | ||||||
| 	// git gc --prune=now --aggressive
 |  | ||||||
| 
 |  | ||||||
| 	return err |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (e *gitExportJob) process(exporters []Exporter) error { |  | ||||||
| 	if false { // NEEDS a real user ID first
 |  | ||||||
| 		err := exportSnapshots(e.helper, e) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	for _, exp := range exporters { |  | ||||||
| 		if e.cfg.Exclude[exp.Key] { |  | ||||||
| 			continue |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		e.status.Target = exp.Key |  | ||||||
| 		e.helper.exporter = exp.Key |  | ||||||
| 
 |  | ||||||
| 		before := e.helper.counter |  | ||||||
| 		if exp.process != nil { |  | ||||||
| 			err := exp.process(e.helper, e) |  | ||||||
| 
 |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		if exp.Exporters != nil { |  | ||||||
| 			err := e.process(exp.Exporters) |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		// Aggregate the counts for each org in the same report
 |  | ||||||
| 		e.status.Count[exp.Key] += (e.helper.counter - before) |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func prettyJSON(v interface{}) []byte { |  | ||||||
| 	b, _ := json.MarshalIndent(v, "", "  ") |  | ||||||
| 	return b |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
| 
 |  | ||||||
| git remote add origin git@github.com:ryantxu/test-dash-repo.git |  | ||||||
| git branch -M main |  | ||||||
| git push -u origin main |  | ||||||
| 
 |  | ||||||
| **/ |  | ||||||
|  | @ -1,267 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"context" |  | ||||||
| 	"encoding/json" |  | ||||||
| 	"fmt" |  | ||||||
| 	"net/http" |  | ||||||
| 	"os" |  | ||||||
| 	"path/filepath" |  | ||||||
| 	"sync" |  | ||||||
| 	"time" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/api/response" |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/appcontext" |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/db" |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/log" |  | ||||||
| 	contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/dashboardsnapshots" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/datasources" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/featuremgmt" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/live" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/org" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/playlist" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/store/entity" |  | ||||||
| 	"github.com/grafana/grafana/pkg/setting" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| type ExportService interface { |  | ||||||
| 	// List folder contents
 |  | ||||||
| 	HandleGetStatus(c *contextmodel.ReqContext) response.Response |  | ||||||
| 
 |  | ||||||
| 	// List Get Options
 |  | ||||||
| 	HandleGetOptions(c *contextmodel.ReqContext) response.Response |  | ||||||
| 
 |  | ||||||
| 	// Read raw file contents out of the store
 |  | ||||||
| 	HandleRequestExport(c *contextmodel.ReqContext) response.Response |  | ||||||
| 
 |  | ||||||
| 	// Cancel any running export
 |  | ||||||
| 	HandleRequestStop(c *contextmodel.ReqContext) response.Response |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| var exporters = []Exporter{ |  | ||||||
| 	{ |  | ||||||
| 		Key:         "auth", |  | ||||||
| 		Name:        "Authentication", |  | ||||||
| 		Description: "Saves raw SQL tables", |  | ||||||
| 		process:     dumpAuthTables, |  | ||||||
| 	}, |  | ||||||
| 	{ |  | ||||||
| 		Key:         "dash", |  | ||||||
| 		Name:        "Dashboards", |  | ||||||
| 		Description: "Save dashboard JSON", |  | ||||||
| 		process:     exportDashboards, |  | ||||||
| 		Exporters: []Exporter{ |  | ||||||
| 			{ |  | ||||||
| 				Key:         "dash_thumbs", |  | ||||||
| 				Name:        "Dashboard thumbnails", |  | ||||||
| 				Description: "Save current dashboard preview images", |  | ||||||
| 				process:     exportDashboardThumbnails, |  | ||||||
| 			}, |  | ||||||
| 		}, |  | ||||||
| 	}, |  | ||||||
| 	{ |  | ||||||
| 		Key:         "alerts", |  | ||||||
| 		Name:        "Alerts", |  | ||||||
| 		Description: "Archive alert rules and configuration", |  | ||||||
| 		process:     exportAlerts, |  | ||||||
| 	}, |  | ||||||
| 	{ |  | ||||||
| 		Key:         "ds", |  | ||||||
| 		Name:        "Data sources", |  | ||||||
| 		Description: "Data source configurations", |  | ||||||
| 		process:     exportDataSources, |  | ||||||
| 	}, |  | ||||||
| 	{ |  | ||||||
| 		Key:         "system", |  | ||||||
| 		Name:        "System", |  | ||||||
| 		Description: "Save service settings", |  | ||||||
| 		Exporters: []Exporter{ |  | ||||||
| 			{ |  | ||||||
| 				Key:         "system_preferences", |  | ||||||
| 				Name:        "Preferences", |  | ||||||
| 				Description: "User and team preferences", |  | ||||||
| 				process:     exportSystemPreferences, |  | ||||||
| 			}, |  | ||||||
| 			{ |  | ||||||
| 				Key:         "system_stars", |  | ||||||
| 				Name:        "Stars", |  | ||||||
| 				Description: "User stars", |  | ||||||
| 				process:     exportSystemStars, |  | ||||||
| 			}, |  | ||||||
| 			{ |  | ||||||
| 				Key:         "system_playlists", |  | ||||||
| 				Name:        "Playlists", |  | ||||||
| 				Description: "Playlists", |  | ||||||
| 				process:     exportSystemPlaylists, |  | ||||||
| 			}, |  | ||||||
| 			{ |  | ||||||
| 				Key:         "system_kv_store", |  | ||||||
| 				Name:        "Key Value store", |  | ||||||
| 				Description: "Internal KV store", |  | ||||||
| 				process:     exportKVStore, |  | ||||||
| 			}, |  | ||||||
| 			{ |  | ||||||
| 				Key:         "system_short_url", |  | ||||||
| 				Name:        "Short URLs", |  | ||||||
| 				Description: "saved links", |  | ||||||
| 				process:     exportSystemShortURL, |  | ||||||
| 			}, |  | ||||||
| 		}, |  | ||||||
| 	}, |  | ||||||
| 	{ |  | ||||||
| 		Key:         "files", |  | ||||||
| 		Name:        "Files", |  | ||||||
| 		Description: "Export internal file system", |  | ||||||
| 		process:     exportFiles, |  | ||||||
| 	}, |  | ||||||
| 	{ |  | ||||||
| 		Key:         "anno", |  | ||||||
| 		Name:        "Annotations", |  | ||||||
| 		Description: "Write an DataFrame for all annotations on a dashboard", |  | ||||||
| 		process:     exportAnnotations, |  | ||||||
| 	}, |  | ||||||
| 	{ |  | ||||||
| 		Key:         "plugins", |  | ||||||
| 		Name:        "Plugins", |  | ||||||
| 		Description: "Save settings for all configured plugins", |  | ||||||
| 		process:     exportPlugins, |  | ||||||
| 	}, |  | ||||||
| 	{ |  | ||||||
| 		Key:         "usage", |  | ||||||
| 		Name:        "Usage", |  | ||||||
| 		Description: "archive current usage stats", |  | ||||||
| 		process:     exportUsage, |  | ||||||
| 	}, |  | ||||||
| 	// {
 |  | ||||||
| 	// 	Key:         "snapshots",
 |  | ||||||
| 	// 	Name:        "Snapshots",
 |  | ||||||
| 	// 	Description: "write snapshots",
 |  | ||||||
| 	// 	process:     exportSnapshots,
 |  | ||||||
| 	// },
 |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| type StandardExport struct { |  | ||||||
| 	logger  log.Logger |  | ||||||
| 	glive   *live.GrafanaLive |  | ||||||
| 	mutex   sync.Mutex |  | ||||||
| 	dataDir string |  | ||||||
| 
 |  | ||||||
| 	// Services
 |  | ||||||
| 	db                        db.DB |  | ||||||
| 	dashboardsnapshotsService dashboardsnapshots.Service |  | ||||||
| 	playlistService           playlist.Service |  | ||||||
| 	orgService                org.Service |  | ||||||
| 	datasourceService         datasources.DataSourceService |  | ||||||
| 	store                     entity.EntityStoreServer |  | ||||||
| 
 |  | ||||||
| 	// updated with mutex
 |  | ||||||
| 	exportJob Job |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func ProvideService(db db.DB, features featuremgmt.FeatureToggles, gl *live.GrafanaLive, cfg *setting.Cfg, |  | ||||||
| 	dashboardsnapshotsService dashboardsnapshots.Service, playlistService playlist.Service, orgService org.Service, |  | ||||||
| 	datasourceService datasources.DataSourceService, store entity.EntityStoreServer) ExportService { |  | ||||||
| 	if !features.IsEnabled(featuremgmt.FlagExport) { |  | ||||||
| 		return &StubExport{} |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return &StandardExport{ |  | ||||||
| 		glive:                     gl, |  | ||||||
| 		logger:                    log.New("export_service"), |  | ||||||
| 		dashboardsnapshotsService: dashboardsnapshotsService, |  | ||||||
| 		playlistService:           playlistService, |  | ||||||
| 		orgService:                orgService, |  | ||||||
| 		datasourceService:         datasourceService, |  | ||||||
| 		exportJob:                 &stoppedJob{}, |  | ||||||
| 		dataDir:                   cfg.DataPath, |  | ||||||
| 		store:                     store, |  | ||||||
| 		db:                        db, |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (ex *StandardExport) HandleGetOptions(c *contextmodel.ReqContext) response.Response { |  | ||||||
| 	info := map[string]interface{}{ |  | ||||||
| 		"exporters": exporters, |  | ||||||
| 	} |  | ||||||
| 	return response.JSON(http.StatusOK, info) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (ex *StandardExport) HandleGetStatus(c *contextmodel.ReqContext) response.Response { |  | ||||||
| 	ex.mutex.Lock() |  | ||||||
| 	defer ex.mutex.Unlock() |  | ||||||
| 
 |  | ||||||
| 	return response.JSON(http.StatusOK, ex.exportJob.getStatus()) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (ex *StandardExport) HandleRequestStop(c *contextmodel.ReqContext) response.Response { |  | ||||||
| 	ex.mutex.Lock() |  | ||||||
| 	defer ex.mutex.Unlock() |  | ||||||
| 
 |  | ||||||
| 	ex.exportJob.requestStop() |  | ||||||
| 
 |  | ||||||
| 	return response.JSON(http.StatusOK, ex.exportJob.getStatus()) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (ex *StandardExport) HandleRequestExport(c *contextmodel.ReqContext) response.Response { |  | ||||||
| 	var cfg ExportConfig |  | ||||||
| 	err := json.NewDecoder(c.Req.Body).Decode(&cfg) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return response.Error(http.StatusBadRequest, "unable to read config", err) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	ex.mutex.Lock() |  | ||||||
| 	defer ex.mutex.Unlock() |  | ||||||
| 
 |  | ||||||
| 	status := ex.exportJob.getStatus() |  | ||||||
| 	if status.Running { |  | ||||||
| 		ex.logger.Error("export already running") |  | ||||||
| 		return response.Error(http.StatusLocked, "export already running", nil) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	ctx := appcontext.WithUser(context.Background(), c.SignedInUser) |  | ||||||
| 	var job Job |  | ||||||
| 	broadcast := func(s ExportStatus) { |  | ||||||
| 		ex.broadcastStatus(c.OrgID, s) |  | ||||||
| 	} |  | ||||||
| 	switch cfg.Format { |  | ||||||
| 	case "dummy": |  | ||||||
| 		job, err = startDummyExportJob(cfg, broadcast) |  | ||||||
| 	case "entityStore": |  | ||||||
| 		job, err = startEntityStoreJob(ctx, cfg, broadcast, ex.db, ex.playlistService, ex.store, ex.dashboardsnapshotsService) |  | ||||||
| 	case "git": |  | ||||||
| 		dir := filepath.Join(ex.dataDir, "export_git", fmt.Sprintf("git_%d", time.Now().Unix())) |  | ||||||
| 		if err := os.MkdirAll(dir, os.ModePerm); err != nil { |  | ||||||
| 			return response.Error(http.StatusBadRequest, "Error creating export folder", nil) |  | ||||||
| 		} |  | ||||||
| 		job, err = startGitExportJob(ctx, cfg, ex.db, ex.dashboardsnapshotsService, dir, c.OrgID, broadcast, ex.playlistService, ex.orgService, ex.datasourceService) |  | ||||||
| 	default: |  | ||||||
| 		return response.Error(http.StatusBadRequest, "Unsupported job format", nil) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	if err != nil { |  | ||||||
| 		ex.logger.Error("failed to start export job", "err", err) |  | ||||||
| 		return response.Error(http.StatusBadRequest, "failed to start export job", err) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	ex.exportJob = job |  | ||||||
| 
 |  | ||||||
| 	info := map[string]interface{}{ |  | ||||||
| 		"cfg":    cfg, // parsed job we are running
 |  | ||||||
| 		"status": ex.exportJob.getStatus(), |  | ||||||
| 	} |  | ||||||
| 	return response.JSON(http.StatusOK, info) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (ex *StandardExport) broadcastStatus(orgID int64, s ExportStatus) { |  | ||||||
| 	msg, err := json.Marshal(s) |  | ||||||
| 	if err != nil { |  | ||||||
| 		ex.logger.Warn("Error making message", "err", err) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
| 	err = ex.glive.Publish(orgID, "grafana/broadcast/export", msg) |  | ||||||
| 	if err != nil { |  | ||||||
| 		ex.logger.Warn("Error Publish message", "err", err) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
|  | @ -1,21 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import "time" |  | ||||||
| 
 |  | ||||||
| var _ Job = new(stoppedJob) |  | ||||||
| 
 |  | ||||||
| type stoppedJob struct { |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (e *stoppedJob) getStatus() ExportStatus { |  | ||||||
| 	return ExportStatus{ |  | ||||||
| 		Running: false, |  | ||||||
| 		Changed: time.Now().UnixMilli(), |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (e *stoppedJob) getConfig() ExportConfig { |  | ||||||
| 	return ExportConfig{} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (e *stoppedJob) requestStop() {} |  | ||||||
|  | @ -1,28 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"net/http" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/api/response" |  | ||||||
| 	contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| var _ ExportService = new(StubExport) |  | ||||||
| 
 |  | ||||||
| type StubExport struct{} |  | ||||||
| 
 |  | ||||||
| func (ex *StubExport) HandleGetStatus(c *contextmodel.ReqContext) response.Response { |  | ||||||
| 	return response.Error(http.StatusForbidden, "feature not enabled", nil) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (ex *StubExport) HandleGetOptions(c *contextmodel.ReqContext) response.Response { |  | ||||||
| 	return response.Error(http.StatusForbidden, "feature not enabled", nil) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (ex *StubExport) HandleRequestExport(c *contextmodel.ReqContext) response.Response { |  | ||||||
| 	return response.Error(http.StatusForbidden, "feature not enabled", nil) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (ex *StubExport) HandleRequestStop(c *contextmodel.ReqContext) response.Response { |  | ||||||
| 	return response.Error(http.StatusForbidden, "feature not enabled", nil) |  | ||||||
| } |  | ||||||
|  | @ -1,46 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| // Export status.  Only one running at a time
 |  | ||||||
| type ExportStatus struct { |  | ||||||
| 	Running  bool           `json:"running"` |  | ||||||
| 	Target   string         `json:"target"` // description of where it is going (no secrets)
 |  | ||||||
| 	Started  int64          `json:"started,omitempty"` |  | ||||||
| 	Finished int64          `json:"finished,omitempty"` |  | ||||||
| 	Changed  int64          `json:"update,omitempty"` |  | ||||||
| 	Last     string         `json:"last,omitempty"` |  | ||||||
| 	Status   string         `json:"status"` // ERROR, SUCCESS, ETC
 |  | ||||||
| 	Index    int            `json:"index,omitempty"` |  | ||||||
| 	Count    map[string]int `json:"count,omitempty"` |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // Basic export config (for now)
 |  | ||||||
| type ExportConfig struct { |  | ||||||
| 	Format            string `json:"format"` |  | ||||||
| 	GeneralFolderPath string `json:"generalFolderPath"` |  | ||||||
| 	KeepHistory       bool   `json:"history"` |  | ||||||
| 
 |  | ||||||
| 	Exclude map[string]bool `json:"exclude"` |  | ||||||
| 
 |  | ||||||
| 	// Depends on the format
 |  | ||||||
| 	Git GitExportConfig `json:"git"` |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| type GitExportConfig struct{} |  | ||||||
| 
 |  | ||||||
| type Job interface { |  | ||||||
| 	getStatus() ExportStatus |  | ||||||
| 	getConfig() ExportConfig |  | ||||||
| 	requestStop() |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // Will broadcast the live status
 |  | ||||||
| type statusBroadcaster func(s ExportStatus) |  | ||||||
| 
 |  | ||||||
| type Exporter struct { |  | ||||||
| 	Key         string     `json:"key"` |  | ||||||
| 	Name        string     `json:"name"` |  | ||||||
| 	Description string     `json:"description"` |  | ||||||
| 	Exporters   []Exporter `json:"exporters,omitempty"` |  | ||||||
| 
 |  | ||||||
| 	process func(helper *commitHelper, job *gitExportJob) error |  | ||||||
| } |  | ||||||
|  | @ -1,29 +0,0 @@ | ||||||
| package export |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"strings" |  | ||||||
| 
 |  | ||||||
| 	"github.com/grafana/grafana/pkg/infra/db" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| func isTableNotExistsError(err error) bool { |  | ||||||
| 	txt := err.Error() |  | ||||||
| 	return strings.HasPrefix(txt, "no such table") || // SQLite
 |  | ||||||
| 		strings.HasSuffix(txt, " does not exist") || // PostgreSQL
 |  | ||||||
| 		strings.HasSuffix(txt, " doesn't exist") // MySQL
 |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func removeQuotesFromQuery(query string, remove bool) string { |  | ||||||
| 	if remove { |  | ||||||
| 		return strings.ReplaceAll(query, `"`, "") |  | ||||||
| 	} |  | ||||||
| 	return query |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func isMySQLEngine(sql db.DB) bool { |  | ||||||
| 	return sql.GetDBType() == "mysql" |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func isPostgreSQL(sql db.DB) bool { |  | ||||||
| 	return sql.GetDBType() == "postgres" |  | ||||||
| } |  | ||||||
|  | @ -120,12 +120,6 @@ var ( | ||||||
| 			State:           FeatureStateAlpha, | 			State:           FeatureStateAlpha, | ||||||
| 			RequiresDevMode: true, // Also a gate on automatic git storage (for now)
 | 			RequiresDevMode: true, // Also a gate on automatic git storage (for now)
 | ||||||
| 		}, | 		}, | ||||||
| 		{ |  | ||||||
| 			Name:            "export", |  | ||||||
| 			Description:     "Export grafana instance (to git, etc)", |  | ||||||
| 			State:           FeatureStateAlpha, |  | ||||||
| 			RequiresDevMode: true, |  | ||||||
| 		}, |  | ||||||
| 		{ | 		{ | ||||||
| 			Name:         "exploreMixedDatasource", | 			Name:         "exploreMixedDatasource", | ||||||
| 			Description:  "Enable mixed datasource in Explore", | 			Description:  "Enable mixed datasource in Explore", | ||||||
|  |  | ||||||
|  | @ -91,10 +91,6 @@ const ( | ||||||
| 	// Load dashboards from the generic storage interface
 | 	// Load dashboards from the generic storage interface
 | ||||||
| 	FlagDashboardsFromStorage = "dashboardsFromStorage" | 	FlagDashboardsFromStorage = "dashboardsFromStorage" | ||||||
| 
 | 
 | ||||||
| 	// FlagExport
 |  | ||||||
| 	// Export grafana instance (to git, etc)
 |  | ||||||
| 	FlagExport = "export" |  | ||||||
| 
 |  | ||||||
| 	// FlagExploreMixedDatasource
 | 	// FlagExploreMixedDatasource
 | ||||||
| 	// Enable mixed datasource in Explore
 | 	// Enable mixed datasource in Explore
 | ||||||
| 	FlagExploreMixedDatasource = "exploreMixedDatasource" | 	FlagExploreMixedDatasource = "exploreMixedDatasource" | ||||||
|  |  | ||||||
|  | @ -161,16 +161,6 @@ func (s *ServiceImpl) getServerAdminNode(c *contextmodel.ReqContext) *navtree.Na | ||||||
| 			Url:      s.cfg.AppSubURL + "/admin/storage", | 			Url:      s.cfg.AppSubURL + "/admin/storage", | ||||||
| 		} | 		} | ||||||
| 		adminNavLinks = append(adminNavLinks, storage) | 		adminNavLinks = append(adminNavLinks, storage) | ||||||
| 
 |  | ||||||
| 		if s.features.IsEnabled(featuremgmt.FlagExport) { |  | ||||||
| 			storage.Children = append(storage.Children, &navtree.NavLink{ |  | ||||||
| 				Text:     "Export", |  | ||||||
| 				Id:       "export", |  | ||||||
| 				SubTitle: "Export grafana settings", |  | ||||||
| 				Icon:     "cube", |  | ||||||
| 				Url:      s.cfg.AppSubURL + "/admin/storage/export", |  | ||||||
| 			}) |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if s.cfg.LDAPEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)) { | 	if s.cfg.LDAPEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)) { | ||||||
|  |  | ||||||
|  | @ -1,278 +0,0 @@ | ||||||
| import React, { useEffect, useState, useCallback } from 'react'; |  | ||||||
| import { useAsync, useLocalStorage } from 'react-use'; |  | ||||||
| 
 |  | ||||||
| import { isLiveChannelMessageEvent, isLiveChannelStatusEvent, LiveChannelScope, SelectableValue } from '@grafana/data'; |  | ||||||
| import { getBackendSrv, getGrafanaLiveSrv, config } from '@grafana/runtime'; |  | ||||||
| import { |  | ||||||
|   Button, |  | ||||||
|   CodeEditor, |  | ||||||
|   Collapse, |  | ||||||
|   Field, |  | ||||||
|   HorizontalGroup, |  | ||||||
|   InlineField, |  | ||||||
|   InlineFieldRow, |  | ||||||
|   InlineSwitch, |  | ||||||
|   Input, |  | ||||||
|   LinkButton, |  | ||||||
|   Select, |  | ||||||
|   Switch, |  | ||||||
|   Alert, |  | ||||||
| } from '@grafana/ui'; |  | ||||||
| import { Page } from 'app/core/components/Page/Page'; |  | ||||||
| import { useNavModel } from 'app/core/hooks/useNavModel'; |  | ||||||
| import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; |  | ||||||
| 
 |  | ||||||
| export const EXPORT_LOCAL_STORAGE_KEY = 'grafana.export.config'; |  | ||||||
| 
 |  | ||||||
| interface ExportStatusMessage { |  | ||||||
|   running: boolean; |  | ||||||
|   target: string; |  | ||||||
|   started: number; |  | ||||||
|   finished: number; |  | ||||||
|   update: number; |  | ||||||
|   count: number; |  | ||||||
|   current: number; |  | ||||||
|   last: string; |  | ||||||
|   status: string; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| interface ExportJob { |  | ||||||
|   format: string; // 'git';
 |  | ||||||
|   generalFolderPath: string; |  | ||||||
|   history: boolean; |  | ||||||
|   exclude: Record<string, boolean>; |  | ||||||
| 
 |  | ||||||
|   git?: {}; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| const defaultJob: ExportJob = { |  | ||||||
|   format: 'git', |  | ||||||
|   generalFolderPath: 'general', |  | ||||||
|   history: true, |  | ||||||
|   exclude: {}, |  | ||||||
|   git: {}, |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| interface ExporterInfo { |  | ||||||
|   key: string; |  | ||||||
|   name: string; |  | ||||||
|   description: string; |  | ||||||
|   children?: ExporterInfo[]; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| enum StorageFormat { |  | ||||||
|   Git = 'git', |  | ||||||
|   EntityStore = 'entityStore', |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| const formats: Array<SelectableValue<string>> = [ |  | ||||||
|   { label: 'GIT', value: StorageFormat.Git, description: 'Exports a fresh git repository' }, |  | ||||||
|   { label: 'Entity store', value: StorageFormat.EntityStore, description: 'Export to the SQL based entity store' }, |  | ||||||
| ]; |  | ||||||
| 
 |  | ||||||
| interface Props extends GrafanaRouteComponentProps {} |  | ||||||
| 
 |  | ||||||
| const labelWith = 18; |  | ||||||
| 
 |  | ||||||
| export default function ExportPage(props: Props) { |  | ||||||
|   const navModel = useNavModel('export'); |  | ||||||
|   const [status, setStatus] = useState<ExportStatusMessage>(); |  | ||||||
|   const [body, setBody] = useLocalStorage<ExportJob>(EXPORT_LOCAL_STORAGE_KEY, defaultJob); |  | ||||||
|   const [details, setDetails] = useState(false); |  | ||||||
| 
 |  | ||||||
|   const serverOptions = useAsync(() => { |  | ||||||
|     return getBackendSrv().get<{ exporters: ExporterInfo[] }>('/api/admin/export/options'); |  | ||||||
|   }, []); |  | ||||||
| 
 |  | ||||||
|   const doStart = () => { |  | ||||||
|     getBackendSrv() |  | ||||||
|       .post('/api/admin/export', body) |  | ||||||
|       .then((v) => { |  | ||||||
|         if (v.cfg && v.status.running) { |  | ||||||
|           setBody(v.cfg); // saves the valid parsed body
 |  | ||||||
|         } |  | ||||||
|       }); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const doStop = () => { |  | ||||||
|     getBackendSrv().post('/api/admin/export/stop'); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const setInclude = useCallback( |  | ||||||
|     (k: string, v: boolean) => { |  | ||||||
|       if (!serverOptions.value || !body) { |  | ||||||
|         return; |  | ||||||
|       } |  | ||||||
|       const exclude: Record<string, boolean> = {}; |  | ||||||
|       if (k === '*') { |  | ||||||
|         if (!v) { |  | ||||||
|           for (let exp of serverOptions.value.exporters) { |  | ||||||
|             exclude[exp.key] = true; |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|         setBody({ ...body, exclude }); |  | ||||||
|         return; |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       for (let exp of serverOptions.value.exporters) { |  | ||||||
|         let val = body.exclude?.[exp.key]; |  | ||||||
|         if (k === exp.key) { |  | ||||||
|           val = !v; |  | ||||||
|         } |  | ||||||
|         if (val) { |  | ||||||
|           exclude[exp.key] = val; |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|       setBody({ ...body, exclude }); |  | ||||||
|     }, |  | ||||||
|     [body, setBody, serverOptions] |  | ||||||
|   ); |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     const subscription = getGrafanaLiveSrv() |  | ||||||
|       .getStream<ExportStatusMessage>({ |  | ||||||
|         scope: LiveChannelScope.Grafana, |  | ||||||
|         namespace: 'broadcast', |  | ||||||
|         path: 'export', |  | ||||||
|       }) |  | ||||||
|       .subscribe({ |  | ||||||
|         next: (evt) => { |  | ||||||
|           if (isLiveChannelMessageEvent(evt)) { |  | ||||||
|             setStatus(evt.message); |  | ||||||
|           } else if (isLiveChannelStatusEvent(evt)) { |  | ||||||
|             setStatus(evt.message); |  | ||||||
|           } |  | ||||||
|         }, |  | ||||||
|       }); |  | ||||||
| 
 |  | ||||||
|     return () => { |  | ||||||
|       subscription.unsubscribe(); |  | ||||||
|     }; |  | ||||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 |  | ||||||
|   }, []); |  | ||||||
| 
 |  | ||||||
|   const renderView = () => { |  | ||||||
|     const isEntityStoreEnabled = body?.format === StorageFormat.EntityStore && config.featureToggles.entityStore; |  | ||||||
|     const shouldDisplayContent = isEntityStoreEnabled || body?.format === StorageFormat.Git; |  | ||||||
| 
 |  | ||||||
|     const statusFragment = status && ( |  | ||||||
|       <div> |  | ||||||
|         <h3>Status</h3> |  | ||||||
|         <pre>{JSON.stringify(status, null, 2)}</pre> |  | ||||||
|         {status.running && ( |  | ||||||
|           <div> |  | ||||||
|             <Button variant="secondary" onClick={doStop}> |  | ||||||
|               Stop |  | ||||||
|             </Button> |  | ||||||
|           </div> |  | ||||||
|         )} |  | ||||||
|       </div> |  | ||||||
|     ); |  | ||||||
| 
 |  | ||||||
|     const formFragment = !Boolean(status?.running) && ( |  | ||||||
|       <div> |  | ||||||
|         <Field label="Format"> |  | ||||||
|           <Select |  | ||||||
|             options={formats} |  | ||||||
|             width={40} |  | ||||||
|             value={formats.find((v) => v.value === body?.format)} |  | ||||||
|             onChange={(v) => setBody({ ...body!, format: v.value! })} |  | ||||||
|           /> |  | ||||||
|         </Field> |  | ||||||
|         {!isEntityStoreEnabled && body?.format !== StorageFormat.Git && ( |  | ||||||
|           <div> |  | ||||||
|             <Alert title="Missing feature flag">Enable the `entityStore` feature flag</Alert> |  | ||||||
|           </div> |  | ||||||
|         )} |  | ||||||
|         {body?.format === StorageFormat.Git && ( |  | ||||||
|           <> |  | ||||||
|             <Field label="Keep history"> |  | ||||||
|               <Switch value={body?.history} onChange={(v) => setBody({ ...body!, history: v.currentTarget.checked })} /> |  | ||||||
|             </Field> |  | ||||||
| 
 |  | ||||||
|             <Field label="Include"> |  | ||||||
|               <> |  | ||||||
|                 <InlineFieldRow> |  | ||||||
|                   <InlineField label="Toggle all" labelWidth={labelWith}> |  | ||||||
|                     <InlineSwitch |  | ||||||
|                       value={Object.keys(body?.exclude ?? {}).length === 0} |  | ||||||
|                       onChange={(v) => setInclude('*', v.currentTarget.checked)} |  | ||||||
|                     /> |  | ||||||
|                   </InlineField> |  | ||||||
|                 </InlineFieldRow> |  | ||||||
|                 {serverOptions.value && ( |  | ||||||
|                   <div> |  | ||||||
|                     {serverOptions.value.exporters.map((ex) => ( |  | ||||||
|                       <InlineFieldRow key={ex.key}> |  | ||||||
|                         <InlineField label={ex.name} labelWidth={labelWith} tooltip={ex.description}> |  | ||||||
|                           <InlineSwitch |  | ||||||
|                             value={body?.exclude?.[ex.key] !== true} |  | ||||||
|                             onChange={(v) => setInclude(ex.key, v.currentTarget.checked)} |  | ||||||
|                           /> |  | ||||||
|                         </InlineField> |  | ||||||
|                       </InlineFieldRow> |  | ||||||
|                     ))} |  | ||||||
|                   </div> |  | ||||||
|                 )} |  | ||||||
|               </> |  | ||||||
|             </Field> |  | ||||||
|           </> |  | ||||||
|         )} |  | ||||||
| 
 |  | ||||||
|         {shouldDisplayContent && ( |  | ||||||
|           <> |  | ||||||
|             <Field label="General folder" description="Set the folder name for items without a real folder"> |  | ||||||
|               <Input |  | ||||||
|                 width={40} |  | ||||||
|                 value={body?.generalFolderPath ?? ''} |  | ||||||
|                 onChange={(v) => setBody({ ...body!, generalFolderPath: v.currentTarget.value })} |  | ||||||
|                 placeholder="root folder path" |  | ||||||
|               /> |  | ||||||
|             </Field> |  | ||||||
| 
 |  | ||||||
|             <HorizontalGroup> |  | ||||||
|               <Button onClick={doStart} variant="primary"> |  | ||||||
|                 Export |  | ||||||
|               </Button> |  | ||||||
|               <LinkButton href="admin/storage/" variant="secondary"> |  | ||||||
|                 Cancel |  | ||||||
|               </LinkButton> |  | ||||||
|             </HorizontalGroup> |  | ||||||
|           </> |  | ||||||
|         )} |  | ||||||
|       </div> |  | ||||||
|     ); |  | ||||||
| 
 |  | ||||||
|     const requestDetailsFragment = (isEntityStoreEnabled || body?.format === StorageFormat.Git) && ( |  | ||||||
|       <Collapse label="Request details" isOpen={details} onToggle={setDetails} collapsible={true}> |  | ||||||
|         <CodeEditor |  | ||||||
|           height={275} |  | ||||||
|           value={JSON.stringify(body, null, 2) ?? ''} |  | ||||||
|           showLineNumbers={false} |  | ||||||
|           readOnly={false} |  | ||||||
|           language="json" |  | ||||||
|           showMiniMap={false} |  | ||||||
|           onBlur={(text: string) => { |  | ||||||
|             setBody(JSON.parse(text)); // force JSON?
 |  | ||||||
|           }} |  | ||||||
|         /> |  | ||||||
|       </Collapse> |  | ||||||
|     ); |  | ||||||
| 
 |  | ||||||
|     return ( |  | ||||||
|       <div> |  | ||||||
|         {statusFragment} |  | ||||||
|         {formFragment} |  | ||||||
|         <br /> |  | ||||||
|         <br /> |  | ||||||
|         {requestDetailsFragment} |  | ||||||
|       </div> |  | ||||||
|     ); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <Page navModel={navModel}> |  | ||||||
|       <Page.Contents>{renderView()}</Page.Contents> |  | ||||||
|     </Page> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
|  | @ -366,13 +366,6 @@ export function getAppRoutes(): RouteDescriptor[] { | ||||||
|         () => import(/* webpackChunkName: "AdminEditOrgPage" */ 'app/features/admin/AdminEditOrgPage') |         () => import(/* webpackChunkName: "AdminEditOrgPage" */ 'app/features/admin/AdminEditOrgPage') | ||||||
|       ), |       ), | ||||||
|     }, |     }, | ||||||
|     { |  | ||||||
|       path: '/admin/storage/export', |  | ||||||
|       roles: () => ['Admin'], |  | ||||||
|       component: SafeDynamicImport( |  | ||||||
|         () => import(/* webpackChunkName: "ExportPage" */ 'app/features/storage/ExportPage') |  | ||||||
|       ), |  | ||||||
|     }, |  | ||||||
|     { |     { | ||||||
|       path: '/admin/storage/:path*', |       path: '/admin/storage/:path*', | ||||||
|       roles: () => ['Admin'], |       roles: () => ['Admin'], | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue