mirror of
https://github.com/grafana/grafana.git
synced 2024-12-01 21:19:28 -06:00
455 lines
13 KiB
Go
455 lines
13 KiB
Go
package thumbs
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"time"
|
|
|
|
"github.com/grafana/grafana/pkg/api/response"
|
|
"github.com/grafana/grafana/pkg/infra/log"
|
|
"github.com/grafana/grafana/pkg/infra/serverlock"
|
|
"github.com/grafana/grafana/pkg/infra/usagestats"
|
|
"github.com/grafana/grafana/pkg/models"
|
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
|
"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/sqlstore"
|
|
"github.com/grafana/grafana/pkg/setting"
|
|
"github.com/grafana/grafana/pkg/web"
|
|
"github.com/segmentio/encoding/json"
|
|
)
|
|
|
|
type Service interface {
|
|
Run(ctx context.Context) error
|
|
Enabled() bool
|
|
GetImage(c *models.ReqContext)
|
|
GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig
|
|
|
|
// from dashboard page
|
|
SetImage(c *models.ReqContext) // form post
|
|
UpdateThumbnailState(c *models.ReqContext)
|
|
|
|
// Must be admin
|
|
StartCrawler(c *models.ReqContext) response.Response
|
|
StopCrawler(c *models.ReqContext) response.Response
|
|
CrawlerStatus(c *models.ReqContext) response.Response
|
|
}
|
|
|
|
type thumbService struct {
|
|
scheduleOptions crawlerScheduleOptions
|
|
renderer dashRenderer
|
|
renderingService rendering.Service
|
|
thumbnailRepo thumbnailRepo
|
|
lockService *serverlock.ServerLockService
|
|
features featuremgmt.FeatureToggles
|
|
store sqlstore.Store
|
|
crawlLockServiceActionName string
|
|
log log.Logger
|
|
usageStatsService usagestats.Service
|
|
}
|
|
|
|
type crawlerScheduleOptions struct {
|
|
crawlInterval time.Duration
|
|
tickerInterval time.Duration
|
|
maxCrawlDuration time.Duration
|
|
crawlerMode CrawlerMode
|
|
thumbnailKind models.ThumbnailKind
|
|
themes []models.Theme
|
|
auth CrawlerAuth
|
|
}
|
|
|
|
func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, lockService *serverlock.ServerLockService, renderService rendering.Service, gl *live.GrafanaLive, store *sqlstore.SQLStore, authSetupService CrawlerAuthSetupService, usageStatsService usagestats.Service) Service {
|
|
if !features.IsEnabled(featuremgmt.FlagDashboardPreviews) {
|
|
return &dummyService{}
|
|
}
|
|
logger := log.New("thumbnails_service")
|
|
|
|
thumbnailRepo := newThumbnailRepo(store)
|
|
|
|
crawlerAuth, err := authSetupService.Setup(context.Background())
|
|
if err != nil {
|
|
logger.Error("Failed to setup auth for the dashboard previews crawler", "err", err)
|
|
return &dummyService{}
|
|
}
|
|
t := &thumbService{
|
|
usageStatsService: usageStatsService,
|
|
renderingService: renderService,
|
|
renderer: newSimpleCrawler(renderService, gl, thumbnailRepo),
|
|
thumbnailRepo: thumbnailRepo,
|
|
store: store,
|
|
features: features,
|
|
lockService: lockService,
|
|
crawlLockServiceActionName: "dashboard-crawler",
|
|
log: logger,
|
|
|
|
scheduleOptions: crawlerScheduleOptions{
|
|
tickerInterval: time.Hour,
|
|
crawlInterval: time.Hour * 12,
|
|
maxCrawlDuration: time.Hour,
|
|
crawlerMode: CrawlerModeThumbs,
|
|
thumbnailKind: models.ThumbnailKindDefault,
|
|
themes: []models.Theme{models.ThemeDark, models.ThemeLight},
|
|
auth: crawlerAuth,
|
|
},
|
|
}
|
|
|
|
t.registerUsageStats()
|
|
return t
|
|
}
|
|
|
|
func (hs *thumbService) registerUsageStats() {
|
|
hs.usageStatsService.RegisterMetricsFunc(func(ctx context.Context) (map[string]interface{}, error) {
|
|
s := hs.getDashboardPreviewsSetupSettings(ctx)
|
|
|
|
stats := make(map[string]interface{})
|
|
|
|
if s.SystemRequirements.Met {
|
|
stats["stats.dashboard_previews.system_req_met.count"] = 1
|
|
}
|
|
|
|
if s.ThumbnailsExist {
|
|
stats["stats.dashboard_previews.thumbnails_exist.count"] = 1
|
|
}
|
|
|
|
return stats, nil
|
|
})
|
|
}
|
|
|
|
func (hs *thumbService) Enabled() bool {
|
|
return hs.features.IsEnabled(featuremgmt.FlagDashboardPreviews)
|
|
}
|
|
|
|
func (hs *thumbService) parseImageReq(c *models.ReqContext, checkSave bool) *previewRequest {
|
|
params := web.Params(c.Req)
|
|
|
|
kind, err := models.ParseThumbnailKind(params[":kind"])
|
|
if err != nil {
|
|
c.JSON(400, map[string]string{"error": "invalid size"})
|
|
return nil
|
|
}
|
|
|
|
theme, err := models.ParseTheme(params[":theme"])
|
|
if err != nil {
|
|
c.JSON(400, map[string]string{"error": "invalid theme"})
|
|
return nil
|
|
}
|
|
|
|
req := &previewRequest{
|
|
OrgID: c.OrgId,
|
|
UID: params[":uid"],
|
|
Theme: theme,
|
|
Kind: kind,
|
|
}
|
|
|
|
if len(req.UID) < 1 {
|
|
c.JSON(400, map[string]string{"error": "missing UID"})
|
|
return nil
|
|
}
|
|
|
|
// Check permissions and status
|
|
status := hs.getStatus(c, req.UID, checkSave)
|
|
if status != 200 {
|
|
c.JSON(status, map[string]string{"error": fmt.Sprintf("code: %d", status)})
|
|
return nil
|
|
}
|
|
return req
|
|
}
|
|
|
|
type updateThumbnailStateRequest struct {
|
|
State models.ThumbnailState `json:"state" binding:"Required"`
|
|
}
|
|
|
|
func (hs *thumbService) UpdateThumbnailState(c *models.ReqContext) {
|
|
req := hs.parseImageReq(c, false)
|
|
if req == nil {
|
|
return // already returned value
|
|
}
|
|
|
|
var body = &updateThumbnailStateRequest{}
|
|
|
|
err := web.Bind(c.Req, body)
|
|
if err != nil {
|
|
hs.log.Error("Error parsing update thumbnail state request", "dashboardUid", req.UID, "err", err.Error())
|
|
c.JSON(500, map[string]string{"dashboardUID": req.UID, "error": "unknown"})
|
|
return
|
|
}
|
|
|
|
err = hs.thumbnailRepo.updateThumbnailState(c.Req.Context(), body.State, models.DashboardThumbnailMeta{
|
|
DashboardUID: req.UID,
|
|
OrgId: req.OrgID,
|
|
Theme: req.Theme,
|
|
Kind: models.ThumbnailKindDefault,
|
|
})
|
|
|
|
if err != nil {
|
|
hs.log.Error("Error when trying to update thumbnail state", "dashboardUid", req.UID, "err", err.Error(), "newState", body.State)
|
|
c.JSON(500, map[string]string{"dashboardUID": req.UID, "error": "unknown"})
|
|
return
|
|
}
|
|
|
|
hs.log.Info("Updated dashboard thumbnail state", "dashboardUid", req.UID, "theme", req.Theme, "newState", body.State)
|
|
c.JSON(200, map[string]string{"success": "true"})
|
|
}
|
|
|
|
func (hs *thumbService) GetImage(c *models.ReqContext) {
|
|
req := hs.parseImageReq(c, false)
|
|
if req == nil {
|
|
return // already returned value
|
|
}
|
|
|
|
res, err := hs.thumbnailRepo.getThumbnail(c.Req.Context(), models.DashboardThumbnailMeta{
|
|
DashboardUID: req.UID,
|
|
OrgId: req.OrgID,
|
|
Theme: req.Theme,
|
|
Kind: models.ThumbnailKindDefault,
|
|
})
|
|
|
|
if errors.Is(err, models.ErrDashboardThumbnailNotFound) {
|
|
c.Resp.WriteHeader(404)
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
hs.log.Error("Error when retrieving thumbnail", "dashboardUid", req.UID, "err", err.Error())
|
|
c.JSON(500, map[string]string{"dashboardUID": req.UID, "error": "unknown"})
|
|
return
|
|
}
|
|
|
|
c.Resp.Header().Set("Content-Type", res.MimeType)
|
|
if _, err := c.Resp.Write(res.Image); err != nil {
|
|
hs.log.Error("Error writing to response", "dashboardUid", req.UID, "err", err)
|
|
}
|
|
}
|
|
|
|
func (hs *thumbService) GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig {
|
|
return hs.getDashboardPreviewsSetupSettings(c.Req.Context())
|
|
}
|
|
|
|
func (hs *thumbService) getDashboardPreviewsSetupSettings(ctx context.Context) dashboardPreviewsSetupConfig {
|
|
systemRequirements := hs.getSystemRequirements()
|
|
thumbnailsExist, err := hs.thumbnailRepo.doThumbnailsExist(ctx)
|
|
|
|
if err != nil {
|
|
return dashboardPreviewsSetupConfig{
|
|
SystemRequirements: systemRequirements,
|
|
ThumbnailsExist: false,
|
|
}
|
|
}
|
|
|
|
return dashboardPreviewsSetupConfig{
|
|
SystemRequirements: systemRequirements,
|
|
ThumbnailsExist: thumbnailsExist,
|
|
}
|
|
}
|
|
|
|
func (hs *thumbService) getSystemRequirements() dashboardPreviewsSystemRequirements {
|
|
res, err := hs.renderingService.HasCapability(rendering.ScalingDownImages)
|
|
if err != nil {
|
|
hs.log.Error("Error when verifying dashboard previews system requirements thumbnail", "err", err.Error())
|
|
return dashboardPreviewsSystemRequirements{
|
|
Met: false,
|
|
}
|
|
}
|
|
|
|
if !res.IsSupported {
|
|
return dashboardPreviewsSystemRequirements{
|
|
Met: false,
|
|
RequiredImageRendererPluginVersion: res.SemverConstraint,
|
|
}
|
|
}
|
|
|
|
return dashboardPreviewsSystemRequirements{
|
|
Met: true,
|
|
}
|
|
}
|
|
|
|
// Hack for now -- lets you upload images explicitly
|
|
func (hs *thumbService) SetImage(c *models.ReqContext) {
|
|
req := hs.parseImageReq(c, false)
|
|
if req == nil {
|
|
return // already returned value
|
|
}
|
|
|
|
r := c.Req
|
|
|
|
// Parse our multipart form, 10 << 20 specifies a maximum
|
|
// upload of 10 MB files.
|
|
err := r.ParseMultipartForm(10 << 20)
|
|
if err != nil {
|
|
c.JSON(400, map[string]string{"error": "invalid upload size"})
|
|
return
|
|
}
|
|
|
|
// FormFile returns the first file for the given key `myFile`
|
|
// it also returns the FileHeader so we can get the Filename,
|
|
// the Header and the size of the file
|
|
file, handler, err := r.FormFile("file")
|
|
if err != nil {
|
|
c.JSON(400, map[string]string{"error": "missing multi-part form field named 'file'"})
|
|
fmt.Println("error", err)
|
|
return
|
|
}
|
|
defer func() {
|
|
_ = file.Close()
|
|
}()
|
|
hs.log.Info("Uploaded File: %+v\n", handler.Filename)
|
|
hs.log.Info("File Size: %+v\n", handler.Size)
|
|
hs.log.Info("MIME Header: %+v\n", handler.Header)
|
|
|
|
fileBytes, err := ioutil.ReadAll(file)
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
c.JSON(400, map[string]string{"error": "error reading file"})
|
|
return
|
|
}
|
|
|
|
_, err = hs.thumbnailRepo.saveFromBytes(c.Req.Context(), fileBytes, getMimeType(handler.Filename), models.DashboardThumbnailMeta{
|
|
DashboardUID: req.UID,
|
|
OrgId: req.OrgID,
|
|
Theme: req.Theme,
|
|
Kind: req.Kind,
|
|
}, models.DashboardVersionForManualThumbnailUpload)
|
|
|
|
if err != nil {
|
|
c.JSON(400, map[string]string{"error": "error saving thumbnail file"})
|
|
fmt.Println("error", err)
|
|
return
|
|
}
|
|
|
|
c.JSON(200, map[string]int{"OK": len(fileBytes)})
|
|
}
|
|
|
|
func (hs *thumbService) StartCrawler(c *models.ReqContext) response.Response {
|
|
body, err := io.ReadAll(c.Req.Body)
|
|
if err != nil {
|
|
return response.Error(500, "error reading bytes", err)
|
|
}
|
|
cmd := &crawlCmd{}
|
|
err = json.Unmarshal(body, cmd)
|
|
if err != nil {
|
|
return response.Error(500, "error parsing bytes", err)
|
|
}
|
|
if cmd.Mode == "" {
|
|
cmd.Mode = CrawlerModeThumbs
|
|
}
|
|
|
|
go hs.runOnDemandCrawl(context.Background(), cmd.Theme, cmd.Mode, models.ThumbnailKindDefault, rendering.AuthOpts{
|
|
OrgID: c.OrgId,
|
|
UserID: c.UserId,
|
|
OrgRole: c.OrgRole,
|
|
})
|
|
|
|
status, err := hs.renderer.Status()
|
|
if err != nil {
|
|
return response.Error(500, "error starting", err)
|
|
}
|
|
|
|
return response.JSON(200, status)
|
|
}
|
|
|
|
func (hs *thumbService) StopCrawler(c *models.ReqContext) response.Response {
|
|
msg, err := hs.renderer.Stop()
|
|
if err != nil {
|
|
return response.Error(500, "error starting", err)
|
|
}
|
|
return response.JSON(200, msg)
|
|
}
|
|
|
|
func (hs *thumbService) CrawlerStatus(c *models.ReqContext) response.Response {
|
|
msg, err := hs.renderer.Status()
|
|
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!
|
|
func (hs *thumbService) getStatus(c *models.ReqContext, uid string, checkSave bool) int {
|
|
dashboardID, err := hs.getDashboardId(c, uid)
|
|
if err != nil {
|
|
return 404
|
|
}
|
|
|
|
guardian := guardian.New(c.Req.Context(), dashboardID, c.OrgId, c.SignedInUser)
|
|
if checkSave {
|
|
if canSave, err := guardian.CanSave(); err != nil || !canSave {
|
|
return 403 // forbidden
|
|
}
|
|
return 200
|
|
}
|
|
|
|
if canView, err := guardian.CanView(); err != nil || !canView {
|
|
return 403 // forbidden
|
|
}
|
|
|
|
return 200 // found and OK
|
|
}
|
|
|
|
func (hs *thumbService) getDashboardId(c *models.ReqContext, uid string) (int64, error) {
|
|
query := models.GetDashboardQuery{Uid: uid, OrgId: c.OrgId}
|
|
|
|
if err := hs.store.GetDashboard(c.Req.Context(), &query); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return query.Result.Id, nil
|
|
}
|
|
|
|
func (hs *thumbService) runOnDemandCrawl(parentCtx context.Context, theme models.Theme, mode CrawlerMode, kind models.ThumbnailKind, authOpts rendering.AuthOpts) {
|
|
crawlerCtx, cancel := context.WithTimeout(parentCtx, hs.scheduleOptions.maxCrawlDuration)
|
|
defer cancel()
|
|
|
|
// wait for at least a minute after the last completed run
|
|
interval := time.Minute
|
|
err := hs.lockService.LockAndExecute(crawlerCtx, hs.crawlLockServiceActionName, interval, func(ctx context.Context) {
|
|
if err := hs.renderer.Run(crawlerCtx, hs.scheduleOptions.auth, mode, theme, kind); err != nil {
|
|
hs.log.Error("On demand crawl error", "mode", mode, "theme", theme, "kind", kind, "userId", authOpts.UserID, "orgId", authOpts.OrgID, "orgRole", authOpts.OrgRole)
|
|
}
|
|
})
|
|
|
|
if err != nil {
|
|
hs.log.Error("On demand crawl lock error", "err", err)
|
|
}
|
|
}
|
|
|
|
func (hs *thumbService) runScheduledCrawl(parentCtx context.Context) {
|
|
crawlerCtx, cancel := context.WithTimeout(parentCtx, hs.scheduleOptions.maxCrawlDuration)
|
|
defer cancel()
|
|
|
|
err := hs.lockService.LockAndExecute(crawlerCtx, hs.crawlLockServiceActionName, hs.scheduleOptions.crawlInterval, func(ctx context.Context) {
|
|
for _, theme := range hs.scheduleOptions.themes {
|
|
if err := hs.renderer.Run(crawlerCtx, hs.scheduleOptions.auth, hs.scheduleOptions.crawlerMode, theme, hs.scheduleOptions.thumbnailKind); err != nil {
|
|
hs.log.Error("Scheduled crawl error", "theme", theme, "kind", hs.scheduleOptions.thumbnailKind, "err", err)
|
|
}
|
|
}
|
|
})
|
|
|
|
if err != nil {
|
|
hs.log.Error("Scheduled crawl lock error", "err", err)
|
|
}
|
|
}
|
|
|
|
func (hs *thumbService) Run(ctx context.Context) error {
|
|
if !hs.features.IsEnabled(featuremgmt.FlagDashboardPreviewsScheduler) {
|
|
return nil
|
|
}
|
|
|
|
gc := time.NewTicker(hs.scheduleOptions.tickerInterval)
|
|
|
|
for {
|
|
select {
|
|
case <-gc.C:
|
|
go hs.runScheduledCrawl(ctx)
|
|
case <-ctx.Done():
|
|
hs.log.Debug("Grafana is shutting down - stopping dashboard crawler")
|
|
gc.Stop()
|
|
|
|
return nil
|
|
}
|
|
}
|
|
}
|