mirror of https://github.com/grafana/grafana.git
				
				
				
			Crawler: use existing render service to generate dashboard thumbnails (#43515)
Co-authored-by: Artur Wierzbicki <artur@arturwierzbicki.com>
This commit is contained in:
		
							parent
							
								
									cc9e70be5c
								
							
						
					
					
						commit
						b404aae9c3
					
				|  | @ -109,7 +109,7 @@ ctx := req.Request.Context() | ||||||
| query := &models.FindDashboardQuery{ | query := &models.FindDashboardQuery{ | ||||||
|     ID: "foo", |     ID: "foo", | ||||||
| } | } | ||||||
| if err := bus.DispatchCtx(ctx, query); err != nil { | if err := bus.Dispatch(ctx, query); err != nil { | ||||||
|     return err |     return err | ||||||
| } | } | ||||||
| // The query now contains a result. | // The query now contains a result. | ||||||
|  |  | ||||||
|  | @ -461,6 +461,12 @@ func (hs *HTTPServer) registerRoutes() { | ||||||
| 		adminRoute.Get("/stats", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)), routing.Wrap(AdminGetStats)) | 		adminRoute.Get("/stats", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)), routing.Wrap(AdminGetStats)) | ||||||
| 		adminRoute.Post("/pause-all-alerts", reqGrafanaAdmin, routing.Wrap(PauseAllAlerts)) | 		adminRoute.Post("/pause-all-alerts", reqGrafanaAdmin, routing.Wrap(PauseAllAlerts)) | ||||||
| 
 | 
 | ||||||
