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/store/ @grafana/multitenancy-squad | ||||
| /pkg/services/querylibrary/ @grafana/multitenancy-squad | ||||
| /pkg/services/export/ @grafana/multitenancy-squad | ||||
| /pkg/infra/filestorage/ @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 | | ||||
| | `k8s`                          | Explore native k8s integrations                                         | | ||||
| | `dashboardsFromStorage`        | Load dashboards from the generic storage interface                      | | ||||
| | `export`                       | Export grafana instance (to git, etc)                                   | | ||||
| | `grpcServer`                   | Run GRPC server                                                         | | ||||
| | `entityStore`                  | SQL-based entity store (requires storage flag also)                     | | ||||
| | `queryLibrary`                 | Reusable query library                                                  | | ||||
|  |  | |||
|  | @ -37,7 +37,6 @@ export interface FeatureToggles { | |||
|   storage?: boolean; | ||||
|   k8s?: boolean; | ||||
|   dashboardsFromStorage?: boolean; | ||||
|   export?: boolean; | ||||
|   exploreMixedDatasource?: boolean; | ||||
|   tracing?: 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.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/reencrypt-data-keys", reqGrafanaAdmin, routing.Wrap(hs.AdminReEncryptEncryptionKeys)) | ||||
| 		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/permissions" | ||||
| 	"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/folder" | ||||
| 	"github.com/grafana/grafana/pkg/services/hooks" | ||||
|  | @ -146,7 +145,6 @@ type HTTPServer struct { | |||
| 	Live                         *live.GrafanaLive | ||||
| 	LivePushGateway              *pushhttp.Gateway | ||||
| 	ThumbService                 thumbs.Service | ||||
| 	ExportService                export.ExportService | ||||
| 	StorageService               store.StorageService | ||||
| 	httpEntityStore              httpentitystore.HTTPEntityStore | ||||
| 	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, | ||||
| 	contextHandler *contexthandler.ContextHandler, features *featuremgmt.FeatureManager, | ||||
| 	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, | ||||
| 	pluginsUpdateChecker *updatechecker.PluginsService, searchUsersService searchusers.Service, | ||||
| 	dataSourcesService datasources.DataSourceService, queryDataService *query.Service, | ||||
|  | @ -298,7 +296,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi | |||
| 		DataProxy:                    dataSourceProxy, | ||||
| 		SearchV2HTTPService:          searchv2HTTPService, | ||||
| 		SearchService:                searchService, | ||||
| 		ExportService:                exportService, | ||||
| 		Live:                         live, | ||||
| 		LivePushGateway:              livePushGateway, | ||||
| 		PluginContextProvider:        plugCtxProvider, | ||||
|  |  | |||
|  | @ -5,6 +5,7 @@ package runner | |||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 
 | ||||
| 	"github.com/grafana/grafana/pkg/services/folder" | ||||
| 	"github.com/grafana/grafana/pkg/services/folder/folderimpl" | ||||
| 
 | ||||
|  | @ -57,7 +58,6 @@ import ( | |||
| 	datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" | ||||
| 	"github.com/grafana/grafana/pkg/services/encryption" | ||||
| 	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/guardian" | ||||
| 	"github.com/grafana/grafana/pkg/services/hooks" | ||||
|  | @ -209,7 +209,6 @@ var wireSet = wire.NewSet( | |||
| 	search.ProvideService, | ||||
| 	searchV2.ProvideService, | ||||
| 	store.ProvideService, | ||||
| 	export.ProvideService, | ||||
| 	live.ProvideService, | ||||
| 	pushhttp.ProvideService, | ||||
| 	contexthandler.ProvideService, | ||||
|  |  | |||
|  | @ -6,6 +6,7 @@ package server | |||
| import ( | ||||
| 	"github.com/google/wire" | ||||
| 	sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" | ||||
| 
 | ||||
| 	"github.com/grafana/grafana/pkg/services/folder" | ||||
| 
 | ||||
| 	"github.com/grafana/grafana/pkg/api" | ||||
|  | @ -59,7 +60,6 @@ import ( | |||
| 	datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" | ||||
| 	"github.com/grafana/grafana/pkg/services/encryption" | ||||
| 	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/folder/folderimpl" | ||||
| 	"github.com/grafana/grafana/pkg/services/grpcserver" | ||||
|  | @ -231,7 +231,6 @@ var wireBasicSet = wire.NewSet( | |||
| 	searchV2.ProvideSearchHTTPService, | ||||
| 	store.ProvideService, | ||||
| 	store.ProvideSystemUsersService, | ||||
| 	export.ProvideService, | ||||
| 	live.ProvideService, | ||||
| 	pushhttp.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, | ||||
| 			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", | ||||
| 			Description:  "Enable mixed datasource in Explore", | ||||
|  |  | |||
|  | @ -91,10 +91,6 @@ const ( | |||
| 	// Load dashboards from the generic storage interface
 | ||||
| 	FlagDashboardsFromStorage = "dashboardsFromStorage" | ||||
| 
 | ||||
| 	// FlagExport
 | ||||
| 	// Export grafana instance (to git, etc)
 | ||||
| 	FlagExport = "export" | ||||
| 
 | ||||
| 	// FlagExploreMixedDatasource
 | ||||
| 	// Enable mixed datasource in Explore
 | ||||
| 	FlagExploreMixedDatasource = "exploreMixedDatasource" | ||||
|  |  | |||
|  | @ -161,16 +161,6 @@ func (s *ServiceImpl) getServerAdminNode(c *contextmodel.ReqContext) *navtree.Na | |||
| 			Url:      s.cfg.AppSubURL + "/admin/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)) { | ||||
|  |  | |||
|  | @ -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') | ||||
|       ), | ||||
|     }, | ||||
|     { | ||||
|       path: '/admin/storage/export', | ||||
|       roles: () => ['Admin'], | ||||
|       component: SafeDynamicImport( | ||||
|         () => import(/* webpackChunkName: "ExportPage" */ 'app/features/storage/ExportPage') | ||||
|       ), | ||||
|     }, | ||||
|     { | ||||
|       path: '/admin/storage/:path*', | ||||
|       roles: () => ['Admin'], | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue