Backend image rendering as plugin (#11966)
* rendering: headless chrome progress * renderer: minor change * grpc: version hell * updated grpc libs * wip: minor progess * rendering: new image rendering plugin is starting to work * feat: now phantomjs works as well and updated alerting to use new rendering service * refactor: renamed renderer package and service to rendering to make renderer name less confusing (rendering is internal service that handles the renderer plugin now) * rendering: now render key is passed and render auth is working in plugin mode * removed unneeded lines from gitignore * rendering: now plugin mode supports waiting for all panels to complete rendering * fix: LastSeenAt fix for render calls, was not set which causes a lot of updates to Last Seen at during rendering, this should fix sqlite db locked issues in seen in previous releases * change: changed render tz url parameter to use proper timezone name as chrome does not handle UTC offset TZ values * fix: another update to tz param generation * renderer: added http mode to renderer service, new ini setting [rendering] server_url
This commit is contained in:
+1
-1
@@ -375,7 +375,7 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
}, reqGrafanaAdmin)
|
||||
|
||||
// rendering
|
||||
r.Get("/render/*", reqSignedIn, RenderToPng)
|
||||
r.Get("/render/*", reqSignedIn, hs.RenderToPng)
|
||||
|
||||
// grafana.net proxy
|
||||
r.Any("/api/gnet/*", reqSignedIn, ProxyGnetRequest)
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
@@ -42,8 +43,10 @@ type HTTPServer struct {
|
||||
cache *gocache.Cache
|
||||
httpSrv *http.Server
|
||||
|
||||
RouteRegister RouteRegister `inject:""`
|
||||
Bus bus.Bus `inject:""`
|
||||
RouteRegister RouteRegister `inject:""`
|
||||
Bus bus.Bus `inject:""`
|
||||
RenderService rendering.Service `inject:""`
|
||||
Cfg *setting.Cfg `inject:""`
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) Init() error {
|
||||
@@ -179,7 +182,7 @@ func (hs *HTTPServer) newMacaron() *macaron.Macaron {
|
||||
hs.mapStatic(m, setting.StaticRootPath, "robots.txt", "robots.txt")
|
||||
|
||||
if setting.ImageUploadProvider == "local" {
|
||||
hs.mapStatic(m, setting.ImagesDir, "", "/public/img/attachments")
|
||||
hs.mapStatic(m, hs.Cfg.ImagesDir, "", "/public/img/attachments")
|
||||
}
|
||||
|
||||
m.Use(macaron.Renderer(macaron.RenderOptions{
|
||||
|
||||
+31
-12
@@ -3,35 +3,54 @@ package api
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/renderer"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
func RenderToPng(c *m.ReqContext) {
|
||||
func (hs *HTTPServer) RenderToPng(c *m.ReqContext) {
|
||||
queryReader, err := util.NewUrlQueryReader(c.Req.URL)
|
||||
if err != nil {
|
||||
c.Handle(400, "Render parameters error", err)
|
||||
return
|
||||
}
|
||||
|
||||
queryParams := fmt.Sprintf("?%s", c.Req.URL.RawQuery)
|
||||
|
||||
renderOpts := &renderer.RenderOpts{
|
||||
Path: c.Params("*") + queryParams,
|
||||
Width: queryReader.Get("width", "800"),
|
||||
Height: queryReader.Get("height", "400"),
|
||||
Timeout: queryReader.Get("timeout", "60"),
|
||||
width, err := strconv.Atoi(queryReader.Get("width", "800"))
|
||||
if err != nil {
|
||||
c.Handle(400, "Render parameters error", fmt.Errorf("Cannot parse width as int: %s", err))
|
||||
return
|
||||
}
|
||||
|
||||
height, err := strconv.Atoi(queryReader.Get("height", "400"))
|
||||
if err != nil {
|
||||
c.Handle(400, "Render parameters error", fmt.Errorf("Cannot parse height as int: %s", err))
|
||||
return
|
||||
}
|
||||
|
||||
timeout, err := strconv.Atoi(queryReader.Get("timeout", "60"))
|
||||
if err != nil {
|
||||
c.Handle(400, "Render parameters error", fmt.Errorf("Cannot parse timeout as int: %s", err))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := hs.RenderService.Render(c.Req.Context(), rendering.Opts{
|
||||
Width: width,
|
||||
Height: height,
|
||||
Timeout: time.Duration(timeout) * time.Second,
|
||||
OrgId: c.OrgId,
|
||||
UserId: c.UserId,
|
||||
OrgRole: c.OrgRole,
|
||||
Path: c.Params("*") + queryParams,
|
||||
Timezone: queryReader.Get("tz", ""),
|
||||
Encoding: queryReader.Get("encoding", ""),
|
||||
}
|
||||
})
|
||||
|
||||
pngPath, err := renderer.RenderToPng(renderOpts)
|
||||
|
||||
if err != nil && err == renderer.ErrTimeout {
|
||||
if err != nil && err == rendering.ErrTimeout {
|
||||
c.Handle(500, err.Error(), err)
|
||||
return
|
||||
}
|
||||
@@ -42,5 +61,5 @@ func RenderToPng(c *m.ReqContext) {
|
||||
}
|
||||
|
||||
c.Resp.Header().Set("Content-Type", "image/png")
|
||||
http.ServeFile(c.Resp, c.Req.Request, pngPath)
|
||||
http.ServeFile(c.Resp, c.Req.Request, result.FilePath)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
_ "github.com/grafana/grafana/pkg/services/cleanup"
|
||||
_ "github.com/grafana/grafana/pkg/services/notifications"
|
||||
_ "github.com/grafana/grafana/pkg/services/provisioning"
|
||||
_ "github.com/grafana/grafana/pkg/services/rendering"
|
||||
_ "github.com/grafana/grafana/pkg/services/search"
|
||||
_ "github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
_ "github.com/grafana/grafana/pkg/tracing"
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"strconv"
|
||||
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/middleware"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
type RenderOpts struct {
|
||||
Path string
|
||||
Width string
|
||||
Height string
|
||||
Timeout string
|
||||
OrgId int64
|
||||
UserId int64
|
||||
OrgRole models.RoleType
|
||||
Timezone string
|
||||
IsAlertContext bool
|
||||
Encoding string
|
||||
}
|
||||
|
||||
var ErrTimeout = errors.New("Timeout error. You can set timeout in seconds with &timeout url parameter")
|
||||
var rendererLog log.Logger = log.New("png-renderer")
|
||||
|
||||
func isoTimeOffsetToPosixTz(isoOffset string) string {
|
||||
// invert offset
|
||||
if strings.HasPrefix(isoOffset, "UTC+") {
|
||||
return strings.Replace(isoOffset, "UTC+", "UTC-", 1)
|
||||
}
|
||||
if strings.HasPrefix(isoOffset, "UTC-") {
|
||||
return strings.Replace(isoOffset, "UTC-", "UTC+", 1)
|
||||
}
|
||||
return isoOffset
|
||||
}
|
||||
|
||||
func appendEnviron(baseEnviron []string, name string, value string) []string {
|
||||
results := make([]string, 0)
|
||||
prefix := fmt.Sprintf("%s=", name)
|
||||
for _, v := range baseEnviron {
|
||||
if !strings.HasPrefix(v, prefix) {
|
||||
results = append(results, v)
|
||||
}
|
||||
}
|
||||
return append(results, fmt.Sprintf("%s=%s", name, value))
|
||||
}
|
||||
|
||||
func RenderToPng(params *RenderOpts) (string, error) {
|
||||
rendererLog.Info("Rendering", "path", params.Path)
|
||||
|
||||
var executable = "phantomjs"
|
||||
if runtime.GOOS == "windows" {
|
||||
executable = executable + ".exe"
|
||||
}
|
||||
|
||||
localDomain := "localhost"
|
||||
if setting.HttpAddr != setting.DEFAULT_HTTP_ADDR {
|
||||
localDomain = setting.HttpAddr
|
||||
}
|
||||
|
||||
// &render=1 signals to the legacy redirect layer to
|
||||
// avoid redirect these requests.
|
||||
url := fmt.Sprintf("%s://%s:%s/%s&render=1", setting.Protocol, localDomain, setting.HttpPort, params.Path)
|
||||
|
||||
binPath, _ := filepath.Abs(filepath.Join(setting.PhantomDir, executable))
|
||||
scriptPath, _ := filepath.Abs(filepath.Join(setting.PhantomDir, "render.js"))
|
||||
pngPath, _ := filepath.Abs(filepath.Join(setting.ImagesDir, util.GetRandomString(20)))
|
||||
pngPath = pngPath + ".png"
|
||||
|
||||
orgRole := params.OrgRole
|
||||
if params.IsAlertContext {
|
||||
orgRole = models.ROLE_ADMIN
|
||||
}
|
||||
renderKey := middleware.AddRenderAuthKey(params.OrgId, params.UserId, orgRole)
|
||||
defer middleware.RemoveRenderAuthKey(renderKey)
|
||||
|
||||
timeout, err := strconv.Atoi(params.Timeout)
|
||||
if err != nil {
|
||||
timeout = 15
|
||||
}
|
||||
|
||||
phantomDebugArg := "--debug=false"
|
||||
if log.GetLogLevelFor("png-renderer") >= log.LvlDebug {
|
||||
phantomDebugArg = "--debug=true"
|
||||
}
|
||||
|
||||
cmdArgs := []string{
|
||||
"--ignore-ssl-errors=true",
|
||||
"--web-security=false",
|
||||
phantomDebugArg,
|
||||
scriptPath,
|
||||
"url=" + url,
|
||||
"width=" + params.Width,
|
||||
"height=" + params.Height,
|
||||
"png=" + pngPath,
|
||||
"domain=" + localDomain,
|
||||
"timeout=" + strconv.Itoa(timeout),
|
||||
"renderKey=" + renderKey,
|
||||
}
|
||||
|
||||
if params.Encoding != "" {
|
||||
cmdArgs = append([]string{fmt.Sprintf("--output-encoding=%s", params.Encoding)}, cmdArgs...)
|
||||
}
|
||||
|
||||
cmd := exec.Command(binPath, cmdArgs...)
|
||||
output, err := cmd.StdoutPipe()
|
||||
|
||||
if err != nil {
|
||||
rendererLog.Error("Could not acquire stdout pipe", err)
|
||||
return "", err
|
||||
}
|
||||
cmd.Stderr = cmd.Stdout
|
||||
|
||||
if params.Timezone != "" {
|
||||
baseEnviron := os.Environ()
|
||||
cmd.Env = appendEnviron(baseEnviron, "TZ", isoTimeOffsetToPosixTz(params.Timezone))
|
||||
}
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
rendererLog.Error("Could not start command", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
logWriter := log.NewLogWriter(rendererLog, log.LvlDebug, "[phantom] ")
|
||||
go io.Copy(logWriter, output)
|
||||
|
||||
done := make(chan error)
|
||||
go func() {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
rendererLog.Error("failed to render an image", "error", err)
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-time.After(time.Duration(timeout) * time.Second):
|
||||
if err := cmd.Process.Kill(); err != nil {
|
||||
rendererLog.Error("failed to kill", "error", err)
|
||||
}
|
||||
return "", ErrTimeout
|
||||
case <-done:
|
||||
}
|
||||
|
||||
rendererLog.Debug("Image rendered", "path", pngPath)
|
||||
return pngPath, nil
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package renderer
|
||||
|
||||
//
|
||||
// import (
|
||||
// "io/ioutil"
|
||||
// "os"
|
||||
// "testing"
|
||||
//
|
||||
// . "github.com/smartystreets/goconvey/convey"
|
||||
// )
|
||||
//
|
||||
// func TestPhantomRender(t *testing.T) {
|
||||
//
|
||||
// Convey("Can render url", t, func() {
|
||||
// tempDir, _ := ioutil.TempDir("", "img")
|
||||
// ipng, err := RenderToPng("http://www.google.com")
|
||||
// So(err, ShouldBeNil)
|
||||
// So(exists(png), ShouldEqual, true)
|
||||
//
|
||||
// //_, err = os.Stat(store.getFilePathForDashboard("hello"))
|
||||
// //So(err, ShouldBeNil)
|
||||
// })
|
||||
//
|
||||
// }
|
||||
//
|
||||
// func exists(path string) bool {
|
||||
// _, err := os.Stat(path)
|
||||
// if err == nil {
|
||||
// return true
|
||||
// }
|
||||
// if os.IsNotExist(err) {
|
||||
// return false
|
||||
// }
|
||||
// return false
|
||||
// }
|
||||
@@ -49,7 +49,6 @@ func GetContextHandler() macaron.Handler {
|
||||
|
||||
c.Map(ctx)
|
||||
|
||||
// update last seen at
|
||||
// update last seen every 5min
|
||||
if ctx.ShouldUpdateLastSeenAt() {
|
||||
ctx.Logger.Debug("Updating last user_seen_at", "user_id", ctx.UserId)
|
||||
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
@@ -28,6 +29,7 @@ func initContextWithRenderAuth(ctx *m.ReqContext) bool {
|
||||
ctx.IsSignedIn = true
|
||||
ctx.SignedInUser = renderUser
|
||||
ctx.IsRenderCall = true
|
||||
ctx.LastSeenAt = time.Now()
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ComposePluginStartCommmand(executable string) string {
|
||||
os := strings.ToLower(runtime.GOOS)
|
||||
arch := runtime.GOARCH
|
||||
extension := ""
|
||||
|
||||
if os == "windows" {
|
||||
extension = ".exe"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s_%s_%s%s", executable, os, strings.ToLower(arch), extension)
|
||||
}
|
||||
@@ -5,12 +5,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana-plugin-model/go/datasource"
|
||||
"github.com/grafana/grafana/pkg/components/null"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/tsdb"
|
||||
"github.com/grafana/grafana_plugin_model/go/datasource"
|
||||
)
|
||||
|
||||
func NewDatasourcePluginWrapper(log log.Logger, plugin datasource.DatasourcePlugin) *DatasourcePluginWrapper {
|
||||
|
||||
@@ -3,9 +3,9 @@ package wrapper
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana-plugin-model/go/datasource"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/tsdb"
|
||||
"github.com/grafana/grafana_plugin_model/go/datasource"
|
||||
)
|
||||
|
||||
func TestMapTables(t *testing.T) {
|
||||
|
||||
@@ -3,20 +3,17 @@ package plugins
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-model/go/datasource"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/plugins/datasource/wrapper"
|
||||
"github.com/grafana/grafana/pkg/tsdb"
|
||||
"github.com/grafana/grafana_plugin_model/go/datasource"
|
||||
plugin "github.com/hashicorp/go-plugin"
|
||||
)
|
||||
|
||||
@@ -66,16 +63,6 @@ var handshakeConfig = plugin.HandshakeConfig{
|
||||
MagicCookieValue: "datasource",
|
||||
}
|
||||
|
||||
func composeBinaryName(executable, os, arch string) string {
|
||||
var extension string
|
||||
os = strings.ToLower(os)
|
||||
if os == "windows" {
|
||||
extension = ".exe"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s_%s_%s%s", executable, os, strings.ToLower(arch), extension)
|
||||
}
|
||||
|
||||
func (p *DataSourcePlugin) startBackendPlugin(ctx context.Context, log log.Logger) error {
|
||||
p.log = log.New("plugin-id", p.Id)
|
||||
|
||||
@@ -88,7 +75,7 @@ func (p *DataSourcePlugin) startBackendPlugin(ctx context.Context, log log.Logge
|
||||
}
|
||||
|
||||
func (p *DataSourcePlugin) spawnSubProcess() error {
|
||||
cmd := composeBinaryName(p.Executable, runtime.GOOS, runtime.GOARCH)
|
||||
cmd := ComposePluginStartCommmand(p.Executable)
|
||||
fullpath := path.Join(p.PluginDir, cmd)
|
||||
|
||||
p.client = plugin.NewClient(&plugin.ClientConfig{
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestComposeBinaryName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
os string
|
||||
arch string
|
||||
|
||||
expectedPath string
|
||||
}{
|
||||
{
|
||||
name: "simple-json",
|
||||
os: "linux",
|
||||
arch: "amd64",
|
||||
expectedPath: `simple-json_linux_amd64`,
|
||||
},
|
||||
{
|
||||
name: "simple-json",
|
||||
os: "windows",
|
||||
arch: "amd64",
|
||||
expectedPath: `simple-json_windows_amd64.exe`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, v := range tests {
|
||||
have := composeBinaryName(v.name, v.os, v.arch)
|
||||
if have != v.expectedPath {
|
||||
t.Errorf("expected %s got %s", v.expectedPath, have)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ var (
|
||||
Apps map[string]*AppPlugin
|
||||
Plugins map[string]*PluginBase
|
||||
PluginTypes map[string]interface{}
|
||||
Renderer *RendererPlugin
|
||||
|
||||
GrafanaLatestVersion string
|
||||
GrafanaHasUpdate bool
|
||||
@@ -58,6 +59,7 @@ func (pm *PluginManager) Init() error {
|
||||
"panel": PanelPlugin{},
|
||||
"datasource": DataSourcePlugin{},
|
||||
"app": AppPlugin{},
|
||||
"renderer": RendererPlugin{},
|
||||
}
|
||||
|
||||
pm.log.Info("Starting plugin search")
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package plugins
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type RendererPlugin struct {
|
||||
PluginBase
|
||||
|
||||
Executable string `json:"executable,omitempty"`
|
||||
}
|
||||
|
||||
func (r *RendererPlugin) Load(decoder *json.Decoder, pluginDir string) error {
|
||||
if err := decoder.Decode(&r); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.registerPlugin(pluginDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Renderer = r
|
||||
return nil
|
||||
}
|
||||
@@ -12,11 +12,14 @@ import (
|
||||
"github.com/benbjohnson/clock"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type AlertingService struct {
|
||||
RenderService rendering.Service `inject:""`
|
||||
|
||||
execQueue chan *Job
|
||||
//clock clock.Clock
|
||||
ticker *Ticker
|
||||
@@ -48,7 +51,7 @@ func (e *AlertingService) Init() error {
|
||||
e.evalHandler = NewEvalHandler()
|
||||
e.ruleReader = NewRuleReader()
|
||||
e.log = log.New("alerting.engine")
|
||||
e.resultHandler = NewResultHandler()
|
||||
e.resultHandler = NewResultHandler(e.RenderService)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,15 @@ package alerting
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/imguploader"
|
||||
"github.com/grafana/grafana/pkg/components/renderer"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/metrics"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
)
|
||||
@@ -27,18 +28,16 @@ type NotificationService interface {
|
||||
SendIfNeeded(context *EvalContext) error
|
||||
}
|
||||
|
||||
func NewNotificationService() NotificationService {
|
||||
return newNotificationService()
|
||||
func NewNotificationService(renderService rendering.Service) NotificationService {
|
||||
return ¬ificationService{
|
||||
log: log.New("alerting.notifier"),
|
||||
renderService: renderService,
|
||||
}
|
||||
}
|
||||
|
||||
type notificationService struct {
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
func newNotificationService() *notificationService {
|
||||
return ¬ificationService{
|
||||
log: log.New("alerting.notifier"),
|
||||
}
|
||||
log log.Logger
|
||||
renderService rendering.Service
|
||||
}
|
||||
|
||||
func (n *notificationService) SendIfNeeded(context *EvalContext) error {
|
||||
@@ -79,26 +78,27 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
renderOpts := &renderer.RenderOpts{
|
||||
Width: "800",
|
||||
Height: "400",
|
||||
Timeout: "30",
|
||||
OrgId: context.Rule.OrgId,
|
||||
IsAlertContext: true,
|
||||
renderOpts := rendering.Opts{
|
||||
Width: 1000,
|
||||
Height: 500,
|
||||
Timeout: time.Second * 30,
|
||||
OrgId: context.Rule.OrgId,
|
||||
OrgRole: m.ROLE_ADMIN,
|
||||
}
|
||||
|
||||
ref, err := context.GetDashboardUID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
renderOpts.Path = fmt.Sprintf("d-solo/%s/%s?panelId=%d", ref.Uid, ref.Slug, context.Rule.PanelId)
|
||||
|
||||
imagePath, err := renderer.RenderToPng(renderOpts)
|
||||
result, err := n.renderService.Render(context.Ctx, renderOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
context.ImageOnDiskPath = imagePath
|
||||
|
||||
context.ImageOnDiskPath = result.FilePath
|
||||
context.ImagePublicUrl, err = uploader.Upload(context.Ctx, context.ImageOnDiskPath)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/metrics"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/annotations"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
)
|
||||
|
||||
type ResultHandler interface {
|
||||
@@ -20,10 +21,10 @@ type DefaultResultHandler struct {
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
func NewResultHandler() *DefaultResultHandler {
|
||||
func NewResultHandler(renderService rendering.Service) *DefaultResultHandler {
|
||||
return &DefaultResultHandler{
|
||||
log: log.New("alerting.resultHandler"),
|
||||
notifier: NewNotificationService(),
|
||||
notifier: NewNotificationService(renderService),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ func init() {
|
||||
}
|
||||
|
||||
func handleNotificationTestCommand(cmd *NotificationTestCommand) error {
|
||||
notifier := newNotificationService()
|
||||
notifier := NewNotificationService(nil).(*notificationService)
|
||||
|
||||
model := &m.AlertNotification{
|
||||
Name: cmd.Name,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package rendering
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
var netTransport = &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
Dial: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
DualStack: true,
|
||||
}).Dial,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
func (rs *RenderingService) renderViaHttp(ctx context.Context, opts Opts) (*RenderResult, error) {
|
||||
filePath := rs.getFilePathForNewImage()
|
||||
|
||||
var netClient = &http.Client{
|
||||
Timeout: opts.Timeout,
|
||||
Transport: netTransport,
|
||||
}
|
||||
|
||||
rendererUrl, err := url.Parse(rs.Cfg.RendererUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
queryParams := rendererUrl.Query()
|
||||
queryParams.Add("url", rs.getURL(opts.Path))
|
||||
queryParams.Add("renderKey", rs.getRenderKey(opts.UserId, opts.OrgId, opts.OrgRole))
|
||||
queryParams.Add("width", strconv.Itoa(opts.Width))
|
||||
queryParams.Add("height", strconv.Itoa(opts.Height))
|
||||
queryParams.Add("domain", rs.getLocalDomain())
|
||||
queryParams.Add("timezone", isoTimeOffsetToPosixTz(opts.Timezone))
|
||||
queryParams.Add("encoding", opts.Encoding)
|
||||
queryParams.Add("timeout", strconv.Itoa(int(opts.Timeout.Seconds())))
|
||||
rendererUrl.RawQuery = queryParams.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", rendererUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// make request to renderer server
|
||||
resp, err := netClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// save response to file
|
||||
defer resp.Body.Close()
|
||||
out, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer out.Close()
|
||||
io.Copy(out, resp.Body)
|
||||
|
||||
return &RenderResult{FilePath: filePath}, err
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package rendering
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
)
|
||||
|
||||
var ErrTimeout = errors.New("Timeout error. You can set timeout in seconds with &timeout url parameter")
|
||||
var ErrNoRenderer = errors.New("No renderer plugin found nor is an external render server configured")
|
||||
|
||||
type Opts struct {
|
||||
Width int
|
||||
Height int
|
||||
Timeout time.Duration
|
||||
OrgId int64
|
||||
UserId int64
|
||||
OrgRole models.RoleType
|
||||
Path string
|
||||
Encoding string
|
||||
Timezone string
|
||||
}
|
||||
|
||||
type RenderResult struct {
|
||||
FilePath string
|
||||
}
|
||||
|
||||
type renderFunc func(ctx context.Context, options Opts) (*RenderResult, error)
|
||||
|
||||
type Service interface {
|
||||
Render(ctx context.Context, opts Opts) (*RenderResult, error)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package rendering
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/middleware"
|
||||
)
|
||||
|
||||
func (rs *RenderingService) renderViaPhantomJS(ctx context.Context, opts Opts) (*RenderResult, error) {
|
||||
rs.log.Info("Rendering", "path", opts.Path)
|
||||
|
||||
var executable = "phantomjs"
|
||||
if runtime.GOOS == "windows" {
|
||||
executable = executable + ".exe"
|
||||
}
|
||||
|
||||
url := rs.getURL(opts.Path)
|
||||
binPath, _ := filepath.Abs(filepath.Join(rs.Cfg.PhantomDir, executable))
|
||||
scriptPath, _ := filepath.Abs(filepath.Join(rs.Cfg.PhantomDir, "render.js"))
|
||||
pngPath := rs.getFilePathForNewImage()
|
||||
|
||||
renderKey := middleware.AddRenderAuthKey(opts.OrgId, opts.UserId, opts.OrgRole)
|
||||
defer middleware.RemoveRenderAuthKey(renderKey)
|
||||
|
||||
phantomDebugArg := "--debug=false"
|
||||
if log.GetLogLevelFor("renderer") >= log.LvlDebug {
|
||||
phantomDebugArg = "--debug=true"
|
||||
}
|
||||
|
||||
cmdArgs := []string{
|
||||
"--ignore-ssl-errors=true",
|
||||
"--web-security=false",
|
||||
phantomDebugArg,
|
||||
scriptPath,
|
||||
fmt.Sprintf("url=%v", url),
|
||||
fmt.Sprintf("width=%v", opts.Width),
|
||||
fmt.Sprintf("height=%v", opts.Height),
|
||||
fmt.Sprintf("png=%v", pngPath),
|
||||
fmt.Sprintf("domain=%v", rs.getLocalDomain()),
|
||||
fmt.Sprintf("timeout=%v", opts.Timeout.Seconds()),
|
||||
fmt.Sprintf("renderKey=%v", renderKey),
|
||||
}
|
||||
|
||||
if opts.Encoding != "" {
|
||||
cmdArgs = append([]string{fmt.Sprintf("--output-encoding=%s", opts.Encoding)}, cmdArgs...)
|
||||
}
|
||||
|
||||
commandCtx, _ := context.WithTimeout(ctx, opts.Timeout+time.Second*2)
|
||||
cmd := exec.CommandContext(commandCtx, binPath, cmdArgs...)
|
||||
cmd.Stderr = cmd.Stdout
|
||||
|
||||
if opts.Timezone != "" {
|
||||
baseEnviron := os.Environ()
|
||||
cmd.Env = appendEnviron(baseEnviron, "TZ", isoTimeOffsetToPosixTz(opts.Timezone))
|
||||
}
|
||||
|
||||
out, err := cmd.Output()
|
||||
|
||||
// check for timeout first
|
||||
if commandCtx.Err() == context.DeadlineExceeded {
|
||||
rs.log.Info("Rendering timed out")
|
||||
return nil, ErrTimeout
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
rs.log.Error("Phantomjs exited with non zero exit code", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rs.log.Debug("Phantomjs output", "out", string(out))
|
||||
|
||||
rs.log.Debug("Image rendered", "path", pngPath)
|
||||
return &RenderResult{FilePath: pngPath}, nil
|
||||
}
|
||||
|
||||
func isoTimeOffsetToPosixTz(isoOffset string) string {
|
||||
// invert offset
|
||||
if strings.HasPrefix(isoOffset, "UTC+") {
|
||||
return strings.Replace(isoOffset, "UTC+", "UTC-", 1)
|
||||
}
|
||||
if strings.HasPrefix(isoOffset, "UTC-") {
|
||||
return strings.Replace(isoOffset, "UTC-", "UTC+", 1)
|
||||
}
|
||||
return isoOffset
|
||||
}
|
||||
|
||||
func appendEnviron(baseEnviron []string, name string, value string) []string {
|
||||
results := make([]string, 0)
|
||||
prefix := fmt.Sprintf("%s=", name)
|
||||
for _, v := range baseEnviron {
|
||||
if !strings.HasPrefix(v, prefix) {
|
||||
results = append(results, v)
|
||||
}
|
||||
}
|
||||
return append(results, fmt.Sprintf("%s=%s", name, value))
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package rendering
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
pluginModel "github.com/grafana/grafana-plugin-model/go/renderer"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
plugin "github.com/hashicorp/go-plugin"
|
||||
)
|
||||
|
||||
func (rs *RenderingService) startPlugin(ctx context.Context) error {
|
||||
cmd := plugins.ComposePluginStartCommmand("plugin_start")
|
||||
fullpath := path.Join(rs.pluginInfo.PluginDir, cmd)
|
||||
|
||||
var handshakeConfig = plugin.HandshakeConfig{
|
||||
ProtocolVersion: 1,
|
||||
MagicCookieKey: "grafana_plugin_type",
|
||||
MagicCookieValue: "renderer",
|
||||
}
|
||||
|
||||
rs.log.Info("Renderer plugin found, starting", "cmd", cmd)
|
||||
|
||||
rs.pluginClient = plugin.NewClient(&plugin.ClientConfig{
|
||||
HandshakeConfig: handshakeConfig,
|
||||
Plugins: map[string]plugin.Plugin{
|
||||
plugins.Renderer.Id: &pluginModel.RendererPluginImpl{},
|
||||
},
|
||||
Cmd: exec.Command(fullpath),
|
||||
AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC},
|
||||
Logger: plugins.LogWrapper{Logger: rs.log},
|
||||
})
|
||||
|
||||
rpcClient, err := rs.pluginClient.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
raw, err := rpcClient.Dispense(rs.pluginInfo.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rs.grpcPlugin = raw.(pluginModel.RendererPlugin)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rs *RenderingService) watchAndRestartPlugin(ctx context.Context) error {
|
||||
ticker := time.NewTicker(time.Second * 1)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
if rs.pluginClient.Exited() {
|
||||
err := rs.startPlugin(ctx)
|
||||
rs.log.Debug("Render plugin existed, restarting...")
|
||||
if err != nil {
|
||||
rs.log.Error("Failed to start render plugin", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (rs *RenderingService) renderViaPlugin(ctx context.Context, opts Opts) (*RenderResult, error) {
|
||||
pngPath := rs.getFilePathForNewImage()
|
||||
|
||||
rsp, err := rs.grpcPlugin.Render(ctx, &pluginModel.RenderRequest{
|
||||
Url: rs.getURL(opts.Path),
|
||||
Width: int32(opts.Width),
|
||||
Height: int32(opts.Height),
|
||||
FilePath: pngPath,
|
||||
Timeout: int32(opts.Timeout.Seconds()),
|
||||
RenderKey: rs.getRenderKey(opts.UserId, opts.OrgId, opts.OrgRole),
|
||||
Encoding: opts.Encoding,
|
||||
Timezone: isoTimeOffsetToPosixTz(opts.Timezone),
|
||||
Domain: rs.getLocalDomain(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if rsp.Error != "" {
|
||||
return nil, fmt.Errorf("Rendering failed: %v", rsp.Error)
|
||||
}
|
||||
|
||||
return &RenderResult{FilePath: pngPath}, err
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package rendering
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
plugin "github.com/hashicorp/go-plugin"
|
||||
|
||||
pluginModel "github.com/grafana/grafana-plugin-model/go/renderer"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/middleware"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registry.RegisterService(&RenderingService{})
|
||||
}
|
||||
|
||||
type RenderingService struct {
|
||||
log log.Logger
|
||||
pluginClient *plugin.Client
|
||||
grpcPlugin pluginModel.RendererPlugin
|
||||
pluginInfo *plugins.RendererPlugin
|
||||
renderAction renderFunc
|
||||
|
||||
Cfg *setting.Cfg `inject:""`
|
||||
}
|
||||
|
||||
func (rs *RenderingService) Init() error {
|
||||
rs.log = log.New("rendering")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rs *RenderingService) Run(ctx context.Context) error {
|
||||
if rs.Cfg.RendererUrl != "" {
|
||||
rs.log.Info("Backend rendering via external http server")
|
||||
rs.renderAction = rs.renderViaHttp
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
if plugins.Renderer == nil {
|
||||
rs.renderAction = rs.renderViaPhantomJS
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
rs.pluginInfo = plugins.Renderer
|
||||
|
||||
if err := rs.startPlugin(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rs.renderAction = rs.renderViaPlugin
|
||||
|
||||
err := rs.watchAndRestartPlugin(ctx)
|
||||
|
||||
if rs.pluginClient != nil {
|
||||
rs.log.Debug("Killing renderer plugin process")
|
||||
rs.pluginClient.Kill()
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (rs *RenderingService) Render(ctx context.Context, opts Opts) (*RenderResult, error) {
|
||||
if rs.renderAction != nil {
|
||||
return rs.renderAction(ctx, opts)
|
||||
} else {
|
||||
return nil, fmt.Errorf("No renderer found")
|
||||
}
|
||||
}
|
||||
|
||||
func (rs *RenderingService) getFilePathForNewImage() string {
|
||||
pngPath, _ := filepath.Abs(filepath.Join(rs.Cfg.ImagesDir, util.GetRandomString(20)))
|
||||
return pngPath + ".png"
|
||||
}
|
||||
|
||||
func (rs *RenderingService) getURL(path string) string {
|
||||
// &render=1 signals to the legacy redirect layer to
|
||||
return fmt.Sprintf("%s://%s:%s/%s&render=1", setting.Protocol, rs.getLocalDomain(), setting.HttpPort, path)
|
||||
}
|
||||
|
||||
func (rs *RenderingService) getLocalDomain() string {
|
||||
if setting.HttpAddr != setting.DEFAULT_HTTP_ADDR {
|
||||
return setting.HttpAddr
|
||||
}
|
||||
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
func (rs *RenderingService) getRenderKey(orgId, userId int64, orgRole models.RoleType) string {
|
||||
return middleware.AddRenderAuthKey(orgId, userId, orgRole)
|
||||
}
|
||||
@@ -141,10 +141,6 @@ var (
|
||||
ConfRootPath string
|
||||
IsWindows bool
|
||||
|
||||
// PhantomJs Rendering
|
||||
ImagesDir string
|
||||
PhantomDir string
|
||||
|
||||
// for logging purposes
|
||||
configFiles []string
|
||||
appliedCommandLineProperties []string
|
||||
@@ -193,7 +189,10 @@ type Cfg struct {
|
||||
// SMTP email settings
|
||||
Smtp SmtpSettings
|
||||
|
||||
// Rendering
|
||||
ImagesDir string
|
||||
PhantomDir string
|
||||
RendererUrl string
|
||||
DisableBruteForceLoginProtection bool
|
||||
}
|
||||
|
||||
@@ -631,10 +630,11 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
|
||||
// global plugin settings
|
||||
PluginAppsSkipVerifyTLS = iniFile.Section("plugins").Key("app_tls_skip_verify_insecure").MustBool(false)
|
||||
|
||||
// PhantomJS rendering
|
||||
// Rendering
|
||||
renderSec := iniFile.Section("rendering")
|
||||
cfg.RendererUrl = renderSec.Key("server_url").String()
|
||||
cfg.ImagesDir = filepath.Join(DataPath, "png")
|
||||
ImagesDir = cfg.ImagesDir
|
||||
PhantomDir = filepath.Join(HomePath, "tools/phantomjs")
|
||||
cfg.PhantomDir = filepath.Join(HomePath, "tools/phantomjs")
|
||||
|
||||
analytics := iniFile.Section("analytics")
|
||||
ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true)
|
||||
|
||||
Reference in New Issue
Block a user