Rendering: Adds PDF support behind feature toggle (#81811)
* start pdf refactor * Update AppChrome.tsx * Update AppChrome.tsx * add encoding param to rendering grpc service * fix plugin mode * clean up * fix backend tests * fix lint errors * Support pdf encoding in render http api --------- Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
co-authored by
Torkel Ödegaard
parent
90a26e18db
commit
28e66b4ad8
@@ -14,6 +14,8 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
)
|
||||
|
||||
var netTransport = &http.Transport{
|
||||
@@ -36,8 +38,16 @@ var (
|
||||
remoteVersionRefreshInterval = time.Minute * 15
|
||||
)
|
||||
|
||||
func (rs *RenderingService) renderViaHTTP(ctx context.Context, renderKey string, opts Opts) (*RenderResult, error) {
|
||||
filePath, err := rs.getNewFilePath(RenderPNG)
|
||||
func (rs *RenderingService) renderViaHTTP(ctx context.Context, renderType RenderType, renderKey string, opts Opts) (*RenderResult, error) {
|
||||
if renderType == RenderPDF {
|
||||
if !rs.features.IsEnabled(ctx, featuremgmt.FlagNewPDFRendering) {
|
||||
return nil, fmt.Errorf("feature 'newPDFRendering' disabled")
|
||||
}
|
||||
|
||||
opts.Encoding = "pdf"
|
||||
}
|
||||
|
||||
filePath, err := rs.getNewFilePath(renderType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ type RenderType string
|
||||
const (
|
||||
RenderCSV RenderType = "csv"
|
||||
RenderPNG RenderType = "png"
|
||||
RenderPDF RenderType = "pdf"
|
||||
)
|
||||
|
||||
type TimeoutOpts struct {
|
||||
@@ -93,7 +94,7 @@ type RenderCSVResult struct {
|
||||
FileName string
|
||||
}
|
||||
|
||||
type renderFunc func(ctx context.Context, renderKey string, options Opts) (*RenderResult, error)
|
||||
type renderFunc func(ctx context.Context, renderType RenderType, renderKey string, options Opts) (*RenderResult, error)
|
||||
type renderCSVFunc func(ctx context.Context, renderKey string, options CSVOpts) (*RenderCSVResult, error)
|
||||
type sanitizeFunc func(ctx context.Context, req *SanitizeSVGRequest) (*SanitizeSVGResponse, error)
|
||||
|
||||
@@ -121,7 +122,7 @@ type CapabilitySupportRequestResult struct {
|
||||
type Service interface {
|
||||
IsAvailable(ctx context.Context) bool
|
||||
Version() string
|
||||
Render(ctx context.Context, opts Opts, session Session) (*RenderResult, error)
|
||||
Render(ctx context.Context, renderType RenderType, opts Opts, session Session) (*RenderResult, error)
|
||||
RenderCSV(ctx context.Context, opts CSVOpts, session Session) (*RenderCSVResult, error)
|
||||
RenderErrorImage(theme models.Theme, error error) (*RenderResult, error)
|
||||
GetRenderUser(ctx context.Context, key string) (*RenderUser, bool)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// 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 (
|
||||
@@ -37,122 +36,122 @@ func (m *MockService) EXPECT() *MockServiceMockRecorder {
|
||||
}
|
||||
|
||||
// CreateRenderingSession mocks base method.
|
||||
func (m *MockService) CreateRenderingSession(arg0 context.Context, arg1 AuthOpts, arg2 SessionOpts) (Session, error) {
|
||||
func (m *MockService) CreateRenderingSession(ctx context.Context, authOpts AuthOpts, sessionOpts SessionOpts) (Session, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CreateRenderingSession", arg0, arg1, arg2)
|
||||
ret := m.ctrl.Call(m, "CreateRenderingSession", ctx, authOpts, sessionOpts)
|
||||
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 any) *gomock.Call {
|
||||
func (mr *MockServiceMockRecorder) CreateRenderingSession(ctx, authOpts, sessionOpts interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateRenderingSession", reflect.TypeOf((*MockService)(nil).CreateRenderingSession), arg0, arg1, arg2)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateRenderingSession", reflect.TypeOf((*MockService)(nil).CreateRenderingSession), ctx, authOpts, sessionOpts)
|
||||
}
|
||||
|
||||
// GetRenderUser mocks base method.
|
||||
func (m *MockService) GetRenderUser(arg0 context.Context, arg1 string) (*RenderUser, bool) {
|
||||
func (m *MockService) GetRenderUser(ctx context.Context, key string) (*RenderUser, bool) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetRenderUser", arg0, arg1)
|
||||
ret := m.ctrl.Call(m, "GetRenderUser", ctx, key)
|
||||
ret0, _ := ret[0].(*RenderUser)
|
||||
ret1, _ := ret[1].(bool)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetRenderUser indicates an expected call of GetRenderUser.
|
||||
func (mr *MockServiceMockRecorder) GetRenderUser(arg0, arg1 any) *gomock.Call {
|
||||
func (mr *MockServiceMockRecorder) GetRenderUser(ctx, key interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRenderUser", reflect.TypeOf((*MockService)(nil).GetRenderUser), arg0, arg1)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRenderUser", reflect.TypeOf((*MockService)(nil).GetRenderUser), ctx, key)
|
||||
}
|
||||
|
||||
// HasCapability mocks base method.
|
||||
func (m *MockService) HasCapability(arg0 context.Context, arg1 CapabilityName) (CapabilitySupportRequestResult, error) {
|
||||
func (m *MockService) HasCapability(ctx context.Context, capability CapabilityName) (CapabilitySupportRequestResult, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "HasCapability", arg0, arg1)
|
||||
ret := m.ctrl.Call(m, "HasCapability", ctx, capability)
|
||||
ret0, _ := ret[0].(CapabilitySupportRequestResult)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// HasCapability indicates an expected call of HasCapability.
|
||||
func (mr *MockServiceMockRecorder) HasCapability(arg0, arg1 any) *gomock.Call {
|
||||
func (mr *MockServiceMockRecorder) HasCapability(ctx, capability interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasCapability", reflect.TypeOf((*MockService)(nil).HasCapability), arg0, arg1)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasCapability", reflect.TypeOf((*MockService)(nil).HasCapability), ctx, capability)
|
||||
}
|
||||
|
||||
// IsAvailable mocks base method.
|
||||
func (m *MockService) IsAvailable(arg0 context.Context) bool {
|
||||
func (m *MockService) IsAvailable(ctx context.Context) bool {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "IsAvailable", arg0)
|
||||
ret := m.ctrl.Call(m, "IsAvailable", ctx)
|
||||
ret0, _ := ret[0].(bool)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// IsAvailable indicates an expected call of IsAvailable.
|
||||
func (mr *MockServiceMockRecorder) IsAvailable(arg0 any) *gomock.Call {
|
||||
func (mr *MockServiceMockRecorder) IsAvailable(ctx interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsAvailable", reflect.TypeOf((*MockService)(nil).IsAvailable), arg0)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsAvailable", reflect.TypeOf((*MockService)(nil).IsAvailable), ctx)
|
||||
}
|
||||
|
||||
// Render mocks base method.
|
||||
func (m *MockService) Render(arg0 context.Context, arg1 Opts, arg2 Session) (*RenderResult, error) {
|
||||
func (m *MockService) Render(ctx context.Context, renderType RenderType, opts Opts, session Session) (*RenderResult, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Render", arg0, arg1, arg2)
|
||||
ret := m.ctrl.Call(m, "Render", ctx, renderType, opts, session)
|
||||
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 any) *gomock.Call {
|
||||
func (mr *MockServiceMockRecorder) Render(ctx, renderType, opts, session interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Render", reflect.TypeOf((*MockService)(nil).Render), arg0, arg1, arg2)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Render", reflect.TypeOf((*MockService)(nil).Render), ctx, renderType, opts, session)
|
||||
}
|
||||
|
||||
// RenderCSV mocks base method.
|
||||
func (m *MockService) RenderCSV(arg0 context.Context, arg1 CSVOpts, arg2 Session) (*RenderCSVResult, error) {
|
||||
func (m *MockService) RenderCSV(ctx context.Context, opts CSVOpts, session Session) (*RenderCSVResult, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "RenderCSV", arg0, arg1, arg2)
|
||||
ret := m.ctrl.Call(m, "RenderCSV", ctx, opts, session)
|
||||
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 any) *gomock.Call {
|
||||
func (mr *MockServiceMockRecorder) RenderCSV(ctx, opts, session interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenderCSV", reflect.TypeOf((*MockService)(nil).RenderCSV), arg0, arg1, arg2)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenderCSV", reflect.TypeOf((*MockService)(nil).RenderCSV), ctx, opts, session)
|
||||
}
|
||||
|
||||
// RenderErrorImage mocks base method.
|
||||
func (m *MockService) RenderErrorImage(arg0 models.Theme, arg1 error) (*RenderResult, error) {
|
||||
func (m *MockService) RenderErrorImage(theme models.Theme, err error) (*RenderResult, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "RenderErrorImage", arg0, arg1)
|
||||
ret := m.ctrl.Call(m, "RenderErrorImage", theme, err)
|
||||
ret0, _ := ret[0].(*RenderResult)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// RenderErrorImage indicates an expected call of RenderErrorImage.
|
||||
func (mr *MockServiceMockRecorder) RenderErrorImage(arg0, arg1 any) *gomock.Call {
|
||||
func (mr *MockServiceMockRecorder) RenderErrorImage(theme, error interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenderErrorImage", reflect.TypeOf((*MockService)(nil).RenderErrorImage), arg0, arg1)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenderErrorImage", reflect.TypeOf((*MockService)(nil).RenderErrorImage), theme, error)
|
||||
}
|
||||
|
||||
// SanitizeSVG mocks base method.
|
||||
func (m *MockService) SanitizeSVG(arg0 context.Context, arg1 *SanitizeSVGRequest) (*SanitizeSVGResponse, error) {
|
||||
func (m *MockService) SanitizeSVG(ctx context.Context, req *SanitizeSVGRequest) (*SanitizeSVGResponse, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SanitizeSVG", arg0, arg1)
|
||||
ret := m.ctrl.Call(m, "SanitizeSVG", ctx, req)
|
||||
ret0, _ := ret[0].(*SanitizeSVGResponse)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// SanitizeSVG indicates an expected call of SanitizeSVG.
|
||||
func (mr *MockServiceMockRecorder) SanitizeSVG(arg0, arg1 any) *gomock.Call {
|
||||
func (mr *MockServiceMockRecorder) SanitizeSVG(ctx, req interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SanitizeSVG", reflect.TypeOf((*MockService)(nil).SanitizeSVG), arg0, arg1)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SanitizeSVG", reflect.TypeOf((*MockService)(nil).SanitizeSVG), ctx, req)
|
||||
}
|
||||
|
||||
// Version mocks base method.
|
||||
|
||||
@@ -6,14 +6,23 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
)
|
||||
|
||||
func (rs *RenderingService) renderViaPlugin(ctx context.Context, renderKey string, opts Opts) (*RenderResult, error) {
|
||||
func (rs *RenderingService) renderViaPlugin(ctx context.Context, renderType RenderType, renderKey string, opts Opts) (*RenderResult, error) {
|
||||
if renderType == RenderPDF {
|
||||
if !rs.features.IsEnabled(ctx, featuremgmt.FlagNewPDFRendering) {
|
||||
return nil, fmt.Errorf("feature 'newPDFRendering' disabled")
|
||||
}
|
||||
|
||||
opts.Encoding = "pdf"
|
||||
}
|
||||
|
||||
// gives plugin some additional time to timeout and return possible errors.
|
||||
ctx, cancel := context.WithTimeout(ctx, getRequestTimeout(opts.TimeoutOpts))
|
||||
defer cancel()
|
||||
|
||||
filePath, err := rs.getNewFilePath(RenderPNG)
|
||||
filePath, err := rs.getNewFilePath(renderType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -38,6 +47,7 @@ func (rs *RenderingService) renderViaPlugin(ctx context.Context, renderKey strin
|
||||
Domain: rs.domain,
|
||||
Headers: headers,
|
||||
AuthToken: rs.Cfg.RendererAuthToken,
|
||||
Encoding: opts.Encoding,
|
||||
}
|
||||
rs.log.Debug("Calling renderer plugin", "req", req)
|
||||
|
||||
|
||||
@@ -58,16 +58,18 @@ type Plugin interface {
|
||||
}
|
||||
|
||||
func ProvideService(cfg *setting.Cfg, features *featuremgmt.FeatureManager, remoteCache *remotecache.RemoteCache, rm PluginManager) (*RenderingService, error) {
|
||||
// ensure ImagesDir exists
|
||||
err := os.MkdirAll(cfg.ImagesDir, 0700)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create images directory %q: %w", cfg.ImagesDir, err)
|
||||
folders := []string{
|
||||
cfg.ImagesDir,
|
||||
cfg.CSVsDir,
|
||||
cfg.PDFsDir,
|
||||
}
|
||||
|
||||
// ensure CSVsDir exists
|
||||
err = os.MkdirAll(cfg.CSVsDir, 0700)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create CSVs directory %q: %w", cfg.CSVsDir, err)
|
||||
// ensure folders exists
|
||||
for _, f := range folders {
|
||||
err := os.MkdirAll(f, 0700)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory %q: %w", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
logger := log.New("rendering")
|
||||
@@ -248,14 +250,14 @@ func (rs *RenderingService) renderUnavailableImage() *RenderResult {
|
||||
}
|
||||
}
|
||||
|
||||
func (rs *RenderingService) Render(ctx context.Context, opts Opts, session Session) (*RenderResult, error) {
|
||||
func (rs *RenderingService) Render(ctx context.Context, renderType RenderType, opts Opts, session Session) (*RenderResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
renderKeyProvider := rs.perRequestRenderKeyProvider
|
||||
if session != nil {
|
||||
renderKeyProvider = session
|
||||
}
|
||||
result, err := rs.render(ctx, opts, renderKeyProvider)
|
||||
result, err := rs.render(ctx, renderType, opts, renderKeyProvider)
|
||||
|
||||
elapsedTime := time.Since(startTime).Milliseconds()
|
||||
saveMetrics(elapsedTime, err, RenderPNG)
|
||||
@@ -263,7 +265,7 @@ func (rs *RenderingService) Render(ctx context.Context, opts Opts, session Sessi
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (rs *RenderingService) render(ctx context.Context, opts Opts, renderKeyProvider renderKeyProvider) (*RenderResult, error) {
|
||||
func (rs *RenderingService) render(ctx context.Context, renderType RenderType, opts Opts, renderKeyProvider renderKeyProvider) (*RenderResult, error) {
|
||||
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)
|
||||
if opts.ErrorConcurrentLimitReached {
|
||||
@@ -306,7 +308,7 @@ func (rs *RenderingService) render(ctx context.Context, opts Opts, renderKeyProv
|
||||
}()
|
||||
|
||||
metrics.MRenderingQueue.Set(float64(atomic.AddInt32(&rs.inProgressCount, 1)))
|
||||
return rs.renderAction(ctx, renderKey, opts)
|
||||
return rs.renderAction(ctx, renderType, renderKey, opts)
|
||||
}
|
||||
|
||||
func (rs *RenderingService) RenderCSV(ctx context.Context, opts CSVOpts, session Session) (*RenderCSVResult, error) {
|
||||
@@ -373,11 +375,18 @@ func (rs *RenderingService) getNewFilePath(rt RenderType) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ext := "png"
|
||||
folder := rs.Cfg.ImagesDir
|
||||
if rt == RenderCSV {
|
||||
var ext string
|
||||
var folder string
|
||||
switch rt {
|
||||
case RenderCSV:
|
||||
ext = "csv"
|
||||
folder = rs.Cfg.CSVsDir
|
||||
case RenderPDF:
|
||||
ext = "pdf"
|
||||
folder = rs.Cfg.PDFsDir
|
||||
default:
|
||||
ext = "png"
|
||||
folder = rs.Cfg.ImagesDir
|
||||
}
|
||||
|
||||
return filepath.Abs(filepath.Join(folder, fmt.Sprintf("%s.%s", rand, ext)))
|
||||
|
||||
@@ -110,7 +110,7 @@ func TestRenderUnavailableError(t *testing.T) {
|
||||
RendererPluginManager: &dummyPluginManager{},
|
||||
}
|
||||
opts := Opts{ErrorOpts: ErrorOpts{ErrorRenderUnavailable: true}}
|
||||
result, err := rs.Render(context.Background(), opts, nil)
|
||||
result, err := rs.Render(context.Background(), RenderPNG, opts, nil)
|
||||
assert.Equal(t, ErrRenderUnavailable, err)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func TestRenderLimitImage(t *testing.T) {
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
opts := Opts{Theme: tc.theme, ConcurrentLimit: 1}
|
||||
result, err := rs.Render(context.Background(), opts, nil)
|
||||
result, err := rs.Render(context.Background(), RenderPNG, opts, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, result.FilePath)
|
||||
})
|
||||
@@ -170,7 +170,7 @@ func TestRenderLimitImageError(t *testing.T) {
|
||||
ConcurrentLimit: 1,
|
||||
Theme: models.ThemeDark,
|
||||
}
|
||||
result, err := rs.Render(context.Background(), opts, nil)
|
||||
result, err := rs.Render(context.Background(), RenderPNG, opts, nil)
|
||||
assert.Equal(t, ErrConcurrentLimitReached, err)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user