mirror of
https://github.com/grafana/grafana.git
synced 2024-12-26 17:01:09 -06:00
Alerting: Add a general screenshot service and alerting-specific image service. (#49293)
This commit adds a pkg/services/screenshot package for taking and uploading screenshots of Grafana dashboards. It supports taking screenshots of both dashboards and individual panels within a dashboard, using the rendering service. The screenshot package has the following services, most of which can be composed: BrowserScreenshotService (Takes screenshots with headless Chrome) CachableScreenshotService (Caches screenshots taken with another service such as BrowserScreenshotService) NoopScreenshotService (A no-op screenshot service for tests) SingleFlightScreenshotService (Prevents duplicate screenshots when taking screenshots of the same dashboard or panel in parallel) ScreenshotUnavailableService (A screenshot service that returns ErrScreenshotsUnavailable) UploadingScreenshotService (A screenshot service that uploads taken screenshots) The screenshot package does not support wire dependency injection yet. ngalert constructs its own version of the service. See https://github.com/grafana/grafana/issues/49296 This PR also adds an ImageScreenshotService to ngAlert. This is used to take screenshots with a screenshotservice and then store their location reference for use by alert instances and notifiers.
This commit is contained in:
parent
f06d9164a6
commit
687e79538b
@ -841,6 +841,22 @@ max_attempts = 3
|
||||
# The interval string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. 30s or 1m.
|
||||
min_interval = 10s
|
||||
|
||||
[unified_alerting.screenshots]
|
||||
# Enable screenshots in notifications. This option requires a remote HTTP image rendering service. Please
|
||||
# see [rendering] for further configuration options.
|
||||
enabled =
|
||||
|
||||
# The maximum number of screenshots that can be taken at the same time. This option is different from
|
||||
# concurrent_render_request_limit as max_concurrent_screenshots sets the number of concurrent screenshots
|
||||
# that can be taken at the same time for all firing alerts where as concurrent_render_request_limit sets
|
||||
# the total number of concurrent screenshots across all Grafana services.
|
||||
max_concurrent_screenshots = 5
|
||||
|
||||
# Uploads screenshots to the local Grafana server or remote storage such as Azure, S3 and GCS. Please
|
||||
# see [external_image_storage] for further configuration options. If this option is false then
|
||||
# screenshots will be persisted to disk for up to temp_data_lifetime.
|
||||
upload_external_image_storage = false
|
||||
|
||||
#################################### Alerting ############################
|
||||
[alerting]
|
||||
# Enable the legacy alerting sub-system and interface. If Unified Alerting is already enabled and you try to go back to legacy alerting, all data that is part of Unified Alerting will be deleted. When this configuration section and flag are not defined, the state is defined at runtime. See the documentation for more details.
|
||||
@ -981,16 +997,16 @@ disable_shared_zipkin_spans = false
|
||||
|
||||
[tracing.opentelemetry.jaeger]
|
||||
# jaeger destination (ex http://localhost:14268/api/traces)
|
||||
address =
|
||||
address =
|
||||
# Propagation specifies the text map propagation format: w3c, jaeger
|
||||
propagation =
|
||||
propagation =
|
||||
|
||||
# This is a configuration for OTLP exporter with GRPC protocol
|
||||
[tracing.opentelemetry.otlp]
|
||||
# otlp destination (ex localhost:4317)
|
||||
address =
|
||||
# otlp destination (ex localhost:4317)
|
||||
address =
|
||||
# Propagation specifies the text map propagation format: w3c, jaeger
|
||||
propagation =
|
||||
propagation =
|
||||
|
||||
#################################### External Image Storage ##############
|
||||
[external_image_storage]
|
||||
|
@ -16,6 +16,7 @@ const (
|
||||
defaultGCSSignedURLExpiration = 7 * 24 * time.Hour // 7 days
|
||||
)
|
||||
|
||||
//go:generate mockgen -destination=mock.go -package=imguploader github.com/grafana/grafana/pkg/components/imguploader ImageUploader
|
||||
type ImageUploader interface {
|
||||
Upload(ctx context.Context, path string) (string, error)
|
||||
}
|
||||
|
50
pkg/components/imguploader/mock.go
Normal file
50
pkg/components/imguploader/mock.go
Normal file
@ -0,0 +1,50 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/grafana/grafana/pkg/components/imguploader (interfaces: ImageUploader)
|
||||
|
||||
// Package imguploader is a generated GoMock package.
|
||||
package imguploader
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
)
|
||||
|
||||
// MockImageUploader is a mock of ImageUploader interface.
|
||||
type MockImageUploader struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockImageUploaderMockRecorder
|
||||
}
|
||||
|
||||
// MockImageUploaderMockRecorder is the mock recorder for MockImageUploader.
|
||||
type MockImageUploaderMockRecorder struct {
|
||||
mock *MockImageUploader
|
||||
}
|
||||
|
||||
// NewMockImageUploader creates a new mock instance.
|
||||
func NewMockImageUploader(ctrl *gomock.Controller) *MockImageUploader {
|
||||
mock := &MockImageUploader{ctrl: ctrl}
|
||||
mock.recorder = &MockImageUploaderMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockImageUploader) EXPECT() *MockImageUploaderMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Upload mocks base method.
|
||||
func (m *MockImageUploader) Upload(arg0 context.Context, arg1 string) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Upload", arg0, arg1)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Upload indicates an expected call of Upload.
|
||||
func (mr *MockImageUploaderMockRecorder) Upload(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Upload", reflect.TypeOf((*MockImageUploader)(nil).Upload), arg0, arg1)
|
||||
}
|
53
pkg/services/ngalert/image/mock.go
Normal file
53
pkg/services/ngalert/image/mock.go
Normal file
@ -0,0 +1,53 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/grafana/grafana/pkg/services/ngalert/image (interfaces: ImageService)
|
||||
|
||||
// Package image is a generated GoMock package.
|
||||
package image
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
data "github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
models "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
store "github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
)
|
||||
|
||||
// MockImageService is a mock of ImageService interface.
|
||||
type MockImageService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockImageServiceMockRecorder
|
||||
}
|
||||
|
||||
// MockImageServiceMockRecorder is the mock recorder for MockImageService.
|
||||
type MockImageServiceMockRecorder struct {
|
||||
mock *MockImageService
|
||||
}
|
||||
|
||||
// NewMockImageService creates a new mock instance.
|
||||
func NewMockImageService(ctrl *gomock.Controller) *MockImageService {
|
||||
mock := &MockImageService{ctrl: ctrl}
|
||||
mock.recorder = &MockImageServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockImageService) EXPECT() *MockImageServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// NewImage mocks base method.
|
||||
func (m *MockImageService) NewImage(arg0 context.Context, arg1 *models.AlertRule, arg2 data.Labels) (*store.Image, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "NewImage", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(*store.Image)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// NewImage indicates an expected call of NewImage.
|
||||
func (mr *MockImageServiceMockRecorder) NewImage(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewImage", reflect.TypeOf((*MockImageService)(nil).NewImage), arg0, arg1, arg2)
|
||||
}
|
118
pkg/services/ngalert/image/service.go
Normal file
118
pkg/services/ngalert/image/service.go
Normal file
@ -0,0 +1,118 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/imguploader"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
"github.com/grafana/grafana/pkg/services/screenshot"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
//go:generate mockgen -destination=mock.go -package=image github.com/grafana/grafana/pkg/services/ngalert/image ImageService
|
||||
type ImageService interface {
|
||||
// NewImage returns a new image for the alert instance.
|
||||
NewImage(ctx context.Context, r *ngmodels.AlertRule) (*store.Image, error)
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrNoDashboard is returned when the alert rule does not have a dashboard.
|
||||
ErrNoDashboard = errors.New("no dashboard")
|
||||
|
||||
// ErrNoPanel is returned when the alert rule does not have a panel in a dashboard.
|
||||
ErrNoPanel = errors.New("no panel")
|
||||
)
|
||||
|
||||
const (
|
||||
screenshotTimeout = 10 * time.Second
|
||||
screenshotCacheTTL = 15 * time.Second
|
||||
)
|
||||
|
||||
// ScreenshotImageService takes screenshots of the panel for an alert rule and
|
||||
// saves the image in the store. The image contains a unique token that can be
|
||||
// passed as an annotation or label to the Alertmanager.
|
||||
type ScreenshotImageService struct {
|
||||
screenshots screenshot.ScreenshotService
|
||||
store store.ImageStore
|
||||
}
|
||||
|
||||
func NewScreenshotImageService(screenshots screenshot.ScreenshotService, store store.ImageStore) ImageService {
|
||||
return &ScreenshotImageService{
|
||||
screenshots: screenshots,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// NewScreenshotImageServiceFromCfg returns a new ScreenshotImageService
|
||||
// from the configuration.
|
||||
func NewScreenshotImageServiceFromCfg(cfg *setting.Cfg, metrics prometheus.Registerer,
|
||||
db *store.DBstore, ds dashboards.DashboardService, rs rendering.Service) (ImageService, error) {
|
||||
if !cfg.UnifiedAlerting.Screenshots.Enabled {
|
||||
return &ScreenshotImageService{
|
||||
screenshots: &screenshot.ScreenshotUnavailableService{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
s := screenshot.NewBrowserScreenshotService(ds, rs)
|
||||
if cfg.UnifiedAlerting.Screenshots.UploadExternalImageStorage {
|
||||
u, err := imguploader.NewImageUploader()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize uploading screenshot service: %w", err)
|
||||
}
|
||||
s = screenshot.NewUploadingScreenshotService(metrics, s, u)
|
||||
}
|
||||
s = screenshot.NewRateLimitScreenshotService(s, cfg.UnifiedAlerting.Screenshots.MaxConcurrentScreenshots)
|
||||
s = screenshot.NewSingleFlightScreenshotService(s)
|
||||
s = screenshot.NewCachableScreenshotService(metrics, screenshotCacheTTL, s)
|
||||
s = screenshot.NewObservableScreenshotService(metrics, s)
|
||||
|
||||
return &ScreenshotImageService{
|
||||
store: db,
|
||||
screenshots: s,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewImage returns a screenshot of the panel for the alert rule. It returns
|
||||
// ErrNoDashboard if the alert rule does not have a dashboard and ErrNoPanel
|
||||
// when the alert rule does not have a panel in a dashboard.
|
||||
func (s *ScreenshotImageService) NewImage(ctx context.Context, r *ngmodels.AlertRule) (*store.Image, error) {
|
||||
if r.DashboardUID == nil {
|
||||
return nil, ErrNoDashboard
|
||||
}
|
||||
if r.PanelID == nil || *r.PanelID == 0 {
|
||||
return nil, ErrNoPanel
|
||||
}
|
||||
|
||||
screenshot, err := s.screenshots.Take(ctx, screenshot.ScreenshotOptions{
|
||||
Timeout: screenshotTimeout,
|
||||
DashboardUID: *r.DashboardUID,
|
||||
PanelID: *r.PanelID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to take screenshot: %w", err)
|
||||
}
|
||||
|
||||
v := store.Image{
|
||||
Path: screenshot.Path,
|
||||
URL: screenshot.URL,
|
||||
}
|
||||
if err := s.store.SaveImage(ctx, &v); err != nil {
|
||||
return nil, fmt.Errorf("failed to save image: %w", err)
|
||||
}
|
||||
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
type NoopImageService struct{}
|
||||
|
||||
func (s *NoopImageService) NewImage(ctx context.Context, r *ngmodels.AlertRule) (*store.Image, error) {
|
||||
return &store.Image{}, nil
|
||||
}
|
@ -17,6 +17,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/image"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
|
||||
@ -25,6 +26,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
"github.com/grafana/grafana/pkg/services/secrets"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
@ -33,7 +35,7 @@ import (
|
||||
func ProvideService(cfg *setting.Cfg, dataSourceCache datasources.CacheService, routeRegister routing.RouteRegister,
|
||||
sqlStore *sqlstore.SQLStore, kvStore kvstore.KVStore, expressionService *expr.Service, dataProxy *datasourceproxy.DataSourceProxyService,
|
||||
quotaService *quota.QuotaService, secretsService secrets.Service, notificationService notifications.Service, m *metrics.NGAlert,
|
||||
folderService dashboards.FolderService, ac accesscontrol.AccessControl, dashboardService dashboards.DashboardService) (*AlertNG, error) {
|
||||
folderService dashboards.FolderService, ac accesscontrol.AccessControl, dashboardService dashboards.DashboardService, renderService rendering.Service) (*AlertNG, error) {
|
||||
ng := &AlertNG{
|
||||
Cfg: cfg,
|
||||
DataSourceCache: dataSourceCache,
|
||||
@ -50,6 +52,7 @@ func ProvideService(cfg *setting.Cfg, dataSourceCache datasources.CacheService,
|
||||
folderService: folderService,
|
||||
accesscontrol: ac,
|
||||
dashboardService: dashboardService,
|
||||
renderService: renderService,
|
||||
}
|
||||
|
||||
if ng.IsDisabled() {
|
||||
@ -77,6 +80,8 @@ type AlertNG struct {
|
||||
Metrics *metrics.NGAlert
|
||||
NotificationService notifications.Service
|
||||
Log log.Logger
|
||||
renderService rendering.Service
|
||||
imageService image.ImageService
|
||||
schedule schedule.ScheduleService
|
||||
stateManager *state.Manager
|
||||
folderService dashboards.FolderService
|
||||
@ -106,6 +111,12 @@ func (ng *AlertNG) init() error {
|
||||
return err
|
||||
}
|
||||
|
||||
imageService, err := image.NewScreenshotImageServiceFromCfg(ng.Cfg, ng.Metrics.Registerer, store, ng.dashboardService, ng.renderService)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ng.imageService = imageService
|
||||
|
||||
// Let's make sure we're able to complete an initial sync of Alertmanagers before we start the alerting components.
|
||||
if err := ng.MultiOrgAlertmanager.LoadAndSyncAlertmanagersForOrgs(context.Background()); err != nil {
|
||||
return err
|
||||
@ -133,7 +144,8 @@ func (ng *AlertNG) init() error {
|
||||
ng.Log.Error("Failed to parse application URL. Continue without it.", "error", err)
|
||||
appUrl = nil
|
||||
}
|
||||
stateManager := state.NewManager(ng.Log, ng.Metrics.GetStateMetrics(), appUrl, store, store, ng.SQLStore, ng.dashboardService)
|
||||
|
||||
stateManager := state.NewManager(ng.Log, ng.Metrics.GetStateMetrics(), appUrl, store, store, ng.SQLStore, ng.dashboardService, ng.imageService)
|
||||
scheduler := schedule.NewScheduler(schedCfg, ng.ExpressionService, appUrl, stateManager)
|
||||
|
||||
ng.stateManager = stateManager
|
||||
|
@ -9,23 +9,23 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/image"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/schedule"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/tests"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var testMetrics = metrics.NewNGAlert(prometheus.NewPedanticRegistry())
|
||||
@ -107,7 +107,7 @@ func TestWarmStateCache(t *testing.T) {
|
||||
Metrics: testMetrics.GetSchedulerMetrics(),
|
||||
AdminConfigPollInterval: 10 * time.Minute, // do not poll in unit tests.
|
||||
}
|
||||
st := state.NewManager(schedCfg.Logger, testMetrics.GetStateMetrics(), nil, dbstore, dbstore, ng.SQLStore, &dashboards.FakeDashboardService{})
|
||||
st := state.NewManager(schedCfg.Logger, testMetrics.GetStateMetrics(), nil, dbstore, dbstore, ng.SQLStore, &dashboards.FakeDashboardService{}, &image.NoopImageService{})
|
||||
st.Warm(ctx)
|
||||
|
||||
t.Run("instance cache has expected entries", func(t *testing.T) {
|
||||
@ -159,7 +159,7 @@ func TestAlertingTicker(t *testing.T) {
|
||||
disabledOrgID: {},
|
||||
},
|
||||
}
|
||||
st := state.NewManager(schedCfg.Logger, testMetrics.GetStateMetrics(), nil, dbstore, dbstore, ng.SQLStore, &dashboards.FakeDashboardService{})
|
||||
st := state.NewManager(schedCfg.Logger, testMetrics.GetStateMetrics(), nil, dbstore, dbstore, ng.SQLStore, &dashboards.FakeDashboardService{}, &image.NoopImageService{})
|
||||
appUrl := &url.URL{
|
||||
Scheme: "http",
|
||||
Host: "localhost",
|
||||
|
@ -23,6 +23,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/annotations"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/image"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
|
||||
@ -926,7 +927,7 @@ func setupScheduler(t *testing.T, rs store.RuleStore, is store.InstanceStore, ac
|
||||
Metrics: m.GetSchedulerMetrics(),
|
||||
AdminConfigPollInterval: 10 * time.Minute, // do not poll in unit tests.
|
||||
}
|
||||
st := state.NewManager(schedCfg.Logger, m.GetStateMetrics(), nil, rs, is, mockstore.NewSQLStoreMock(), &dashboards.FakeDashboardService{})
|
||||
st := state.NewManager(schedCfg.Logger, m.GetStateMetrics(), nil, rs, is, mockstore.NewSQLStoreMock(), &dashboards.FakeDashboardService{}, &image.NoopImageService{})
|
||||
appUrl := &url.URL{
|
||||
Scheme: "http",
|
||||
Host: "localhost",
|
||||
|
@ -2,6 +2,7 @@ package state
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@ -15,9 +16,11 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/annotations"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/image"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
ngModels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/screenshot"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
)
|
||||
|
||||
@ -41,11 +44,12 @@ type Manager struct {
|
||||
instanceStore store.InstanceStore
|
||||
sqlStore sqlstore.Store
|
||||
dashboardService dashboards.DashboardService
|
||||
imageService image.ImageService
|
||||
}
|
||||
|
||||
func NewManager(logger log.Logger, metrics *metrics.State, externalURL *url.URL,
|
||||
ruleStore store.RuleStore, instanceStore store.InstanceStore, sqlStore sqlstore.Store,
|
||||
dashboardService dashboards.DashboardService) *Manager {
|
||||
dashboardService dashboards.DashboardService, imageService image.ImageService) *Manager {
|
||||
manager := &Manager{
|
||||
cache: newCache(logger, metrics, externalURL),
|
||||
quit: make(chan struct{}),
|
||||
@ -56,6 +60,7 @@ func NewManager(logger log.Logger, metrics *metrics.State, externalURL *url.URL,
|
||||
instanceStore: instanceStore,
|
||||
sqlStore: sqlStore,
|
||||
dashboardService: dashboardService,
|
||||
imageService: imageService,
|
||||
}
|
||||
go manager.recordMetrics()
|
||||
return manager
|
||||
@ -165,6 +170,22 @@ func (st *Manager) ProcessEvalResults(ctx context.Context, alertRule *ngModels.A
|
||||
return states
|
||||
}
|
||||
|
||||
//nolint:unused
|
||||
func (st *Manager) newImage(ctx context.Context, alertRule *ngModels.AlertRule, state *State) error {
|
||||
if state.Image == nil {
|
||||
image, err := st.imageService.NewImage(ctx, alertRule)
|
||||
if errors.Is(err, screenshot.ErrScreenshotsUnavailable) {
|
||||
// It's not an error if screenshots are disabled.
|
||||
return nil
|
||||
} else if err != nil {
|
||||
st.log.Error("failed to create image", "error", err)
|
||||
return err
|
||||
}
|
||||
state.Image = image
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set the current state based on evaluation results
|
||||
func (st *Manager) setNextState(ctx context.Context, alertRule *ngModels.AlertRule, result eval.Result) *State {
|
||||
currentState := st.getOrCreate(ctx, alertRule, result)
|
||||
|
@ -8,24 +8,23 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/annotations"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
|
||||
"github.com/grafana/grafana/pkg/expr"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/tests"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/expr"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/annotations"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/image"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/tests"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
|
||||
)
|
||||
|
||||
var testMetrics = metrics.NewNGAlert(prometheus.NewPedanticRegistry())
|
||||
@ -38,7 +37,7 @@ func TestDashboardAnnotations(t *testing.T) {
|
||||
_, dbstore := tests.SetupTestEnv(t, 1)
|
||||
|
||||
sqlStore := mockstore.NewSQLStoreMock()
|
||||
st := state.NewManager(log.New("test_stale_results_handler"), testMetrics.GetStateMetrics(), nil, dbstore, dbstore, sqlStore, &dashboards.FakeDashboardService{})
|
||||
st := state.NewManager(log.New("test_stale_results_handler"), testMetrics.GetStateMetrics(), nil, dbstore, dbstore, sqlStore, &dashboards.FakeDashboardService{}, &image.NoopImageService{})
|
||||
|
||||
fakeAnnoRepo := store.NewFakeAnnotationsRepo()
|
||||
annotations.SetRepository(fakeAnnoRepo)
|
||||
@ -1771,7 +1770,7 @@ func TestProcessEvalResults(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
ss := mockstore.NewSQLStoreMock()
|
||||
st := state.NewManager(log.New("test_state_manager"), testMetrics.GetStateMetrics(), nil, nil, &store.FakeInstanceStore{}, ss, &dashboards.FakeDashboardService{})
|
||||
st := state.NewManager(log.New("test_state_manager"), testMetrics.GetStateMetrics(), nil, nil, &store.FakeInstanceStore{}, ss, &dashboards.FakeDashboardService{}, &image.NoopImageService{})
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
fakeAnnoRepo := store.NewFakeAnnotationsRepo()
|
||||
annotations.SetRepository(fakeAnnoRepo)
|
||||
@ -1882,7 +1881,7 @@ func TestStaleResultsHandler(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
ctx := context.Background()
|
||||
sqlStore := mockstore.NewSQLStoreMock()
|
||||
st := state.NewManager(log.New("test_stale_results_handler"), testMetrics.GetStateMetrics(), nil, dbstore, dbstore, sqlStore, &dashboards.FakeDashboardService{})
|
||||
st := state.NewManager(log.New("test_stale_results_handler"), testMetrics.GetStateMetrics(), nil, dbstore, dbstore, sqlStore, &dashboards.FakeDashboardService{}, &image.NoopImageService{})
|
||||
st.Warm(ctx)
|
||||
existingStatesForRule := st.GetStatesForRuleUID(rule.OrgID, rule.UID)
|
||||
|
||||
|
@ -12,6 +12,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/expr"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
ngModels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
)
|
||||
|
||||
type State struct {
|
||||
@ -29,6 +30,7 @@ type State struct {
|
||||
LastSentAt time.Time
|
||||
Annotations map[string]string
|
||||
Labels data.Labels
|
||||
Image *store.Image
|
||||
Error error
|
||||
}
|
||||
|
||||
|
@ -6,13 +6,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
ptr "github.com/xorcare/pointer"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNeedsSending(t *testing.T) {
|
||||
|
96
pkg/services/ngalert/store/image.go
Normal file
96
pkg/services/ngalert/store/image.go
Normal file
@ -0,0 +1,96 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrImageNotFound is returned when the image does not exist.
|
||||
ErrImageNotFound = errors.New("image not found")
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
ID int64 `xorm:"pk autoincr 'id'"`
|
||||
Token string `xorm:"token"`
|
||||
Path string `xorm:"path"`
|
||||
URL string `xorm:"url"`
|
||||
CreatedAt time.Time `xorm:"created_at"`
|
||||
ExpiresAt time.Time `xorm:"expires_at"`
|
||||
}
|
||||
|
||||
// A XORM interface that lets us clean up our SQL session definition.
|
||||
func (i *Image) TableName() string {
|
||||
return "alert_image"
|
||||
}
|
||||
|
||||
type ImageStore interface {
|
||||
// Get returns the image with the token or ErrImageNotFound.
|
||||
GetImage(ctx context.Context, token string) (*Image, error)
|
||||
|
||||
// Saves the image or returns an error.
|
||||
SaveImage(ctx context.Context, img *Image) error
|
||||
}
|
||||
|
||||
func (st DBstore) GetImage(ctx context.Context, token string) (*Image, error) {
|
||||
var img Image
|
||||
if err := st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
|
||||
exists, err := sess.Where("token = ?", token).Get(&img)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get image: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return ErrImageNotFound
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &img, nil
|
||||
}
|
||||
|
||||
func (st DBstore) SaveImage(ctx context.Context, img *Image) error {
|
||||
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
|
||||
// TODO: Is this a good idea? Do we actually want to automatically expire
|
||||
// rows? See issue https://github.com/grafana/grafana/issues/49366
|
||||
img.ExpiresAt = TimeNow().Add(1 * time.Minute).UTC()
|
||||
if img.ID == 0 { // xorm will fill this field on Insert.
|
||||
token, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create token: %w", err)
|
||||
}
|
||||
img.Token = token.String()
|
||||
img.CreatedAt = TimeNow().UTC()
|
||||
if _, err := sess.Insert(img); err != nil {
|
||||
return fmt.Errorf("failed to insert screenshot: %w", err)
|
||||
}
|
||||
} else {
|
||||
affected, err := sess.ID(img.ID).Update(img)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update screenshot: %v", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return fmt.Errorf("update statement had no effect")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
//nolint:unused
|
||||
func (st DBstore) DeleteExpiredImages(ctx context.Context) error {
|
||||
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
|
||||
n, err := sess.Where("expires_at < ?", TimeNow()).Delete(&Image{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete expired images: %w", err)
|
||||
}
|
||||
st.Logger.Info("deleted expired images", "n", n)
|
||||
return err
|
||||
})
|
||||
}
|
129
pkg/services/ngalert/store/image_test.go
Normal file
129
pkg/services/ngalert/store/image_test.go
Normal file
@ -0,0 +1,129 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/tests"
|
||||
)
|
||||
|
||||
func createTestImg(fakeUrl string, fakePath string) *store.Image {
|
||||
return &store.Image{
|
||||
ID: 0,
|
||||
Token: "",
|
||||
Path: fakeUrl + "local",
|
||||
URL: fakeUrl,
|
||||
}
|
||||
}
|
||||
|
||||
func addID(img *store.Image, id int64) *store.Image {
|
||||
img.ID = id
|
||||
return img
|
||||
}
|
||||
|
||||
func addToken(img *store.Image) *store.Image {
|
||||
token, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
panic("wat")
|
||||
}
|
||||
img.Token = token.String()
|
||||
return img
|
||||
}
|
||||
|
||||
func TestSaveAndGetImage(t *testing.T) {
|
||||
mockTimeNow()
|
||||
ctx := context.Background()
|
||||
_, dbstore := tests.SetupTestEnv(t, baseIntervalSeconds)
|
||||
|
||||
// Here are some images to save.
|
||||
imgs := []struct {
|
||||
name string
|
||||
img *store.Image
|
||||
errors bool
|
||||
}{
|
||||
{
|
||||
"with file path",
|
||||
createTestImg("", "path"),
|
||||
false,
|
||||
},
|
||||
{
|
||||
"with URL",
|
||||
createTestImg("url", ""),
|
||||
false,
|
||||
},
|
||||
{
|
||||
"ID already set, should not change",
|
||||
addToken(addID(createTestImg("Foo", ""), 123)),
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range imgs {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
err := dbstore.SaveImage(ctx, test.img)
|
||||
if test.errors {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
returned, err := dbstore.GetImage(ctx, test.img.Token)
|
||||
assert.NoError(t, err, "Shouldn't error when getting the image")
|
||||
assert.Equal(t, test.img, returned)
|
||||
|
||||
// Save again to test update path.
|
||||
err = dbstore.SaveImage(ctx, test.img)
|
||||
require.NoError(t, err, "Should have no error on second write")
|
||||
returned, err = dbstore.GetImage(ctx, test.img.Token)
|
||||
assert.NoError(t, err, "Shouldn't error when getting the image a second time")
|
||||
assert.Equal(t, test.img, returned)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteExpiredImages(t *testing.T) {
|
||||
mockTimeNow()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||
defer cancel()
|
||||
_, dbstore := tests.SetupTestEnv(t, baseIntervalSeconds)
|
||||
|
||||
// Save two images.
|
||||
imgs := []*store.Image{
|
||||
createTestImg("", ""),
|
||||
createTestImg("", ""),
|
||||
}
|
||||
|
||||
for _, img := range imgs {
|
||||
err := dbstore.SaveImage(ctx, img)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Wait until timeout.
|
||||
for i := 0; i < 120; i++ {
|
||||
store.TimeNow()
|
||||
}
|
||||
|
||||
// Call expired
|
||||
err := dbstore.DeleteExpiredImages(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// All images are gone.
|
||||
img, err := dbstore.GetImage(ctx, imgs[0].Token)
|
||||
require.Nil(t, img)
|
||||
require.Error(t, err)
|
||||
|
||||
img, err = dbstore.GetImage(ctx, imgs[1].Token)
|
||||
require.Nil(t, img)
|
||||
require.Error(t, err)
|
||||
}
|
@ -17,6 +17,7 @@ import (
|
||||
|
||||
const baseIntervalSeconds = 10
|
||||
|
||||
// Every time this is called, time advances by 1 second.
|
||||
func mockTimeNow() {
|
||||
var timeSeed int64
|
||||
store.TimeNow = func() time.Time {
|
||||
|
@ -66,7 +66,7 @@ func SetupTestEnv(t *testing.T, baseInterval time.Duration) (*ngalert.AlertNG, *
|
||||
|
||||
ng, err := ngalert.ProvideService(
|
||||
cfg, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, nil,
|
||||
secretsService, nil, m, folderService, ac, &dashboards.FakeDashboardService{},
|
||||
secretsService, nil, m, folderService, ac, &dashboards.FakeDashboardService{}, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
return ng, &store.DBstore{
|
||||
|
@ -104,6 +104,7 @@ type CapabilitySupportRequestResult struct {
|
||||
SemverConstraint string
|
||||
}
|
||||
|
||||
//go:generate mockgen -destination=mock.go -package=rendering github.com/grafana/grafana/pkg/services/rendering Service
|
||||
type Service interface {
|
||||
IsAvailable() bool
|
||||
Version() string
|
||||
|
154
pkg/services/rendering/mock.go
Normal file
154
pkg/services/rendering/mock.go
Normal file
@ -0,0 +1,154 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/grafana/grafana/pkg/services/rendering (interfaces: Service)
|
||||
|
||||
// Package rendering is a generated GoMock package.
|
||||
package rendering
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
models "github.com/grafana/grafana/pkg/models"
|
||||
)
|
||||
|
||||
// MockService is a mock of Service interface.
|
||||
type MockService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockServiceMockRecorder
|
||||
}
|
||||
|
||||
// MockServiceMockRecorder is the mock recorder for MockService.
|
||||
type MockServiceMockRecorder struct {
|
||||
mock *MockService
|
||||
}
|
||||
|
||||
// NewMockService creates a new mock instance.
|
||||
func NewMockService(ctrl *gomock.Controller) *MockService {
|
||||
mock := &MockService{ctrl: ctrl}
|
||||
mock.recorder = &MockServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockService) EXPECT() *MockServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// CreateRenderingSession mocks base method.
|
||||
func (m *MockService) CreateRenderingSession(arg0 context.Context, arg1 AuthOpts, arg2 SessionOpts) (Session, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CreateRenderingSession", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(Session)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CreateRenderingSession indicates an expected call of CreateRenderingSession.
|
||||
func (mr *MockServiceMockRecorder) CreateRenderingSession(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateRenderingSession", reflect.TypeOf((*MockService)(nil).CreateRenderingSession), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// GetRenderUser mocks base method.
|
||||
func (m *MockService) GetRenderUser(arg0 context.Context, arg1 string) (*RenderUser, bool) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetRenderUser", arg0, arg1)
|
||||
ret0, _ := ret[0].(*RenderUser)
|
||||
ret1, _ := ret[1].(bool)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetRenderUser indicates an expected call of GetRenderUser.
|
||||
func (mr *MockServiceMockRecorder) GetRenderUser(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRenderUser", reflect.TypeOf((*MockService)(nil).GetRenderUser), arg0, arg1)
|
||||
}
|
||||
|
||||
// HasCapability mocks base method.
|
||||
func (m *MockService) HasCapability(arg0 CapabilityName) (CapabilitySupportRequestResult, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "HasCapability", arg0)
|
||||
ret0, _ := ret[0].(CapabilitySupportRequestResult)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// HasCapability indicates an expected call of HasCapability.
|
||||
func (mr *MockServiceMockRecorder) HasCapability(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasCapability", reflect.TypeOf((*MockService)(nil).HasCapability), arg0)
|
||||
}
|
||||
|
||||
// IsAvailable mocks base method.
|
||||
func (m *MockService) IsAvailable() bool {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "IsAvailable")
|
||||
ret0, _ := ret[0].(bool)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// IsAvailable indicates an expected call of IsAvailable.
|
||||
func (mr *MockServiceMockRecorder) IsAvailable() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsAvailable", reflect.TypeOf((*MockService)(nil).IsAvailable))
|
||||
}
|
||||
|
||||
// Render mocks base method.
|
||||
func (m *MockService) Render(arg0 context.Context, arg1 Opts, arg2 Session) (*RenderResult, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Render", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(*RenderResult)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Render indicates an expected call of Render.
|
||||
func (mr *MockServiceMockRecorder) Render(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Render", reflect.TypeOf((*MockService)(nil).Render), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// RenderCSV mocks base method.
|
||||
func (m *MockService) RenderCSV(arg0 context.Context, arg1 CSVOpts, arg2 Session) (*RenderCSVResult, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "RenderCSV", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(*RenderCSVResult)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// RenderCSV indicates an expected call of RenderCSV.
|
||||
func (mr *MockServiceMockRecorder) RenderCSV(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenderCSV", reflect.TypeOf((*MockService)(nil).RenderCSV), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// RenderErrorImage mocks base method.
|
||||
func (m *MockService) RenderErrorImage(arg0 models.Theme, arg1 error) (*RenderResult, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "RenderErrorImage", arg0, arg1)
|
||||
ret0, _ := ret[0].(*RenderResult)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// RenderErrorImage indicates an expected call of RenderErrorImage.
|
||||
func (mr *MockServiceMockRecorder) RenderErrorImage(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenderErrorImage", reflect.TypeOf((*MockService)(nil).RenderErrorImage), arg0, arg1)
|
||||
}
|
||||
|
||||
// Version mocks base method.
|
||||
func (m *MockService) Version() string {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Version")
|
||||
ret0, _ := ret[0].(string)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Version indicates an expected call of Version.
|
||||
func (mr *MockServiceMockRecorder) Version() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Version", reflect.TypeOf((*MockService)(nil).Version))
|
||||
}
|
50
pkg/services/screenshot/mock.go
Normal file
50
pkg/services/screenshot/mock.go
Normal file
@ -0,0 +1,50 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/grafana/grafana/pkg/services/screenshot (interfaces: ScreenshotService)
|
||||
|
||||
// Package screenshot is a generated GoMock package.
|
||||
package screenshot
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
)
|
||||
|
||||
// MockScreenshotService is a mock of ScreenshotService interface.
|
||||
type MockScreenshotService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockScreenshotServiceMockRecorder
|
||||
}
|
||||
|
||||
// MockScreenshotServiceMockRecorder is the mock recorder for MockScreenshotService.
|
||||
type MockScreenshotServiceMockRecorder struct {
|
||||
mock *MockScreenshotService
|
||||
}
|
||||
|
||||
// NewMockScreenshotService creates a new mock instance.
|
||||
func NewMockScreenshotService(ctrl *gomock.Controller) *MockScreenshotService {
|
||||
mock := &MockScreenshotService{ctrl: ctrl}
|
||||
mock.recorder = &MockScreenshotServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockScreenshotService) EXPECT() *MockScreenshotServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Take mocks base method.
|
||||
func (m *MockScreenshotService) Take(arg0 context.Context, arg1 ScreenshotOptions) (*Screenshot, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Take", arg0, arg1)
|
||||
ret0, _ := ret[0].(*Screenshot)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Take indicates an expected call of Take.
|
||||
func (mr *MockScreenshotServiceMockRecorder) Take(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Take", reflect.TypeOf((*MockScreenshotService)(nil).Take), arg0, arg1)
|
||||
}
|
354
pkg/services/screenshot/screenshot.go
Normal file
354
pkg/services/screenshot/screenshot.go
Normal file
@ -0,0 +1,354 @@
|
||||
package screenshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
gocache "github.com/patrickmn/go-cache"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/imguploader"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
const (
|
||||
namespace = "grafana"
|
||||
subsystem = "screenshot"
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultTheme = models.ThemeDark
|
||||
DefaultTimeout = 15 * time.Second
|
||||
DefaultHeight = 500
|
||||
DefaultWidth = 1000
|
||||
)
|
||||
|
||||
var (
|
||||
ErrScreenshotsUnavailable = errors.New("screenshots unavailable")
|
||||
)
|
||||
|
||||
// Screenshot represents a screenshot of a dashboard in Grafana.
|
||||
//
|
||||
// A screenshot can have a Path and an URL if the screenshot is stored on disk
|
||||
// and uploaded to a cloud storage service or made accessible via the Grafana
|
||||
// HTTP server.
|
||||
type Screenshot struct {
|
||||
Path string
|
||||
URL string
|
||||
}
|
||||
|
||||
// ScreenshotOptions are the options for taking a screenshot.
|
||||
type ScreenshotOptions struct {
|
||||
DashboardUID string
|
||||
PanelID int64
|
||||
Width int
|
||||
Height int
|
||||
Theme models.Theme
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// SetDefaults sets default values for missing or invalid options.
|
||||
func (s ScreenshotOptions) SetDefaults() ScreenshotOptions {
|
||||
if s.Width <= 0 {
|
||||
s.Width = DefaultWidth
|
||||
}
|
||||
if s.Height <= 0 {
|
||||
s.Height = DefaultHeight
|
||||
}
|
||||
switch s.Theme {
|
||||
case models.ThemeDark, models.ThemeLight:
|
||||
default:
|
||||
s.Theme = DefaultTheme
|
||||
}
|
||||
if s.Timeout <= 0 {
|
||||
s.Timeout = DefaultTimeout
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ScreenshotService is an interface for taking screenshots.
|
||||
//go:generate mockgen -destination=mock.go -package=screenshot github.com/grafana/grafana/pkg/services/screenshot ScreenshotService
|
||||
type ScreenshotService interface {
|
||||
Take(ctx context.Context, opts ScreenshotOptions) (*Screenshot, error)
|
||||
}
|
||||
|
||||
// BrowserScreenshotService takes screenshots using a headless browser.
|
||||
type BrowserScreenshotService struct {
|
||||
ds dashboards.DashboardService
|
||||
rs rendering.Service
|
||||
}
|
||||
|
||||
func NewBrowserScreenshotService(ds dashboards.DashboardService, rs rendering.Service) ScreenshotService {
|
||||
return &BrowserScreenshotService{
|
||||
ds: ds,
|
||||
rs: rs,
|
||||
}
|
||||
}
|
||||
|
||||
// Take returns a screenshot or an error if either the dashboard does not exist
|
||||
// or it failed to screenshot the dashboard. It uses both the context and the
|
||||
// timeout in ScreenshotOptions, however the timeout in ScreenshotOptions is
|
||||
// sent to the remote browser where it is used as a client timeout.
|
||||
func (s *BrowserScreenshotService) Take(ctx context.Context, opts ScreenshotOptions) (*Screenshot, error) {
|
||||
q := models.GetDashboardQuery{Uid: opts.DashboardUID}
|
||||
if err := s.ds.GetDashboard(ctx, &q); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts = opts.SetDefaults()
|
||||
|
||||
// Compute the URL to screenshot.
|
||||
renderPath := path.Join("d-solo", q.Result.Uid, q.Result.Slug)
|
||||
url := &url.URL{}
|
||||
url.Path = renderPath
|
||||
qParams := url.Query()
|
||||
qParams.Add("orgId", fmt.Sprint(q.Result.OrgId))
|
||||
if opts.PanelID != 0 {
|
||||
qParams.Add("panelId", fmt.Sprint(opts.PanelID))
|
||||
}
|
||||
url.RawQuery = qParams.Encode()
|
||||
path := url.String()
|
||||
|
||||
renderOpts := rendering.Opts{
|
||||
AuthOpts: rendering.AuthOpts{
|
||||
OrgID: q.Result.OrgId,
|
||||
OrgRole: models.ROLE_ADMIN,
|
||||
},
|
||||
ErrorOpts: rendering.ErrorOpts{
|
||||
ErrorConcurrentLimitReached: true,
|
||||
ErrorRenderUnavailable: true,
|
||||
},
|
||||
TimeoutOpts: rendering.TimeoutOpts{
|
||||
Timeout: opts.Timeout,
|
||||
},
|
||||
Width: opts.Width,
|
||||
Height: opts.Height,
|
||||
Theme: opts.Theme,
|
||||
ConcurrentLimit: setting.AlertingRenderLimit,
|
||||
Path: path,
|
||||
}
|
||||
|
||||
result, err := s.rs.Render(ctx, renderOpts, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to take screenshot: %w", err)
|
||||
}
|
||||
|
||||
screenshot := Screenshot{Path: result.FilePath}
|
||||
return &screenshot, nil
|
||||
}
|
||||
|
||||
// CachableScreenshotService caches screenshots.
|
||||
type CachableScreenshotService struct {
|
||||
cache *gocache.Cache
|
||||
service ScreenshotService
|
||||
cacheHits prometheus.Counter
|
||||
cacheMisses prometheus.Counter
|
||||
}
|
||||
|
||||
func NewCachableScreenshotService(r prometheus.Registerer, expiration time.Duration, service ScreenshotService) ScreenshotService {
|
||||
return &CachableScreenshotService{
|
||||
cache: gocache.New(expiration, time.Minute),
|
||||
service: service,
|
||||
cacheHits: promauto.With(r).NewCounter(prometheus.CounterOpts{
|
||||
Name: "cache_hits_total",
|
||||
Namespace: namespace,
|
||||
Subsystem: subsystem,
|
||||
}),
|
||||
cacheMisses: promauto.With(r).NewCounter(prometheus.CounterOpts{
|
||||
Name: "cache_misses_total",
|
||||
Namespace: namespace,
|
||||
Subsystem: subsystem,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Take returns the screenshot from the cache or asks the service to take a
|
||||
// new screenshot and cache it before returning it.
|
||||
func (s *CachableScreenshotService) Take(ctx context.Context, opts ScreenshotOptions) (*Screenshot, error) {
|
||||
k := fmt.Sprintf("%s-%d-%s", opts.DashboardUID, opts.PanelID, opts.Theme)
|
||||
|
||||
if v, ok := s.cache.Get(k); ok {
|
||||
defer s.cacheHits.Inc()
|
||||
return v.(*Screenshot), nil
|
||||
}
|
||||
|
||||
defer s.cacheMisses.Inc()
|
||||
screenshot, err := s.service.Take(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.cache.Set(k, screenshot, 0)
|
||||
|
||||
return screenshot, nil
|
||||
}
|
||||
|
||||
// NoopScreenshotService is a service that takes no-op screenshots.
|
||||
type NoopScreenshotService struct{}
|
||||
|
||||
func (s *NoopScreenshotService) Take(_ context.Context, _ ScreenshotOptions) (*Screenshot, error) {
|
||||
return &Screenshot{}, nil
|
||||
}
|
||||
|
||||
// ObservableScreenshotService is a service that records metrics about screenshots.
|
||||
type ObservableScreenshotService struct {
|
||||
service ScreenshotService
|
||||
duration prometheus.Histogram
|
||||
failures prometheus.Counter
|
||||
successes prometheus.Counter
|
||||
}
|
||||
|
||||
func NewObservableScreenshotService(r prometheus.Registerer, service ScreenshotService) ScreenshotService {
|
||||
return &ObservableScreenshotService{
|
||||
service: service,
|
||||
duration: promauto.With(r).NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "duration_seconds",
|
||||
Buckets: []float64{0.1, 0.25, 0.5, 1, 2, 5, 10, 15},
|
||||
Namespace: namespace,
|
||||
Subsystem: subsystem,
|
||||
}),
|
||||
failures: promauto.With(r).NewCounter(prometheus.CounterOpts{
|
||||
Name: "failures_total",
|
||||
Namespace: namespace,
|
||||
Subsystem: subsystem,
|
||||
}),
|
||||
successes: promauto.With(r).NewCounter(prometheus.CounterOpts{
|
||||
Name: "successes_total",
|
||||
Namespace: namespace,
|
||||
Subsystem: subsystem,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ObservableScreenshotService) Take(ctx context.Context, opts ScreenshotOptions) (*Screenshot, error) {
|
||||
start := time.Now()
|
||||
defer func() { s.duration.Observe(time.Since(start).Seconds()) }()
|
||||
|
||||
screenshot, err := s.service.Take(ctx, opts)
|
||||
if err != nil {
|
||||
defer s.failures.Inc()
|
||||
} else {
|
||||
defer s.successes.Inc()
|
||||
}
|
||||
return screenshot, err
|
||||
}
|
||||
|
||||
type ScreenshotUnavailableService struct{}
|
||||
|
||||
func (s *ScreenshotUnavailableService) Take(_ context.Context, _ ScreenshotOptions) (*Screenshot, error) {
|
||||
return nil, ErrScreenshotsUnavailable
|
||||
}
|
||||
|
||||
// SingleFlightScreenshotService prevents duplicate screenshots.
|
||||
type SingleFlightScreenshotService struct {
|
||||
f singleflight.Group
|
||||
service ScreenshotService
|
||||
}
|
||||
|
||||
func NewSingleFlightScreenshotService(service ScreenshotService) ScreenshotService {
|
||||
return &SingleFlightScreenshotService{service: service}
|
||||
}
|
||||
|
||||
// Take returns a screenshot or an error. It ensures that at most one screenshot
|
||||
// can be taken at a time for the same dashboard and theme. Duplicate screenshots
|
||||
// wait for the first screenshot to complete and receive the same screenshot.
|
||||
func (s *SingleFlightScreenshotService) Take(ctx context.Context, opts ScreenshotOptions) (*Screenshot, error) {
|
||||
k := fmt.Sprintf("%s-%d-%s", opts.DashboardUID, opts.PanelID, opts.Theme)
|
||||
|
||||
v, err, _ := s.f.Do(k, func() (interface{}, error) {
|
||||
return s.service.Take(ctx, opts)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
screenshot := v.(*Screenshot)
|
||||
return screenshot, err
|
||||
}
|
||||
|
||||
// RateLimitScreenshotService ensures that at most N screenshots can be taken
|
||||
// at a time.
|
||||
type RateLimitScreenshotService struct {
|
||||
service ScreenshotService
|
||||
tokens chan struct{}
|
||||
}
|
||||
|
||||
func NewRateLimitScreenshotService(service ScreenshotService, n int64) ScreenshotService {
|
||||
return &RateLimitScreenshotService{
|
||||
service: service,
|
||||
tokens: make(chan struct{}, n),
|
||||
}
|
||||
}
|
||||
|
||||
// Take returns a screenshot or an error. It ensures that at most N screenshots
|
||||
// can be taken at a time. The service has N tokens such that a token is consumed
|
||||
// at the start of a screenshot and returned when the screenshot has either
|
||||
// succeeded or failed. A screenshot can timeout if the context is canceled
|
||||
// while waiting for a token or while the screenshot is being taken.
|
||||
func (s *RateLimitScreenshotService) Take(ctx context.Context, opts ScreenshotOptions) (*Screenshot, error) {
|
||||
select {
|
||||
// the context is canceled
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
// there is a token available
|
||||
case s.tokens <- struct{}{}:
|
||||
}
|
||||
// acquired token must be returned
|
||||
defer func() {
|
||||
<-s.tokens
|
||||
}()
|
||||
return s.service.Take(ctx, opts)
|
||||
}
|
||||
|
||||
// UploadingScreenshotService uploads taken screenshots.
|
||||
type UploadingScreenshotService struct {
|
||||
service ScreenshotService
|
||||
uploader imguploader.ImageUploader
|
||||
uploadFailures prometheus.Counter
|
||||
uploadSuccesses prometheus.Counter
|
||||
}
|
||||
|
||||
func NewUploadingScreenshotService(r prometheus.Registerer, service ScreenshotService, uploader imguploader.ImageUploader) ScreenshotService {
|
||||
return &UploadingScreenshotService{
|
||||
service: service,
|
||||
uploader: uploader,
|
||||
uploadFailures: promauto.With(r).NewCounter(prometheus.CounterOpts{
|
||||
Name: "upload_failures",
|
||||
Namespace: namespace,
|
||||
Subsystem: subsystem,
|
||||
}),
|
||||
uploadSuccesses: promauto.With(r).NewCounter(prometheus.CounterOpts{
|
||||
Name: "upload_successes",
|
||||
Namespace: namespace,
|
||||
Subsystem: subsystem,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Take uploads a screenshot with a path and returns a new screenshot with the
|
||||
// unmodified path and a URL. It returns the unmodified screenshot on error.
|
||||
func (s *UploadingScreenshotService) Take(ctx context.Context, opts ScreenshotOptions) (*Screenshot, error) {
|
||||
screenshot, err := s.service.Take(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url, err := s.uploader.Upload(ctx, screenshot.Path)
|
||||
if err != nil {
|
||||
defer s.uploadFailures.Inc()
|
||||
return screenshot, fmt.Errorf("failed to upload screenshot: %w", err)
|
||||
}
|
||||
screenshot.URL = url
|
||||
|
||||
defer s.uploadSuccesses.Inc()
|
||||
return screenshot, nil
|
||||
}
|
338
pkg/services/screenshot/screenshot_test.go
Normal file
338
pkg/services/screenshot/screenshot_test.go
Normal file
@ -0,0 +1,338 @@
|
||||
package screenshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/imguploader"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestScreenshotOptions(t *testing.T) {
|
||||
o := ScreenshotOptions{}
|
||||
assert.Equal(t, ScreenshotOptions{}, o)
|
||||
|
||||
o = o.SetDefaults()
|
||||
assert.Equal(t, ScreenshotOptions{
|
||||
Width: DefaultWidth,
|
||||
Height: DefaultHeight,
|
||||
Theme: DefaultTheme,
|
||||
Timeout: DefaultTimeout,
|
||||
}, o)
|
||||
|
||||
o.Width = 100
|
||||
o = o.SetDefaults()
|
||||
assert.Equal(t, ScreenshotOptions{
|
||||
Width: 100,
|
||||
Height: DefaultHeight,
|
||||
Theme: DefaultTheme,
|
||||
Timeout: DefaultTimeout,
|
||||
}, o)
|
||||
|
||||
o.Height = 100
|
||||
o = o.SetDefaults()
|
||||
assert.Equal(t, ScreenshotOptions{
|
||||
Width: 100,
|
||||
Height: 100,
|
||||
Theme: DefaultTheme,
|
||||
Timeout: DefaultTimeout,
|
||||
}, o)
|
||||
|
||||
o.Theme = "Not a theme"
|
||||
o = o.SetDefaults()
|
||||
assert.Equal(t, ScreenshotOptions{
|
||||
Width: 100,
|
||||
Height: 100,
|
||||
Theme: DefaultTheme,
|
||||
Timeout: DefaultTimeout,
|
||||
}, o)
|
||||
|
||||
o.Timeout = DefaultTimeout + 1
|
||||
assert.Equal(t, ScreenshotOptions{
|
||||
Width: 100,
|
||||
Height: 100,
|
||||
Theme: DefaultTheme,
|
||||
Timeout: DefaultTimeout + 1,
|
||||
}, o)
|
||||
}
|
||||
|
||||
func TestBrowserScreenshotService(t *testing.T) {
|
||||
c := gomock.NewController(t)
|
||||
defer c.Finish()
|
||||
|
||||
d := dashboards.FakeDashboardService{}
|
||||
r := rendering.NewMockService(c)
|
||||
s := NewBrowserScreenshotService(&d, r)
|
||||
|
||||
// a non-existent dashboard should return error
|
||||
d.GetDashboardFn = func(ctx context.Context, cmd *models.GetDashboardQuery) error {
|
||||
return models.ErrDashboardNotFound
|
||||
}
|
||||
ctx := context.Background()
|
||||
opts := ScreenshotOptions{}
|
||||
screenshot, err := s.Take(ctx, opts)
|
||||
assert.EqualError(t, err, "Dashboard not found")
|
||||
assert.Nil(t, screenshot)
|
||||
|
||||
d.GetDashboardFn = func(ctx context.Context, cmd *models.GetDashboardQuery) error {
|
||||
cmd.Result = &models.Dashboard{Id: 1, Uid: "foo", Slug: "bar", OrgId: 2}
|
||||
return nil
|
||||
}
|
||||
|
||||
renderOpts := rendering.Opts{
|
||||
AuthOpts: rendering.AuthOpts{
|
||||
OrgID: 2,
|
||||
OrgRole: models.ROLE_ADMIN,
|
||||
},
|
||||
ErrorOpts: rendering.ErrorOpts{
|
||||
ErrorConcurrentLimitReached: true,
|
||||
ErrorRenderUnavailable: true,
|
||||
},
|
||||
TimeoutOpts: rendering.TimeoutOpts{
|
||||
Timeout: DefaultTimeout,
|
||||
},
|
||||
Width: DefaultWidth,
|
||||
Height: DefaultHeight,
|
||||
Theme: DefaultTheme,
|
||||
Path: "d-solo/foo/bar?orgId=2&panelId=4",
|
||||
ConcurrentLimit: setting.AlertingRenderLimit,
|
||||
}
|
||||
|
||||
opts.DashboardUID = "foo"
|
||||
opts.PanelID = 4
|
||||
r.EXPECT().
|
||||
Render(ctx, renderOpts, nil).
|
||||
Return(&rendering.RenderResult{FilePath: "panel.png"}, nil)
|
||||
screenshot, err = s.Take(ctx, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "panel.png"}, *screenshot)
|
||||
|
||||
// a timeout should return error
|
||||
r.EXPECT().
|
||||
Render(ctx, renderOpts, nil).
|
||||
Return(nil, rendering.ErrTimeout)
|
||||
screenshot, err = s.Take(ctx, opts)
|
||||
assert.EqualError(t, err, fmt.Sprintf("failed to take screenshot: %s", rendering.ErrTimeout))
|
||||
assert.Nil(t, screenshot)
|
||||
}
|
||||
|
||||
func TestCachableScreenshotService(t *testing.T) {
|
||||
c := gomock.NewController(t)
|
||||
defer c.Finish()
|
||||
|
||||
m := NewMockScreenshotService(c)
|
||||
s := NewCachableScreenshotService(prometheus.DefaultRegisterer, time.Second, m)
|
||||
|
||||
ctx := context.Background()
|
||||
opts := ScreenshotOptions{DashboardUID: "foo", PanelID: 1}
|
||||
|
||||
// should be a miss and ask the mock service to take a screenshot
|
||||
m.EXPECT().Take(ctx, opts).Return(&Screenshot{Path: "panel.png"}, nil)
|
||||
screenshot, err := s.Take(ctx, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "panel.png"}, *screenshot)
|
||||
|
||||
// should be a hit
|
||||
screenshot, err = s.Take(ctx, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "panel.png"}, *screenshot)
|
||||
|
||||
// wait 1s and the cached screenshot should have expired
|
||||
<-time.After(time.Second)
|
||||
|
||||
// should be a miss as the cached screenshot has expired
|
||||
m.EXPECT().Take(ctx, opts).Return(&Screenshot{Path: "panel.png"}, nil)
|
||||
screenshot, err = s.Take(ctx, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "panel.png"}, *screenshot)
|
||||
}
|
||||
|
||||
func TestNoopScreenshotService(t *testing.T) {
|
||||
s := NoopScreenshotService{}
|
||||
screenshot, err := s.Take(context.Background(), ScreenshotOptions{})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, screenshot)
|
||||
}
|
||||
|
||||
func TestScreenshotUnavailableService(t *testing.T) {
|
||||
s := ScreenshotUnavailableService{}
|
||||
screenshot, err := s.Take(context.Background(), ScreenshotOptions{})
|
||||
assert.Equal(t, err, ErrScreenshotsUnavailable)
|
||||
assert.Nil(t, screenshot)
|
||||
}
|
||||
|
||||
func TestSingleFlightScreenshotService(t *testing.T) {
|
||||
c := gomock.NewController(t)
|
||||
defer c.Finish()
|
||||
|
||||
m := NewMockScreenshotService(c)
|
||||
s := NewSingleFlightScreenshotService(m)
|
||||
|
||||
ctx := context.Background()
|
||||
opts := ScreenshotOptions{DashboardUID: "foo", PanelID: 1}
|
||||
|
||||
// expect 1 invocation of the mock service for the same options
|
||||
m.EXPECT().Take(ctx, opts).
|
||||
Do(func(_ context.Context, _ ScreenshotOptions) { <-time.After(time.Second) }).
|
||||
Return(&Screenshot{Path: "panel.png"}, nil)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
screenshot, err := s.Take(ctx, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "panel.png"}, *screenshot)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// expect two invocations of the mock service for different dashboards
|
||||
opts1 := ScreenshotOptions{DashboardUID: "foo", PanelID: 1}
|
||||
opts2 := ScreenshotOptions{DashboardUID: "bar", PanelID: 1}
|
||||
m.EXPECT().Take(ctx, opts1).Return(&Screenshot{Path: "foo.png"}, nil)
|
||||
m.EXPECT().Take(ctx, opts2).Return(&Screenshot{Path: "bar.png"}, nil)
|
||||
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
screenshot, err := s.Take(ctx, opts1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "foo.png"}, *screenshot)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
screenshot, err := s.Take(ctx, opts2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "bar.png"}, *screenshot)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
// expect two invocations of the mock service for different panels in the same dashboard
|
||||
opts1 = ScreenshotOptions{DashboardUID: "foo", PanelID: 1}
|
||||
opts2 = ScreenshotOptions{DashboardUID: "foo", PanelID: 2}
|
||||
m.EXPECT().Take(ctx, opts1).Return(&Screenshot{Path: "panel1.png"}, nil)
|
||||
m.EXPECT().Take(ctx, opts2).Return(&Screenshot{Path: "panel2.png"}, nil)
|
||||
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
screenshot, err := s.Take(ctx, opts1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "panel1.png"}, *screenshot)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
screenshot, err := s.Take(ctx, opts2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "panel2.png"}, *screenshot)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
// expect two invocations of the mock service for different panels in the same dashboard
|
||||
opts1 = ScreenshotOptions{DashboardUID: "foo", PanelID: 1, Theme: models.ThemeDark}
|
||||
opts2 = ScreenshotOptions{DashboardUID: "foo", PanelID: 1, Theme: models.ThemeLight}
|
||||
m.EXPECT().Take(ctx, opts1).Return(&Screenshot{Path: "dark.png"}, nil)
|
||||
m.EXPECT().Take(ctx, opts2).Return(&Screenshot{Path: "light.png"}, nil)
|
||||
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
screenshot, err := s.Take(ctx, opts1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "dark.png"}, *screenshot)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
screenshot, err := s.Take(ctx, opts2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "light.png"}, *screenshot)
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestRateLimitScreenshotService(t *testing.T) {
|
||||
c := gomock.NewController(t)
|
||||
defer c.Finish()
|
||||
|
||||
m := NewMockScreenshotService(c)
|
||||
s := NewRateLimitScreenshotService(m, 1)
|
||||
ctx := context.Background()
|
||||
opts := ScreenshotOptions{DashboardUID: "foo", PanelID: 1}
|
||||
|
||||
var v int64
|
||||
for i := 0; i < 10; i++ {
|
||||
m.EXPECT().Take(ctx, opts).
|
||||
Do(func(_ context.Context, _ ScreenshotOptions) {
|
||||
// v should be 0 to show that no tokens have been acquired
|
||||
assert.Equal(t, int64(0), atomic.LoadInt64(&v))
|
||||
atomic.AddInt64(&v, 1)
|
||||
assert.Equal(t, int64(1), atomic.LoadInt64(&v))
|
||||
|
||||
// interrupt so other goroutines can attempt to acquire the token
|
||||
<-time.After(time.Microsecond)
|
||||
|
||||
// v should be 1 to show that no other goroutines acquired the token
|
||||
assert.Equal(t, int64(1), atomic.LoadInt64(&v))
|
||||
atomic.AddInt64(&v, -1)
|
||||
assert.Equal(t, int64(0), atomic.LoadInt64(&v))
|
||||
}).
|
||||
Return(&Screenshot{Path: "foo.png"}, nil)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result, err := s.Take(ctx, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "foo.png"}, *result)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestUploadingScreenshotService(t *testing.T) {
|
||||
c := gomock.NewController(t)
|
||||
defer c.Finish()
|
||||
|
||||
m := NewMockScreenshotService(c)
|
||||
u := imguploader.NewMockImageUploader(c)
|
||||
s := NewUploadingScreenshotService(prometheus.DefaultRegisterer, m, u)
|
||||
|
||||
ctx := context.Background()
|
||||
opts := ScreenshotOptions{DashboardUID: "foo", PanelID: 1}
|
||||
|
||||
m.EXPECT().Take(ctx, opts).Return(&Screenshot{Path: "foo.png"}, nil)
|
||||
u.EXPECT().Upload(ctx, "foo.png").Return("https://example.com/foo.png", nil)
|
||||
screenshot, err := s.Take(ctx, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{
|
||||
Path: "foo.png",
|
||||
URL: "https://example.com/foo.png",
|
||||
}, *screenshot)
|
||||
|
||||
// error on upload should still return screenshot on disk
|
||||
m.EXPECT().Take(ctx, opts).Return(&Screenshot{Path: "foo.png"}, nil)
|
||||
u.EXPECT().Upload(ctx, "foo.png").Return("", errors.New("service is unavailable"))
|
||||
screenshot, err = s.Take(ctx, opts)
|
||||
assert.EqualError(t, err, "failed to upload screenshot: service is unavailable")
|
||||
assert.Equal(t, Screenshot{
|
||||
Path: "foo.png",
|
||||
}, *screenshot)
|
||||
}
|
@ -25,6 +25,8 @@ func AddTablesMigrations(mg *migrator.Migrator) {
|
||||
|
||||
// Create provisioning data table
|
||||
AddProvisioningMigrations(mg)
|
||||
|
||||
AddAlertImageMigrations(mg)
|
||||
}
|
||||
|
||||
// AddAlertDefinitionMigrations should not be modified.
|
||||
@ -353,3 +355,22 @@ func AddProvisioningMigrations(mg *migrator.Migrator) {
|
||||
mg.AddMigration("create provenance_type table", migrator.NewAddTableMigration(provisioningTable))
|
||||
mg.AddMigration("add index to uniquify (record_key, record_type, org_id) columns", migrator.NewAddIndexMigration(provisioningTable, provisioningTable.Indices[0]))
|
||||
}
|
||||
|
||||
func AddAlertImageMigrations(mg *migrator.Migrator) {
|
||||
imageTable := migrator.Table{
|
||||
Name: "alert_image",
|
||||
Columns: []*migrator.Column{
|
||||
{Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
|
||||
{Name: "token", Type: migrator.DB_NVarchar, Length: 190, Nullable: false},
|
||||
{Name: "path", Type: migrator.DB_NVarchar, Length: 190, Nullable: false},
|
||||
{Name: "url", Type: migrator.DB_NVarchar, Length: 190, Nullable: false},
|
||||
{Name: "created_at", Type: migrator.DB_DateTime, Nullable: false},
|
||||
{Name: "expires_at", Type: migrator.DB_DateTime, Nullable: false},
|
||||
},
|
||||
Indices: []*migrator.Index{
|
||||
{Cols: []string{"token"}, Type: migrator.UniqueIndex},
|
||||
},
|
||||
}
|
||||
mg.AddMigration("create alert_image table", migrator.NewAddTableMigration(imageTable))
|
||||
mg.AddMigration("add unique index on token to alert_image table", migrator.NewAddIndexMigration(imageTable, imageTable.Indices[0]))
|
||||
}
|
||||
|
@ -457,6 +457,7 @@ func (m *SQLStoreMock) GetDashboardTags(ctx context.Context, query *models.GetDa
|
||||
}
|
||||
|
||||
func (m *SQLStoreMock) GetDashboards(ctx context.Context, query *models.GetDashboardsQuery) error {
|
||||
query.Result = m.ExpectedDashboards
|
||||
return m.ExpectedError
|
||||
}
|
||||
|
||||
|
@ -48,6 +48,9 @@ const (
|
||||
schedulereDefaultExecuteAlerts = true
|
||||
schedulerDefaultMaxAttempts = 3
|
||||
schedulerDefaultLegacyMinInterval = 1
|
||||
screenshotsDefaultEnabled = false
|
||||
screenshotsDefaultMaxConcurrent = 5
|
||||
screenshotsDefaultUploadImageStorage = false
|
||||
// SchedulerBaseInterval base interval of the scheduler. Controls how often the scheduler fetches database for new changes as well as schedules evaluation of a rule
|
||||
// changing this value is discouraged because this could cause existing alert definition
|
||||
// with intervals that are not exactly divided by this number not to be evaluated
|
||||
@ -77,6 +80,13 @@ type UnifiedAlertingSettings struct {
|
||||
BaseInterval time.Duration
|
||||
// DefaultRuleEvaluationInterval default interval between evaluations of a rule.
|
||||
DefaultRuleEvaluationInterval time.Duration
|
||||
Screenshots UnifiedAlertingScreenshotSettings
|
||||
}
|
||||
|
||||
type UnifiedAlertingScreenshotSettings struct {
|
||||
Enabled bool
|
||||
MaxConcurrentScreenshots int64
|
||||
UploadExternalImageStorage bool
|
||||
}
|
||||
|
||||
// IsEnabled returns true if UnifiedAlertingSettings.Enabled is either nil or true.
|
||||
@ -244,6 +254,14 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error {
|
||||
uaCfg.DefaultRuleEvaluationInterval = uaMinInterval
|
||||
}
|
||||
|
||||
screenshots := iniFile.Section("unified_alerting.screenshots")
|
||||
uaCfgScreenshots := uaCfg.Screenshots
|
||||
|
||||
uaCfgScreenshots.Enabled = screenshots.Key("enabled").MustBool(screenshotsDefaultEnabled)
|
||||
uaCfgScreenshots.MaxConcurrentScreenshots = screenshots.Key("max_concurrent_screenshots").MustInt64(screenshotsDefaultMaxConcurrent)
|
||||
uaCfgScreenshots.UploadExternalImageStorage = screenshots.Key("upload_external_image_storage").MustBool(screenshotsDefaultUploadImageStorage)
|
||||
uaCfg.Screenshots = uaCfgScreenshots
|
||||
|
||||
cfg.UnifiedAlerting = uaCfg
|
||||
return nil
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user