diff --git a/data/sessions/5/a/5a7a6d798450f878d373110c50ed07ae3bc99d63 b/data/sessions/5/a/5a7a6d798450f878d373110c50ed07ae3bc99d63 new file mode 100644 index 00000000000..f1f06840570 Binary files /dev/null and b/data/sessions/5/a/5a7a6d798450f878d373110c50ed07ae3bc99d63 differ diff --git a/data/sessions/7/a/7ad60c89b1bc7a310c66e59570df698fc75d28b3 b/data/sessions/7/a/7ad60c89b1bc7a310c66e59570df698fc75d28b3 new file mode 100644 index 00000000000..f1f06840570 Binary files /dev/null and b/data/sessions/7/a/7ad60c89b1bc7a310c66e59570df698fc75d28b3 differ diff --git a/data/sessions/7/b/7b786a2d47bb26f2fce2d9aa874615c6428c55a3 b/data/sessions/7/b/7b786a2d47bb26f2fce2d9aa874615c6428c55a3 new file mode 100644 index 00000000000..f1f06840570 Binary files /dev/null and b/data/sessions/7/b/7b786a2d47bb26f2fce2d9aa874615c6428c55a3 differ diff --git a/data/sessions/b/7/b724e1a2a6d52de49c11d1d62d6e0d83cba2911a b/data/sessions/b/7/b724e1a2a6d52de49c11d1d62d6e0d83cba2911a new file mode 100644 index 00000000000..f1f06840570 Binary files /dev/null and b/data/sessions/b/7/b724e1a2a6d52de49c11d1d62d6e0d83cba2911a differ diff --git a/grafana-pro b/grafana-pro index f0a58bc6f6a..6e7357699ff 100755 Binary files a/grafana-pro and b/grafana-pro differ diff --git a/pkg/cmd/web.go b/pkg/cmd/web.go index 7a0b27b28fc..76d26b7ed2c 100644 --- a/pkg/cmd/web.go +++ b/pkg/cmd/web.go @@ -15,7 +15,6 @@ import ( "github.com/torkelo/grafana-pro/pkg/log" "github.com/torkelo/grafana-pro/pkg/middleware" "github.com/torkelo/grafana-pro/pkg/routes" - "github.com/torkelo/grafana-pro/pkg/routes/login" "github.com/torkelo/grafana-pro/pkg/setting" "github.com/torkelo/grafana-pro/pkg/stores/rethink" ) @@ -70,13 +69,7 @@ func runWeb(*cli.Context) { log.Info("Starting Grafana-Pro v.1-alpha") m := newMacaron() - - auth := middleware.Auth() - - // index - m.Get("/", auth, routes.Index) - m.Get("/login", routes.Index) - m.Post("/login", login.LoginPost) + routes.Register(m) var err error listenAddr := fmt.Sprintf("%s:%s", setting.HttpAddr, setting.HttpPort) diff --git a/pkg/components/renderer/renderer.go b/pkg/components/renderer/renderer.go new file mode 100644 index 00000000000..6140af41216 --- /dev/null +++ b/pkg/components/renderer/renderer.go @@ -0,0 +1,69 @@ +package renderer + +import ( + "crypto/md5" + "encoding/hex" + "io" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/torkelo/grafana-pro/pkg/log" + "github.com/torkelo/grafana-pro/pkg/setting" +) + +type RenderOpts struct { + Url string + Width string + Height string +} + +func RenderToPng(params *RenderOpts) (string, error) { + log.Info("PhantomRenderer::renderToPng url %v", params.Url) + binPath, _ := filepath.Abs(filepath.Join(setting.PhantomDir, "phantomjs")) + scriptPath, _ := filepath.Abs(filepath.Join(setting.PhantomDir, "render.js")) + pngPath, _ := filepath.Abs(filepath.Join(setting.ImagesDir, getHash(params.Url))) + pngPath = pngPath + ".png" + + cmd := exec.Command(binPath, scriptPath, "url="+params.Url, "width="+params.Width, "height="+params.Height, "png="+pngPath) + stdout, err := cmd.StdoutPipe() + + if err != nil { + return "", err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return "", err + } + + err = cmd.Start() + if err != nil { + return "", err + } + + go io.Copy(os.Stdout, stdout) + go io.Copy(os.Stdout, stderr) + + done := make(chan error) + go func() { + cmd.Wait() + close(done) + }() + + select { + case <-time.After(10 * time.Second): + if err := cmd.Process.Kill(); err != nil { + log.Error(4, "failed to kill: %v", err) + } + case <-done: + } + + return pngPath, nil +} + +func getHash(text string) string { + hasher := md5.New() + hasher.Write([]byte(text)) + return hex.EncodeToString(hasher.Sum(nil)) +} diff --git a/pkg/components/phantom_renderer_test.go b/pkg/components/renderer/renderer_test.go similarity index 75% rename from pkg/components/phantom_renderer_test.go rename to pkg/components/renderer/renderer_test.go index ffec267bebb..0798a88786f 100644 --- a/pkg/components/phantom_renderer_test.go +++ b/pkg/components/renderer/renderer_test.go @@ -1,4 +1,4 @@ -package components +package renderer import ( "io/ioutil" @@ -12,8 +12,7 @@ func TestPhantomRender(t *testing.T) { Convey("Can render url", t, func() { tempDir, _ := ioutil.TempDir("", "img") - renderer := &PhantomRenderer{ImagesDir: tempDir, PhantomDir: "../../_vendor/phantomjs/"} - png, err := renderer.RenderToPng("http://www.google.com") + png, err := RenderToPng("http://www.google.com") So(err, ShouldBeNil) So(exists(png), ShouldEqual, true) diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 02aa5caf371..faf7dcdd0a2 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -21,6 +21,10 @@ type Context struct { IsSigned bool } +func (c *Context) GetAccountId() int { + return c.Account.Id +} + func GetContextHandler() macaron.Handler { return func(c *macaron.Context, sess session.Store) { ctx := &Context{ @@ -51,6 +55,30 @@ func (ctx *Context) Handle(status int, title string, err error) { ctx.HTML(status, "index") } +func (ctx *Context) ApiError(status int, message string, err error) { + resp := make(map[string]interface{}) + + if err != nil { + log.Error(4, "%s: %v", message, err) + if macaron.Env != macaron.PROD { + resp["error"] = err + } + } + + switch status { + case 404: + resp["message"] = "Not Found" + case 500: + resp["message"] = "Internal Server Error" + } + + if message != "" { + resp["message"] = message + } + + ctx.HTML(status, "index") +} + func (ctx *Context) JsonBody(model interface{}) bool { b, _ := ioutil.ReadAll(ctx.Req.Body) err := json.Unmarshal(b, &model) diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 2a507564a45..802e0e156ec 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -8,6 +8,13 @@ import ( "time" ) +var ( + GetDashboard func(slug string, accountId int) (*Dashboard, error) + SaveDashboard func(dash *Dashboard) error + DeleteDashboard func(slug string, accountId int) error + SearchQuery func(query string, acccountId int) ([]*SearchResult, error) +) + type Dashboard struct { Id string `gorethink:"id,omitempty"` Slug string diff --git a/pkg/routes/api/api_dashboard.go b/pkg/routes/api/api_dashboard.go new file mode 100644 index 00000000000..64952f6e0d5 --- /dev/null +++ b/pkg/routes/api/api_dashboard.go @@ -0,0 +1,82 @@ +package api + +import ( + "github.com/gin-gonic/gin" + + "github.com/torkelo/grafana-pro/pkg/middleware" + "github.com/torkelo/grafana-pro/pkg/models" + "github.com/torkelo/grafana-pro/pkg/routes/apimodel" +) + +func GetDashboard(c *middleware.Context) { + slug := c.Params(":slug") + + dash, err := models.GetDashboard(slug, c.GetAccountId()) + if err != nil { + c.ApiError(404, "Dashboard not found", nil) + return + } + + dash.Data["id"] = dash.Id + + c.JSON(200, dash.Data) +} + +func DeleteDashboard(c *middleware.Context) { + slug := c.Params(":slug") + + dash, err := models.GetDashboard(slug, c.GetAccountId()) + if err != nil { + c.ApiError(404, "Dashboard not found", nil) + return + } + + err = models.DeleteDashboard(slug, c.GetAccountId()) + if err != nil { + c.ApiError(500, "Failed to delete dashboard", err) + return + } + + var resp = map[string]interface{}{"title": dash.Title} + + c.JSON(200, resp) +} + +func Search(c *middleware.Context) { + query := c.Query("q") + + results, err := models.SearchQuery(query, c.GetAccountId()) + if err != nil { + c.ApiError(500, "Search failed", err) + return + } + + c.JSON(200, results) +} + +func PostDashboard(c *middleware.Context) { + var command apimodel.SaveDashboardCommand + + if !c.JsonBody(&command) { + c.ApiError(400, "bad request", nil) + return + } + + dashboard := models.NewDashboard("test") + dashboard.Data = command.Dashboard + dashboard.Title = dashboard.Data["title"].(string) + dashboard.AccountId = c.GetAccountId() + dashboard.UpdateSlug() + + if dashboard.Data["id"] != nil { + dashboard.Id = dashboard.Data["id"].(string) + } + + err := models.SaveDashboard(dashboard) + if err != nil { + c.ApiError(500, "Failed to save dashboard", err) + return + } + + c.JSON(200, gin.H{"status": "success", "slug": dashboard.Slug}) +} diff --git a/pkg/routes/api/api_render.go b/pkg/routes/api/api_render.go new file mode 100644 index 00000000000..fea4acb3f79 --- /dev/null +++ b/pkg/routes/api/api_render.go @@ -0,0 +1,30 @@ +package api + +import ( + "strconv" + + "github.com/torkelo/grafana-pro/pkg/components/renderer" + "github.com/torkelo/grafana-pro/pkg/middleware" + "github.com/torkelo/grafana-pro/pkg/utils" +) + +func RenderToPng(c *middleware.Context) { + accountId := c.GetAccountId() + queryReader := utils.NewUrlQueryReader(c.Req.URL) + queryParams := "?render&accountId=" + strconv.Itoa(accountId) + "&" + c.Req.URL.RawQuery + + renderOpts := &renderer.RenderOpts{ + Url: c.Params("url") + queryParams, + Width: queryReader.Get("width", "800"), + Height: queryReader.Get("height", "400"), + } + + renderOpts.Url = "http://localhost:3000" + renderOpts.Url + + pngPath, err := renderer.RenderToPng(renderOpts) + if err != nil { + c.HTML(500, "error.html", nil) + } + + c.ServeFile(pngPath) +} diff --git a/pkg/routes/apimodel/models.go b/pkg/routes/apimodel/models.go index c6ab14afe36..211bcb1646b 100644 --- a/pkg/routes/apimodel/models.go +++ b/pkg/routes/apimodel/models.go @@ -34,3 +34,9 @@ func getGravatarUrl(text string) string { hasher.Write([]byte(strings.ToLower(text))) return fmt.Sprintf("https://secure.gravatar.com/avatar/%x?s=90&default=mm", hasher.Sum(nil)) } + +type SaveDashboardCommand struct { + Id string `json:"id"` + Title string `json:"title"` + Dashboard map[string]interface{} `json:"dashboard"` +} diff --git a/pkg/routes/index.go b/pkg/routes/index.go index 3867b2912ca..ca51e723b54 100644 --- a/pkg/routes/index.go +++ b/pkg/routes/index.go @@ -1,10 +1,35 @@ package routes import ( + "github.com/Unknwon/macaron" "github.com/torkelo/grafana-pro/pkg/middleware" + "github.com/torkelo/grafana-pro/pkg/routes/api" "github.com/torkelo/grafana-pro/pkg/routes/apimodel" + "github.com/torkelo/grafana-pro/pkg/routes/login" ) +func Register(m *macaron.Macaron) { + auth := middleware.Auth() + + // index + m.Get("/", auth, Index) + m.Post("/logout", login.LogoutPost) + m.Post("/login", login.LoginPost) + + // no auth + m.Get("/login", Index) + + // dashboards + m.Get("/dashboard/*", auth, Index) + m.Get("/api/dashboards/:slug", auth, api.GetDashboard) + m.Get("/api/search/", auth, api.Search) + m.Post("/api/dashboard/", auth, api.PostDashboard) + m.Delete("/api/dashboard/:slug", auth, api.DeleteDashboard) + + // rendering + m.Get("/render/*url", auth, api.RenderToPng) +} + func Index(ctx *middleware.Context) { ctx.Data["User"] = apimodel.NewCurrentUserDto(ctx.UserAccount) ctx.HTML(200, "index") diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 6b7ce09625e..c85879bf7a7 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -58,6 +58,10 @@ var ( ProdMode bool RunUser string IsWindows bool + + // PhantomJs Rendering + ImagesDir string + PhantomDir string ) func init() { @@ -140,6 +144,10 @@ func NewConfigContext() { StaticRootPath = Cfg.MustValue("server", "static_root_path", workDir) RouterLogging = Cfg.MustBool("server", "router_logging", false) + + // PhantomJS rendering + ImagesDir = "data/png" + PhantomDir = "_vendor/phantomjs" } func initSessionService() { diff --git a/pkg/stores/rethink/rethink.go b/pkg/stores/rethink/rethink.go index d388d2feec4..0b22614e689 100644 --- a/pkg/stores/rethink/rethink.go +++ b/pkg/stores/rethink/rethink.go @@ -34,6 +34,11 @@ func Init() { models.GetAccount = GetAccount models.GetAccountByLogin = GetAccountByLogin + + models.GetDashboard = GetDashboard + models.SearchQuery = SearchQuery + models.DeleteDashboard = DeleteDashboard + models.SaveDashboard = SaveDashboard } func createRethinkDBTablesAndIndices() { diff --git a/pkg/stores/rethink/rethink_dashboards.go b/pkg/stores/rethink/rethink_dashboards.go new file mode 100644 index 00000000000..c8d008b56dc --- /dev/null +++ b/pkg/stores/rethink/rethink_dashboards.go @@ -0,0 +1,80 @@ +package rethink + +import ( + "errors" + + r "github.com/dancannon/gorethink" + + "github.com/torkelo/grafana-pro/pkg/log" + "github.com/torkelo/grafana-pro/pkg/models" +) + +func SaveDashboard(dash *models.Dashboard) error { + resp, err := r.Table("dashboards").Insert(dash, r.InsertOpts{Conflict: "update"}).RunWrite(session) + if err != nil { + return err + } + + log.Info("Inserted: %v, Errors: %v, Updated: %v", resp.Inserted, resp.Errors, resp.Updated) + log.Info("First error:", resp.FirstError) + if len(resp.GeneratedKeys) > 0 { + dash.Id = resp.GeneratedKeys[0] + } + + return nil +} + +func GetDashboard(slug string, accountId int) (*models.Dashboard, error) { + resp, err := r.Table("dashboards"). + GetAllByIndex("AccountIdSlug", []interface{}{accountId, slug}). + Run(session) + + if err != nil { + return nil, err + } + + var dashboard models.Dashboard + err = resp.One(&dashboard) + if err != nil { + return nil, err + } + + return &dashboard, nil +} + +func DeleteDashboard(slug string, accountId int) error { + resp, err := r.Table("dashboards"). + GetAllByIndex("AccountIdSlug", []interface{}{accountId, slug}). + Delete().RunWrite(session) + + if err != nil { + return err + } + + if resp.Deleted != 1 { + return errors.New("Did not find dashboard to delete") + } + + return nil +} + +func SearchQuery(query string, accountId int) ([]*models.SearchResult, error) { + docs, err := r.Table("dashboards"). + GetAllByIndex("AccountId", []interface{}{accountId}). + Filter(r.Row.Field("Title").Match(".*")).Run(session) + + if err != nil { + return nil, err + } + + results := make([]*models.SearchResult, 0, 50) + var dashboard models.Dashboard + for docs.Next(&dashboard) { + results = append(results, &models.SearchResult{ + Title: dashboard.Title, + Id: dashboard.Slug, + }) + } + + return results, nil +}