|  | 		if hs.ThumbService != nil { | ||||||
|  | 			adminRoute.Post("/crawler/start", reqGrafanaAdmin, routing.Wrap(hs.ThumbService.StartCrawler)) | ||||||
|  | 			adminRoute.Post("/crawler/stop", reqGrafanaAdmin, routing.Wrap(hs.ThumbService.StopCrawler)) | ||||||
|  | 			adminRoute.Get("/crawler/status", reqGrafanaAdmin, routing.Wrap(hs.ThumbService.CrawlerStatus)) | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
| 		adminRoute.Post("/provisioning/dashboards/reload", authorize(reqGrafanaAdmin, ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersDashboards)), routing.Wrap(hs.AdminProvisioningReloadDashboards)) | 		adminRoute.Post("/provisioning/dashboards/reload", authorize(reqGrafanaAdmin, ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersDashboards)), routing.Wrap(hs.AdminProvisioningReloadDashboards)) | ||||||
| 		adminRoute.Post("/provisioning/plugins/reload", authorize(reqGrafanaAdmin, ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersPlugins)), routing.Wrap(hs.AdminProvisioningReloadPlugins)) | 		adminRoute.Post("/provisioning/plugins/reload", authorize(reqGrafanaAdmin, ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersPlugins)), routing.Wrap(hs.AdminProvisioningReloadPlugins)) | ||||||
| 		adminRoute.Post("/provisioning/datasources/reload", authorize(reqGrafanaAdmin, ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersDatasources)), routing.Wrap(hs.AdminProvisioningReloadDatasources)) | 		adminRoute.Post("/provisioning/datasources/reload", authorize(reqGrafanaAdmin, ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersDatasources)), routing.Wrap(hs.AdminProvisioningReloadDatasources)) | ||||||
|  |  | ||||||
|  | @ -207,6 +207,8 @@ func (rs *RenderingService) Render(ctx context.Context, opts Opts) (*RenderResul | ||||||
| 
 | 
 | ||||||
| func (rs *RenderingService) render(ctx context.Context, opts Opts) (*RenderResult, error) { | func (rs *RenderingService) render(ctx context.Context, opts Opts) (*RenderResult, error) { | ||||||
| 	if int(atomic.LoadInt32(&rs.inProgressCount)) > opts.ConcurrentLimit { | 	if int(atomic.LoadInt32(&rs.inProgressCount)) > opts.ConcurrentLimit { | ||||||
|  | 		rs.log.Warn("Could not render image, hit the currency limit", "concurrencyLimit", opts.ConcurrentLimit, "path", opts.Path) | ||||||
|  | 
 | ||||||
| 		theme := ThemeDark | 		theme := ThemeDark | ||||||
| 		if opts.Theme != "" { | 		if opts.Theme != "" { | ||||||
| 			theme = opts.Theme | 			theme = opts.Theme | ||||||
|  | @ -225,7 +227,7 @@ func (rs *RenderingService) render(ctx context.Context, opts Opts) (*RenderResul | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	rs.log.Info("Rendering", "path", opts.Path) | 	rs.log.Info("Rendering", "path", opts.Path) | ||||||
| 	if math.IsInf(opts.DeviceScaleFactor, 0) || math.IsNaN(opts.DeviceScaleFactor) || opts.DeviceScaleFactor <= 0 { | 	if math.IsInf(opts.DeviceScaleFactor, 0) || math.IsNaN(opts.DeviceScaleFactor) || opts.DeviceScaleFactor == 0 { | ||||||
| 		opts.DeviceScaleFactor = 1 | 		opts.DeviceScaleFactor = 1 | ||||||
| 	} | 	} | ||||||
| 	renderKey, err := rs.generateAndStoreRenderKey(ctx, opts.OrgID, opts.UserID, opts.OrgRole) | 	renderKey, err := rs.generateAndStoreRenderKey(ctx, opts.OrgID, opts.UserID, opts.OrgRole) | ||||||
|  |  | ||||||
|  | @ -110,6 +110,7 @@ func TestRenderLimitImage(t *testing.T) { | ||||||
| 			HomePath: path, | 			HomePath: path, | ||||||
| 		}, | 		}, | ||||||
| 		inProgressCount: 2, | 		inProgressCount: 2, | ||||||
|  | 		log:             log.New("test"), | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	tests := []struct { | 	tests := []struct { | ||||||
|  |  | ||||||
|  | @ -0,0 +1,249 @@ | ||||||
|  | package thumbs | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"errors" | ||||||
|  | 	"fmt" | ||||||
|  | 	"math/rand" | ||||||
|  | 	"os" | ||||||
|  | 	"strings" | ||||||
|  | 	"sync" | ||||||
|  | 	"time" | ||||||
|  | 
 | ||||||
|  | 	"github.com/grafana/grafana/pkg/bus" | ||||||
|  | 	"github.com/grafana/grafana/pkg/models" | ||||||
|  | 	"github.com/grafana/grafana/pkg/services/live" | ||||||
|  | 	"github.com/grafana/grafana/pkg/services/rendering" | ||||||
|  | 	"github.com/grafana/grafana/pkg/services/search" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | type dashItem struct { | ||||||
|  | 	uid string | ||||||
|  | 	url string | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type simpleCrawler struct { | ||||||
|  | 	screenshotsFolder string | ||||||
|  | 	renderService     rendering.Service | ||||||
|  | 	threadCount       int | ||||||
|  | 
 | ||||||
|  | 	glive  *live.GrafanaLive | ||||||
|  | 	mode   CrawlerMode | ||||||
|  | 	opts   rendering.Opts | ||||||
|  | 	status crawlStatus | ||||||
|  | 	queue  []dashItem | ||||||
|  | 	mu     sync.Mutex | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func newSimpleCrawler(folder string, renderService rendering.Service, gl *live.GrafanaLive) dashRenderer { | ||||||
|  | 	c := &simpleCrawler{ | ||||||
|  | 		screenshotsFolder: folder, | ||||||
|  | 		renderService:     renderService, | ||||||
|  | 		threadCount:       5, | ||||||
|  | 		glive:             gl, | ||||||
|  | 		status: crawlStatus{ | ||||||
|  | 			State:    "init", | ||||||
|  | 			Complete: 0, | ||||||
|  | 			Queue:    0, | ||||||
|  | 		}, | ||||||
|  | 		queue: make([]dashItem, 0), | ||||||
|  | 	} | ||||||
|  | 	c.broadcastStatus() | ||||||
|  | 	return c | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *simpleCrawler) next() *dashItem { | ||||||
|  | 	if len(r.queue) < 1 { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 	r.mu.Lock() | ||||||
|  | 	defer r.mu.Unlock() | ||||||
|  | 
 | ||||||
|  | 	v := r.queue[0] | ||||||
|  | 	r.queue = r.queue[1:] | ||||||
|  | 	return &v | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *simpleCrawler) broadcastStatus() { | ||||||
|  | 	s, err := r.Status() | ||||||
|  | 	if err != nil { | ||||||
|  | 		tlog.Warn("error reading status") | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	msg, err := json.Marshal(s) | ||||||
|  | 	if err != nil { | ||||||
|  | 		tlog.Warn("error making message") | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	err = r.glive.Publish(r.opts.OrgID, "grafana/broadcast/crawler", msg) | ||||||
|  | 	if err != nil { | ||||||
|  | 		tlog.Warn("error Publish message") | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *simpleCrawler) GetPreview(req *previewRequest) *previewResponse { | ||||||
|  | 	p := getFilePath(r.screenshotsFolder, req) | ||||||
|  | 	if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) { | ||||||
|  | 		return r.queueRender(p, req) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return &previewResponse{ | ||||||
|  | 		Path: p, | ||||||
|  | 		Code: 200, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *simpleCrawler) queueRender(p string, req *previewRequest) *previewResponse { | ||||||
|  | 	go func() { | ||||||
|  | 		fmt.Printf("todo? queue") | ||||||
|  | 	}() | ||||||
|  | 
 | ||||||
|  | 	return &previewResponse{ | ||||||
|  | 		Code: 202, | ||||||
|  | 		Path: p, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *simpleCrawler) Start(c *models.ReqContext, mode CrawlerMode, theme rendering.Theme) (crawlStatus, error) { | ||||||
|  | 	if r.status.State == "running" { | ||||||
|  | 		tlog.Info("already running") | ||||||
|  | 		return r.Status() | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	r.mu.Lock() | ||||||
|  | 	defer r.mu.Unlock() | ||||||
|  | 
 | ||||||
|  | 	searchQuery := search.Query{ | ||||||
|  | 		SignedInUser: c.SignedInUser, | ||||||
|  | 		OrgId:        c.OrgId, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	err := bus.Dispatch(context.Background(), &searchQuery) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return crawlStatus{}, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	queue := make([]dashItem, 0, len(searchQuery.Result)) | ||||||
|  | 	for _, v := range searchQuery.Result { | ||||||
|  | 		if v.Type == search.DashHitDB { | ||||||
|  | 			queue = append(queue, dashItem{ | ||||||
|  | 				uid: v.UID, | ||||||
|  | 				url: v.URL, | ||||||
|  | 			}) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	rand.Seed(time.Now().UnixNano()) | ||||||
|  | 	rand.Shuffle(len(queue), func(i, j int) { queue[i], queue[j] = queue[j], queue[i] }) | ||||||
|  | 
 | ||||||
|  | 	r.mode = mode | ||||||
|  | 	r.opts = rendering.Opts{ | ||||||
|  | 		OrgID:           c.OrgId, | ||||||
|  | 		UserID:          c.UserId, | ||||||
|  | 		OrgRole:         c.OrgRole, | ||||||
|  | 		Theme:           theme, | ||||||
|  | 		ConcurrentLimit: 10, | ||||||
|  | 	} | ||||||
|  | 	r.queue = queue | ||||||
|  | 	r.status = crawlStatus{ | ||||||
|  | 		Started:  time.Now(), | ||||||
|  | 		State:    "running", | ||||||
|  | 		Complete: 0, | ||||||
|  | 	} | ||||||
|  | 	r.broadcastStatus() | ||||||
|  | 
 | ||||||
|  | 	// create a pool of workers
 | ||||||
|  | 	for i := 0; i < r.threadCount; i++ { | ||||||
|  | 		go r.walk() | ||||||
|  | 
 | ||||||
|  | 		// wait 1/2 second before starting a new thread
 | ||||||
|  | 		time.Sleep(500 * time.Millisecond) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	r.broadcastStatus() | ||||||
|  | 	return r.Status() | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *simpleCrawler) Stop() (crawlStatus, error) { | ||||||
|  | 	// cheap hack!
 | ||||||
|  | 	if r.status.State == "running" { | ||||||
|  | 		r.status.State = "stopping" | ||||||
|  | 	} | ||||||
|  | 	return r.Status() | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *simpleCrawler) Status() (crawlStatus, error) { | ||||||
|  | 	status := crawlStatus{ | ||||||
|  | 		State:    r.status.State, | ||||||
|  | 		Started:  r.status.Started, | ||||||
|  | 		Complete: r.status.Complete, | ||||||
|  | 		Errors:   r.status.Errors, | ||||||
|  | 		Queue:    len(r.queue), | ||||||
|  | 		Last:     r.status.Last, | ||||||
|  | 	} | ||||||
|  | 	return status, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *simpleCrawler) walk() { | ||||||
|  | 	for { | ||||||
|  | 		if r.status.State == "stopping" { | ||||||
|  | 			break | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		item := r.next() | ||||||
|  | 		if item == nil { | ||||||
|  | 			break | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		tlog.Info("GET THUMBNAIL", "url", item.url) | ||||||
|  | 
 | ||||||
|  | 		// Hack (for now) pick a URL that will render
 | ||||||
|  | 		panelURL := strings.TrimPrefix(item.url, "/") + "?kiosk" | ||||||
|  | 		res, err := r.renderService.Render(context.Background(), rendering.Opts{ | ||||||
|  | 			Width:             320, | ||||||
|  | 			Height:            240, | ||||||
|  | 			Path:              panelURL, | ||||||
|  | 			OrgID:             r.opts.OrgID, | ||||||
|  | 			UserID:            r.opts.UserID, | ||||||
|  | 			ConcurrentLimit:   r.opts.ConcurrentLimit, | ||||||
|  | 			OrgRole:           r.opts.OrgRole, | ||||||
|  | 			Theme:             r.opts.Theme, | ||||||
|  | 			Timeout:           10 * time.Second, | ||||||
|  | 			DeviceScaleFactor: -5, // negative numbers will render larger then scale down
 | ||||||
|  | 		}) | ||||||
|  | 		if err != nil { | ||||||
|  | 			tlog.Warn("error getting image", "err", err) | ||||||
|  | 			r.status.Errors++ | ||||||
|  | 		} else if res.FilePath == "" { | ||||||
|  | 			tlog.Warn("error getting image... no response") | ||||||
|  | 			r.status.Errors++ | ||||||
|  | 		} else if strings.Contains(res.FilePath, "public/img") { | ||||||
|  | 			tlog.Warn("error getting image... internal result", "img", res.FilePath) | ||||||
|  | 			r.status.Errors++ | ||||||
|  | 		} else { | ||||||
|  | 			p := getFilePath(r.screenshotsFolder, &previewRequest{ | ||||||
|  | 				UID:   item.uid, | ||||||
|  | 				OrgID: r.opts.OrgID, | ||||||
|  | 				Theme: r.opts.Theme, | ||||||
|  | 				Size:  PreviewSizeThumb, | ||||||
|  | 			}) | ||||||
|  | 			err = os.Rename(res.FilePath, p) | ||||||
|  | 			if err != nil { | ||||||
|  | 				r.status.Errors++ | ||||||
|  | 				tlog.Warn("error moving image", "err", err) | ||||||
|  | 			} else { | ||||||
|  | 				r.status.Complete++ | ||||||
|  | 				tlog.Info("saved thumbnail", "img", item.url) | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		time.Sleep(5 * time.Second) | ||||||
|  | 		r.status.Last = time.Now() | ||||||
|  | 		r.broadcastStatus() | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	r.status.State = "stopped" | ||||||
|  | 	r.status.Finished = time.Now() | ||||||
|  | 	r.broadcastStatus() | ||||||
|  | } | ||||||
|  | @ -1,72 +0,0 @@ | ||||||
| package thumbs |  | ||||||
| 
 |  | ||||||
| import ( |  | ||||||
| 	"bytes" |  | ||||||
| 	"encoding/json" |  | ||||||
| 	"errors" |  | ||||||
| 	"fmt" |  | ||||||
| 	"io/ioutil" |  | ||||||
| 	"net/http" |  | ||||||
| 	"os" |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| type renderHttp struct { |  | ||||||
| 	crawlerURL string |  | ||||||
| 	config     crawConfig |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func newRenderHttp(crawlerURL string, cfg crawConfig) dashRenderer { |  | ||||||
| 	return &renderHttp{ |  | ||||||
| 		crawlerURL: crawlerURL, |  | ||||||
| 		config:     cfg, |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (r *renderHttp) GetPreview(req *previewRequest) *previewResponse { |  | ||||||
| 	p := getFilePath(r.config.ScreenshotsFolder, req) |  | ||||||
| 	if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) { |  | ||||||
| 		return r.queueRender(p, req) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return &previewResponse{ |  | ||||||
| 		Path: p, |  | ||||||
| 		Code: 200, |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (r *renderHttp) CrawlerCmd(cfg *crawlCmd) (json.RawMessage, error) { |  | ||||||
| 	cmd := r.config |  | ||||||
| 	cmd.crawlCmd = *cfg |  | ||||||
| 
 |  | ||||||
| 	jsonData, err := json.Marshal(cmd) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
| 	request, err := http.NewRequest("POST", r.crawlerURL, bytes.NewBuffer(jsonData)) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
| 	request.Header.Set("Content-Type", "application/json; charset=UTF-8") |  | ||||||
| 
 |  | ||||||
| 	client := &http.Client{} |  | ||||||
| 	response, error := client.Do(request) |  | ||||||
| 	if error != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
| 	defer func() { |  | ||||||
| 		_ = response.Body.Close() |  | ||||||
| 	}() |  | ||||||
| 
 |  | ||||||
| 	return ioutil.ReadAll(response.Body) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (r *renderHttp) queueRender(p string, req *previewRequest) *previewResponse { |  | ||||||
| 	go func() { |  | ||||||
| 		fmt.Printf("todo? queue") |  | ||||||
| 	}() |  | ||||||
| 
 |  | ||||||
| 	return &previewResponse{ |  | ||||||
| 		Code: 202, |  | ||||||
| 		Path: p, |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
|  | @ -25,8 +25,15 @@ func (ds *dummyService) StartCrawler(c *models.ReqContext) response.Response { | ||||||
| 	result["error"] = "Not enabled" | 	result["error"] = "Not enabled" | ||||||
| 	return response.JSON(200, result) | 	return response.JSON(200, result) | ||||||
| } | } | ||||||
|  | 
 | ||||||
| func (ds *dummyService) StopCrawler(c *models.ReqContext) response.Response { | func (ds *dummyService) StopCrawler(c *models.ReqContext) response.Response { | ||||||
| 	result := make(map[string]string) | 	result := make(map[string]string) | ||||||
| 	result["error"] = "Not enabled" | 	result["error"] = "Not enabled" | ||||||
| 	return response.JSON(200, result) | 	return response.JSON(200, result) | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | func (ds *dummyService) CrawlerStatus(c *models.ReqContext) response.Response { | ||||||
|  | 	result := make(map[string]string) | ||||||
|  | 	result["error"] = "Not enabled" | ||||||
|  | 	return response.JSON(200, result) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -1,8 +1,14 @@ | ||||||
| package thumbs | package thumbs | ||||||
| 
 | 
 | ||||||
| import "encoding/json" | import ( | ||||||
|  | 	"time" | ||||||
|  | 
 | ||||||
|  | 	"github.com/grafana/grafana/pkg/models" | ||||||
|  | 	"github.com/grafana/grafana/pkg/services/rendering" | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
| type PreviewSize string | type PreviewSize string | ||||||
|  | type CrawlerMode string | ||||||
| 
 | 
 | ||||||
| const ( | const ( | ||||||
| 	// PreviewSizeThumb is a small 320x240 preview
 | 	// PreviewSizeThumb is a small 320x240 preview
 | ||||||
|  | @ -13,6 +19,15 @@ const ( | ||||||
| 
 | 
 | ||||||
| 	// PreviewSizeLarge is a large image 512x????
 | 	// PreviewSizeLarge is a large image 512x????
 | ||||||
| 	PreviewSizeTall PreviewSize = "tall" | 	PreviewSizeTall PreviewSize = "tall" | ||||||
|  | 
 | ||||||
|  | 	// CrawlerModeThumbs will create small thumbnails for everything
 | ||||||
|  | 	CrawlerModeThumbs CrawlerMode = "thumbs" | ||||||
|  | 
 | ||||||
|  | 	// CrawlerModeAnalytics will get full page results for everythign
 | ||||||
|  | 	CrawlerModeAnalytics CrawlerMode = "analytics" | ||||||
|  | 
 | ||||||
|  | 	// CrawlerModeMigrate will migrate all dashboards with old schema
 | ||||||
|  | 	CrawlerModeMigrate CrawlerMode = "migrate" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // IsKnownSize checks if the value is a standard size
 | // IsKnownSize checks if the value is a standard size
 | ||||||
|  | @ -39,22 +54,21 @@ func getPreviewSize(str string) (PreviewSize, bool) { | ||||||
| 	return PreviewSizeThumb, false | 	return PreviewSizeThumb, false | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func getTheme(str string) (string, bool) { | func getTheme(str string) (rendering.Theme, bool) { | ||||||
| 	switch str { | 	switch str { | ||||||
| 	case "light": | 	case "light": | ||||||
| 		return str, true | 		return rendering.ThemeLight, true | ||||||
| 	case "dark": | 	case "dark": | ||||||
| 		return str, true | 		return rendering.ThemeDark, true | ||||||
| 	} | 	} | ||||||
| 	return "dark", false | 	return rendering.ThemeDark, false | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type previewRequest struct { | type previewRequest struct { | ||||||
| 	Kind  string      `json:"kind"` |  | ||||||
| 	OrgID int64           `json:"orgId"` | 	OrgID int64           `json:"orgId"` | ||||||
| 	UID   string          `json:"uid"` | 	UID   string          `json:"uid"` | ||||||
| 	Size  PreviewSize     `json:"size"` | 	Size  PreviewSize     `json:"size"` | ||||||
| 	Theme string      `json:"theme"` | 	Theme rendering.Theme `json:"theme"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type previewResponse struct { | type previewResponse struct { | ||||||
|  | @ -63,35 +77,19 @@ type previewResponse struct { | ||||||
| 	URL  string `json:"url"`  // redirect to this URL
 | 	URL  string `json:"url"`  // redirect to this URL
 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // export enum CrawlerMode {
 |  | ||||||
| // 	Thumbs = 'thumbs',
 |  | ||||||
| // 	Analytics = 'analytics', // Enterprise only
 |  | ||||||
| // 	Migrate = 'migrate',
 |  | ||||||
| //   }
 |  | ||||||
| 
 |  | ||||||
| //   export enum CrawlerAction {
 |  | ||||||
| // 	Run = 'run',
 |  | ||||||
| // 	Stop = 'stop',
 |  | ||||||
| // 	Queue = 'queue', // TODO (later!) move some to the front
 |  | ||||||
| //   }
 |  | ||||||
| 
 |  | ||||||
| type crawlCmd struct { | type crawlCmd struct { | ||||||
| 	Mode        string `json:"mode"`        // thumbs | analytics | migrate
 | 	Mode  CrawlerMode     `json:"mode"`  // thumbs | analytics | migrate
 | ||||||
| 	Action      string `json:"action"`      // run | stop | queue
 | 	Theme rendering.Theme `json:"theme"` // light | dark
 | ||||||
| 	Theme       string `json:"theme"`       // light | dark
 |  | ||||||
| 	User        string `json:"user"`        // :(
 |  | ||||||
| 	Password    string `json:"password"`    // :(
 |  | ||||||
| 	Concurrency int    `json:"concurrency"` // number of pages to run in parallel
 |  | ||||||
| 
 |  | ||||||
| 	Path string `json:"path"` // eventually for queue
 |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type crawConfig struct { | type crawlStatus struct { | ||||||
| 	crawlCmd | 	State    string    `json:"state"` | ||||||
| 
 | 	Started  time.Time `json:"started,omitempty"` | ||||||
| 	// Sent to the crawler with each command
 | 	Finished time.Time `json:"finished,omitempty"` | ||||||
| 	URL               string `json:"url"` | 	Complete int       `json:"complete"` | ||||||
| 	ScreenshotsFolder string `json:"screenshotsFolder"` | 	Errors   int       `json:"errors"` | ||||||
|  | 	Queue    int       `json:"queue"` | ||||||
|  | 	Last     time.Time `json:"last,omitempty"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type dashRenderer interface { | type dashRenderer interface { | ||||||
|  | @ -99,5 +97,11 @@ type dashRenderer interface { | ||||||
| 	GetPreview(req *previewRequest) *previewResponse | 	GetPreview(req *previewRequest) *previewResponse | ||||||
| 
 | 
 | ||||||
| 	// Assumes you have already authenticated as admin
 | 	// Assumes you have already authenticated as admin
 | ||||||
| 	CrawlerCmd(cfg *crawlCmd) (json.RawMessage, error) | 	Start(c *models.ReqContext, mode CrawlerMode, theme rendering.Theme) (crawlStatus, error) | ||||||
|  | 
 | ||||||
|  | 	// Assumes you have already authenticated as admin
 | ||||||
|  | 	Stop() (crawlStatus, error) | ||||||
|  | 
 | ||||||
|  | 	// Assumes you have already authenticated as admin
 | ||||||
|  | 	Status() (crawlStatus, error) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -15,6 +15,7 @@ import ( | ||||||
| 	"github.com/grafana/grafana/pkg/bus" | 	"github.com/grafana/grafana/pkg/bus" | ||||||
| 	"github.com/grafana/grafana/pkg/models" | 	"github.com/grafana/grafana/pkg/models" | ||||||
| 	"github.com/grafana/grafana/pkg/services/guardian" | 	"github.com/grafana/grafana/pkg/services/guardian" | ||||||
|  | 	"github.com/grafana/grafana/pkg/services/live" | ||||||
| 	"github.com/grafana/grafana/pkg/services/rendering" | 	"github.com/grafana/grafana/pkg/services/rendering" | ||||||
| 	"github.com/grafana/grafana/pkg/setting" | 	"github.com/grafana/grafana/pkg/setting" | ||||||
| 	"github.com/grafana/grafana/pkg/web" | 	"github.com/grafana/grafana/pkg/web" | ||||||
|  | @ -28,29 +29,27 @@ var ( | ||||||
| type Service interface { | type Service interface { | ||||||
| 	Enabled() bool | 	Enabled() bool | ||||||
| 	GetImage(c *models.ReqContext) | 	GetImage(c *models.ReqContext) | ||||||
|  | 
 | ||||||
|  | 	// Form post (from dashboard page)
 | ||||||
| 	SetImage(c *models.ReqContext) | 	SetImage(c *models.ReqContext) | ||||||
| 
 | 
 | ||||||
| 	// Must be admin
 | 	// Must be admin
 | ||||||
| 	StartCrawler(c *models.ReqContext) response.Response | 	StartCrawler(c *models.ReqContext) response.Response | ||||||
| 	StopCrawler(c *models.ReqContext) response.Response | 	StopCrawler(c *models.ReqContext) response.Response | ||||||
|  | 	CrawlerStatus(c *models.ReqContext) response.Response | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func ProvideService(cfg *setting.Cfg, renderService rendering.Service) Service { | func ProvideService(cfg *setting.Cfg, renderService rendering.Service, gl *live.GrafanaLive) Service { | ||||||
| 	if !cfg.IsDashboardPreviesEnabled() { | 	if !cfg.IsDashboardPreviesEnabled() { | ||||||
| 		return &dummyService{} | 		return &dummyService{} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	root := filepath.Join(cfg.DataPath, "crawler", "preview") | 	root := filepath.Join(cfg.DataPath, "crawler", "preview") | ||||||
| 	url := strings.TrimSuffix(cfg.RendererUrl, "/render") + "/scan" |  | ||||||
| 
 |  | ||||||
| 	renderer := newRenderHttp(url, crawConfig{ |  | ||||||
| 		URL:               strings.TrimSuffix(cfg.RendererCallbackUrl, "/"), |  | ||||||
| 		ScreenshotsFolder: root, |  | ||||||
| 	}) |  | ||||||
| 
 |  | ||||||
| 	tempdir := filepath.Join(cfg.DataPath, "temp") | 	tempdir := filepath.Join(cfg.DataPath, "temp") | ||||||
|  | 	_ = os.MkdirAll(root, 0700) | ||||||
| 	_ = os.MkdirAll(tempdir, 0700) | 	_ = os.MkdirAll(tempdir, 0700) | ||||||
| 
 | 
 | ||||||
|  | 	renderer := newSimpleCrawler(root, renderService, gl) | ||||||
| 	return &thumbService{ | 	return &thumbService{ | ||||||
| 		renderer: renderer, | 		renderer: renderer, | ||||||
| 		root:     root, | 		root:     root, | ||||||
|  | @ -84,7 +83,6 @@ func (hs *thumbService) parseImageReq(c *models.ReqContext, checkSave bool) *pre | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	req := &previewRequest{ | 	req := &previewRequest{ | ||||||
| 		Kind:  "dash", |  | ||||||
| 		OrgID: c.OrgId, | 		OrgID: c.OrgId, | ||||||
| 		UID:   params[":uid"], | 		UID:   params[":uid"], | ||||||
| 		Theme: theme, | 		Theme: theme, | ||||||
|  | @ -137,6 +135,7 @@ func (hs *thumbService) GetImage(c *models.ReqContext) { | ||||||
| 	c.JSON(500, map[string]string{"path": rsp.Path, "error": "unknown!"}) | 	c.JSON(500, map[string]string{"path": rsp.Path, "error": "unknown!"}) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // Hack for now -- lets you upload images explicitly
 | ||||||
| func (hs *thumbService) SetImage(c *models.ReqContext) { | func (hs *thumbService) SetImage(c *models.ReqContext) { | ||||||
| 	req := hs.parseImageReq(c, false) | 	req := hs.parseImageReq(c, false) | ||||||
| 	if req == nil { | 	if req == nil { | ||||||
|  | @ -217,29 +216,30 @@ func (hs *thumbService) StartCrawler(c *models.ReqContext) response.Response { | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return response.Error(500, "error parsing bytes", err) | 		return response.Error(500, "error parsing bytes", err) | ||||||
| 	} | 	} | ||||||
| 	cmd.Action = "start" | 	if cmd.Mode == "" { | ||||||
| 
 | 		cmd.Mode = CrawlerModeThumbs | ||||||
| 	msg, err := hs.renderer.CrawlerCmd(cmd) | 	} | ||||||
|  | 	msg, err := hs.renderer.Start(c, cmd.Mode, cmd.Theme) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return response.Error(500, "error starting", err) | 		return response.Error(500, "error starting", err) | ||||||
| 	} | 	} | ||||||
| 
 | 	return response.JSON(200, msg) | ||||||
| 	header := make(http.Header) |  | ||||||
| 	header.Set("Content-Type", "application/json") |  | ||||||
| 	return response.CreateNormalResponse(header, msg, 200) |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (hs *thumbService) StopCrawler(c *models.ReqContext) response.Response { | func (hs *thumbService) StopCrawler(c *models.ReqContext) response.Response { | ||||||
| 	_, err := hs.renderer.CrawlerCmd(&crawlCmd{ | 	msg, err := hs.renderer.Stop() | ||||||
| 		Action: "stop", |  | ||||||
| 	}) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return response.Error(500, "error stopping crawler", err) | 		return response.Error(500, "error starting", err) | ||||||
| 	} | 	} | ||||||
|  | 	return response.JSON(200, msg) | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| 	result := make(map[string]string) | func (hs *thumbService) CrawlerStatus(c *models.ReqContext) response.Response { | ||||||
| 	result["message"] = "Stopping..." | 	msg, err := hs.renderer.Status() | ||||||
| 	return response.JSON(200, result) | 	if err != nil { | ||||||
|  | 		return response.Error(500, "error starting", err) | ||||||
|  | 	} | ||||||
|  | 	return response.JSON(200, msg) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Ideally this service would not require first looking up the full dashboard just to bet the id!
 | // Ideally this service would not require first looking up the full dashboard just to bet the id!
 | ||||||
|  |  | ||||||
|  | @ -0,0 +1,61 @@ | ||||||
|  | import React, { useState } from 'react'; | ||||||
|  | import { css } from '@emotion/css'; | ||||||
|  | import { Button, CodeEditor, Modal, useTheme2 } from '@grafana/ui'; | ||||||
|  | import { GrafanaTheme2 } from '@grafana/data'; | ||||||
|  | import { getBackendSrv, config } from '@grafana/runtime'; | ||||||
|  | 
 | ||||||
|  | export const CrawlerStartButton = () => { | ||||||
|  |   const styles = getStyles(useTheme2()); | ||||||
|  |   const [open, setOpen] = useState(false); | ||||||
|  |   const [body, setBody] = useState({ | ||||||
|  |     mode: 'thumbs', | ||||||
|  |     theme: config.theme2.isLight ? 'light' : 'dark', | ||||||
|  |   }); | ||||||
|  |   const onDismiss = () => setOpen(false); | ||||||
|  |   const doStart = () => { | ||||||
|  |     getBackendSrv() | ||||||
|  |       .post('/api/admin/crawler/start', body) | ||||||
|  |       .then((v) => { | ||||||
|  |         console.log('GOT', v); | ||||||
|  |         onDismiss(); | ||||||
|  |       }); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <Modal title={'Start crawler'} isOpen={open} onDismiss={onDismiss}> | ||||||
|  |         <div className={styles.wrap}> | ||||||
|  |           <CodeEditor | ||||||
|  |             height={200} | ||||||
|  |             value={JSON.stringify(body, null, 2) ?? ''} | ||||||
|  |             showLineNumbers={false} | ||||||
|  |             readOnly={false} | ||||||
|  |             language="json" | ||||||
|  |             showMiniMap={false} | ||||||
|  |             onBlur={(text: string) => { | ||||||
|  |               setBody(JSON.parse(text)); // force JSON?
 | ||||||
|  |             }} | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |         <Modal.ButtonRow> | ||||||
|  |           <Button onClick={doStart}>Start</Button> | ||||||
|  |           <Button variant="secondary" onClick={onDismiss}> | ||||||
|  |             Cancel | ||||||
|  |           </Button> | ||||||
|  |         </Modal.ButtonRow> | ||||||
|  |       </Modal> | ||||||
|  | 
 | ||||||
|  |       <Button onClick={() => setOpen(true)} variant="primary"> | ||||||
|  |         Start | ||||||
|  |       </Button> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const getStyles = (theme: GrafanaTheme2) => { | ||||||
|  |   return { | ||||||
|  |     wrap: css` | ||||||
|  |       border: 2px solid #111; | ||||||
|  |     `,
 | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  | @ -0,0 +1,79 @@ | ||||||
|  | import React, { useEffect, useState } from 'react'; | ||||||
|  | import { css } from '@emotion/css'; | ||||||
|  | import { Button, useTheme2 } from '@grafana/ui'; | ||||||
|  | import { GrafanaTheme2, isLiveChannelMessageEvent, isLiveChannelStatusEvent, LiveChannelScope } from '@grafana/data'; | ||||||
|  | import { getBackendSrv, getGrafanaLiveSrv } from '@grafana/runtime'; | ||||||
|  | import { CrawlerStartButton } from './CrawlerStartButton'; | ||||||
|  | 
 | ||||||
|  | interface CrawlerStatusMessage { | ||||||
|  |   state: string; | ||||||
|  |   started: string; | ||||||
|  |   finished: string; | ||||||
|  |   complete: number; | ||||||
|  |   queue: number; | ||||||
|  |   last: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const CrawlerStatus = () => { | ||||||
|  |   const styles = getStyles(useTheme2()); | ||||||
|  |   const [status, setStatus] = useState<CrawlerStatusMessage>(); | ||||||
|  | 
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     const subscription = getGrafanaLiveSrv() | ||||||
|  |       .getStream<CrawlerStatusMessage>({ | ||||||
|  |         scope: LiveChannelScope.Grafana, | ||||||
|  |         namespace: 'broadcast', | ||||||
|  |         path: 'crawler', | ||||||
|  |       }) | ||||||
|  |       .subscribe({ | ||||||
|  |         next: (evt) => { | ||||||
|  |           if (isLiveChannelMessageEvent(evt)) { | ||||||
|  |             setStatus(evt.message); | ||||||
|  |           } else if (isLiveChannelStatusEvent(evt)) { | ||||||
|  |             setStatus(evt.message); | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |       }); | ||||||
|  |     return () => { | ||||||
|  |       subscription.unsubscribe(); | ||||||
|  |     }; | ||||||
|  |   }, []); | ||||||
|  | 
 | ||||||
|  |   if (!status) { | ||||||
|  |     return ( | ||||||
|  |       <div className={styles.wrap}> | ||||||
|  |         No status (never run) | ||||||
|  |         <br /> | ||||||
|  |         <CrawlerStartButton /> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   return ( | ||||||
|  |     <div className={styles.wrap}> | ||||||
|  |       <pre>{JSON.stringify(status, null, 2)}</pre> | ||||||
|  |       {status.state !== 'running' && <CrawlerStartButton />} | ||||||
|  |       {status.state !== 'stopped' && ( | ||||||
|  |         <Button | ||||||
|  |           variant="secondary" | ||||||
|  |           onClick={() => { | ||||||
|  |             getBackendSrv().post('/api/admin/crawler/stop'); | ||||||
|  |           }} | ||||||
|  |         > | ||||||
|  |           Stop | ||||||
|  |         </Button> | ||||||
|  |       )} | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const getStyles = (theme: GrafanaTheme2) => { | ||||||
|  |   return { | ||||||
|  |     wrap: css` | ||||||
|  |       border: 4px solid red; | ||||||
|  |     `,
 | ||||||
|  |     running: css` | ||||||
|  |       border: 4px solid green; | ||||||
|  |     `,
 | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  | @ -6,6 +6,8 @@ import { AccessControlAction } from 'app/types'; | ||||||
| import { getServerStats, ServerStat } from './state/apis'; | import { getServerStats, ServerStat } from './state/apis'; | ||||||
| import { contextSrv } from '../../core/services/context_srv'; | import { contextSrv } from '../../core/services/context_srv'; | ||||||
| import { Loader } from '../plugins/admin/components/Loader'; | import { Loader } from '../plugins/admin/components/Loader'; | ||||||
|  | import { config } from '@grafana/runtime'; | ||||||
|  | import { CrawlerStatus } from './CrawlerStatus'; | ||||||
| 
 | 
 | ||||||
| export const ServerStats = () => { | export const ServerStats = () => { | ||||||
|   const [stats, setStats] = useState<ServerStat | null>(null); |   const [stats, setStats] = useState<ServerStat | null>(null); | ||||||
|  | @ -84,6 +86,8 @@ export const ServerStats = () => { | ||||||
|       ) : ( |       ) : ( | ||||||
|         <p className={styles.notFound}>No stats found.</p> |         <p className={styles.notFound}>No stats found.</p> | ||||||
|       )} |       )} | ||||||
|  | 
 | ||||||
|  |       {config.featureToggles.dashboardPreviews && <CrawlerStatus />} | ||||||
|     </> |     </> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue