Dash previews: populate crawler queue from SQL query (#44083)

* add SQL migrations

* dashboard previews from sql: poc

* added todos

* refactor: use the same enums where possible

* use useEffect, always return json

* added todo

* refactor + delete files after use

* refactor + fix manual thumbnail upload

* refactor: move all interactions with sqlStore to thumbnail repo

* refactor: remove file operations in thumb crawler/service

* refactor: fix dashboard_thumbs sql store

* refactor: extracted thumbnail fetching/updating to a hook

* refactor: store thumbnails in redux store

* refactor: store thumbnails in redux store

* refactor: private'd repo methods

* removed redux storage, saving images as blobs

* allow for configurable rendering timeouts

* added 1) query for dashboards with stale thumbnails, 2) command for marking thumbnails as stale

* use sql-based queue in crawler

* ui for marking thumbnails as stale

* replaced `stale` boolean prop with `state` enum

* introduce rendering session

* compilation errors

* fix crawler stop button

* rename thumbnail state frozen to locked

* #44449: fix merge conflicts

* #44449: remove thumb methods from `Store` interface

* #44449: clean filepath, defer file closing

* #44449: fix rendering.Theme cyclic import

* #44449: linting

* #44449: linting

* #44449: mutex'd crawlerStatus access

* #44449: added integration tests for `sqlstore.dashboard_thumbs`

* #44449: added comments to explain the `ThumbnailState` enum

* #44449: use os.ReadFile rather then os.Open

* #44449: always enable dashboardPreviews feature during integration tests

* #44449: remove sleep time, adjust number of threads

* #44449: review fix: add `orgId` to `DashboardThumbnailMeta`

* #44449: review fix: automatic parsing of thumbnailState

* #44449: lint fixes

* #44449: review fix: prefer `WithDbSession` over `WithTransactionalDbSession`

* #44449: review fix: add a comment explaining source of the filepath

* #44449: review fix: added filepath validation

* #44449: review fixes https://github.com/grafana/grafana/pull/45063/files @fzambia

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
Co-authored-by: Alexander Emelin <frvzmb@gmail.com>
This commit is contained in:
Artur Wierzbicki
2022-02-09 13:23:32 +04:00
committed by GitHub
parent 4e3a72fc2a
commit a025109647
22 changed files with 1003 additions and 306 deletions

View File

@@ -335,8 +335,9 @@ func (hs *HTTPServer) registerRoutes() {
dashboardRoute.Delete("/uid/:uid", routing.Wrap(hs.DeleteDashboardByUID)) dashboardRoute.Delete("/uid/:uid", routing.Wrap(hs.DeleteDashboardByUID))
if hs.ThumbService != nil { if hs.ThumbService != nil {
dashboardRoute.Get("/uid/:uid/img/:size/:theme", hs.ThumbService.GetImage) dashboardRoute.Get("/uid/:uid/img/:kind/:theme", hs.ThumbService.GetImage)
dashboardRoute.Post("/uid/:uid/img/:size/:theme", hs.ThumbService.SetImage) dashboardRoute.Post("/uid/:uid/img/:kind/:theme", hs.ThumbService.SetImage)
dashboardRoute.Put("/uid/:uid/img/:kind/:theme", hs.ThumbService.UpdateThumbnailState)
} }
dashboardRoute.Post("/calculate-diff", routing.Wrap(CalculateDashboardDiff)) dashboardRoute.Post("/calculate-diff", routing.Wrap(CalculateDashboardDiff))

View File

@@ -69,7 +69,7 @@ func (hs *HTTPServer) RenderToPng(c *models.ReqContext) {
ConcurrentLimit: hs.Cfg.RendererConcurrentRequestLimit, ConcurrentLimit: hs.Cfg.RendererConcurrentRequestLimit,
DeviceScaleFactor: scale, DeviceScaleFactor: scale,
Headers: headers, Headers: headers,
Theme: rendering.ThemeDark, Theme: models.ThemeDark,
}, nil) }, nil)
if err != nil { if err != nil {
if errors.Is(err, rendering.ErrTimeout) { if errors.Is(err, rendering.ErrTimeout) {

View File

@@ -0,0 +1,137 @@
package models
import (
"encoding/json"
"errors"
"fmt"
"time"
)
type ThumbnailKind string
type ThumbnailState string
type CrawlerMode string
const (
// ThumbnailKindDefault is a small 320x240 preview
ThumbnailKindDefault ThumbnailKind = "thumb"
// unsupported for now
// - ThumbnailKindLarge ThumbnailKind = "large"
// - ThumbnailKindTall ThumbnailKind = "tall"
)
const (
// ThumbnailStateDefault is the initial state for all thumbnails. Thumbnails in the "default" state will be considered stale,
// and thus refreshed by the crawler, if the dashboard version from the time of taking the thumbnail is different from the current dashboard version
ThumbnailStateDefault ThumbnailState = "default"
// ThumbnailStateStale is a manually assigned state. Thumbnails in the "stale" state will be refreshed on the next crawler run
ThumbnailStateStale ThumbnailState = "stale"
// ThumbnailStateLocked is a manually assigned state. Thumbnails in the "locked" state will not be refreshed by the crawler as long as they remain in the "locked" state.
ThumbnailStateLocked ThumbnailState = "locked"
)
func (s ThumbnailState) IsValid() bool {
return s == ThumbnailStateDefault || s == ThumbnailStateStale || s == ThumbnailStateLocked
}
func (s *ThumbnailState) UnmarshalJSON(data []byte) error {
var str string
err := json.Unmarshal(data, &str)
if err != nil {
return err
}
*s = ThumbnailState(str)
if !s.IsValid() {
if (*s) != "" {
return fmt.Errorf("JSON validation error: invalid thumbnail state value: %s", *s)
}
*s = ThumbnailStateDefault
}
return nil
}
// IsKnownThumbnailKind checks if the value is supported
func (p ThumbnailKind) IsKnownThumbnailKind() bool {
switch p {
case
ThumbnailKindDefault:
return true
}
return false
}
func ParseThumbnailKind(str string) (ThumbnailKind, error) {
switch str {
case string(ThumbnailKindDefault):
return ThumbnailKindDefault, nil
}
return ThumbnailKindDefault, errors.New("unknown thumbnail kind " + str)
}
// A DashboardThumbnail includes all metadata for a dashboard thumbnail
type DashboardThumbnail struct {
Id int64 `json:"id"`
DashboardId int64 `json:"dashboardId"`
DashboardVersion int `json:"dashboardVersion"`
State ThumbnailState `json:"state"`
PanelId int64 `json:"panelId,omitempty"`
Kind ThumbnailKind `json:"kind"`
Theme Theme `json:"theme"`
Image []byte `json:"image"`
MimeType string `json:"mimeType"`
Updated time.Time `json:"updated"`
}
//
// Commands
//
// DashboardThumbnailMeta uniquely identifies a thumbnail; a natural key
type DashboardThumbnailMeta struct {
DashboardUID string
OrgId int64
PanelID int64
Kind ThumbnailKind
Theme Theme
}
type GetDashboardThumbnailCommand struct {
DashboardThumbnailMeta
Result *DashboardThumbnail
}
const DashboardVersionForManualThumbnailUpload = -1
type DashboardWithStaleThumbnail struct {
Id int64
OrgId int64
Uid string
Version int
Slug string
}
type FindDashboardsWithStaleThumbnailsCommand struct {
IncludeManuallyUploadedThumbnails bool
Result []*DashboardWithStaleThumbnail
}
type SaveDashboardThumbnailCommand struct {
DashboardThumbnailMeta
DashboardVersion int
Image []byte
MimeType string
Result *DashboardThumbnail
}
type UpdateThumbnailStateCommand struct {
State ThumbnailState
DashboardThumbnailMeta
}

View File

@@ -108,6 +108,11 @@ var (
StatusCode: 404, StatusCode: 404,
Status: "not-found", Status: "not-found",
} }
ErrDashboardThumbnailNotFound = DashboardErr{
Reason: "Dashboard thumbnail not found",
StatusCode: 404,
Status: "not-found",
}
) )
// DashboardErr represents a dashboard error. // DashboardErr represents a dashboard error.
@@ -317,6 +322,11 @@ func GetDashboardUrl(uid string, slug string) string {
return fmt.Sprintf("%s/d/%s/%s", setting.AppSubUrl, uid, slug) return fmt.Sprintf("%s/d/%s/%s", setting.AppSubUrl, uid, slug)
} }
// GetKioskModeDashboardUrl returns the HTML url for a dashboard in kiosk mode.
func GetKioskModeDashboardUrl(uid string, slug string) string {
return fmt.Sprintf("%s?kiosk", GetDashboardUrl(uid, slug))
}
// GetFullDashboardUrl returns the full URL for a dashboard. // GetFullDashboardUrl returns the full URL for a dashboard.
func GetFullDashboardUrl(uid string, slug string) string { func GetFullDashboardUrl(uid string, slug string) string {
return fmt.Sprintf("%sd/%s/%s", setting.AppUrl, uid, slug) return fmt.Sprintf("%sd/%s/%s", setting.AppUrl, uid, slug)

20
pkg/models/theme.go Normal file
View File

@@ -0,0 +1,20 @@
package models
import "errors"
type Theme string
const (
ThemeLight Theme = "light"
ThemeDark Theme = "dark"
)
func ParseTheme(str string) (Theme, error) {
switch str {
case string(ThemeLight):
return ThemeLight, nil
case string(ThemeDark):
return ThemeDark, nil
}
return ThemeDark, errors.New("unknown theme " + str)
}

View File

@@ -214,7 +214,7 @@ func (n *notificationService) renderAndUploadImage(evalCtx *EvalContext, timeout
Width: 1000, Width: 1000,
Height: 500, Height: 500,
ConcurrentLimit: setting.AlertingRenderLimit, ConcurrentLimit: setting.AlertingRenderLimit,
Theme: rendering.ThemeDark, Theme: models.ThemeDark,
} }
ref, err := evalCtx.GetDashboardUID() ref, err := evalCtx.GetDashboardUID()

View File

@@ -373,7 +373,7 @@ func (s *testRenderService) RenderCSV(ctx context.Context, opts rendering.CSVOpt
return nil, nil return nil, nil
} }
func (s *testRenderService) RenderErrorImage(theme rendering.Theme, err error) (*rendering.RenderResult, error) { func (s *testRenderService) RenderErrorImage(theme models.Theme, err error) (*rendering.RenderResult, error) {
if s.renderErrorImageProvider != nil { if s.renderErrorImageProvider != nil {
return s.renderErrorImageProvider(err) return s.renderErrorImageProvider(err)
} }

View File

@@ -19,13 +19,6 @@ const (
RenderPNG RenderType = "png" RenderPNG RenderType = "png"
) )
type Theme string
const (
ThemeLight Theme = "light"
ThemeDark Theme = "dark"
)
type TimeoutOpts struct { type TimeoutOpts struct {
Timeout time.Duration // Timeout param passed to image-renderer service Timeout time.Duration // Timeout param passed to image-renderer service
RequestTimeoutMultiplier time.Duration // RequestTimeoutMultiplier used for plugin/HTTP request context timeout RequestTimeoutMultiplier time.Duration // RequestTimeoutMultiplier used for plugin/HTTP request context timeout
@@ -56,7 +49,7 @@ type Opts struct {
ConcurrentLimit int ConcurrentLimit int
DeviceScaleFactor float64 DeviceScaleFactor float64
Headers map[string][]string Headers map[string][]string
Theme Theme Theme models.Theme
} }
type CSVOpts struct { type CSVOpts struct {
@@ -106,7 +99,7 @@ type Service interface {
Version() string Version() string
Render(ctx context.Context, opts Opts, session Session) (*RenderResult, error) Render(ctx context.Context, opts Opts, session Session) (*RenderResult, error)
RenderCSV(ctx context.Context, opts CSVOpts, session Session) (*RenderCSVResult, error) RenderCSV(ctx context.Context, opts CSVOpts, session Session) (*RenderCSVResult, error)
RenderErrorImage(theme Theme, error error) (*RenderResult, error) RenderErrorImage(theme models.Theme, error error) (*RenderResult, error)
GetRenderUser(ctx context.Context, key string) (*RenderUser, bool) GetRenderUser(ctx context.Context, key string) (*RenderUser, bool)
HasCapability(capability CapabilityName) (CapabilitySupportRequestResult, error) HasCapability(capability CapabilityName) (CapabilitySupportRequestResult, error)
CreateRenderingSession(ctx context.Context, authOpts AuthOpts, sessionOpts SessionOpts) (Session, error) CreateRenderingSession(ctx context.Context, authOpts AuthOpts, sessionOpts SessionOpts) (Session, error)

View File

@@ -17,6 +17,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
@@ -176,9 +177,9 @@ func (rs *RenderingService) Version() string {
return rs.version return rs.version
} }
func (rs *RenderingService) RenderErrorImage(theme Theme, err error) (*RenderResult, error) { func (rs *RenderingService) RenderErrorImage(theme models.Theme, err error) (*RenderResult, error) {
if theme == "" { if theme == "" {
theme = ThemeDark theme = models.ThemeDark
} }
imgUrl := "public/img/rendering_%s_%s.png" imgUrl := "public/img/rendering_%s_%s.png"
if errors.Is(err, ErrTimeout) { if errors.Is(err, ErrTimeout) {
@@ -224,7 +225,7 @@ func (rs *RenderingService) render(ctx context.Context, opts Opts, renderKeyProv
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) rs.log.Warn("Could not render image, hit the currency limit", "concurrencyLimit", opts.ConcurrentLimit, "path", opts.Path)
theme := ThemeDark theme := models.ThemeDark
if opts.Theme != "" { if opts.Theme != "" {
theme = opts.Theme theme = opts.Theme
} }

View File

@@ -10,6 +10,7 @@ import (
"time" "time"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -83,13 +84,13 @@ func TestRenderErrorImage(t *testing.T) {
}) })
t.Run("Timeout error returns timeout error image", func(t *testing.T) { t.Run("Timeout error returns timeout error image", func(t *testing.T) {
result, err := rs.RenderErrorImage(ThemeLight, ErrTimeout) result, err := rs.RenderErrorImage(models.ThemeLight, ErrTimeout)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, result.FilePath, path+"/public/img/rendering_timeout_light.png") assert.Equal(t, result.FilePath, path+"/public/img/rendering_timeout_light.png")
}) })
t.Run("Generic error returns error image", func(t *testing.T) { t.Run("Generic error returns error image", func(t *testing.T) {
result, err := rs.RenderErrorImage(ThemeLight, errors.New("an error")) result, err := rs.RenderErrorImage(models.ThemeLight, errors.New("an error"))
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, result.FilePath, path+"/public/img/rendering_error_light.png") assert.Equal(t, result.FilePath, path+"/public/img/rendering_error_light.png")
}) })
@@ -115,17 +116,17 @@ func TestRenderLimitImage(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
theme Theme theme models.Theme
expected string expected string
}{ }{
{ {
name: "Light theme returns light image", name: "Light theme returns light image",
theme: ThemeLight, theme: models.ThemeLight,
expected: path + "/public/img/rendering_limit_light.png", expected: path + "/public/img/rendering_limit_light.png",
}, },
{ {
name: "Dark theme returns dark image", name: "Dark theme returns dark image",
theme: ThemeDark, theme: models.ThemeDark,
expected: path + "/public/img/rendering_limit_dark.png", expected: path + "/public/img/rendering_limit_dark.png",
}, },
{ {

View File

@@ -0,0 +1,167 @@
package sqlstore
import (
"context"
"errors"
"time"
"github.com/grafana/grafana/pkg/models"
)
func (ss *SQLStore) GetThumbnail(ctx context.Context, query *models.GetDashboardThumbnailCommand) (*models.DashboardThumbnail, error) {
err := ss.WithDbSession(ctx, func(sess *DBSession) error {
result, err := findThumbnailByMeta(sess, query.DashboardThumbnailMeta)
if err != nil {
return err
}
query.Result = result
return nil
})
return query.Result, err
}
func (ss *SQLStore) SaveThumbnail(ctx context.Context, cmd *models.SaveDashboardThumbnailCommand) (*models.DashboardThumbnail, error) {
err := ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error {
existing, err := findThumbnailByMeta(sess, cmd.DashboardThumbnailMeta)
if err != nil && !errors.Is(err, models.ErrDashboardThumbnailNotFound) {
return err
}
if existing != nil {
existing.Image = cmd.Image
existing.MimeType = cmd.MimeType
existing.Updated = time.Now()
existing.DashboardVersion = cmd.DashboardVersion
existing.State = models.ThumbnailStateDefault
_, err = sess.ID(existing.Id).Update(existing)
cmd.Result = existing
return err
}
thumb := &models.DashboardThumbnail{}
dash, err := findDashboardIdByThumbMeta(sess, cmd.DashboardThumbnailMeta)
if err != nil {
return err
}
thumb.Updated = time.Now()
thumb.Theme = cmd.Theme
thumb.Kind = cmd.Kind
thumb.Image = cmd.Image
thumb.MimeType = cmd.MimeType
thumb.DashboardId = dash.Id
thumb.DashboardVersion = cmd.DashboardVersion
thumb.State = models.ThumbnailStateDefault
thumb.PanelId = cmd.PanelID
_, err = sess.Insert(thumb)
cmd.Result = thumb
return err
})
return cmd.Result, err
}
func (ss *SQLStore) UpdateThumbnailState(ctx context.Context, cmd *models.UpdateThumbnailStateCommand) error {
err := ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error {
existing, err := findThumbnailByMeta(sess, cmd.DashboardThumbnailMeta)
if err != nil {
return err
}
existing.State = cmd.State
_, err = sess.ID(existing.Id).Update(existing)
return err
})
return err
}
func (ss *SQLStore) FindDashboardsWithStaleThumbnails(ctx context.Context, cmd *models.FindDashboardsWithStaleThumbnailsCommand) ([]*models.DashboardWithStaleThumbnail, error) {
err := ss.WithDbSession(ctx, func(sess *DBSession) error {
sess.Table("dashboard")
sess.Join("LEFT", "dashboard_thumbnail", "dashboard.id = dashboard_thumbnail.dashboard_id")
sess.Where("dashboard.is_folder = ?", dialect.BooleanStr(false))
sess.Where("(dashboard.version != dashboard_thumbnail.dashboard_version "+
"OR dashboard_thumbnail.state = ? "+
"OR dashboard_thumbnail.id IS NULL)", models.ThumbnailStateStale)
if !cmd.IncludeManuallyUploadedThumbnails {
sess.Where("(dashboard_thumbnail.id is not null AND dashboard_thumbnail.dashboard_version != ?) "+
"OR dashboard_thumbnail.id is null "+
"OR dashboard_thumbnail.state = ?", models.DashboardVersionForManualThumbnailUpload, models.ThumbnailStateStale)
}
sess.Where("(dashboard_thumbnail.id IS NULL OR dashboard_thumbnail.state != ?)", models.ThumbnailStateLocked)
sess.Cols("dashboard.id",
"dashboard.uid",
"dashboard.org_id",
"dashboard.version",
"dashboard.slug")
var dashboards = make([]*models.DashboardWithStaleThumbnail, 0)
err := sess.Find(&dashboards)
if err != nil {
return err
}
cmd.Result = dashboards
return err
})
return cmd.Result, err
}
func findThumbnailByMeta(sess *DBSession, meta models.DashboardThumbnailMeta) (*models.DashboardThumbnail, error) {
result := &models.DashboardThumbnail{}
sess.Table("dashboard_thumbnail")
sess.Join("INNER", "dashboard", "dashboard.id = dashboard_thumbnail.dashboard_id")
sess.Where("dashboard.uid = ? AND dashboard.org_id = ? AND panel_id = ? AND kind = ? AND theme = ?", meta.DashboardUID, meta.OrgId, meta.PanelID, meta.Kind, meta.Theme)
sess.Cols("dashboard_thumbnail.id",
"dashboard_thumbnail.dashboard_id",
"dashboard_thumbnail.panel_id",
"dashboard_thumbnail.image",
"dashboard_thumbnail.dashboard_version",
"dashboard_thumbnail.state",
"dashboard_thumbnail.kind",
"dashboard_thumbnail.mime_type",
"dashboard_thumbnail.theme",
"dashboard_thumbnail.updated")
exists, err := sess.Get(result)
if !exists {
return nil, models.ErrDashboardThumbnailNotFound
}
if err != nil {
return nil, err
}
return result, nil
}
type dash struct {
Id int64
}
func findDashboardIdByThumbMeta(sess *DBSession, meta models.DashboardThumbnailMeta) (*dash, error) {
result := &dash{}
sess.Table("dashboard").Where("dashboard.uid = ? AND dashboard.org_id = ?", meta.DashboardUID, meta.OrgId).Cols("id")
exists, err := sess.Get(result)
if err != nil {
return nil, err
}
if !exists {
return nil, models.ErrDashboardNotFound
}
return result, err
}

View File

@@ -0,0 +1,216 @@
//go:build integration
// +build integration
package sqlstore
import (
"context"
"testing"
"github.com/grafana/grafana/pkg/models"
"github.com/stretchr/testify/require"
)
func TestSqlStorage(t *testing.T) {
var sqlStore *SQLStore
var savedFolder *models.Dashboard
setup := func() {
sqlStore = InitTestDB(t)
savedFolder = insertTestDashboard(t, sqlStore, "1 test dash folder", 1, 0, true, "prod", "webapp")
}
t.Run("Should insert dashboard in default state", func(t *testing.T) {
setup()
dash := insertTestDashboard(t, sqlStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp")
upsertTestDashboardThumbnail(t, sqlStore, dash.Uid, dash.OrgId, dash.Version)
thumb := getThumbnail(t, sqlStore, dash.Uid, dash.OrgId)
require.Positive(t, thumb.Id)
require.Equal(t, models.ThumbnailStateDefault, thumb.State)
require.Equal(t, dash.Version, thumb.DashboardVersion)
})
t.Run("Should be able to update the thumbnail", func(t *testing.T) {
setup()
dash := insertTestDashboard(t, sqlStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp")
upsertTestDashboardThumbnail(t, sqlStore, dash.Uid, dash.OrgId, dash.Version)
thumb := getThumbnail(t, sqlStore, dash.Uid, dash.OrgId)
insertedThumbnailId := thumb.Id
upsertTestDashboardThumbnail(t, sqlStore, dash.Uid, dash.OrgId, dash.Version+1)
updatedThumb := getThumbnail(t, sqlStore, dash.Uid, dash.OrgId)
require.Equal(t, insertedThumbnailId, updatedThumb.Id)
require.Equal(t, dash.Version+1, updatedThumb.DashboardVersion)
})
t.Run("Should return empty array if all dashboards have thumbnails", func(t *testing.T) {
setup()
dash := insertTestDashboard(t, sqlStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp")
upsertTestDashboardThumbnail(t, sqlStore, dash.Uid, dash.OrgId, dash.Version)
cmd := models.FindDashboardsWithStaleThumbnailsCommand{}
res, err := sqlStore.FindDashboardsWithStaleThumbnails(context.Background(), &cmd)
require.NoError(t, err)
require.Len(t, res, 0)
})
t.Run("Should return dashboards with thumbnails marked as stale", func(t *testing.T) {
setup()
dash := insertTestDashboard(t, sqlStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp")
upsertTestDashboardThumbnail(t, sqlStore, dash.Uid, dash.OrgId, dash.Version)
updateThumbnailState(t, sqlStore, dash.Uid, dash.OrgId, models.ThumbnailStateStale)
cmd := models.FindDashboardsWithStaleThumbnailsCommand{}
res, err := sqlStore.FindDashboardsWithStaleThumbnails(context.Background(), &cmd)
require.NoError(t, err)
require.Len(t, res, 1)
require.Equal(t, dash.Id, res[0].Id)
})
t.Run("Should not return dashboards with updated thumbnails that had been marked as stale", func(t *testing.T) {
setup()
dash := insertTestDashboard(t, sqlStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp")
upsertTestDashboardThumbnail(t, sqlStore, dash.Uid, dash.OrgId, dash.Version)
updateThumbnailState(t, sqlStore, dash.Uid, dash.OrgId, models.ThumbnailStateStale)
upsertTestDashboardThumbnail(t, sqlStore, dash.Uid, dash.OrgId, dash.Version)
cmd := models.FindDashboardsWithStaleThumbnailsCommand{}
res, err := sqlStore.FindDashboardsWithStaleThumbnails(context.Background(), &cmd)
require.NoError(t, err)
require.Len(t, res, 0)
})
t.Run("Should find dashboards without thumbnails", func(t *testing.T) {
setup()
dash := insertTestDashboard(t, sqlStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp")
cmd := models.FindDashboardsWithStaleThumbnailsCommand{}
res, err := sqlStore.FindDashboardsWithStaleThumbnails(context.Background(), &cmd)
require.NoError(t, err)
require.Len(t, res, 1)
require.Equal(t, dash.Id, res[0].Id)
})
t.Run("Should find dashboards with outdated thumbnails", func(t *testing.T) {
setup()
dash := insertTestDashboard(t, sqlStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp")
upsertTestDashboardThumbnail(t, sqlStore, dash.Uid, dash.OrgId, dash.Version)
updateTestDashboard(t, sqlStore, dash, map[string]interface{}{
"tags": "different-tag",
})
cmd := models.FindDashboardsWithStaleThumbnailsCommand{}
res, err := sqlStore.FindDashboardsWithStaleThumbnails(context.Background(), &cmd)
require.NoError(t, err)
require.Len(t, res, 1)
require.Equal(t, dash.Id, res[0].Id)
})
t.Run("Should not return dashboards with locked thumbnails even if they are outdated", func(t *testing.T) {
setup()
dash := insertTestDashboard(t, sqlStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp")
upsertTestDashboardThumbnail(t, sqlStore, dash.Uid, dash.OrgId, dash.Version)
updateThumbnailState(t, sqlStore, dash.Uid, dash.OrgId, models.ThumbnailStateLocked)
updateTestDashboard(t, sqlStore, dash, map[string]interface{}{
"tags": "different-tag",
})
cmd := models.FindDashboardsWithStaleThumbnailsCommand{}
res, err := sqlStore.FindDashboardsWithStaleThumbnails(context.Background(), &cmd)
require.NoError(t, err)
require.Len(t, res, 0)
})
t.Run("Should not return dashboards with manually uploaded thumbnails by default", func(t *testing.T) {
setup()
dash := insertTestDashboard(t, sqlStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp")
upsertTestDashboardThumbnail(t, sqlStore, dash.Uid, dash.OrgId, models.DashboardVersionForManualThumbnailUpload)
updateTestDashboard(t, sqlStore, dash, map[string]interface{}{
"tags": "different-tag",
})
cmd := models.FindDashboardsWithStaleThumbnailsCommand{}
res, err := sqlStore.FindDashboardsWithStaleThumbnails(context.Background(), &cmd)
require.NoError(t, err)
require.Len(t, res, 0)
})
t.Run("Should return dashboards with manually uploaded thumbnails if requested", func(t *testing.T) {
setup()
dash := insertTestDashboard(t, sqlStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp")
upsertTestDashboardThumbnail(t, sqlStore, dash.Uid, dash.OrgId, models.DashboardVersionForManualThumbnailUpload)
updateTestDashboard(t, sqlStore, dash, map[string]interface{}{
"tags": "different-tag",
})
cmd := models.FindDashboardsWithStaleThumbnailsCommand{
IncludeManuallyUploadedThumbnails: true,
}
res, err := sqlStore.FindDashboardsWithStaleThumbnails(context.Background(), &cmd)
require.NoError(t, err)
require.Len(t, res, 1)
require.Equal(t, dash.Id, res[0].Id)
})
}
func getThumbnail(t *testing.T, sqlStore *SQLStore, dashboardUID string, orgId int64) *models.DashboardThumbnail {
t.Helper()
cmd := models.GetDashboardThumbnailCommand{
DashboardThumbnailMeta: models.DashboardThumbnailMeta{
DashboardUID: dashboardUID,
OrgId: orgId,
PanelID: 0,
Kind: models.ThumbnailKindDefault,
Theme: models.ThemeDark,
},
}
thumb, err := sqlStore.GetThumbnail(context.Background(), &cmd)
require.NoError(t, err)
return thumb
}
func upsertTestDashboardThumbnail(t *testing.T, sqlStore *SQLStore, dashboardUID string, orgId int64, dashboardVersion int) *models.DashboardThumbnail {
t.Helper()
cmd := models.SaveDashboardThumbnailCommand{
DashboardThumbnailMeta: models.DashboardThumbnailMeta{
DashboardUID: dashboardUID,
OrgId: orgId,
PanelID: 0,
Kind: models.ThumbnailKindDefault,
Theme: models.ThemeDark,
},
DashboardVersion: dashboardVersion,
Image: make([]byte, 0),
MimeType: "image/png",
}
dash, err := sqlStore.SaveThumbnail(context.Background(), &cmd)
require.NoError(t, err)
require.NotNil(t, dash)
return dash
}
func updateThumbnailState(t *testing.T, sqlStore *SQLStore, dashboardUID string, orgId int64, state models.ThumbnailState) {
t.Helper()
cmd := models.UpdateThumbnailStateCommand{
DashboardThumbnailMeta: models.DashboardThumbnailMeta{
DashboardUID: dashboardUID,
OrgId: orgId,
PanelID: 0,
Kind: models.ThumbnailKindDefault,
Theme: models.ThemeDark,
},
State: state,
}
err := sqlStore.UpdateThumbnailState(context.Background(), &cmd)
require.NoError(t, err)
}

View File

@@ -0,0 +1,27 @@
package migrations
import "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
func addDashboardThumbsMigrations(mg *migrator.Migrator) {
dashThumbs := migrator.Table{
Name: "dashboard_thumbnail",
Columns: []*migrator.Column{
{Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "dashboard_id", Type: migrator.DB_BigInt, Nullable: false}, // can join with dashboard table
{Name: "dashboard_version", Type: migrator.DB_Int, Nullable: false}, // screenshoted version of the dashboard
{Name: "state", Type: migrator.DB_NVarchar, Length: 10, Nullable: false}, // stale | locked
{Name: "panel_id", Type: migrator.DB_SmallInt, Nullable: false, Default: "0"}, // for panel thumbnails
{Name: "image", Type: migrator.DB_MediumBlob, Nullable: false}, // image stored as blob. MediumBlob has a max limit of 16mb in MySQL
{Name: "mime_type", Type: migrator.DB_NVarchar, Length: 255, Nullable: false}, // e.g. image/png, image/webp
{Name: "kind", Type: migrator.DB_NVarchar, Length: 8, Nullable: false}, // thumb | tall
{Name: "theme", Type: migrator.DB_NVarchar, Length: 8, Nullable: false}, // light|dark
{Name: "updated", Type: migrator.DB_DateTime, Nullable: false},
},
Indices: []*migrator.Index{
{Cols: []string{"dashboard_id", "panel_id", "kind", "theme"}, Type: migrator.UniqueIndex},
},
}
mg.AddMigration("create dashboard_thumbnail table", migrator.NewAddTableMigration(dashThumbs))
mg.AddMigration("add unique indexes for dashboard_thumbnail", migrator.NewAddIndexMigration(dashThumbs, dashThumbs.Indices[0]))
}

View File

@@ -61,6 +61,9 @@ func (*OSSMigrations) AddMigration(mg *Migrator) {
if mg.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagLiveConfig) { if mg.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagLiveConfig) {
addLiveChannelMigrations(mg) addLiveChannelMigrations(mg)
} }
if mg.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagDashboardPreviews) {
addDashboardThumbsMigrations(mg)
}
} }
ualert.RerunDashAlertMigration(mg) ualert.RerunDashAlertMigration(mg)

View File

@@ -463,6 +463,10 @@ type InitTestDBOpt struct {
EnsureDefaultOrgAndUser bool EnsureDefaultOrgAndUser bool
} }
var featuresEnabledDuringTests = []string{
featuremgmt.FlagDashboardPreviews,
}
// InitTestDBWithMigration initializes the test DB given custom migrations. // InitTestDBWithMigration initializes the test DB given custom migrations.
func InitTestDBWithMigration(t ITestDB, migration registry.DatabaseMigrator, opts ...InitTestDBOpt) *SQLStore { func InitTestDBWithMigration(t ITestDB, migration registry.DatabaseMigrator, opts ...InitTestDBOpt) *SQLStore {
t.Helper() t.Helper()
@@ -500,7 +504,15 @@ func initTestDB(migration registry.DatabaseMigrator, opts ...InitTestDBOpt) (*SQ
// set test db config // set test db config
cfg := setting.NewCfg() cfg := setting.NewCfg()
cfg.IsFeatureToggleEnabled = func(key string) bool { return false } cfg.IsFeatureToggleEnabled = func(requestedFeature string) bool {
for _, enabledFeature := range featuresEnabledDuringTests {
if enabledFeature == requestedFeature {
return true
}
}
return false
}
sec, err := cfg.Raw.NewSection("database") sec, err := cfg.Raw.NewSection("database")
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -3,66 +3,60 @@ package thumbs
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt"
"math/rand"
"os" "os"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/live" "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/services/search"
) )
type dashItem struct {
uid string
url string
}
type simpleCrawler struct { type simpleCrawler struct {
screenshotsFolder string renderService rendering.Service
renderService rendering.Service threadCount int
threadCount int
glive *live.GrafanaLive glive *live.GrafanaLive
mode CrawlerMode thumbnailRepo thumbnailRepo
opts rendering.Opts mode CrawlerMode
status crawlStatus thumbnailKind models.ThumbnailKind
queue []dashItem opts rendering.Opts
mu sync.Mutex status crawlStatus
statusMutex sync.RWMutex
queue []*models.DashboardWithStaleThumbnail
queueMutex sync.Mutex
renderingSession rendering.Session
} }
func newSimpleCrawler(folder string, renderService rendering.Service, gl *live.GrafanaLive) dashRenderer { func newSimpleCrawler(renderService rendering.Service, gl *live.GrafanaLive, repo thumbnailRepo) dashRenderer {
c := &simpleCrawler{ c := &simpleCrawler{
screenshotsFolder: folder, renderService: renderService,
renderService: renderService, threadCount: 6,
threadCount: 5, glive: gl,
glive: gl, thumbnailRepo: repo,
status: crawlStatus{ status: crawlStatus{
State: "init", State: initializing,
Complete: 0, Complete: 0,
Queue: 0, Queue: 0,
}, },
queue: make([]dashItem, 0), queue: nil,
} }
c.broadcastStatus() c.broadcastStatus()
return c return c
} }
func (r *simpleCrawler) next() *dashItem { func (r *simpleCrawler) next() *models.DashboardWithStaleThumbnail {
if len(r.queue) < 1 { r.queueMutex.Lock()
defer r.queueMutex.Unlock()
if r.queue == nil || len(r.queue) < 1 {
return nil return nil
} }
r.mu.Lock()
defer r.mu.Unlock()
v := r.queue[0] v := r.queue[0]
r.queue = r.queue[1:] r.queue = r.queue[1:]
return &v return v
} }
func (r *simpleCrawler) broadcastStatus() { func (r *simpleCrawler) broadcastStatus() {
@@ -83,61 +77,42 @@ func (r *simpleCrawler) broadcastStatus() {
} }
} }
func (r *simpleCrawler) GetPreview(req *previewRequest) *previewResponse { func (r *simpleCrawler) Start(c *models.ReqContext, mode CrawlerMode, theme models.Theme, thumbnailKind models.ThumbnailKind) (crawlStatus, error) {
p := getFilePath(r.screenshotsFolder, req) if r.status.State == running {
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") tlog.Info("already running")
return r.Status() return r.Status()
} }
r.mu.Lock() r.queueMutex.Lock()
defer r.mu.Unlock() defer r.queueMutex.Unlock()
searchQuery := search.Query{ now := time.Now()
SignedInUser: c.SignedInUser,
OrgId: c.OrgId,
}
err := bus.Dispatch(context.Background(), &searchQuery) ctx := c.Req.Context()
items, err := r.thumbnailRepo.findDashboardsWithStaleThumbnails(ctx)
if err != nil { if err != nil {
return crawlStatus{}, err tlog.Error("error when fetching dashboards with stale thumbnails", "err", err.Error())
return crawlStatus{
Started: now,
Finished: now,
Last: now,
State: stopped,
Complete: 0,
}, err
} }
queue := make([]dashItem, 0, len(searchQuery.Result)) if len(items) == 0 {
for _, v := range searchQuery.Result { return crawlStatus{
if v.Type == search.DashHitDB { Started: now,
queue = append(queue, dashItem{ Finished: now,
uid: v.UID, Last: now,
url: v.URL, State: stopped,
}) Complete: 0,
} }, err
} }
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.mode = mode
r.thumbnailKind = thumbnailKind
r.opts = rendering.Opts{ r.opts = rendering.Opts{
AuthOpts: rendering.AuthOpts{ AuthOpts: rendering.AuthOpts{
OrgID: c.OrgId, OrgID: c.OrgId,
@@ -151,35 +126,53 @@ func (r *simpleCrawler) Start(c *models.ReqContext, mode CrawlerMode, theme rend
Theme: theme, Theme: theme,
ConcurrentLimit: 10, ConcurrentLimit: 10,
} }
r.queue = queue renderingSession, err := r.renderService.CreateRenderingSession(context.Background(), r.opts.AuthOpts, rendering.SessionOpts{
Expiry: 5 * time.Minute,
RefreshExpiryOnEachRequest: true,
})
if err != nil {
tlog.Error("error when creating rendering session", "err", err.Error())
return crawlStatus{
Started: now,
Finished: now,
Last: now,
State: stopped,
Complete: 0,
}, err
}
r.renderingSession = renderingSession
r.queue = items
r.status = crawlStatus{ r.status = crawlStatus{
Started: time.Now(), Started: now,
State: "running", State: running,
Complete: 0, Complete: 0,
} }
r.broadcastStatus() r.broadcastStatus()
tlog.Info("Starting dashboard crawler", "dashboardsToCrawl", len(items))
// create a pool of workers // create a pool of workers
for i := 0; i < r.threadCount; i++ { for i := 0; i < r.threadCount; i++ {
go r.walk() go r.walk(ctx)
// wait 1/2 second before starting a new thread
time.Sleep(500 * time.Millisecond)
} }
r.broadcastStatus()
return r.Status() return r.Status()
} }
func (r *simpleCrawler) Stop() (crawlStatus, error) { func (r *simpleCrawler) Stop() (crawlStatus, error) {
// cheap hack! r.statusMutex.Lock()
if r.status.State == "running" { if r.status.State == running {
r.status.State = "stopping" r.status.State = stopping
} }
r.statusMutex.Unlock()
return r.Status() return r.Status()
} }
func (r *simpleCrawler) Status() (crawlStatus, error) { func (r *simpleCrawler) Status() (crawlStatus, error) {
r.statusMutex.RLock()
defer r.statusMutex.RUnlock()
status := crawlStatus{ status := crawlStatus{
State: r.status.State, State: r.status.State,
Started: r.status.Started, Started: r.status.Started,
@@ -191,9 +184,41 @@ func (r *simpleCrawler) Status() (crawlStatus, error) {
return status, nil return status, nil
} }
func (r *simpleCrawler) walk() { func (r *simpleCrawler) newErrorResult() {
r.statusMutex.Lock()
defer r.statusMutex.Unlock()
r.status.Errors++
r.status.Last = time.Now()
}
func (r *simpleCrawler) newSuccessResult() {
r.statusMutex.Lock()
defer r.statusMutex.Unlock()
r.status.Complete++
r.status.Last = time.Now()
}
func (r *simpleCrawler) walkFinished() {
r.statusMutex.Lock()
defer r.statusMutex.Unlock()
r.status.State = stopped
r.status.Finished = time.Now()
tlog.Info("Crawler finished", "startTime", r.status.Started, "endTime", r.status.Finished, "durationInSeconds", int64(time.Since(r.status.Started)/time.Second))
}
func (r *simpleCrawler) shouldWalk() bool {
r.statusMutex.RLock()
defer r.statusMutex.RUnlock()
return r.status.State == running
}
func (r *simpleCrawler) walk(ctx context.Context) {
for { for {
if r.status.State == "stopping" { if !r.shouldWalk() {
break break
} }
@@ -202,51 +227,57 @@ func (r *simpleCrawler) walk() {
break break
} }
tlog.Info("GET THUMBNAIL", "url", item.url) url := models.GetKioskModeDashboardUrl(item.Uid, item.Slug)
tlog.Info("Getting dashboard thumbnail", "dashboardUID", item.Uid, "url", 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{ res, err := r.renderService.Render(context.Background(), rendering.Opts{
Width: 320, Width: 320,
Height: 240, Height: 240,
Path: panelURL, Path: strings.TrimPrefix(url, "/"),
AuthOpts: r.opts.AuthOpts, AuthOpts: r.opts.AuthOpts,
TimeoutOpts: r.opts.TimeoutOpts, TimeoutOpts: r.opts.TimeoutOpts,
ConcurrentLimit: r.opts.ConcurrentLimit,
Theme: r.opts.Theme, Theme: r.opts.Theme,
DeviceScaleFactor: -5, // negative numbers will render larger then scale down DeviceScaleFactor: -5, // negative numbers will render larger and then scale down.
}, nil) }, r.renderingSession)
if err != nil { if err != nil {
tlog.Warn("error getting image", "err", err) tlog.Warn("error getting image", "dashboardUID", item.Uid, "url", url, "err", err)
r.status.Errors++ r.newErrorResult()
} else if res.FilePath == "" { } else if res.FilePath == "" {
tlog.Warn("error getting image... no response") tlog.Warn("error getting image... no response", "dashboardUID", item.Uid, "url", url)
r.status.Errors++ r.newErrorResult()
} else if strings.Contains(res.FilePath, "public/img") { } else if strings.Contains(res.FilePath, "public/img") {
tlog.Warn("error getting image... internal result", "img", res.FilePath) tlog.Warn("error getting image... internal result", "dashboardUID", item.Uid, "url", url, "img", res.FilePath)
r.status.Errors++ // rendering service returned a static error image - we should not remove that file
r.newErrorResult()
} else { } else {
p := getFilePath(r.screenshotsFolder, &previewRequest{ func() {
UID: item.uid, defer func() {
OrgID: r.opts.OrgID, err := os.Remove(res.FilePath)
Theme: r.opts.Theme, if err != nil {
Size: PreviewSizeThumb, tlog.Error("failed to remove thumbnail temp file", "dashboardUID", item.Uid, "url", url, "err", err)
}) }
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) thumbnailId, err := r.thumbnailRepo.saveFromFile(ctx, res.FilePath, models.DashboardThumbnailMeta{
r.status.Last = time.Now() DashboardUID: item.Uid,
OrgId: item.OrgId,
Theme: r.opts.Theme,
Kind: r.thumbnailKind,
}, item.Version)
if err != nil {
tlog.Warn("error saving image image", "dashboardUID", item.Uid, "url", url, "err", err)
r.newErrorResult()
} else {
tlog.Info("saved thumbnail", "dashboardUID", item.Uid, "url", url, "thumbnailId", thumbnailId)
r.newSuccessResult()
}
}()
}
r.broadcastStatus() r.broadcastStatus()
} }
r.status.State = "stopped" r.walkFinished()
r.status.Finished = time.Now()
r.broadcastStatus() r.broadcastStatus()
} }

View File

@@ -12,6 +12,10 @@ func (ds *dummyService) GetImage(c *models.ReqContext) {
c.JSON(400, map[string]string{"error": "invalid size"}) c.JSON(400, map[string]string{"error": "invalid size"})
} }
func (ds *dummyService) UpdateThumbnailState(c *models.ReqContext) {
c.JSON(400, map[string]string{"error": "invalid size"})
}
func (ds *dummyService) SetImage(c *models.ReqContext) { func (ds *dummyService) SetImage(c *models.ReqContext) {
c.JSON(400, map[string]string{"error": "invalid size"}) c.JSON(400, map[string]string{"error": "invalid size"})
} }

View File

@@ -1,107 +1,73 @@
package thumbs package thumbs
import ( import (
"context"
"time" "time"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/rendering"
) )
type PreviewSize string
type CrawlerMode string type CrawlerMode string
const ( const (
// PreviewSizeThumb is a small 320x240 preview
PreviewSizeThumb PreviewSize = "thumb"
// PreviewSizeLarge is a large image 2000x1500 // CrawlerModeThumbs will create small thumbnails for everything.
PreviewSizeLarge PreviewSize = "large"
// PreviewSizeLarge is a large image 512x????
PreviewSizeTall PreviewSize = "tall"
// CrawlerModeThumbs will create small thumbnails for everything
CrawlerModeThumbs CrawlerMode = "thumbs" CrawlerModeThumbs CrawlerMode = "thumbs"
// CrawlerModeAnalytics will get full page results for everythign // CrawlerModeAnalytics will get full page results for everything.
CrawlerModeAnalytics CrawlerMode = "analytics" CrawlerModeAnalytics CrawlerMode = "analytics"
// CrawlerModeMigrate will migrate all dashboards with old schema // CrawlerModeMigrate will migrate all dashboards with old schema.
CrawlerModeMigrate CrawlerMode = "migrate" CrawlerModeMigrate CrawlerMode = "migrate"
) )
// IsKnownSize checks if the value is a standard size type crawlerState string
func (p PreviewSize) IsKnownSize() bool {
switch p {
case
PreviewSizeThumb,
PreviewSizeLarge,
PreviewSizeTall:
return true
}
return false
}
func getPreviewSize(str string) (PreviewSize, bool) { const (
switch str { initializing crawlerState = "initializing"
case string(PreviewSizeThumb): running crawlerState = "running"
return PreviewSizeThumb, true stopping crawlerState = "stopping"
case string(PreviewSizeLarge): stopped crawlerState = "stopped"
return PreviewSizeLarge, true )
case string(PreviewSizeTall):
return PreviewSizeTall, true
}
return PreviewSizeThumb, false
}
func getTheme(str string) (rendering.Theme, bool) {
switch str {
case "light":
return rendering.ThemeLight, true
case "dark":
return rendering.ThemeDark, true
}
return rendering.ThemeDark, false
}
type previewRequest struct { type previewRequest struct {
OrgID int64 `json:"orgId"` OrgID int64 `json:"orgId"`
UID string `json:"uid"` UID string `json:"uid"`
Size PreviewSize `json:"size"` Kind models.ThumbnailKind `json:"kind"`
Theme rendering.Theme `json:"theme"` Theme models.Theme `json:"theme"`
}
type previewResponse struct {
Code int `json:"code"` // 200 | 202
Path string `json:"path"` // local file path to serve
URL string `json:"url"` // redirect to this URL
} }
type crawlCmd struct { type crawlCmd struct {
Mode CrawlerMode `json:"mode"` // thumbs | analytics | migrate Mode CrawlerMode `json:"mode"` // thumbs | analytics | migrate
Theme rendering.Theme `json:"theme"` // light | dark Theme models.Theme `json:"theme"` // light | dark
} }
type crawlStatus struct { type crawlStatus struct {
State string `json:"state"` State crawlerState `json:"state"`
Started time.Time `json:"started,omitempty"` Started time.Time `json:"started,omitempty"`
Finished time.Time `json:"finished,omitempty"` Finished time.Time `json:"finished,omitempty"`
Complete int `json:"complete"` Complete int `json:"complete"`
Errors int `json:"errors"` Errors int `json:"errors"`
Queue int `json:"queue"` Queue int `json:"queue"`
Last time.Time `json:"last,omitempty"` Last time.Time `json:"last,omitempty"`
} }
type dashRenderer interface { type dashRenderer interface {
// Assumes you have already authenticated as admin
GetPreview(req *previewRequest) *previewResponse
// Assumes you have already authenticated as admin // Run Assumes you have already authenticated as admin.
Start(c *models.ReqContext, mode CrawlerMode, theme rendering.Theme) (crawlStatus, error) Start(c *models.ReqContext, mode CrawlerMode, theme models.Theme, kind models.ThumbnailKind) (crawlStatus, error)
// Assumes you have already authenticated as admin // Assumes you have already authenticated as admin.
Stop() (crawlStatus, error) Stop() (crawlStatus, error)
// Assumes you have already authenticated as admin // Assumes you have already authenticated as admin.
Status() (crawlStatus, error) Status() (crawlStatus, error)
} }
type thumbnailRepo interface {
updateThumbnailState(ctx context.Context, state models.ThumbnailState, meta models.DashboardThumbnailMeta) error
saveFromFile(ctx context.Context, filePath string, meta models.DashboardThumbnailMeta, dashboardVersion int) (int64, error)
saveFromBytes(ctx context.Context, bytes []byte, mimeType string, meta models.DashboardThumbnailMeta, dashboardVersion int) (int64, error)
getThumbnail(ctx context.Context, meta models.DashboardThumbnailMeta) (*models.DashboardThumbnail, error)
findDashboardsWithStaleThumbnails(ctx context.Context) ([]*models.DashboardWithStaleThumbnail, error)
}

View File

@@ -0,0 +1,88 @@
package thumbs
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/sqlstore"
)
func newThumbnailRepo(store *sqlstore.SQLStore) thumbnailRepo {
repo := &sqlThumbnailRepository{
store: store,
}
return repo
}
type sqlThumbnailRepository struct {
store *sqlstore.SQLStore
}
func (r *sqlThumbnailRepository) saveFromFile(ctx context.Context, filePath string, meta models.DashboardThumbnailMeta, dashboardVersion int) (int64, error) {
// the filePath variable is never set by the user. it refers to a temporary file created either in
// 1. thumbs/service.go, when user uploads a thumbnail
// 2. the rendering service, when image-renderer returns a screenshot
if !filepath.IsAbs(filePath) {
tlog.Error("Received relative path", "dashboardUID", meta.DashboardUID, "err", filePath)
return 0, errors.New("relative paths are not supported")
}
content, err := os.ReadFile(filepath.Clean(filePath))
if err != nil {
tlog.Error("error reading file", "dashboardUID", meta.DashboardUID, "err", err)
return 0, err
}
return r.saveFromBytes(ctx, content, getMimeType(filePath), meta, dashboardVersion)
}
func getMimeType(filePath string) string {
if strings.HasSuffix(filePath, ".webp") {
return "image/webp"
}
return "image/png"
}
func (r *sqlThumbnailRepository) saveFromBytes(ctx context.Context, content []byte, mimeType string, meta models.DashboardThumbnailMeta, dashboardVersion int) (int64, error) {
cmd := &models.SaveDashboardThumbnailCommand{
DashboardThumbnailMeta: meta,
Image: content,
MimeType: mimeType,
DashboardVersion: dashboardVersion,
}
_, err := r.store.SaveThumbnail(ctx, cmd)
if err != nil {
tlog.Error("error saving to the db", "dashboardUID", meta.DashboardUID, "err", err)
return 0, err
}
return cmd.Result.Id, nil
}
func (r *sqlThumbnailRepository) updateThumbnailState(ctx context.Context, state models.ThumbnailState, meta models.DashboardThumbnailMeta) error {
return r.store.UpdateThumbnailState(ctx, &models.UpdateThumbnailStateCommand{
State: state,
DashboardThumbnailMeta: meta,
})
}
func (r *sqlThumbnailRepository) getThumbnail(ctx context.Context, meta models.DashboardThumbnailMeta) (*models.DashboardThumbnail, error) {
query := &models.GetDashboardThumbnailCommand{
DashboardThumbnailMeta: meta,
}
return r.store.GetThumbnail(ctx, query)
}
func (r *sqlThumbnailRepository) findDashboardsWithStaleThumbnails(ctx context.Context) ([]*models.DashboardWithStaleThumbnail, error) {
return r.store.FindDashboardsWithStaleThumbnails(ctx, &models.FindDashboardsWithStaleThumbnailsCommand{
IncludeManuallyUploadedThumbnails: false,
})
}

View File

@@ -1,23 +1,20 @@
package thumbs package thumbs
import ( import (
"errors"
"fmt" "fmt"
"io" "io"
"io/ioutil" "io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/live" "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/services/sqlstore"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web" "github.com/grafana/grafana/pkg/web"
"github.com/segmentio/encoding/json" "github.com/segmentio/encoding/json"
@@ -31,8 +28,9 @@ type Service interface {
Enabled() bool Enabled() bool
GetImage(c *models.ReqContext) GetImage(c *models.ReqContext)
// Form post (from dashboard page) // from dashboard page
SetImage(c *models.ReqContext) SetImage(c *models.ReqContext) // form post
UpdateThumbnailState(c *models.ReqContext)
// Must be admin // Must be admin
StartCrawler(c *models.ReqContext) response.Response StartCrawler(c *models.ReqContext) response.Response
@@ -40,28 +38,21 @@ type Service interface {
CrawlerStatus(c *models.ReqContext) response.Response CrawlerStatus(c *models.ReqContext) response.Response
} }
func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, renderService rendering.Service, gl *live.GrafanaLive) Service { func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, renderService rendering.Service, gl *live.GrafanaLive, store *sqlstore.SQLStore) Service {
if !features.IsEnabled(featuremgmt.FlagDashboardPreviews) { if !features.IsEnabled(featuremgmt.FlagDashboardPreviews) {
return &dummyService{} return &dummyService{}
} }
root := filepath.Join(cfg.DataPath, "crawler", "preview") thumbnailRepo := newThumbnailRepo(store)
tempdir := filepath.Join(cfg.DataPath, "temp")
_ = os.MkdirAll(root, 0700)
_ = os.MkdirAll(tempdir, 0700)
renderer := newSimpleCrawler(root, renderService, gl)
return &thumbService{ return &thumbService{
renderer: renderer, renderer: newSimpleCrawler(renderService, gl, thumbnailRepo),
root: root, thumbnailRepo: thumbnailRepo,
tempdir: tempdir,
} }
} }
type thumbService struct { type thumbService struct {
renderer dashRenderer renderer dashRenderer
root string thumbnailRepo thumbnailRepo
tempdir string
} }
func (hs *thumbService) Enabled() bool { func (hs *thumbService) Enabled() bool {
@@ -71,14 +62,14 @@ func (hs *thumbService) Enabled() bool {
func (hs *thumbService) parseImageReq(c *models.ReqContext, checkSave bool) *previewRequest { func (hs *thumbService) parseImageReq(c *models.ReqContext, checkSave bool) *previewRequest {
params := web.Params(c.Req) params := web.Params(c.Req)
size, ok := getPreviewSize(params[":size"]) kind, err := models.ParseThumbnailKind(params[":kind"])
if !ok { if err != nil {
c.JSON(400, map[string]string{"error": "invalid size"}) c.JSON(400, map[string]string{"error": "invalid size"})
return nil return nil
} }
theme, ok := getTheme(params[":theme"]) theme, err := models.ParseTheme(params[":theme"])
if !ok { if err != nil {
c.JSON(400, map[string]string{"error": "invalid theme"}) c.JSON(400, map[string]string{"error": "invalid theme"})
return nil return nil
} }
@@ -87,7 +78,7 @@ func (hs *thumbService) parseImageReq(c *models.ReqContext, checkSave bool) *pre
OrgID: c.OrgId, OrgID: c.OrgId,
UID: params[":uid"], UID: params[":uid"],
Theme: theme, Theme: theme,
Size: size, Kind: kind,
} }
if len(req.UID) < 1 { if len(req.UID) < 1 {
@@ -104,36 +95,70 @@ func (hs *thumbService) parseImageReq(c *models.ReqContext, checkSave bool) *pre
return req 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 {
tlog.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 {
tlog.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
}
tlog.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) { func (hs *thumbService) GetImage(c *models.ReqContext) {
req := hs.parseImageReq(c, false) req := hs.parseImageReq(c, false)
if req == nil { if req == nil {
return // already returned value return // already returned value
} }
rsp := hs.renderer.GetPreview(req) res, err := hs.thumbnailRepo.getThumbnail(c.Req.Context(), models.DashboardThumbnailMeta{
if rsp.Code == 200 { DashboardUID: req.UID,
if rsp.Path != "" { OrgId: req.OrgID,
if strings.HasSuffix(rsp.Path, ".webp") { Theme: req.Theme,
c.Resp.Header().Set("Content-Type", "image/webp") Kind: models.ThumbnailKindDefault,
} else if strings.HasSuffix(rsp.Path, ".png") { })
c.Resp.Header().Set("Content-Type", "image/png")
}
c.Resp.Header().Set("Content-Type", "image/png")
http.ServeFile(c.Resp, c.Req, rsp.Path)
return
}
if rsp.URL != "" {
// todo redirect
fmt.Printf("TODO redirect: %s\n", rsp.URL)
}
}
if rsp.Code == 202 { if errors.Is(err, models.ErrDashboardThumbnailNotFound) {
c.JSON(202, map[string]string{"path": rsp.Path, "todo": "queue processing"}) c.Resp.WriteHeader(404)
return return
} }
c.JSON(500, map[string]string{"path": rsp.Path, "error": "unknown!"}) if err != nil {
tlog.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 {
tlog.Error("Error writing to response", "dashboardUid", req.UID, "err", err)
}
} }
// Hack for now -- lets you upload images explicitly // Hack for now -- lets you upload images explicitly
@@ -169,38 +194,23 @@ func (hs *thumbService) SetImage(c *models.ReqContext) {
tlog.Info("File Size: %+v\n", handler.Size) tlog.Info("File Size: %+v\n", handler.Size)
tlog.Info("MIME Header: %+v\n", handler.Header) tlog.Info("MIME Header: %+v\n", handler.Header)
// Create a temporary file within our temp-images directory that follows
// a particular naming pattern
tempFile, err := ioutil.TempFile(hs.tempdir, "upload-*")
if err != nil {
c.JSON(400, map[string]string{"error": "error creating temp file"})
fmt.Println("error", err)
tlog.Info("ERROR", "err", handler.Header)
return
}
defer func() {
_ = tempFile.Close()
}()
// read all of the contents of our uploaded file into a
// byte array
fileBytes, err := ioutil.ReadAll(file) fileBytes, err := ioutil.ReadAll(file)
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
} c.JSON(400, map[string]string{"error": "error reading file"})
// write this byte array to our temporary file
_, err = tempFile.Write(fileBytes)
if err != nil {
c.JSON(400, map[string]string{"error": "error writing file"})
fmt.Println("error", err)
return return
} }
p := getFilePath(hs.root, req) _, err = hs.thumbnailRepo.saveFromBytes(c.Req.Context(), fileBytes, getMimeType(handler.Filename), models.DashboardThumbnailMeta{
err = os.Rename(tempFile.Name(), p) DashboardUID: req.UID,
OrgId: req.OrgID,
Theme: req.Theme,
Kind: req.Kind,
}, models.DashboardVersionForManualThumbnailUpload)
if err != nil { if err != nil {
c.JSON(400, map[string]string{"error": "unable to rename file"}) c.JSON(400, map[string]string{"error": "error saving thumbnail file"})
fmt.Println("error", err)
return return
} }
@@ -220,7 +230,7 @@ func (hs *thumbService) StartCrawler(c *models.ReqContext) response.Response {
if cmd.Mode == "" { if cmd.Mode == "" {
cmd.Mode = CrawlerModeThumbs cmd.Mode = CrawlerModeThumbs
} }
msg, err := hs.renderer.Start(c, cmd.Mode, cmd.Theme) msg, err := hs.renderer.Start(c, cmd.Mode, cmd.Theme, models.ThumbnailKindDefault)
if err != nil { if err != nil {
return response.Error(500, "error starting", err) return response.Error(500, "error starting", err)
} }
@@ -245,15 +255,12 @@ func (hs *thumbService) CrawlerStatus(c *models.ReqContext) response.Response {
// 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!
func (hs *thumbService) getStatus(c *models.ReqContext, uid string, checkSave bool) int { func (hs *thumbService) getStatus(c *models.ReqContext, uid string, checkSave bool) int {
query := models.GetDashboardQuery{Uid: uid, OrgId: c.OrgId} dashboardID, err := hs.getDashboardId(c, uid)
if err != nil {
if err := bus.Dispatch(c.Req.Context(), &query); err != nil { return 404
return 404 // not found
} }
dash := query.Result guardian := guardian.New(c.Req.Context(), dashboardID, c.OrgId, c.SignedInUser)
guardian := guardian.New(c.Req.Context(), dash.Id, c.OrgId, c.SignedInUser)
if checkSave { if checkSave {
if canSave, err := guardian.CanSave(); err != nil || !canSave { if canSave, err := guardian.CanSave(); err != nil || !canSave {
return 403 // forbidden return 403 // forbidden
@@ -267,3 +274,13 @@ func (hs *thumbService) getStatus(c *models.ReqContext, uid string, checkSave bo
return 200 // found and OK 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 := bus.Dispatch(c.Req.Context(), &query); err != nil {
return 0, err
}
return query.Result.Id, nil
}

View File

@@ -1,14 +0,0 @@
package thumbs
import (
"fmt"
"path/filepath"
)
func getFilePath(root string, req *previewRequest) string {
ext := "webp"
if req.Size != PreviewSizeThumb {
ext = "png"
}
return filepath.Join(root, fmt.Sprintf("%s-%s-%s.%s", req.UID, req.Size, req.Theme, ext))
}

View File

@@ -1,6 +1,7 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { CollapsableSection, FileUpload } from '@grafana/ui'; import { Button, CollapsableSection, FileUpload } from '@grafana/ui';
import { getThumbnailURL } from 'app/features/search/components/SearchCard'; import { getThumbnailURL } from 'app/features/search/components/SearchCard';
import { getBackendSrv } from 'app/core/services/backend_srv';
interface Props { interface Props {
uid: string; uid: string;
@@ -36,6 +37,10 @@ export class PreviewSettings extends PureComponent<Props, State> {
}); });
}; };
markAsStale = (isLight: boolean) => async () => {
return getBackendSrv().put(getThumbnailURL(this.props.uid, isLight), { state: 'stale' });
};
render() { render() {
const { uid } = this.props; const { uid } = this.props;
const imgstyle = { maxWidth: 300, maxHeight: 300 }; const imgstyle = { maxWidth: 300, maxHeight: 300 };
@@ -50,6 +55,18 @@ export class PreviewSettings extends PureComponent<Props, State> {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr>
<td>
<Button type="button" variant="primary" onClick={this.markAsStale(false)} fill="outline">
Mark as stale
</Button>
</td>
<td>
<Button type="button" variant="primary" onClick={this.markAsStale(true)} fill="outline">
Mark as stale
</Button>
</td>
</tr>
<tr> <tr>
<td> <td>
<img src={getThumbnailURL(uid, false)} style={imgstyle} /> <img src={getThumbnailURL(uid, false)} style={imgstyle} />