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 @@ +
Type | ++ | + |
+ + {{p.type}} + | ++ + + Edit + + | ++ Enabled + + + | +