From 5cb80104402f8c9553f1e1f992457e73b321c680 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 7 Jul 2022 11:02:01 -0700 Subject: [PATCH] Export: support stopping exports (#51769) --- pkg/api/api.go | 1 + pkg/services/export/commit_helper.go | 54 +++--- pkg/services/export/dummy_job.go | 15 +- pkg/services/export/export_dashboards.go | 43 ++--- pkg/services/export/export_ds.go | 9 +- pkg/services/export/export_files.go | 48 ++++++ pkg/services/export/export_kv_store.go | 46 ++++++ pkg/services/export/export_live.go | 47 ++++++ pkg/services/export/export_snapshots.go | 2 +- pkg/services/export/export_sys_preferences.go | 8 + pkg/services/export/git_export_job.go | 51 ++++-- pkg/services/export/service.go | 12 ++ pkg/services/export/stopped_job.go | 2 + pkg/services/export/stub.go | 4 + pkg/services/export/types.go | 27 ++- public/app/features/storage/ExportView.tsx | 155 ++++++++++-------- 16 files changed, 385 insertions(+), 139 deletions(-) create mode 100644 pkg/services/export/export_files.go create mode 100644 pkg/services/export/export_kv_store.go create mode 100644 pkg/services/export/export_live.go diff --git a/pkg/api/api.go b/pkg/api/api.go index df4aaf68295..717dacdbd9a 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -559,6 +559,7 @@ func (hs *HTTPServer) registerRoutes() { 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.Post("/encryption/rotate-data-keys", reqGrafanaAdmin, routing.Wrap(hs.AdminRotateDataEncryptionKeys)) diff --git a/pkg/services/export/commit_helper.go b/pkg/services/export/commit_helper.go index b19749c6a03..ae5d05edfe2 100644 --- a/pkg/services/export/commit_helper.go +++ b/pkg/services/export/commit_helper.go @@ -17,13 +17,15 @@ import ( ) 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 + 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) } type commitBody struct { @@ -64,6 +66,26 @@ func (ch *commitHelper) initOrg(sql *sqlstore.SQLStore, orgID int64) error { } 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") @@ -87,6 +109,10 @@ func (ch *commitHelper) add(opts commitOptions) error { 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) @@ -100,22 +126,11 @@ func (ch *commitHelper) add(opts commitOptions) error { } } - user, ok := ch.users[opts.userID] - if !ok { - user = &userInfo{ - Name: "admin", - Email: "admin@unknown.org", - } - } - sig := user.getAuthor() - if opts.when.Unix() > 10 { - sig.When = opts.when - } - copts := &git.CommitOptions{ Author: &sig, } + ch.broadcast(opts.body[0].fpath) _, err := ch.work.Commit(opts.comment, copts) return err } @@ -140,6 +155,7 @@ 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(), } } diff --git a/pkg/services/export/dummy_job.go b/pkg/services/export/dummy_job.go index f892b1cea6b..58a38889398 100644 --- a/pkg/services/export/dummy_job.go +++ b/pkg/services/export/dummy_job.go @@ -15,10 +15,11 @@ var _ Job = new(dummyExportJob) type dummyExportJob struct { logger log.Logger - statusMu sync.Mutex - status ExportStatus - cfg ExportConfig - broadcaster statusBroadcaster + statusMu sync.Mutex + status ExportStatus + cfg ExportConfig + broadcaster statusBroadcaster + stopRequested bool } func startDummyExportJob(cfg ExportConfig, broadcaster statusBroadcaster) (Job, error) { @@ -40,6 +41,10 @@ func startDummyExportJob(cfg ExportConfig, broadcaster statusBroadcaster) (Job, return job, nil } +func (e *dummyExportJob) requestStop() { + e.stopRequested = true +} + func (e *dummyExportJob) start() { defer func() { e.logger.Info("Finished dummy export job") @@ -74,7 +79,7 @@ func (e *dummyExportJob) start() { e.statusMu.Unlock() // Wait till we are done - shouldStop := e.status.Current >= e.status.Count + shouldStop := e.stopRequested || e.status.Current >= e.status.Count e.broadcaster(e.status) if shouldStop { diff --git a/pkg/services/export/export_dashboards.go b/pkg/services/export/export_dashboards.go index 212c31c1a38..70a8beb5536 100644 --- a/pkg/services/export/export_dashboards.go +++ b/pkg/services/export/export_dashboards.go @@ -21,8 +21,8 @@ func exportDashboards(helper *commitHelper, job *gitExportJob, lookup dsLookup) folders := make(map[int64]string, 100) // Should root files be at the root or in a subfolder called "general"? - if true { - folders[0] = "general" + if len(job.cfg.GeneralFolderPath) > 0 { + folders[0] = job.cfg.GeneralFolderPath // "general" } rootDir := path.Join(helper.orgDir, "root") @@ -132,7 +132,7 @@ func exportDashboards(helper *commitHelper, job *gitExportJob, lookup dsLookup) // Now walk the history err = job.sql.WithDbSession(helper.ctx, func(sess *sqlstore.DBSession) error { type dashVersionResult struct { - DashId int64 `xorm:"dashboard_id"` + DashId int64 `xorm:"id"` Version int64 `xorm:"version"` Created time.Time `xorm:"created"` CreatedBy int64 `xorm:"created_by"` @@ -142,16 +142,27 @@ func exportDashboards(helper *commitHelper, job *gitExportJob, lookup dsLookup) rows := make([]*dashVersionResult, 0, len(ids)) - sess.Table("dashboard_version"). - Join("INNER", "dashboard", "dashboard.id = dashboard_version.dashboard_id"). - Where("org_id = ?", job.orgID). - Cols("dashboard_version.dashboard_id", - "dashboard_version.version", - "dashboard_version.created", - "dashboard_version.created_by", - "dashboard_version.message", - "dashboard_version.data"). - Asc("dashboard_version.created") + if job.cfg.KeepHistory { + sess.Table("dashboard_version"). + Join("INNER", "dashboard", "dashboard.id = dashboard_version.dashboard_id"). + Where("org_id = ?", job.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 = ?", job.orgID). + Cols("id", + "version", + "created", + "created_by", + "data"). + Asc("created") + } err := sess.Find(&rows) if err != nil { @@ -186,14 +197,8 @@ func exportDashboards(helper *commitHelper, job *gitExportJob, lookup dsLookup) if err != nil { return err } - count++ fmt.Printf("COMMIT: %d // %s (%d)\n", count, fpath, row.Version) - - job.status.Current = count - job.status.Last = fpath - job.status.Changed = time.Now().UnixMilli() - job.broadcaster(job.status) } return nil diff --git a/pkg/services/export/export_ds.go b/pkg/services/export/export_ds.go index 4dbe53c5553..68cc59fadb7 100644 --- a/pkg/services/export/export_ds.go +++ b/pkg/services/export/export_ds.go @@ -11,7 +11,7 @@ import ( type dsLookup func(ref *extract.DataSourceRef) *extract.DataSourceRef -func exportDataSources(helper *commitHelper, job *gitExportJob) (dsLookup, error) { +func exportDataSources(helper *commitHelper, job *gitExportJob, save bool) (dsLookup, error) { cmd := &datasources.GetDataSourcesQuery{ OrgId: job.orgID, } @@ -33,8 +33,15 @@ func exportDataSources(helper *commitHelper, job *gitExportJob) (dsLookup, error } byUID[ds.Uid] = ref byName[ds.Name] = ref + if !save { + continue + } + ds.OrgId = 0 ds.Version = 0 + ds.SecureJsonData = map[string][]byte{ + "TODO": []byte("secret store lookup"), + } err := helper.add(commitOptions{ body: []commitBody{ diff --git a/pkg/services/export/export_files.go b/pkg/services/export/export_files.go new file mode 100644 index 00000000000..0530224626d --- /dev/null +++ b/pkg/services/export/export_files.go @@ -0,0 +1,48 @@ +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 +} diff --git a/pkg/services/export/export_kv_store.go b/pkg/services/export/export_kv_store.go new file mode 100644 index 00000000000..74fad5079db --- /dev/null +++ b/pkg/services/export/export_kv_store.go @@ -0,0 +1,46 @@ +package export + +import ( + "fmt" + "path" + "time" + + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +func exportKVStore(helper *commitHelper, job *gitExportJob) error { + kvdir := path.Join(helper.orgDir, "system", "kv_store") + + return job.sql.WithDbSession(helper.ctx, func(sess *sqlstore.DBSession) 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 + }) +} diff --git a/pkg/services/export/export_live.go b/pkg/services/export/export_live.go new file mode 100644 index 00000000000..352ce24f56a --- /dev/null +++ b/pkg/services/export/export_live.go @@ -0,0 +1,47 @@ +package export + +import ( + "fmt" + "path" + "time" + + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +func exportLive(helper *commitHelper, job *gitExportJob) error { + messagedir := path.Join(helper.orgDir, "system", "live", "message") + + return job.sql.WithDbSession(helper.ctx, func(sess *sqlstore.DBSession) error { + type msgResult struct { + Channel string `xorm:"channel"` + Data string `xorm:"data"` + CreatedBy int64 `xorm:"created_by"` + Created time.Time `xorm:"created"` + } + + rows := make([]*msgResult, 0) + + sess.Table("live_message").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: []byte(row.Data), + fpath: path.Join(messagedir, row.Channel) + ".json", // must be JSON files + }}, + comment: fmt.Sprintf("Exporting: %s", row.Channel), + when: row.Created, + userID: row.CreatedBy, + }) + if err != nil { + return err + } + } + return err + }) +} diff --git a/pkg/services/export/export_snapshots.go b/pkg/services/export/export_snapshots.go index e9390cc8b79..fa2e05ce588 100644 --- a/pkg/services/export/export_snapshots.go +++ b/pkg/services/export/export_snapshots.go @@ -29,7 +29,7 @@ func exportSnapshots(helper *commitHelper, job *gitExportJob) error { gitcmd := commitOptions{ when: time.Now(), - comment: "Export playlists", + comment: "Export snapshots", } for _, snapshot := range cmd.Result { diff --git a/pkg/services/export/export_sys_preferences.go b/pkg/services/export/export_sys_preferences.go index 926240acc04..bcb48888a0a 100644 --- a/pkg/services/export/export_sys_preferences.go +++ b/pkg/services/export/export_sys_preferences.go @@ -58,11 +58,15 @@ func exportSystemPreferences(helper *commitHelper, job *gitExportJob) error { 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) } @@ -105,6 +109,10 @@ func exportSystemPreferences(helper *commitHelper, job *gitExportJob) error { // 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? } diff --git a/pkg/services/export/git_export_job.go b/pkg/services/export/git_export_job.go index 21243e05c52..c65372f442d 100644 --- a/pkg/services/export/git_export_job.go +++ b/pkg/services/export/git_export_job.go @@ -29,6 +29,7 @@ type gitExportJob struct { status ExportStatus cfg ExportConfig broadcaster statusBroadcaster + helper *commitHelper } type simpleExporter = func(helper *commitHelper, job *gitExportJob) error @@ -69,6 +70,10 @@ func (e *gitExportJob) getConfig() ExportConfig { 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() { defer func() { @@ -119,16 +124,21 @@ func (e *gitExportJob) doExportWithHistory() error { if err != nil { return err } - helper := &commitHelper{ + e.helper = &commitHelper{ repo: r, work: w, ctx: context.Background(), workDir: e.rootDir, orgDir: e.rootDir, + broadcast: func(p string) { + e.status.Last = p[len(e.rootDir):] + e.status.Changed = time.Now().UnixMilli() + e.broadcaster(e.status) + }, } cmd := &models.SearchOrgsQuery{} - err = e.sql.SearchOrgs(helper.ctx, cmd) + err = e.sql.SearchOrgs(e.helper.ctx, cmd) if err != nil { return err } @@ -136,14 +146,14 @@ func (e *gitExportJob) doExportWithHistory() error { // Export each org for _, org := range cmd.Result { if len(cmd.Result) > 1 { - helper.orgDir = path.Join(e.rootDir, fmt.Sprintf("org_%d", org.Id)) + e.helper.orgDir = path.Join(e.rootDir, fmt.Sprintf("org_%d", org.Id)) } - err = helper.initOrg(e.sql, org.Id) + err = e.helper.initOrg(e.sql, org.Id) if err != nil { return err } - err = e.doOrgExportWithHistory(helper) + err = e.doOrgExportWithHistory(e.helper) if err != nil { return err } @@ -161,29 +171,38 @@ func (e *gitExportJob) doExportWithHistory() error { } func (e *gitExportJob) doOrgExportWithHistory(helper *commitHelper) error { - lookup, err := exportDataSources(helper, e) + include := e.cfg.Include + lookup, err := exportDataSources(helper, e, include.DS) if err != nil { return err } - if true { + if include.Dash { err = exportDashboards(helper, e, lookup) if err != nil { return err } } - // Run all the simple exporters - exporters := []simpleExporter{ - dumpAuthTables, - exportSystemPreferences, - exportSystemStars, - exportSystemPlaylists, - exportAnnotations, + exporters := []simpleExporter{} + if include.Auth { + exporters = append(exporters, dumpAuthTables) } - // This needs a real admin user to use the interfaces (and decrypt) - if false { + if include.Services { + exporters = append(exporters, exportFiles, + exportSystemPreferences, + exportSystemStars, + exportSystemPlaylists, + exportKVStore, + exportLive) + } + + if include.Anno { + exporters = append(exporters, exportAnnotations) + } + + if include.Snapshots { exporters = append(exporters, exportSnapshots) } diff --git a/pkg/services/export/service.go b/pkg/services/export/service.go index 6551775a065..d39f5fc4fff 100644 --- a/pkg/services/export/service.go +++ b/pkg/services/export/service.go @@ -25,6 +25,9 @@ type ExportService interface { // Read raw file contents out of the store HandleRequestExport(c *models.ReqContext) response.Response + + // Cancel any running export + HandleRequestStop(c *models.ReqContext) response.Response } type StandardExport struct { @@ -63,6 +66,15 @@ func (ex *StandardExport) HandleGetStatus(c *models.ReqContext) response.Respons return response.JSON(http.StatusOK, ex.exportJob.getStatus()) } +func (ex *StandardExport) HandleRequestStop(c *models.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 *models.ReqContext) response.Response { var cfg ExportConfig err := json.NewDecoder(c.Req.Body).Decode(&cfg) diff --git a/pkg/services/export/stopped_job.go b/pkg/services/export/stopped_job.go index b9756f9d72f..2bd248d6a65 100644 --- a/pkg/services/export/stopped_job.go +++ b/pkg/services/export/stopped_job.go @@ -17,3 +17,5 @@ func (e *stoppedJob) getStatus() ExportStatus { func (e *stoppedJob) getConfig() ExportConfig { return ExportConfig{} } + +func (e *stoppedJob) requestStop() {} diff --git a/pkg/services/export/stub.go b/pkg/services/export/stub.go index 551bb730f5c..47e9267bb62 100644 --- a/pkg/services/export/stub.go +++ b/pkg/services/export/stub.go @@ -18,3 +18,7 @@ func (ex *StubExport) HandleGetStatus(c *models.ReqContext) response.Response { func (ex *StubExport) HandleRequestExport(c *models.ReqContext) response.Response { return response.Error(http.StatusForbidden, "feature not enabled", nil) } + +func (ex *StubExport) HandleRequestStop(c *models.ReqContext) response.Response { + return response.Error(http.StatusForbidden, "feature not enabled", nil) +} diff --git a/pkg/services/export/types.go b/pkg/services/export/types.go index 20a1ec49e3d..ec80a12a469 100644 --- a/pkg/services/export/types.go +++ b/pkg/services/export/types.go @@ -15,21 +15,30 @@ type ExportStatus struct { // Basic export config (for now) type ExportConfig struct { - Format string `json:"format"` - Git GitExportConfig `json:"git"` + Format string `json:"format"` + GeneralFolderPath string `json:"generalFolderPath"` + KeepHistory bool `json:"history"` + + Include struct { + Auth bool `json:"auth"` + DS bool `json:"ds"` + Dash bool `json:"dash"` + Services bool `json:"services"` + Usage bool `json:"usage"` + Anno bool `json:"anno"` + Snapshots bool `json:"snapshots"` + } `json:"include"` + + // Depends on the format + Git GitExportConfig `json:"git"` } -type GitExportConfig struct { - // General folder is either at the root or as a subfolder - GeneralAtRoot bool `json:"generalAtRoot"` - - // Keeping all history is nice, but much slower - ExcludeHistory bool `json:"excludeHistory"` -} +type GitExportConfig struct{} type Job interface { getStatus() ExportStatus getConfig() ExportConfig + requestStop() } // Will broadcast the live status diff --git a/public/app/features/storage/ExportView.tsx b/public/app/features/storage/ExportView.tsx index ed983e1639c..77bec1490c5 100644 --- a/public/app/features/storage/ExportView.tsx +++ b/public/app/features/storage/ExportView.tsx @@ -1,11 +1,14 @@ import React, { useEffect, useState } from 'react'; +import { useLocalStorage } from 'react-use'; import { isLiveChannelMessageEvent, isLiveChannelStatusEvent, LiveChannelScope } from '@grafana/data'; import { getBackendSrv, getGrafanaLiveSrv } from '@grafana/runtime'; -import { Button, CodeEditor, Modal } from '@grafana/ui'; +import { Button, CodeEditor, HorizontalGroup, LinkButton } from '@grafana/ui'; import { StorageView } from './types'; +export const EXPORT_LOCAL_STORAGE_KEY = 'grafana.export.config'; + interface ExportStatusMessage { running: boolean; target: string; @@ -18,25 +21,57 @@ interface ExportStatusMessage { status: string; } +interface ExportInclude { + auth: boolean; + ds: boolean; + dash: boolean; + services: boolean; + usage: boolean; + anno: boolean; + snapshots: boolean; +} + +interface ExportJob { + format: 'git'; + generalFolderPath: string; + history: boolean; + include: ExportInclude; + + git?: {}; +} + +const includAll: ExportInclude = { + auth: true, + ds: true, + dash: true, + services: true, + usage: true, + anno: true, + snapshots: false, // will fail until we have a real user +}; + +const defaultJob: ExportJob = { + format: 'git', + generalFolderPath: 'general', + history: true, + include: includAll, + git: {}, +}; + interface Props { onPathChange: (p: string, v?: StorageView) => void; } export const ExportView = ({ onPathChange }: Props) => { const [status, setStatus] = useState(); + const [rawBody, setBody] = useLocalStorage(EXPORT_LOCAL_STORAGE_KEY, defaultJob); + const body = { ...defaultJob, ...rawBody, include: { ...includAll, ...rawBody?.include } }; - const [open, setOpen] = useState(false); - const [body, setBody] = useState({ - format: 'git', - git: {}, - }); - const onDismiss = () => setOpen(false); const doStart = () => { - getBackendSrv() - .post('/api/admin/export', body) - .then((v) => { - onDismiss(); - }); + getBackendSrv().post('/api/admin/export', body); + }; + const doStop = () => { + getBackendSrv().post('/api/admin/export/stop'); }; useEffect(() => { @@ -56,71 +91,53 @@ export const ExportView = ({ onPathChange }: Props) => { }, }); - // if not running, open the thread - setTimeout(() => { - if (!status) { - setOpen(true); - } - }, 500); - return () => { subscription.unsubscribe(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const renderButton = () => { - return ( - <> - -
- { - setBody(JSON.parse(text)); // force JSON? - }} - /> -
- - - - -
- - - - - ); - }; - - if (!status) { - return
{renderButton()}
; - } - return (
-
{JSON.stringify(status, null, 2)}
- {Boolean(!status.running) && renderButton()} - {Boolean(status.running) && ( - + {status && ( +
+

Status

+
{JSON.stringify(status, null, 2)}
+ {status.running && ( +
+ +
+ )} +
+ )} + + {!Boolean(status?.running) && ( +
+

Export grafana instance

+ { + setBody(JSON.parse(text)); // force JSON? + }} + /> +
+ + + + + Cancel + + +
)}
);