diff --git a/pkg/api/api.go b/pkg/api/api.go index e899bff0eef..c1c45181b29 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -13,7 +13,7 @@ func Register(r *macaron.Macaron) { reqSignedIn := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true}) reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true}) reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN) - regOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN) + reqOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN) quota := middleware.Quota bind := binding.Bind @@ -41,6 +41,9 @@ func Register(r *macaron.Macaron) { r.Get("/admin/orgs", reqGrafanaAdmin, Index) r.Get("/admin/orgs/edit/:id", reqGrafanaAdmin, Index) + r.Get("/plugins", reqSignedIn, Index) + r.Get("/plugins/edit/*", reqSignedIn, Index) + r.Get("/dashboard/*", reqSignedIn, Index) r.Get("/dashboard-solo/*", reqSignedIn, Index) @@ -114,7 +117,7 @@ func Register(r *macaron.Macaron) { r.Get("/invites", wrap(GetPendingOrgInvites)) r.Post("/invites", quota("user"), bind(dtos.AddInviteForm{}), wrap(AddOrgInvite)) r.Patch("/invites/:code/revoke", wrap(RevokeInvite)) - }, regOrgAdmin) + }, reqOrgAdmin) // create new org r.Post("/orgs", quota("org"), bind(m.CreateOrgCommand{}), wrap(CreateOrg)) @@ -141,7 +144,7 @@ func Register(r *macaron.Macaron) { r.Get("/", wrap(GetApiKeys)) r.Post("/", quota("api_key"), bind(m.AddApiKeyCommand{}), wrap(AddApiKey)) r.Delete("/:id", wrap(DeleteApiKey)) - }, regOrgAdmin) + }, reqOrgAdmin) // Data sources r.Group("/datasources", func() { @@ -151,7 +154,13 @@ func Register(r *macaron.Macaron) { r.Delete("/:id", DeleteDataSource) r.Get("/:id", wrap(GetDataSourceById)) r.Get("/plugins", GetDataSourcePlugins) - }, regOrgAdmin) + }, reqOrgAdmin) + + // PluginBundles + r.Group("/plugins", func() { + r.Get("/", wrap(GetPluginBundles)) + r.Post("/", bind(m.UpdatePluginBundleCmd{}), wrap(UpdatePluginBundle)) + }, reqOrgAdmin) r.Get("/frontend/settings/", GetFrontendSettings) r.Any("/datasources/proxy/:id/*", reqSignedIn, ProxyDataSourceRequest) @@ -188,5 +197,7 @@ func Register(r *macaron.Macaron) { // rendering r.Get("/render/*", reqSignedIn, RenderToPng) + InitExternalPluginRoutes(r) + r.NotFound(NotFoundHandler) } diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index e393ad3e820..1ac241c69bf 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -117,8 +117,15 @@ func UpdateDataSource(c *middleware.Context, cmd m.UpdateDataSourceCommand) { func GetDataSourcePlugins(c *middleware.Context) { dsList := make(map[string]interface{}) - for key, value := range plugins.DataSources { - if value.(map[string]interface{})["builtIn"] == nil { + orgBundles := m.GetPluginBundlesQuery{OrgId: c.OrgId} + err := bus.Dispatch(&orgBundles) + if err != nil { + c.JsonApiErr(500, "Failed to get org plugin Bundles", err) + } + enabledPlugins := plugins.GetEnabledPlugins(orgBundles.Result) + + for key, value := range enabledPlugins.DataSourcePlugins { + if !value.BuiltIn { dsList[key] = value } } diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go new file mode 100644 index 00000000000..1314d2d94ac --- /dev/null +++ b/pkg/api/dtos/index.go @@ -0,0 +1,25 @@ +package dtos + +type IndexViewData struct { + User *CurrentUser + Settings map[string]interface{} + AppUrl string + AppSubUrl string + GoogleAnalyticsId string + GoogleTagManagerId string + + PluginCss []*PluginCss + PluginJs []string + MainNavLinks []*NavLink +} + +type PluginCss struct { + Light string `json:"light"` + Dark string `json:"dark"` +} + +type NavLink struct { + Text string `json:"text"` + Icon string `json:"icon"` + Href string `json:"href"` +} diff --git a/pkg/api/dtos/plugin_bundle.go b/pkg/api/dtos/plugin_bundle.go new file mode 100644 index 00000000000..f043da39904 --- /dev/null +++ b/pkg/api/dtos/plugin_bundle.go @@ -0,0 +1,8 @@ +package dtos + +type PluginBundle struct { + Type string `json:"type"` + Enabled bool `json:"enabled"` + Module string `json:"module"` + JsonData map[string]interface{} `json:"jsonData"` +} diff --git a/pkg/api/externalplugin.go b/pkg/api/externalplugin.go new file mode 100644 index 00000000000..331de160ec7 --- /dev/null +++ b/pkg/api/externalplugin.go @@ -0,0 +1,75 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httputil" + "net/url" + + "github.com/Unknwon/macaron" + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/util" +) + +func InitExternalPluginRoutes(r *macaron.Macaron) { + for _, plugin := range plugins.ExternalPlugins { + log.Info("Plugin: Adding proxy routes for backend plugin") + for _, route := range plugin.Routes { + url := util.JoinUrlFragments("/api/plugin-proxy/", route.Path) + handlers := make([]macaron.Handler, 0) + if route.ReqSignedIn { + handlers = append(handlers, middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true})) + } + if route.ReqGrafanaAdmin { + handlers = append(handlers, middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true})) + } + if route.ReqSignedIn && route.ReqRole != "" { + if route.ReqRole == m.ROLE_ADMIN { + handlers = append(handlers, middleware.RoleAuth(m.ROLE_ADMIN)) + } else if route.ReqRole == m.ROLE_EDITOR { + handlers = append(handlers, middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)) + } + } + handlers = append(handlers, ExternalPlugin(route.Url)) + r.Route(url, route.Method, handlers...) + log.Info("Plugin: Adding route %s", url) + } + } +} + +func ExternalPlugin(routeUrl string) macaron.Handler { + return func(c *middleware.Context) { + path := c.Params("*") + + //Create a HTTP header with the context in it. + ctx, err := json.Marshal(c.SignedInUser) + if err != nil { + c.JsonApiErr(500, "failed to marshal context to json.", err) + return + } + targetUrl, _ := url.Parse(routeUrl) + proxy := NewExternalPluginProxy(string(ctx), path, targetUrl) + proxy.Transport = dataProxyTransport + proxy.ServeHTTP(c.RW(), c.Req.Request) + } +} + +func NewExternalPluginProxy(ctx string, proxyPath string, targetUrl *url.URL) *httputil.ReverseProxy { + director := func(req *http.Request) { + req.URL.Scheme = targetUrl.Scheme + req.URL.Host = targetUrl.Host + req.Host = targetUrl.Host + + req.URL.Path = util.JoinUrlFragments(targetUrl.Path, proxyPath) + + // clear cookie headers + req.Header.Del("Cookie") + req.Header.Del("Set-Cookie") + req.Header.Add("Grafana-Context", ctx) + } + + return &httputil.ReverseProxy{Director: director} +} diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 19f29924236..80eed15324a 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -29,6 +29,13 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro datasources := make(map[string]interface{}) var defaultDatasource string + orgBundles := m.GetPluginBundlesQuery{OrgId: c.OrgId} + err := bus.Dispatch(&orgBundles) + if err != nil { + return nil, err + } + enabledPlugins := plugins.GetEnabledPlugins(orgBundles.Result) + for _, ds := range orgDataSources { url := ds.Url @@ -42,7 +49,7 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro "url": url, } - meta, exists := plugins.DataSources[ds.Type] + meta, exists := enabledPlugins.DataSourcePlugins[ds.Type] if !exists { log.Error(3, "Could not find plugin definition for data source: %v", ds.Type) continue @@ -109,9 +116,18 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro defaultDatasource = "-- Grafana --" } + panels := map[string]interface{}{} + for _, panel := range enabledPlugins.PanelPlugins { + panels[panel.Type] = map[string]interface{}{ + "module": panel.Module, + "name": panel.Name, + } + } + jsonObj := map[string]interface{}{ "defaultDatasource": defaultDatasource, "datasources": datasources, + "panels": panels, "appSubUrl": setting.AppSubUrl, "allowOrgCreate": (setting.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin, "authProxyEnabled": setting.AuthProxyEnabled, diff --git a/pkg/api/index.go b/pkg/api/index.go index 556db006b2f..a4f5ac69928 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -2,66 +2,121 @@ package api import ( "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/setting" ) -func setIndexViewData(c *middleware.Context) error { +func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { settings, err := getFrontendSettingsMap(c) if err != nil { - return err + return nil, err } - currentUser := &dtos.CurrentUser{ - Id: c.UserId, - IsSignedIn: c.IsSignedIn, - Login: c.Login, - Email: c.Email, - Name: c.Name, - LightTheme: c.Theme == "light", - OrgId: c.OrgId, - OrgName: c.OrgName, - OrgRole: c.OrgRole, - GravatarUrl: dtos.GetGravatarUrl(c.Email), - IsGrafanaAdmin: c.IsGrafanaAdmin, + var data = dtos.IndexViewData{ + User: &dtos.CurrentUser{ + Id: c.UserId, + IsSignedIn: c.IsSignedIn, + Login: c.Login, + Email: c.Email, + Name: c.Name, + LightTheme: c.Theme == "light", + OrgId: c.OrgId, + OrgName: c.OrgName, + OrgRole: c.OrgRole, + GravatarUrl: dtos.GetGravatarUrl(c.Email), + IsGrafanaAdmin: c.IsGrafanaAdmin, + }, + Settings: settings, + AppUrl: setting.AppUrl, + AppSubUrl: setting.AppSubUrl, + GoogleAnalyticsId: setting.GoogleAnalyticsId, + GoogleTagManagerId: setting.GoogleTagManagerId, } if setting.DisableGravatar { - currentUser.GravatarUrl = setting.AppSubUrl + "/img/user_profile.png" + data.User.GravatarUrl = setting.AppSubUrl + "/img/user_profile.png" } - if len(currentUser.Name) == 0 { - currentUser.Name = currentUser.Login + if len(data.User.Name) == 0 { + data.User.Name = data.User.Login } themeUrlParam := c.Query("theme") if themeUrlParam == "light" { - currentUser.LightTheme = true + data.User.LightTheme = true } - c.Data["User"] = currentUser - c.Data["Settings"] = settings - c.Data["AppUrl"] = setting.AppUrl - c.Data["AppSubUrl"] = setting.AppSubUrl + data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{ + Text: "Dashboards", + Icon: "fa fa-fw fa-th-large", + Href: "/", + }) - if setting.GoogleAnalyticsId != "" { - c.Data["GoogleAnalyticsId"] = setting.GoogleAnalyticsId + if c.OrgRole == m.ROLE_ADMIN { + data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{ + Text: "Data Sources", + Icon: "fa fa-fw fa-database", + Href: "/datasources", + }, &dtos.NavLink{ + Text: "Plugins", + Icon: "fa fa-fw fa-cubes", + Href: "/plugins", + }) } - if setting.GoogleTagManagerId != "" { - c.Data["GoogleTagManagerId"] = setting.GoogleTagManagerId + orgBundles := m.GetPluginBundlesQuery{OrgId: c.OrgId} + err = bus.Dispatch(&orgBundles) + if err != nil { + return nil, err + } + enabledPlugins := plugins.GetEnabledPlugins(orgBundles.Result) + + for _, plugin := range enabledPlugins.ExternalPlugins { + for _, js := range plugin.Js { + data.PluginJs = append(data.PluginJs, js.Module) + } + for _, css := range plugin.Css { + data.PluginCss = append(data.PluginCss, &dtos.PluginCss{Light: css.Light, Dark: css.Dark}) + } + for _, item := range plugin.MainNavLinks { + // only show menu items for the specified roles. + var validRoles []m.RoleType + if string(item.ReqRole) == "" || item.ReqRole == m.ROLE_VIEWER { + validRoles = []m.RoleType{m.ROLE_ADMIN, m.ROLE_EDITOR, m.ROLE_VIEWER} + } else if item.ReqRole == m.ROLE_EDITOR { + validRoles = []m.RoleType{m.ROLE_ADMIN, m.ROLE_EDITOR} + } else if item.ReqRole == m.ROLE_ADMIN { + validRoles = []m.RoleType{m.ROLE_ADMIN} + } + ok := true + if len(validRoles) > 0 { + ok = false + for _, role := range validRoles { + if role == c.OrgRole { + ok = true + break + } + } + } + if ok { + data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{Text: item.Text, Href: item.Href, Icon: item.Icon}) + } + } } - return nil + return &data, nil } func Index(c *middleware.Context) { - if err := setIndexViewData(c); err != nil { + if data, err := setIndexViewData(c); err != nil { c.Handle(500, "Failed to get settings", err) return + } else { + c.HTML(200, "index", data) } - - c.HTML(200, "index") } func NotFoundHandler(c *middleware.Context) { @@ -70,10 +125,10 @@ func NotFoundHandler(c *middleware.Context) { return } - if err := setIndexViewData(c); err != nil { + if data, err := setIndexViewData(c); err != nil { c.Handle(500, "Failed to get settings", err) return + } else { + c.HTML(404, "index", data) } - - c.HTML(404, "index") } diff --git a/pkg/api/login.go b/pkg/api/login.go index d691270ad72..d0aace4235c 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -19,19 +19,19 @@ const ( ) func LoginView(c *middleware.Context) { - if err := setIndexViewData(c); err != nil { + viewData, err := setIndexViewData(c) + if err != nil { c.Handle(500, "Failed to get settings", err) return } - settings := c.Data["Settings"].(map[string]interface{}) - settings["googleAuthEnabled"] = setting.OAuthService.Google - settings["githubAuthEnabled"] = setting.OAuthService.GitHub - settings["disableUserSignUp"] = !setting.AllowUserSignUp - settings["loginHint"] = setting.LoginHint + viewData.Settings["googleAuthEnabled"] = setting.OAuthService.Google + viewData.Settings["githubAuthEnabled"] = setting.OAuthService.GitHub + viewData.Settings["disableUserSignUp"] = !setting.AllowUserSignUp + viewData.Settings["loginHint"] = setting.LoginHint if !tryLoginUsingRememberCookie(c) { - c.HTML(200, VIEW_INDEX) + c.HTML(200, VIEW_INDEX, viewData) return } diff --git a/pkg/api/plugin_bundle.go b/pkg/api/plugin_bundle.go new file mode 100644 index 00000000000..9378189af7d --- /dev/null +++ b/pkg/api/plugin_bundle.go @@ -0,0 +1,65 @@ +package api + +import ( + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" +) + +func GetPluginBundles(c *middleware.Context) Response { + query := m.GetPluginBundlesQuery{OrgId: c.OrgId} + + if err := bus.Dispatch(&query); err != nil { + return ApiError(500, "Failed to list Plugin Bundles", err) + } + + installedBundlesMap := make(map[string]*dtos.PluginBundle) + for t, b := range plugins.Bundles { + installedBundlesMap[t] = &dtos.PluginBundle{ + Type: b.Type, + Enabled: b.Enabled, + Module: b.Module, + JsonData: make(map[string]interface{}), + } + } + + seenBundles := make(map[string]bool) + + result := make([]*dtos.PluginBundle, 0) + for _, b := range query.Result { + if def, ok := installedBundlesMap[b.Type]; ok { + result = append(result, &dtos.PluginBundle{ + Type: b.Type, + Enabled: b.Enabled, + Module: def.Module, + JsonData: b.JsonData, + }) + seenBundles[b.Type] = true + } + } + + for t, b := range installedBundlesMap { + if _, ok := seenBundles[t]; !ok { + result = append(result, b) + } + } + + return Json(200, result) +} + +func UpdatePluginBundle(c *middleware.Context, cmd m.UpdatePluginBundleCmd) Response { + cmd.OrgId = c.OrgId + + if _, ok := plugins.Bundles[cmd.Type]; !ok { + return ApiError(404, "Bundle type not installed.", nil) + } + + err := bus.Dispatch(&cmd) + if err != nil { + return ApiError(500, "Failed to update plugin bundle", err) + } + + return ApiSuccess("Plugin updated") +} diff --git a/pkg/cmd/web.go b/pkg/cmd/web.go index 69843b6b095..1debde98a0e 100644 --- a/pkg/cmd/web.go +++ b/pkg/cmd/web.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/api/static" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/setting" ) @@ -28,12 +29,18 @@ func newMacaron() *macaron.Macaron { m.Use(middleware.Gziper()) } - mapStatic(m, "", "public") - mapStatic(m, "app", "app") - mapStatic(m, "css", "css") - mapStatic(m, "img", "img") - mapStatic(m, "fonts", "fonts") - mapStatic(m, "robots.txt", "robots.txt") + for _, route := range plugins.StaticRoutes { + pluginRoute := path.Join("/public/plugins/", route.Url) + log.Info("Plugin: Adding static route %s -> %s", pluginRoute, route.Path) + mapStatic(m, route.Path, "", pluginRoute) + } + + mapStatic(m, setting.StaticRootPath, "", "public") + mapStatic(m, setting.StaticRootPath, "app", "app") + mapStatic(m, setting.StaticRootPath, "css", "css") + mapStatic(m, setting.StaticRootPath, "img", "img") + mapStatic(m, setting.StaticRootPath, "fonts", "fonts") + mapStatic(m, setting.StaticRootPath, "robots.txt", "robots.txt") m.Use(macaron.Renderer(macaron.RenderOptions{ Directory: path.Join(setting.StaticRootPath, "views"), @@ -51,7 +58,7 @@ func newMacaron() *macaron.Macaron { return m } -func mapStatic(m *macaron.Macaron, dir string, prefix string) { +func mapStatic(m *macaron.Macaron, rootDir string, dir string, prefix string) { headers := func(c *macaron.Context) { c.Resp.Header().Set("Cache-Control", "public, max-age=3600") } @@ -63,7 +70,7 @@ func mapStatic(m *macaron.Macaron, dir string, prefix string) { } m.Use(httpstatic.Static( - path.Join(setting.StaticRootPath, dir), + path.Join(rootDir, dir), httpstatic.StaticOptions{ SkipLogging: true, Prefix: prefix, diff --git a/pkg/login/ldap.go b/pkg/login/ldap.go index 355b4fd100a..24c6a99a8f5 100644 --- a/pkg/login/ldap.go +++ b/pkg/login/ldap.go @@ -131,8 +131,8 @@ func (a *ldapAuther) getGrafanaUserFor(ldapUser *ldapUserInfo) (*m.User, error) } return userQuery.Result, nil -} +} func (a *ldapAuther) createGrafanaUser(ldapUser *ldapUserInfo) (*m.User, error) { cmd := m.CreateUserCommand{ Login: ldapUser.Username, diff --git a/pkg/models/plugin_bundle.go b/pkg/models/plugin_bundle.go new file mode 100644 index 00000000000..5f4e508b9b2 --- /dev/null +++ b/pkg/models/plugin_bundle.go @@ -0,0 +1,34 @@ +package models + +import "time" + +type PluginBundle struct { + Id int64 + Type string + OrgId int64 + Enabled bool + JsonData map[string]interface{} + + Created time.Time + Updated time.Time +} + +// ---------------------- +// COMMANDS + +// Also acts as api DTO +type UpdatePluginBundleCmd struct { + Type string `json:"type" binding:"Required"` + Enabled bool `json:"enabled"` + JsonData map[string]interface{} `json:"jsonData"` + + Id int64 `json:"-"` + OrgId int64 `json:"-"` +} + +// --------------------- +// QUERIES +type GetPluginBundlesQuery struct { + OrgId int64 + Result []*PluginBundle +} diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go new file mode 100644 index 00000000000..05b9492f4e5 --- /dev/null +++ b/pkg/plugins/models.go @@ -0,0 +1,85 @@ +package plugins + +import "github.com/grafana/grafana/pkg/models" + +type DataSourcePlugin struct { + Type string `json:"type"` + Name string `json:"name"` + ServiceName string `json:"serviceName"` + Module string `json:"module"` + Partials map[string]interface{} `json:"partials"` + DefaultMatchFormat string `json:"defaultMatchFormat"` + Annotations bool `json:"annotations"` + Metrics bool `json:"metrics"` + BuiltIn bool `json:"builtIn"` + StaticRootConfig *StaticRootConfig `json:"staticRoot"` +} + +type PanelPlugin struct { + Type string `json:"type"` + Name string `json:"name"` + Module string `json:"module"` + StaticRootConfig *StaticRootConfig `json:"staticRoot"` +} + +type StaticRootConfig struct { + Url string `json:"url"` + Path string `json:"path"` +} + +type ExternalPluginRoute struct { + Path string `json:"path"` + Method string `json:"method"` + ReqSignedIn bool `json:"reqSignedIn"` + ReqGrafanaAdmin bool `json:"reqGrafanaAdmin"` + ReqRole models.RoleType `json:"reqRole"` + Url string `json:"url"` +} + +type ExternalPluginJs struct { + Module string `json:"module"` +} + +type ExternalPluginNavLink struct { + Text string `json:"text"` + Icon string `json:"icon"` + Href string `json:"href"` + ReqRole models.RoleType `json:"reqRole"` +} + +type ExternalPluginCss struct { + Light string `json:"light"` + Dark string `json:"dark"` +} + +type ExternalPlugin struct { + Type string `json:"type"` + Routes []*ExternalPluginRoute `json:"routes"` + Js []*ExternalPluginJs `json:"js"` + Css []*ExternalPluginCss `json:"css"` + MainNavLinks []*ExternalPluginNavLink `json:"mainNavLinks"` + StaticRootConfig *StaticRootConfig `json:"staticRoot"` +} + +type PluginBundle struct { + Type string `json:"type"` + Enabled bool `json:"enabled"` + PanelPlugins []string `json:"panelPlugins"` + DatasourcePlugins []string `json:"datasourcePlugins"` + ExternalPlugins []string `json:"externalPlugins"` + Module string `json:"module"` +} + +type EnabledPlugins struct { + PanelPlugins []*PanelPlugin + DataSourcePlugins map[string]*DataSourcePlugin + ExternalPlugins []*ExternalPlugin +} + +func NewEnabledPlugins() EnabledPlugins { + return EnabledPlugins{ + PanelPlugins: make([]*PanelPlugin, 0), + DataSourcePlugins: make(map[string]*DataSourcePlugin), + ExternalPlugins: make([]*ExternalPlugin, 0), + } +} diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 665cf6a36ca..3e3a9d85152 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -6,18 +6,19 @@ import ( "os" "path" "path/filepath" + "strings" "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" ) -type PluginMeta struct { - Type string `json:"type"` - Name string `json:"name"` -} - var ( - DataSources map[string]interface{} + DataSources map[string]DataSourcePlugin + Panels map[string]PanelPlugin + ExternalPlugins map[string]ExternalPlugin + StaticRoutes []*StaticRootConfig + Bundles map[string]PluginBundle ) type PluginScanner struct { @@ -25,13 +26,53 @@ type PluginScanner struct { errors []error } -func Init() { +func Init() error { + DataSources = make(map[string]DataSourcePlugin) + ExternalPlugins = make(map[string]ExternalPlugin) + StaticRoutes = make([]*StaticRootConfig, 0) + Panels = make(map[string]PanelPlugin) + Bundles = make(map[string]PluginBundle) + scan(path.Join(setting.StaticRootPath, "app/plugins")) + checkExternalPluginPaths() + checkDependencies() + return nil +} + +func checkDependencies() { + for bundleType, bundle := range Bundles { + for _, reqPanel := range bundle.PanelPlugins { + if _, ok := Panels[reqPanel]; !ok { + log.Fatal(4, "Bundle %s requires Panel type %s, but it is not present.", bundleType, reqPanel) + } + } + for _, reqDataSource := range bundle.DatasourcePlugins { + if _, ok := DataSources[reqDataSource]; !ok { + log.Fatal(4, "Bundle %s requires DataSource type %s, but it is not present.", bundleType, reqDataSource) + } + } + for _, reqExtPlugin := range bundle.ExternalPlugins { + if _, ok := ExternalPlugins[reqExtPlugin]; !ok { + log.Fatal(4, "Bundle %s requires DataSource type %s, but it is not present.", bundleType, reqExtPlugin) + } + } + } +} + +func checkExternalPluginPaths() error { + for _, section := range setting.Cfg.Sections() { + if strings.HasPrefix(section.Name(), "plugin.") { + path := section.Key("path").String() + if path != "" { + log.Info("Plugin: Scaning dir %s", path) + scan(path) + } + } + } + return nil } func scan(pluginDir string) error { - DataSources = make(map[string]interface{}) - scanner := &PluginScanner{ pluginPath: pluginDir, } @@ -47,7 +88,7 @@ func scan(pluginDir string) error { return nil } -func (scanner *PluginScanner) walker(path string, f os.FileInfo, err error) error { +func (scanner *PluginScanner) walker(currentPath string, f os.FileInfo, err error) error { if err != nil { return err } @@ -57,17 +98,25 @@ func (scanner *PluginScanner) walker(path string, f os.FileInfo, err error) erro } if f.Name() == "plugin.json" { - err := scanner.loadPluginJson(path) + err := scanner.loadPluginJson(currentPath) if err != nil { - log.Error(3, "Failed to load plugin json file: %v, err: %v", path, err) + log.Error(3, "Failed to load plugin json file: %v, err: %v", currentPath, err) scanner.errors = append(scanner.errors, err) } } return nil } -func (scanner *PluginScanner) loadPluginJson(path string) error { - reader, err := os.Open(path) +func addStaticRoot(staticRootConfig *StaticRootConfig, currentDir string) { + if staticRootConfig != nil { + staticRootConfig.Path = path.Join(currentDir, staticRootConfig.Path) + StaticRoutes = append(StaticRoutes, staticRootConfig) + } +} + +func (scanner *PluginScanner) loadPluginJson(pluginJsonFilePath string) error { + currentDir := filepath.Dir(pluginJsonFilePath) + reader, err := os.Open(pluginJsonFilePath) if err != nil { return err } @@ -87,12 +136,95 @@ func (scanner *PluginScanner) loadPluginJson(path string) error { } if pluginType == "datasource" { - datasourceType, exists := pluginJson["type"] - if !exists { + p := DataSourcePlugin{} + reader.Seek(0, 0) + if err := jsonParser.Decode(&p); err != nil { + return err + } + + if p.Type == "" { return errors.New("Did not find type property in plugin.json") } - DataSources[datasourceType.(string)] = pluginJson + + DataSources[p.Type] = p + addStaticRoot(p.StaticRootConfig, currentDir) + } + + if pluginType == "panel" { + p := PanelPlugin{} + reader.Seek(0, 0) + if err := jsonParser.Decode(&p); err != nil { + return err + } + + if p.Type == "" { + return errors.New("Did not find type property in plugin.json") + } + + Panels[p.Type] = p + addStaticRoot(p.StaticRootConfig, currentDir) + } + + if pluginType == "external" { + p := ExternalPlugin{} + reader.Seek(0, 0) + if err := jsonParser.Decode(&p); err != nil { + return err + } + if p.Type == "" { + return errors.New("Did not find type property in plugin.json") + } + ExternalPlugins[p.Type] = p + addStaticRoot(p.StaticRootConfig, currentDir) + } + + if pluginType == "bundle" { + p := PluginBundle{} + reader.Seek(0, 0) + if err := jsonParser.Decode(&p); err != nil { + return err + } + if p.Type == "" { + return errors.New("Did not find type property in plugin.json") + } + Bundles[p.Type] = p } return nil } + +func GetEnabledPlugins(orgBundles []*models.PluginBundle) EnabledPlugins { + enabledPlugins := NewEnabledPlugins() + + orgBundlesMap := make(map[string]*models.PluginBundle) + for _, orgBundle := range orgBundles { + orgBundlesMap[orgBundle.Type] = orgBundle + } + + for bundleType, bundle := range Bundles { + enabled := bundle.Enabled + // check if the bundle is stored in the DB. + if b, ok := orgBundlesMap[bundleType]; ok { + enabled = b.Enabled + } + + if enabled { + for _, d := range bundle.DatasourcePlugins { + if ds, ok := DataSources[d]; ok { + enabledPlugins.DataSourcePlugins[d] = &ds + } + } + for _, p := range bundle.PanelPlugins { + if panel, ok := Panels[p]; ok { + enabledPlugins.PanelPlugins = append(enabledPlugins.PanelPlugins, &panel) + } + } + for _, e := range bundle.ExternalPlugins { + if external, ok := ExternalPlugins[e]; ok { + enabledPlugins.ExternalPlugins = append(enabledPlugins.ExternalPlugins, &external) + } + } + } + } + return enabledPlugins +} diff --git a/pkg/plugins/plugins_test.go b/pkg/plugins/plugins_test.go index 4d3e2c98836..bbeac4bba81 100644 --- a/pkg/plugins/plugins_test.go +++ b/pkg/plugins/plugins_test.go @@ -4,14 +4,17 @@ import ( "path/filepath" "testing" + "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" + "gopkg.in/ini.v1" ) func TestPluginScans(t *testing.T) { Convey("When scaning for plugins", t, func() { - path, _ := filepath.Abs("../../public/app/plugins") - err := scan(path) + setting.StaticRootPath, _ = filepath.Abs("../../public/") + setting.Cfg = ini.Empty() + err := Init() So(err, ShouldBeNil) So(len(DataSources), ShouldBeGreaterThan, 1) diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 8f7054d3959..569d26282ed 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -18,6 +18,7 @@ func AddMigrations(mg *Migrator) { addApiKeyMigrations(mg) addDashboardSnapshotMigrations(mg) addQuotaMigration(mg) + addPluginBundleMigration(mg) } func addMigrationLogMigrations(mg *Migrator) { diff --git a/pkg/services/sqlstore/migrations/plugin_bundle.go b/pkg/services/sqlstore/migrations/plugin_bundle.go new file mode 100644 index 00000000000..b56ea74a13e --- /dev/null +++ b/pkg/services/sqlstore/migrations/plugin_bundle.go @@ -0,0 +1,26 @@ +package migrations + +import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +func addPluginBundleMigration(mg *Migrator) { + + var pluginBundleV1 = Table{ + Name: "plugin_bundle", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "org_id", Type: DB_BigInt, Nullable: true}, + {Name: "type", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "enabled", Type: DB_Bool, Nullable: false}, + {Name: "json_data", Type: DB_Text, Nullable: true}, + {Name: "created", Type: DB_DateTime, Nullable: false}, + {Name: "updated", Type: DB_DateTime, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"org_id", "type"}, Type: UniqueIndex}, + }, + } + mg.AddMigration("create plugin_bundle table v1", NewAddTableMigration(pluginBundleV1)) + + //------- indexes ------------------ + addTableIndicesMigrations(mg, "v1", pluginBundleV1) +} diff --git a/pkg/services/sqlstore/plugin_bundle.go b/pkg/services/sqlstore/plugin_bundle.go new file mode 100644 index 00000000000..c15c263a100 --- /dev/null +++ b/pkg/services/sqlstore/plugin_bundle.go @@ -0,0 +1,46 @@ +package sqlstore + +import ( + "time" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" +) + +func init() { + bus.AddHandler("sql", GetPluginBundles) + bus.AddHandler("sql", UpdatePluginBundle) +} + +func GetPluginBundles(query *m.GetPluginBundlesQuery) error { + sess := x.Where("org_id=?", query.OrgId) + + query.Result = make([]*m.PluginBundle, 0) + return sess.Find(&query.Result) +} + +func UpdatePluginBundle(cmd *m.UpdatePluginBundleCmd) error { + return inTransaction2(func(sess *session) error { + var bundle m.PluginBundle + + exists, err := sess.Where("org_id=? and type=?", cmd.OrgId, cmd.Type).Get(&bundle) + sess.UseBool("enabled") + if !exists { + bundle = m.PluginBundle{ + Type: cmd.Type, + OrgId: cmd.OrgId, + Enabled: cmd.Enabled, + JsonData: cmd.JsonData, + Created: time.Now(), + Updated: time.Now(), + } + _, err = sess.Insert(&bundle) + return err + } else { + bundle.Enabled = cmd.Enabled + bundle.JsonData = cmd.JsonData + _, err = sess.Id(bundle.Id).Update(&bundle) + return err + } + }) +} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 11ac931a7b2..28e0364e766 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -287,13 +287,11 @@ func loadSpecifedConfigFile(configFile string) { defaultSec, err := Cfg.GetSection(section.Name()) if err != nil { - log.Error(3, "Unknown config section %s defined in %s", section.Name(), configFile) - continue + defaultSec, _ = Cfg.NewSection(section.Name()) } defaultKey, err := defaultSec.GetKey(key.Name()) if err != nil { - log.Error(3, "Unknown config key %s defined in section %s, in file %s", key.Name(), section.Name(), configFile) - continue + defaultKey, _ = defaultSec.NewKey(key.Name(), key.Value()) } defaultKey.SetValue(key.Value()) } diff --git a/public/app/app.js b/public/app/app.js index 0637b3aeea7..b8d6579dea1 100644 --- a/public/app/app.js +++ b/public/app/app.js @@ -2,6 +2,7 @@ define([ 'angular', 'jquery', 'lodash', + 'app/core/config', 'require', 'bootstrap', 'angular-route', @@ -12,7 +13,7 @@ define([ 'bindonce', 'app/core/core', ], -function (angular, $, _, appLevelRequire) { +function (angular, $, _, config, appLevelRequire) { "use strict"; var app = angular.module('grafana', []); @@ -35,6 +36,8 @@ function (angular, $, _, appLevelRequire) { } else { _.extend(module, register_fns); } + // push it into the apps dependencies + apps_deps.push(module.name); return module; }; @@ -64,13 +67,15 @@ function (angular, $, _, appLevelRequire) { var module_name = 'grafana.'+type; // create the module app.useModule(angular.module(module_name, [])); - // push it into the apps dependencies - apps_deps.push(module_name); }); - var preBootRequires = [ - 'app/features/all', - ]; + var preBootRequires = ['app/features/all']; + var pluginModules = config.bootData.pluginModules || []; + + // add plugin modules + for (var i = 0; i < pluginModules.length; i++) { + preBootRequires.push(pluginModules[i]); + } app.boot = function() { require(preBootRequires, function () { diff --git a/public/app/core/config.js b/public/app/core/config.js index f8e7bb228a2..410d47f7b9a 100644 --- a/public/app/core/config.js +++ b/public/app/core/config.js @@ -6,6 +6,7 @@ function (Settings) { var bootData = window.grafanaBootData || { settings: {} }; var options = bootData.settings; + options.bootData = bootData; return new Settings(options); diff --git a/public/app/core/controllers/sidemenu_ctrl.js b/public/app/core/controllers/sidemenu_ctrl.js index d18b4ebf86a..17746b6b9d1 100644 --- a/public/app/core/controllers/sidemenu_ctrl.js +++ b/public/app/core/controllers/sidemenu_ctrl.js @@ -15,19 +15,13 @@ function (angular, _, $, coreModule, config) { }; $scope.setupMainNav = function() { - $scope.mainLinks.push({ - text: "Dashboards", - icon: "fa fa-fw fa-th-large", - href: $scope.getUrl("/"), - }); - - if (contextSrv.hasRole('Admin')) { + _.each(config.bootData.mainNavLinks, function(item) { $scope.mainLinks.push({ - text: "Data Sources", - icon: "fa fa-fw fa-database", - href: $scope.getUrl("/datasources"), + text: item.text, + icon: item.icon, + href: $scope.getUrl(item.href) }); - } + }); }; $scope.loadOrgs = function() { diff --git a/public/app/core/routes/all.js b/public/app/core/routes/all.js index 7a912621ba5..218310382d9 100644 --- a/public/app/core/routes/all.js +++ b/public/app/core/routes/all.js @@ -131,6 +131,16 @@ define([ templateUrl: 'app/partials/reset_password.html', controller : 'ResetPasswordCtrl', }) + .when('/plugins', { + templateUrl: 'app/features/org/partials/plugins.html', + controller: 'PluginsCtrl', + resolve: loadOrgBundle, + }) + .when('/plugins/edit/:type', { + templateUrl: 'app/features/org/partials/pluginEdit.html', + controller: 'PluginEditCtrl', + resolve: loadOrgBundle, + }) .when('/global-alerts', { templateUrl: 'app/features/dashboard/partials/globalAlerts.html', }) diff --git a/public/app/core/services/context_srv.js b/public/app/core/services/context_srv.js index 77f10fdf16a..b06a84fe5c3 100644 --- a/public/app/core/services/context_srv.js +++ b/public/app/core/services/context_srv.js @@ -12,8 +12,8 @@ function (angular, _, coreModule, store, config) { var self = this; function User() { - if (window.grafanaBootData.user) { - _.extend(this, window.grafanaBootData.user); + if (config.bootData.user) { + _.extend(this, config.bootData.user); } } diff --git a/public/app/core/settings.js b/public/app/core/settings.js index 59eaf8ea8b9..4c0b84f8da5 100644 --- a/public/app/core/settings.js +++ b/public/app/core/settings.js @@ -8,15 +8,8 @@ function (_) { var defaults = { datasources : {}, window_title_prefix : 'Grafana - ', - panels : { - 'graph': { path: 'app/panels/graph', name: 'Graph' }, - 'table': { path: 'app/panels/table', name: 'Table' }, - 'singlestat': { path: 'app/panels/singlestat', name: 'Single stat' }, - 'text': { path: 'app/panels/text', name: 'Text' }, - 'dashlist': { path: 'app/panels/dashlist', name: 'Dashboard list' }, - }, + panels : {}, new_panel_title: 'Panel Title', - plugins: {}, playlist_timespan: "1m", unsaved_changes_warning: true, appSubUrl: "" diff --git a/public/app/features/org/all.js b/public/app/features/org/all.js index d03d270709d..be9668e33de 100644 --- a/public/app/features/org/all.js +++ b/public/app/features/org/all.js @@ -6,4 +6,8 @@ define([ './userInviteCtrl', './orgApiKeysCtrl', './orgDetailsCtrl', + './pluginsCtrl', + './pluginEditCtrl', + './plugin_srv', + './plugin_directive', ], function () {}); diff --git a/public/app/features/org/partials/pluginConfigCore.html b/public/app/features/org/partials/pluginConfigCore.html new file mode 100644 index 00000000000..1b13b46d0e5 --- /dev/null +++ b/public/app/features/org/partials/pluginConfigCore.html @@ -0,0 +1,3 @@ +
+{{current.type}} plugin does not have any additional config. +
diff --git a/public/app/features/org/partials/pluginEdit.html b/public/app/features/org/partials/pluginEdit.html new file mode 100644 index 00000000000..9276fce277c --- /dev/null +++ b/public/app/features/org/partials/pluginEdit.html @@ -0,0 +1,42 @@ + + + + +
+
+

Edit Plugin

+ + +
+
+
    +
  • + Type +
  • +
  • +
  • + +
  • + +
  • + Default  + + +
  • +
+
+
+
+ +
+ + Cancel +
+
+
+ +
+
\ No newline at end of file diff --git a/public/app/features/org/partials/plugins.html b/public/app/features/org/partials/plugins.html new file mode 100644 index 00000000000..97949649094 --- /dev/null +++ b/public/app/features/org/partials/plugins.html @@ -0,0 +1,41 @@ + + + + +
+
+

Plugins

+ +
+ No plugins defined +
+ + + + + + + + + + + + +
Type
+   + {{p.type}} + + + + Edit + + + Enabled  + + +
+ +
+
\ No newline at end of file diff --git a/public/app/features/org/pluginEditCtrl.js b/public/app/features/org/pluginEditCtrl.js new file mode 100644 index 00000000000..b4ba2c05653 --- /dev/null +++ b/public/app/features/org/pluginEditCtrl.js @@ -0,0 +1,35 @@ +define([ + 'angular', + 'lodash', + 'app/core/config', +], +function (angular, _, config) { + 'use strict'; + + var module = angular.module('grafana.controllers'); + + module.controller('PluginEditCtrl', function($scope, pluginSrv, $routeParams) { + $scope.init = function() { + $scope.current = {}; + $scope.getPlugins(); + }; + + $scope.getPlugins = function() { + pluginSrv.get($routeParams.type).then(function(result) { + $scope.current = _.clone(result); + }); + }; + + $scope.update = function() { + $scope._update(); + }; + + $scope._update = function() { + pluginSrv.update($scope.current).then(function() { + window.location.href = config.appSubUrl + "plugins"; + }); + }; + + $scope.init(); + }); +}); \ No newline at end of file diff --git a/public/app/features/org/plugin_directive.js b/public/app/features/org/plugin_directive.js new file mode 100644 index 00000000000..6fd30730264 --- /dev/null +++ b/public/app/features/org/plugin_directive.js @@ -0,0 +1,47 @@ +define([ + 'angular', +], +function (angular) { + 'use strict'; + + var module = angular.module('grafana.directives'); + + module.directive('pluginConfigLoader', function($compile) { + return { + restrict: 'E', + link: function(scope, elem) { + var directive = 'grafana-plugin-core'; + //wait for the parent scope to be applied. + scope.$watch("current", function(newVal) { + if (newVal) { + if (newVal.module) { + directive = 'grafana-plugin-'+newVal.type; + } + scope.require([newVal.module], function () { + var panelEl = angular.element(document.createElement(directive)); + elem.append(panelEl); + $compile(panelEl)(scope); + }); + } + }); + } + }; + }); + + module.directive('grafanaPluginCore', function() { + return { + restrict: 'E', + templateUrl: 'app/features/org/partials/pluginConfigCore.html', + transclude: true, + link: function(scope) { + scope.update = function() { + //Perform custom save events to the plugins own backend if needed. + + // call parent update to commit the change to the plugin object. + // this will cause the page to reload. + scope._update(); + }; + } + }; + }); +}); \ No newline at end of file diff --git a/public/app/features/org/plugin_srv.js b/public/app/features/org/plugin_srv.js new file mode 100644 index 00000000000..863828c7659 --- /dev/null +++ b/public/app/features/org/plugin_srv.js @@ -0,0 +1,58 @@ +define([ + 'angular', + 'lodash', +], +function (angular, _) { + 'use strict'; + + var module = angular.module('grafana.services'); + + module.service('pluginSrv', function($rootScope, $timeout, $q, backendSrv) { + var self = this; + this.init = function() { + console.log("pluginSrv init"); + this.plugins = {}; + }; + + this.get = function(type) { + return $q(function(resolve) { + if (type in self.plugins) { + return resolve(self.plugins[type]); + } + backendSrv.get('/api/plugins').then(function(results) { + _.forEach(results, function(p) { + self.plugins[p.type] = p; + }); + return resolve(self.plugins[type]); + }); + }); + }; + + this.getAll = function() { + return $q(function(resolve) { + if (!_.isEmpty(self.plugins)) { + return resolve(self.plugins); + } + backendSrv.get('api/plugins').then(function(results) { + _.forEach(results, function(p) { + self.plugins[p.type] = p; + }); + return resolve(self.plugins); + }); + }); + }; + + this.update = function(plugin) { + return $q(function(resolve, reject) { + backendSrv.post('/api/plugins', plugin).then(function(resp) { + self.plugins[plugin.type] = plugin; + resolve(resp); + }, function(resp) { + reject(resp); + }); + }); + }; + + this.init(); + }); +}); diff --git a/public/app/features/org/pluginsCtrl.js b/public/app/features/org/pluginsCtrl.js new file mode 100644 index 00000000000..49dc104a840 --- /dev/null +++ b/public/app/features/org/pluginsCtrl.js @@ -0,0 +1,33 @@ +define([ + 'angular', + 'app/core/config', +], +function (angular, config) { + 'use strict'; + + var module = angular.module('grafana.controllers'); + + module.controller('PluginsCtrl', function($scope, $location, pluginSrv) { + + $scope.init = function() { + $scope.plugins = {}; + $scope.getPlugins(); + }; + + $scope.getPlugins = function() { + pluginSrv.getAll().then(function(result) { + console.log(result); + $scope.plugins = result; + }); + }; + + $scope.update = function(plugin) { + pluginSrv.update(plugin).then(function() { + window.location.href = config.appSubUrl + $location.path(); + }); + }; + + $scope.init(); + + }); +}); \ No newline at end of file diff --git a/public/app/features/panel/panel_directive.js b/public/app/features/panel/panel_directive.js index 8dd9d367922..258b47742b2 100644 --- a/public/app/features/panel/panel_directive.js +++ b/public/app/features/panel/panel_directive.js @@ -13,9 +13,9 @@ function (angular, $, config) { restrict: 'E', link: function(scope, elem, attr) { var getter = $parse(attr.type), panelType = getter(scope); - var panelPath = config.panels[panelType].path; + var module = config.panels[panelType].module; - scope.require([panelPath + "/module"], function () { + scope.require([module], function () { var panelEl = angular.element(document.createElement('grafana-panel-' + panelType)); elem.append(panelEl); $compile(panelEl)(scope); diff --git a/public/app/plugins/external/example/readme.md b/public/app/plugins/external/example/readme.md new file mode 100644 index 00000000000..78a9eb686d0 --- /dev/null +++ b/public/app/plugins/external/example/readme.md @@ -0,0 +1,13 @@ +Example app is available at https://github.com/raintank/grafana-plugin-example + +* Clone plugin repo git@github.com:raintank/grafana-plugin-example.git + +* Modify grafana.ini (or custom.ini if your developing Grafana locally) + +```ini +[plugin.external-test] +path = //grafana-plugin-example +``` + + + diff --git a/public/app/panels/dashlist/editor.html b/public/app/plugins/panels/dashlist/editor.html similarity index 100% rename from public/app/panels/dashlist/editor.html rename to public/app/plugins/panels/dashlist/editor.html diff --git a/public/app/panels/dashlist/module.html b/public/app/plugins/panels/dashlist/module.html similarity index 100% rename from public/app/panels/dashlist/module.html rename to public/app/plugins/panels/dashlist/module.html diff --git a/public/app/panels/dashlist/module.js b/public/app/plugins/panels/dashlist/module.js similarity index 91% rename from public/app/panels/dashlist/module.js rename to public/app/plugins/panels/dashlist/module.js index d76664eb5c3..fddc762ffe2 100644 --- a/public/app/panels/dashlist/module.js +++ b/public/app/plugins/panels/dashlist/module.js @@ -14,7 +14,7 @@ function (angular, app, _, config, PanelMeta) { module.directive('grafanaPanelDashlist', function() { return { controller: 'DashListPanelCtrl', - templateUrl: 'app/panels/dashlist/module.html', + templateUrl: 'app/plugins/panels/dashlist/module.html', }; }); @@ -26,7 +26,7 @@ function (angular, app, _, config, PanelMeta) { fullscreen: true, }); - $scope.panelMeta.addEditorTab('Options', 'app/panels/dashlist/editor.html'); + $scope.panelMeta.addEditorTab('Options', 'app/plugins/panels/dashlist/editor.html'); var defaults = { mode: 'starred', diff --git a/public/app/plugins/panels/dashlist/plugin.json b/public/app/plugins/panels/dashlist/plugin.json new file mode 100644 index 00000000000..af9b9d8bbc8 --- /dev/null +++ b/public/app/plugins/panels/dashlist/plugin.json @@ -0,0 +1,8 @@ +{ + "pluginType": "panel", + + "name": "Dashboard list", + "type": "dashlist", + + "module": "app/plugins/panels/dashlist/module" +} diff --git a/public/app/panels/graph/axisEditor.html b/public/app/plugins/panels/graph/axisEditor.html similarity index 100% rename from public/app/panels/graph/axisEditor.html rename to public/app/plugins/panels/graph/axisEditor.html diff --git a/public/app/panels/graph/graph.js b/public/app/plugins/panels/graph/graph.js similarity index 100% rename from public/app/panels/graph/graph.js rename to public/app/plugins/panels/graph/graph.js diff --git a/public/app/panels/graph/graph.tooltip.js b/public/app/plugins/panels/graph/graph.tooltip.js similarity index 100% rename from public/app/panels/graph/graph.tooltip.js rename to public/app/plugins/panels/graph/graph.tooltip.js diff --git a/public/app/panels/graph/legend.js b/public/app/plugins/panels/graph/legend.js similarity index 98% rename from public/app/panels/graph/legend.js rename to public/app/plugins/panels/graph/legend.js index b3e1a998ccb..4f4a0d8ee06 100644 --- a/public/app/panels/graph/legend.js +++ b/public/app/plugins/panels/graph/legend.js @@ -45,7 +45,7 @@ function (angular, _, $) { popoverScope.series = seriesInfo; popoverSrv.show({ element: el, - templateUrl: 'app/panels/graph/legend.popover.html', + templateUrl: 'app/plugins/panels/graph/legend.popover.html', scope: popoverScope }); } diff --git a/public/app/panels/graph/legend.popover.html b/public/app/plugins/panels/graph/legend.popover.html similarity index 100% rename from public/app/panels/graph/legend.popover.html rename to public/app/plugins/panels/graph/legend.popover.html diff --git a/public/app/panels/graph/module.html b/public/app/plugins/panels/graph/module.html similarity index 100% rename from public/app/panels/graph/module.html rename to public/app/plugins/panels/graph/module.html diff --git a/public/app/panels/graph/module.js b/public/app/plugins/panels/graph/module.js similarity index 97% rename from public/app/panels/graph/module.js rename to public/app/plugins/panels/graph/module.js index ff9633d0576..817a924d965 100644 --- a/public/app/panels/graph/module.js +++ b/public/app/plugins/panels/graph/module.js @@ -17,7 +17,7 @@ function (angular, _, moment, kbn, TimeSeries, PanelMeta) { module.directive('grafanaPanelGraph', function() { return { controller: 'GraphCtrl', - templateUrl: 'app/panels/graph/module.html', + templateUrl: 'app/plugins/panels/graph/module.html', }; }); @@ -30,8 +30,8 @@ function (angular, _, moment, kbn, TimeSeries, PanelMeta) { metricsEditor: true, }); - $scope.panelMeta.addEditorTab('Axes & Grid', 'app/panels/graph/axisEditor.html'); - $scope.panelMeta.addEditorTab('Display Styles', 'app/panels/graph/styleEditor.html'); + $scope.panelMeta.addEditorTab('Axes & Grid', 'app/plugins/panels/graph/axisEditor.html'); + $scope.panelMeta.addEditorTab('Display Styles', 'app/plugins/panels/graph/styleEditor.html'); $scope.panelMeta.addEditorTab('Time range', 'app/features/panel/partials/panelTime.html'); $scope.panelMeta.addExtendedMenuItem('Export CSV', '', 'exportCsv()'); diff --git a/public/app/plugins/panels/graph/plugin.json b/public/app/plugins/panels/graph/plugin.json new file mode 100644 index 00000000000..8b683c9d750 --- /dev/null +++ b/public/app/plugins/panels/graph/plugin.json @@ -0,0 +1,8 @@ +{ + "pluginType": "panel", + + "name": "Graph", + "type": "graph", + + "module": "app/plugins/panels/graph/module" +} diff --git a/public/app/panels/graph/seriesOverridesCtrl.js b/public/app/plugins/panels/graph/seriesOverridesCtrl.js similarity index 100% rename from public/app/panels/graph/seriesOverridesCtrl.js rename to public/app/plugins/panels/graph/seriesOverridesCtrl.js diff --git a/public/app/panels/graph/styleEditor.html b/public/app/plugins/panels/graph/styleEditor.html similarity index 100% rename from public/app/panels/graph/styleEditor.html rename to public/app/plugins/panels/graph/styleEditor.html diff --git a/public/app/panels/singlestat/editor.html b/public/app/plugins/panels/singlestat/editor.html similarity index 100% rename from public/app/panels/singlestat/editor.html rename to public/app/plugins/panels/singlestat/editor.html diff --git a/public/app/panels/singlestat/module.html b/public/app/plugins/panels/singlestat/module.html similarity index 100% rename from public/app/panels/singlestat/module.html rename to public/app/plugins/panels/singlestat/module.html diff --git a/public/app/panels/singlestat/module.js b/public/app/plugins/panels/singlestat/module.js similarity index 97% rename from public/app/panels/singlestat/module.js rename to public/app/plugins/panels/singlestat/module.js index 18d02e166f1..47b18ba526f 100644 --- a/public/app/panels/singlestat/module.js +++ b/public/app/plugins/panels/singlestat/module.js @@ -16,7 +16,7 @@ function (angular, app, _, kbn, TimeSeries, PanelMeta) { module.directive('grafanaPanelSinglestat', function() { return { controller: 'SingleStatCtrl', - templateUrl: 'app/panels/singlestat/module.html', + templateUrl: 'app/plugins/panels/singlestat/module.html', }; }); @@ -31,7 +31,7 @@ function (angular, app, _, kbn, TimeSeries, PanelMeta) { $scope.fontSizes = ['20%', '30%','50%','70%','80%','100%', '110%', '120%', '150%', '170%', '200%']; - $scope.panelMeta.addEditorTab('Options', 'app/panels/singlestat/editor.html'); + $scope.panelMeta.addEditorTab('Options', 'app/plugins/panels/singlestat/editor.html'); $scope.panelMeta.addEditorTab('Time range', 'app/features/panel/partials/panelTime.html'); // Set and populate defaults diff --git a/public/app/plugins/panels/singlestat/plugin.json b/public/app/plugins/panels/singlestat/plugin.json new file mode 100644 index 00000000000..dfb38d615c7 --- /dev/null +++ b/public/app/plugins/panels/singlestat/plugin.json @@ -0,0 +1,8 @@ +{ + "pluginType": "panel", + + "name": "Singlestat", + "type": "singlestat", + + "module": "app/plugins/panels/singlestat/module" +} diff --git a/public/app/panels/singlestat/singleStatPanel.js b/public/app/plugins/panels/singlestat/singleStatPanel.js similarity index 100% rename from public/app/panels/singlestat/singleStatPanel.js rename to public/app/plugins/panels/singlestat/singleStatPanel.js diff --git a/public/app/panels/table/controller.ts b/public/app/plugins/panels/table/controller.ts similarity index 96% rename from public/app/panels/table/controller.ts rename to public/app/plugins/panels/table/controller.ts index 54deb28b11e..8116344f1de 100644 --- a/public/app/panels/table/controller.ts +++ b/public/app/plugins/panels/table/controller.ts @@ -1,4 +1,4 @@ -/// +/// import angular = require('angular'); import _ = require('lodash'); @@ -21,7 +21,7 @@ export class TablePanelCtrl { metricsEditor: true, }); - $scope.panelMeta.addEditorTab('Options', 'app/panels/table/options.html'); + $scope.panelMeta.addEditorTab('Options', 'app/plugins/panels/table/options.html'); $scope.panelMeta.addEditorTab('Time range', 'app/features/panel/partials/panelTime.html'); var panelDefaults = { diff --git a/public/app/panels/table/editor.html b/public/app/plugins/panels/table/editor.html similarity index 100% rename from public/app/panels/table/editor.html rename to public/app/plugins/panels/table/editor.html diff --git a/public/app/panels/table/editor.ts b/public/app/plugins/panels/table/editor.ts similarity index 98% rename from public/app/panels/table/editor.ts rename to public/app/plugins/panels/table/editor.ts index 978b37bc235..8d120856fdc 100644 --- a/public/app/panels/table/editor.ts +++ b/public/app/plugins/panels/table/editor.ts @@ -1,5 +1,4 @@ -/// - +/// import angular = require('angular'); import $ = require('jquery'); @@ -122,4 +121,3 @@ export function tablePanelEditor($q, uiSegmentSrv) { controller: TablePanelEditorCtrl, }; } - diff --git a/public/app/panels/table/module.html b/public/app/plugins/panels/table/module.html similarity index 100% rename from public/app/panels/table/module.html rename to public/app/plugins/panels/table/module.html diff --git a/public/app/panels/table/module.ts b/public/app/plugins/panels/table/module.ts similarity index 96% rename from public/app/panels/table/module.ts rename to public/app/plugins/panels/table/module.ts index a1cfdef211e..3f6f1002018 100644 --- a/public/app/panels/table/module.ts +++ b/public/app/plugins/panels/table/module.ts @@ -1,4 +1,4 @@ -/// +/// import angular = require('angular'); import $ = require('jquery'); @@ -14,7 +14,7 @@ export function tablePanel() { 'use strict'; return { restrict: 'E', - templateUrl: 'app/panels/table/module.html', + templateUrl: 'app/plugins/panels/table/module.html', controller: TablePanelCtrl, link: function(scope, elem) { var data; diff --git a/public/app/panels/table/options.html b/public/app/plugins/panels/table/options.html similarity index 100% rename from public/app/panels/table/options.html rename to public/app/plugins/panels/table/options.html diff --git a/public/app/plugins/panels/table/plugin.json b/public/app/plugins/panels/table/plugin.json new file mode 100644 index 00000000000..cdcfb7081dc --- /dev/null +++ b/public/app/plugins/panels/table/plugin.json @@ -0,0 +1,8 @@ +{ + "pluginType": "panel", + + "name": "Table", + "type": "table", + + "module": "app/plugins/panels/table/module" +} diff --git a/public/app/panels/table/renderer.ts b/public/app/plugins/panels/table/renderer.ts similarity index 98% rename from public/app/panels/table/renderer.ts rename to public/app/plugins/panels/table/renderer.ts index c782cce3579..530fb367521 100644 --- a/public/app/panels/table/renderer.ts +++ b/public/app/plugins/panels/table/renderer.ts @@ -1,4 +1,4 @@ -/// +/// import _ = require('lodash'); import kbn = require('app/core/utils/kbn'); diff --git a/public/app/panels/table/specs/renderer_specs.ts b/public/app/plugins/panels/table/specs/renderer_specs.ts similarity index 100% rename from public/app/panels/table/specs/renderer_specs.ts rename to public/app/plugins/panels/table/specs/renderer_specs.ts diff --git a/public/app/panels/table/specs/table_model_specs.ts b/public/app/plugins/panels/table/specs/table_model_specs.ts similarity index 100% rename from public/app/panels/table/specs/table_model_specs.ts rename to public/app/plugins/panels/table/specs/table_model_specs.ts diff --git a/public/app/panels/table/specs/transformers_specs.ts b/public/app/plugins/panels/table/specs/transformers_specs.ts similarity index 100% rename from public/app/panels/table/specs/transformers_specs.ts rename to public/app/plugins/panels/table/specs/transformers_specs.ts diff --git a/public/app/plugins/panels/table/table_model.ts b/public/app/plugins/panels/table/table_model.ts new file mode 100644 index 00000000000..1fa4007e6e3 --- /dev/null +++ b/public/app/plugins/panels/table/table_model.ts @@ -0,0 +1,52 @@ +import {transformers} from './transformers'; + +export class TableModel { + columns: any[]; + rows: any[]; + + constructor() { + this.columns = []; + this.rows = []; + } + + sort(options) { + if (options.col === null || this.columns.length <= options.col) { + return; + } + + this.rows.sort(function(a, b) { + a = a[options.col]; + b = b[options.col]; + if (a < b) { + return -1; + } + if (a > b) { + return 1; + } + return 0; + }); + + this.columns[options.col].sort = true; + + if (options.desc) { + this.rows.reverse(); + this.columns[options.col].desc = true; + } + } + + static transform(data, panel) { + var model = new TableModel(); + + if (!data || data.length === 0) { + return model; + } + + var transformer = transformers[panel.transform]; + if (!transformer) { + throw {message: 'Transformer ' + panel.transformer + ' not found'}; + } + + transformer.transform(data, panel, model); + return model; + } +} diff --git a/public/app/panels/table/transformers.ts b/public/app/plugins/panels/table/transformers.ts similarity index 99% rename from public/app/panels/table/transformers.ts rename to public/app/plugins/panels/table/transformers.ts index 843eb83c034..97650977f84 100644 --- a/public/app/panels/table/transformers.ts +++ b/public/app/plugins/panels/table/transformers.ts @@ -1,4 +1,4 @@ -/// +/// import moment = require('moment'); import _ = require('lodash'); diff --git a/public/app/panels/text/editor.html b/public/app/plugins/panels/text/editor.html similarity index 100% rename from public/app/panels/text/editor.html rename to public/app/plugins/panels/text/editor.html diff --git a/public/app/panels/text/module.html b/public/app/plugins/panels/text/module.html similarity index 100% rename from public/app/panels/text/module.html rename to public/app/plugins/panels/text/module.html diff --git a/public/app/panels/text/module.js b/public/app/plugins/panels/text/module.js similarity index 92% rename from public/app/panels/text/module.js rename to public/app/plugins/panels/text/module.js index c301690d2eb..145b3be9b0c 100644 --- a/public/app/panels/text/module.js +++ b/public/app/plugins/panels/text/module.js @@ -16,7 +16,7 @@ function (angular, app, _, require, PanelMeta) { module.directive('grafanaPanelText', function() { return { controller: 'TextPanelCtrl', - templateUrl: 'app/panels/text/module.html', + templateUrl: 'app/plugins/panels/text/module.html', }; }); @@ -28,7 +28,7 @@ function (angular, app, _, require, PanelMeta) { fullscreen: true, }); - $scope.panelMeta.addEditorTab('Edit text', 'app/panels/text/editor.html'); + $scope.panelMeta.addEditorTab('Edit text', 'app/plugins/panels/text/editor.html'); // Set and populate defaults var _d = { @@ -84,7 +84,7 @@ function (angular, app, _, require, PanelMeta) { $scope.updateContent(converter.makeHtml(text)); } else { - require(['./lib/showdown'], function (Showdown) { + require(['vendor/showdown'], function (Showdown) { converter = new Showdown.converter(); $scope.updateContent(converter.makeHtml(text)); }); diff --git a/public/app/plugins/panels/text/plugin.json b/public/app/plugins/panels/text/plugin.json new file mode 100644 index 00000000000..4a6c039104b --- /dev/null +++ b/public/app/plugins/panels/text/plugin.json @@ -0,0 +1,8 @@ +{ + "pluginType": "panel", + + "name": "Text", + "type": "text", + + "module": "app/plugins/panels/text/module" +} diff --git a/public/app/plugins/plugin.json b/public/app/plugins/plugin.json new file mode 100644 index 00000000000..d7356c7b8ad --- /dev/null +++ b/public/app/plugins/plugin.json @@ -0,0 +1,9 @@ +{ + "pluginType": "bundle", + "type": "core", + "module": "", + "enabled": true, + "panelPlugins": ["graph", "singlestat", "text", "dashlist", "table"], + "datasourcePlugins": ["mixed", "grafana", "graphite", "cloudwatch", "elasticsearch", "influxdb", "influxdb_08", "kairosdb", "opentsdb", "prometheus"], + "externalPlugins": [] +} diff --git a/public/app/plugins/PLUGIN_CHANGES.md b/public/app/plugins/plugin_api.md similarity index 100% rename from public/app/plugins/PLUGIN_CHANGES.md rename to public/app/plugins/plugin_api.md diff --git a/public/test/specs/graph-ctrl-specs.js b/public/test/specs/graph-ctrl-specs.js index a1f5809dee4..81be9a40b46 100644 --- a/public/test/specs/graph-ctrl-specs.js +++ b/public/test/specs/graph-ctrl-specs.js @@ -2,7 +2,7 @@ define([ './helpers', 'app/features/panel/panel_srv', 'app/features/panel/panel_helper', - 'app/panels/graph/module' + 'app/plugins/panels/graph/module' ], function(helpers) { 'use strict'; diff --git a/public/test/specs/graph-specs.js b/public/test/specs/graph-specs.js index 3a596ce4369..60637296564 100644 --- a/public/test/specs/graph-specs.js +++ b/public/test/specs/graph-specs.js @@ -3,7 +3,7 @@ define([ 'angular', 'jquery', 'app/core/time_series', - 'app/panels/graph/graph' + 'app/plugins/panels/graph/graph' ], function(helpers, angular, $, TimeSeries) { 'use strict'; diff --git a/public/test/specs/graph-tooltip-specs.js b/public/test/specs/graph-tooltip-specs.js index 864e87d07f8..9dc84daefa3 100644 --- a/public/test/specs/graph-tooltip-specs.js +++ b/public/test/specs/graph-tooltip-specs.js @@ -1,6 +1,6 @@ define([ 'jquery', - 'app/panels/graph/graph.tooltip' + 'app/plugins/panels/graph/graph.tooltip' ], function($, GraphTooltip) { 'use strict'; diff --git a/public/test/specs/seriesOverridesCtrl-specs.js b/public/test/specs/seriesOverridesCtrl-specs.js index 96b6d9ec6a3..1290e5f0987 100644 --- a/public/test/specs/seriesOverridesCtrl-specs.js +++ b/public/test/specs/seriesOverridesCtrl-specs.js @@ -1,6 +1,6 @@ define([ './helpers', - 'app/panels/graph/seriesOverridesCtrl' + 'app/plugins/panels/graph/seriesOverridesCtrl' ], function(helpers) { 'use strict'; diff --git a/public/test/specs/singlestat-specs.js b/public/test/specs/singlestat-specs.js index aff33396687..14e1ca63cca 100644 --- a/public/test/specs/singlestat-specs.js +++ b/public/test/specs/singlestat-specs.js @@ -2,7 +2,7 @@ define([ './helpers', 'app/features/panel/panel_srv', 'app/features/panel/panel_helper', - 'app/panels/singlestat/module' + 'app/plugins/panels/singlestat/module' ], function(helpers) { 'use strict'; diff --git a/public/test/test-main.js b/public/test/test-main.js index 119f1875475..0cdbaac20e5 100644 --- a/public/test/test-main.js +++ b/public/test/test-main.js @@ -95,6 +95,8 @@ function file2moduleName(filePath) { .replace(/\.\w*$/, ''); } +window.grafanaBootData = {settings: {}}; + require([ 'lodash', 'angular', diff --git a/public/app/panels/text/lib/showdown.js b/public/vendor/showdown.js similarity index 100% rename from public/app/panels/text/lib/showdown.js rename to public/vendor/showdown.js diff --git a/public/views/index.html b/public/views/index.html index b9a1bc3c825..b1bffae89ee 100644 --- a/public/views/index.html +++ b/public/views/index.html @@ -10,10 +10,18 @@ [[if .User.LightTheme]] + [[ range $css := .PluginCss ]] + + [[ end ]] [[else]] + [[ range $css := .PluginCss ]] + + [[ end ]] [[end]] + + @@ -50,10 +58,12 @@ window.grafanaBootData = { user:[[.User]], settings: [[.Settings]], + pluginModules: [[.PluginJs]], + mainNavLinks: [[.MainNavLinks]] }; require(['app/app'], function (app) { - app.boot(); + app.boot(); }) diff --git a/tasks/options/htmlmin.js b/tasks/options/htmlmin.js index 2fa6a769a09..af8677452e7 100644 --- a/tasks/options/htmlmin.js +++ b/tasks/options/htmlmin.js @@ -8,9 +8,7 @@ module.exports = function(config) { expand: true, cwd: '<%= genDir %>', src: [ - //'index.html', - 'app/panels/**/*.html', - 'app/partials/**/*.html' + 'app/**/*.html', ], dest: '<%= genDir %>' } diff --git a/tasks/options/jscs.js b/tasks/options/jscs.js index 7a5dee05778..c27c1aff09d 100644 --- a/tasks/options/jscs.js +++ b/tasks/options/jscs.js @@ -4,7 +4,6 @@ module.exports = function(config) { 'Gruntfile.js', '<%= srcDir %>/app/**/*.js', '<%= srcDir %>/plugins/**/*.js', - '!<%= srcDir %>/app/panels/*/{lib,leaflet}/*', '!<%= srcDir %>/app/dashboards/*' ], options: { @@ -20,4 +19,4 @@ module.exports = function(config) { "disallowRightStickedOperators": ["?", "+", "/", "*", ":", "=", "==", "===", "!=", "!==", ">", ">=", "<", "<="], "requireRightStickedOperators": ["!"], "requireLeftStickedOperators": [","], - */ \ No newline at end of file + */ diff --git a/tasks/options/jshint.js b/tasks/options/jshint.js index 60c212e785d..8e4969c37c0 100644 --- a/tasks/options/jshint.js +++ b/tasks/options/jshint.js @@ -18,9 +18,8 @@ module.exports = function(config) { 'dist/*', 'sample/*', '<%= srcDir %>/vendor/*', - '<%= srcDir %>/app/panels/*/{lib,leaflet}/*', '<%= srcDir %>/app/dashboards/*' ] } }; -}; \ No newline at end of file +}; diff --git a/tasks/options/requirejs.js b/tasks/options/requirejs.js index 9d0522bd349..fd6603e16f4 100644 --- a/tasks/options/requirejs.js +++ b/tasks/options/requirejs.js @@ -62,11 +62,11 @@ module.exports = function(config,grunt) { ]; var fs = require('fs'); - var panelPath = config.srcDir + '/app/panels'; + var panelPath = config.srcDir + '/app/plugins/panels'; // create a module for each directory in public/app/panels/ fs.readdirSync(panelPath).forEach(function (panelName) { - requireModules[0].include.push('app/panels/'+panelName+'/module'); + requireModules[0].include.push('app/plugins/panels/'+panelName+'/module'); }); return { options: options };