diff --git a/pkg/api/api.go b/pkg/api/api.go index 2155978fc81..ec256bb5c52 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -41,8 +41,8 @@ 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("/apps", reqSignedIn, Index) + r.Get("/apps/edit/*", reqSignedIn, Index) r.Get("/dashboard/*", reqSignedIn, Index) r.Get("/dashboard-solo/*", reqSignedIn, Index) @@ -120,6 +120,11 @@ 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)) + + // apps + r.Get("/apps", wrap(GetOrgAppsList)) + r.Get("/apps/:appId/settings", wrap(GetAppSettingsById)) + r.Post("/apps/:appId/settings", bind(m.UpdateAppSettingsCmd{}), wrap(UpdateAppSettings)) }, reqOrgAdmin) // create new org @@ -205,5 +210,7 @@ func Register(r *macaron.Macaron) { // rendering r.Get("/render/*", reqSignedIn, RenderToPng) + InitApiPluginRoutes(r) + r.NotFound(NotFoundHandler) } diff --git a/pkg/api/api_plugin.go b/pkg/api/api_plugin.go new file mode 100644 index 00000000000..a368e9f1073 --- /dev/null +++ b/pkg/api/api_plugin.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 InitApiPluginRoutes(r *macaron.Macaron) { + for _, plugin := range plugins.ApiPlugins { + log.Info("Plugin: Adding proxy routes for api 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, ApiPlugin(route.Url)) + r.Route(url, route.Method, handlers...) + log.Info("Plugin: Adding route %s", url) + } + } +} + +func ApiPlugin(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 := NewApiPluginProxy(string(ctx), path, targetUrl) + proxy.Transport = dataProxyTransport + proxy.ServeHTTP(c.RW(), c.Req.Request) + } +} + +func NewApiPluginProxy(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/app_settings.go b/pkg/api/app_settings.go new file mode 100644 index 00000000000..fd0f1a1eab1 --- /dev/null +++ b/pkg/api/app_settings.go @@ -0,0 +1,59 @@ +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 GetOrgAppsList(c *middleware.Context) Response { + orgApps, err := plugins.GetOrgAppSettings(c.OrgId) + + if err != nil { + return ApiError(500, "Failed to list of apps", err) + } + + result := make([]*dtos.AppSettings, 0) + for _, app := range plugins.Apps { + orgApp := orgApps[app.Id] + result = append(result, dtos.NewAppSettingsDto(app, orgApp)) + } + + return Json(200, result) +} + +func GetAppSettingsById(c *middleware.Context) Response { + appId := c.Params(":appId") + + if pluginDef, exists := plugins.Apps[appId]; !exists { + return ApiError(404, "PluginId not found, no installed plugin with that id", nil) + } else { + orgApps, err := plugins.GetOrgAppSettings(c.OrgId) + if err != nil { + return ApiError(500, "Failed to get org app settings ", nil) + } + orgApp := orgApps[appId] + + return Json(200, dtos.NewAppSettingsDto(pluginDef, orgApp)) + } +} + +func UpdateAppSettings(c *middleware.Context, cmd m.UpdateAppSettingsCmd) Response { + appId := c.Params(":appId") + + cmd.OrgId = c.OrgId + cmd.AppId = appId + + if _, ok := plugins.Apps[cmd.AppId]; !ok { + return ApiError(404, "App type not installed.", nil) + } + + err := bus.Dispatch(&cmd) + if err != nil { + return ApiError(500, "Failed to update App Plugin", err) + } + + return ApiSuccess("App updated") +} diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index bef24d61d24..54959840d03 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -3,6 +3,7 @@ package api import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" + //"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" @@ -115,13 +116,19 @@ func UpdateDataSource(c *middleware.Context, cmd m.UpdateDataSourceCommand) { } func GetDataSourcePlugins(c *middleware.Context) { - dsList := make(map[string]interface{}) + dsList := make(map[string]*plugins.DataSourcePlugin) - for key, value := range plugins.DataSources { - if !value.BuiltIn { - dsList[key] = value + if enabledPlugins, err := plugins.GetEnabledPlugins(c.OrgId); err != nil { + c.JsonApiErr(500, "Failed to get org apps", err) + return + } else { + + for key, value := range enabledPlugins.DataSources { + if !value.BuiltIn { + dsList[key] = value + } } - } - c.JSON(200, dsList) + c.JSON(200, dsList) + } } diff --git a/pkg/api/dtos/apps.go b/pkg/api/dtos/apps.go new file mode 100644 index 00000000000..c520b6921a1 --- /dev/null +++ b/pkg/api/dtos/apps.go @@ -0,0 +1,33 @@ +package dtos + +import ( + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" +) + +type AppSettings struct { + Name string `json:"name"` + AppId string `json:"appId"` + Enabled bool `json:"enabled"` + Pinned bool `json:"pinned"` + Info *plugins.PluginInfo `json:"info"` + Pages []*plugins.AppPluginPage `json:"pages"` + JsonData map[string]interface{} `json:"jsonData"` +} + +func NewAppSettingsDto(def *plugins.AppPlugin, data *models.AppSettings) *AppSettings { + dto := &AppSettings{ + AppId: def.Id, + Name: def.Name, + Info: &def.Info, + Pages: def.Pages, + } + + if data != nil { + dto.Enabled = data.Enabled + dto.Pinned = data.Pinned + dto.Info = &def.Info + } + + return dto +} diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index 1314d2d94ac..c5b81ee0e98 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -8,9 +8,9 @@ type IndexViewData struct { GoogleAnalyticsId string GoogleTagManagerId string - PluginCss []*PluginCss - PluginJs []string - MainNavLinks []*NavLink + PluginCss []*PluginCss + PluginModules []string + MainNavLinks []*NavLink } type PluginCss struct { @@ -21,5 +21,6 @@ type PluginCss struct { type NavLink struct { Text string `json:"text"` Icon string `json:"icon"` - Href string `json:"href"` + Img string `json:"img"` + Url string `json:"url"` } diff --git a/pkg/api/dtos/plugin_bundle.go b/pkg/api/dtos/plugin_bundle.go deleted file mode 100644 index f043da39904..00000000000 --- a/pkg/api/dtos/plugin_bundle.go +++ /dev/null @@ -1,8 +0,0 @@ -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/frontendsettings.go b/pkg/api/frontendsettings.go index 5cba3258122..0f9cdee02a9 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -29,6 +29,11 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro datasources := make(map[string]interface{}) var defaultDatasource string + enabledPlugins, err := plugins.GetEnabledPlugins(c.OrgId) + if err != nil { + return nil, err + } + for _, ds := range orgDataSources { url := ds.Url @@ -42,7 +47,7 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro "url": url, } - meta, exists := plugins.DataSources[ds.Type] + meta, exists := enabledPlugins.DataSources[ds.Type] if !exists { log.Error(3, "Could not find plugin definition for data source: %v", ds.Type) continue @@ -110,8 +115,8 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro } panels := map[string]interface{}{} - for _, panel := range plugins.Panels { - panels[panel.Type] = map[string]interface{}{ + for _, panel := range enabledPlugins.Panels { + panels[panel.Id] = map[string]interface{}{ "module": panel.Module, "name": panel.Name, } diff --git a/pkg/api/index.go b/pkg/api/index.go index ff5a923ebba..df885fdb2f4 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -4,6 +4,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "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" ) @@ -50,7 +51,7 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{ Text: "Dashboards", Icon: "fa fa-fw fa-th-large", - Href: "/", + Url: "/", }) data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{ @@ -63,8 +64,37 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{ Text: "Data Sources", Icon: "fa fa-fw fa-database", - Href: "/datasources", + Url: "/datasources", }) + + data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{ + Text: "Apps", + Icon: "fa fa-fw fa-cubes", + Url: "/apps", + }) + } + + enabledPlugins, err := plugins.GetEnabledPlugins(c.OrgId) + if err != nil { + return nil, err + } + + for _, plugin := range enabledPlugins.Apps { + if plugin.Module != "" { + data.PluginModules = append(data.PluginModules, plugin.Module) + } + + if plugin.Css != nil { + data.PluginCss = append(data.PluginCss, &dtos.PluginCss{Light: plugin.Css.Light, Dark: plugin.Css.Dark}) + } + + if plugin.Pinned { + data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{ + Text: plugin.Name, + Url: "/apps/edit/" + plugin.Id, + Img: plugin.Info.Logos.Small, + }) + } } return &data, nil diff --git a/pkg/cmd/web.go b/pkg/cmd/web.go index 1debde98a0e..ff7f8a053bc 100644 --- a/pkg/cmd/web.go +++ b/pkg/cmd/web.go @@ -30,9 +30,9 @@ func newMacaron() *macaron.Macaron { } 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) + pluginRoute := path.Join("/public/plugins/", route.PluginId) + log.Info("Plugin: Adding static route %s -> %s", pluginRoute, route.Directory) + mapStatic(m, route.Directory, "", pluginRoute) } mapStatic(m, setting.StaticRootPath, "", "public") diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 8704ec5a787..9ad417606f5 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -253,3 +253,7 @@ func (ctx *Context) JsonApiErr(status int, message string, err error) { ctx.JSON(status, resp) } + +func (ctx *Context) HasUserRole(role m.RoleType) bool { + return ctx.OrgRole.Includes(role) +} diff --git a/pkg/models/plugin_bundle.go b/pkg/models/app_settings.go similarity index 59% rename from pkg/models/plugin_bundle.go rename to pkg/models/app_settings.go index 5f4e508b9b2..558946d0277 100644 --- a/pkg/models/plugin_bundle.go +++ b/pkg/models/app_settings.go @@ -2,11 +2,12 @@ package models import "time" -type PluginBundle struct { +type AppSettings struct { Id int64 - Type string + AppId string OrgId int64 Enabled bool + Pinned bool JsonData map[string]interface{} Created time.Time @@ -17,18 +18,18 @@ type PluginBundle struct { // COMMANDS // Also acts as api DTO -type UpdatePluginBundleCmd struct { - Type string `json:"type" binding:"Required"` +type UpdateAppSettingsCmd struct { Enabled bool `json:"enabled"` + Pinned bool `json:"pinned"` JsonData map[string]interface{} `json:"jsonData"` - Id int64 `json:"-"` - OrgId int64 `json:"-"` + AppId string `json:"-"` + OrgId int64 `json:"-"` } // --------------------- // QUERIES -type GetPluginBundlesQuery struct { +type GetAppSettingsQuery struct { OrgId int64 - Result []*PluginBundle + Result []*AppSettings } diff --git a/pkg/models/org_user.go b/pkg/models/org_user.go index f456fa95373..48d17deb9db 100644 --- a/pkg/models/org_user.go +++ b/pkg/models/org_user.go @@ -26,6 +26,17 @@ func (r RoleType) IsValid() bool { return r == ROLE_VIEWER || r == ROLE_ADMIN || r == ROLE_EDITOR || r == ROLE_READ_ONLY_EDITOR } +func (r RoleType) Includes(other RoleType) bool { + if r == ROLE_ADMIN { + return true + } + if r == ROLE_EDITOR || r == ROLE_READ_ONLY_EDITOR { + return other != ROLE_ADMIN + } + + return r == other +} + type OrgUser struct { Id int64 OrgId int64 diff --git a/pkg/plugins/app_plugin.go b/pkg/plugins/app_plugin.go new file mode 100644 index 00000000000..9580f0024f4 --- /dev/null +++ b/pkg/plugins/app_plugin.go @@ -0,0 +1,43 @@ +package plugins + +import ( + "encoding/json" + + "github.com/grafana/grafana/pkg/models" +) + +type AppPluginPage struct { + Name string `json:"name"` + Url string `json:"url"` + ReqRole models.RoleType `json:"reqRole"` +} + +type AppPluginCss struct { + Light string `json:"light"` + Dark string `json:"dark"` +} + +type AppPlugin struct { + FrontendPluginBase + Css *AppPluginCss `json:"css"` + Pages []*AppPluginPage `json:"pages"` + + Pinned bool `json:"-"` + Enabled bool `json:"-"` +} + +func (app *AppPlugin) Load(decoder *json.Decoder, pluginDir string) error { + if err := decoder.Decode(&app); err != nil { + return err + } + + if app.Css != nil { + app.Css.Dark = evalRelativePluginUrlPath(app.Css.Dark, app.Id) + app.Css.Light = evalRelativePluginUrlPath(app.Css.Light, app.Id) + } + + app.PluginDir = pluginDir + app.initFrontendPlugin() + Apps[app.Id] = app + return nil +} diff --git a/pkg/plugins/datasource_plugin.go b/pkg/plugins/datasource_plugin.go new file mode 100644 index 00000000000..e1bb9047297 --- /dev/null +++ b/pkg/plugins/datasource_plugin.go @@ -0,0 +1,25 @@ +package plugins + +import "encoding/json" + +type DataSourcePlugin struct { + FrontendPluginBase + DefaultMatchFormat string `json:"defaultMatchFormat"` + Annotations bool `json:"annotations"` + Metrics bool `json:"metrics"` + BuiltIn bool `json:"builtIn"` + Mixed bool `json:"mixed"` + App string `json:"app"` +} + +func (p *DataSourcePlugin) Load(decoder *json.Decoder, pluginDir string) error { + if err := decoder.Decode(&p); err != nil { + return err + } + + p.PluginDir = pluginDir + p.initFrontendPlugin() + DataSources[p.Id] = p + + return nil +} diff --git a/pkg/plugins/frontend_plugin.go b/pkg/plugins/frontend_plugin.go new file mode 100644 index 00000000000..0f0d1676066 --- /dev/null +++ b/pkg/plugins/frontend_plugin.go @@ -0,0 +1,48 @@ +package plugins + +import ( + "net/url" + "path" + "path/filepath" +) + +type FrontendPluginBase struct { + PluginBase + Module string `json:"module"` + StaticRoot string `json:"staticRoot"` +} + +func (fp *FrontendPluginBase) initFrontendPlugin() { + if fp.StaticRoot != "" { + StaticRoutes = append(StaticRoutes, &PluginStaticRoute{ + Directory: filepath.Join(fp.PluginDir, fp.StaticRoot), + PluginId: fp.Id, + }) + } + + fp.Info.Logos.Small = evalRelativePluginUrlPath(fp.Info.Logos.Small, fp.Id) + fp.Info.Logos.Large = evalRelativePluginUrlPath(fp.Info.Logos.Large, fp.Id) + + fp.handleModuleDefaults() +} + +func (fp *FrontendPluginBase) handleModuleDefaults() { + if fp.Module != "" { + return + } + + if fp.StaticRoot != "" { + fp.Module = path.Join("plugins", fp.Id, "module") + return + } + + fp.Module = path.Join("app/plugins", fp.Type, fp.Id, "module") +} + +func evalRelativePluginUrlPath(pathStr string, pluginId string) string { + u, _ := url.Parse(pathStr) + if u.IsAbs() { + return pathStr + } + return path.Join("public/plugins", pluginId, pathStr) +} diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index 889229e79b9..65bd0ef1f2d 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -1,26 +1,74 @@ package plugins -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"` +import ( + "encoding/json" + + "github.com/grafana/grafana/pkg/models" +) + +type PluginLoader interface { + Load(decoder *json.Decoder, pluginDir string) error } -type PanelPlugin struct { - Type string `json:"type"` - Name string `json:"name"` - Module string `json:"module"` - StaticRootConfig *StaticRootConfig `json:"staticRoot"` +type PluginBase struct { + Type string `json:"type"` + Name string `json:"name"` + Id string `json:"id"` + App string `json:"app"` + Info PluginInfo `json:"info"` + PluginDir string `json:"-"` } -type StaticRootConfig struct { +type PluginInfo struct { + Author PluginInfoLink `json:"author"` + Description string `json:"description"` + Links []PluginInfoLink `json:"links"` + Logos PluginLogos `json:"logos"` + Version string `json:"version"` + Updated string `json:"updated"` +} + +type PluginInfoLink struct { + Name string `json:"name"` Url string `json:"url"` - Path string `json:"path"` +} + +type PluginLogos struct { + Small string `json:"small"` + Large string `json:"large"` +} + +type PluginStaticRoute struct { + Directory string + PluginId string +} + +type ApiPluginRoute 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 ApiPlugin struct { + PluginBase + Routes []*ApiPluginRoute `json:"routes"` +} + +type EnabledPlugins struct { + Panels []*PanelPlugin + DataSources map[string]*DataSourcePlugin + ApiList []*ApiPlugin + Apps []*AppPlugin +} + +func NewEnabledPlugins() EnabledPlugins { + return EnabledPlugins{ + Panels: make([]*PanelPlugin, 0), + DataSources: make(map[string]*DataSourcePlugin), + ApiList: make([]*ApiPlugin, 0), + Apps: make([]*AppPlugin, 0), + } } diff --git a/pkg/plugins/panel_plugin.go b/pkg/plugins/panel_plugin.go new file mode 100644 index 00000000000..5b99ac52344 --- /dev/null +++ b/pkg/plugins/panel_plugin.go @@ -0,0 +1,19 @@ +package plugins + +import "encoding/json" + +type PanelPlugin struct { + FrontendPluginBase +} + +func (p *PanelPlugin) Load(decoder *json.Decoder, pluginDir string) error { + if err := decoder.Decode(&p); err != nil { + return err + } + + p.PluginDir = pluginDir + p.initFrontendPlugin() + Panels[p.Id] = p + + return nil +} diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 23b927e5ea9..1b945141f6a 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -1,12 +1,16 @@ package plugins import ( + "bytes" "encoding/json" "errors" + "io" "os" "path" "path/filepath" + "reflect" "strings" + "text/template" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/setting" @@ -14,9 +18,12 @@ import ( ) var ( - DataSources map[string]DataSourcePlugin - Panels map[string]PanelPlugin - StaticRoutes []*StaticRootConfig + DataSources map[string]*DataSourcePlugin + Panels map[string]*PanelPlugin + ApiPlugins map[string]*ApiPlugin + StaticRoutes []*PluginStaticRoute + Apps map[string]*AppPlugin + PluginTypes map[string]interface{} ) type PluginScanner struct { @@ -25,18 +32,45 @@ type PluginScanner struct { } func Init() error { - DataSources = make(map[string]DataSourcePlugin) - StaticRoutes = make([]*StaticRootConfig, 0) - Panels = make(map[string]PanelPlugin) + DataSources = make(map[string]*DataSourcePlugin) + ApiPlugins = make(map[string]*ApiPlugin) + StaticRoutes = make([]*PluginStaticRoute, 0) + Panels = make(map[string]*PanelPlugin) + Apps = make(map[string]*AppPlugin) + PluginTypes = map[string]interface{}{ + "panel": PanelPlugin{}, + "datasource": DataSourcePlugin{}, + "api": ApiPlugin{}, + "app": AppPlugin{}, + } scan(path.Join(setting.StaticRootPath, "app/plugins")) - scan(path.Join(setting.PluginsPath)) - checkExternalPluginPaths() - + checkPluginPaths() + // checkDependencies() return nil } -func checkExternalPluginPaths() error { +// func checkDependencies() { +// for appType, app := range Apps { +// for _, reqPanel := range app.PanelPlugins { +// if _, ok := Panels[reqPanel]; !ok { +// log.Fatal(4, "App %s requires Panel type %s, but it is not present.", appType, reqPanel) +// } +// } +// for _, reqDataSource := range app.DatasourcePlugins { +// if _, ok := DataSources[reqDataSource]; !ok { +// log.Fatal(4, "App %s requires DataSource type %s, but it is not present.", appType, reqDataSource) +// } +// } +// for _, reqApiPlugin := range app.ApiPlugins { +// if _, ok := ApiPlugins[reqApiPlugin]; !ok { +// log.Fatal(4, "App %s requires ApiPlugin type %s, but it is not present.", appType, reqApiPlugin) +// } +// } +// } +// } + +func checkPluginPaths() error { for _, section := range setting.Cfg.Sections() { if strings.HasPrefix(section.Name(), "plugin.") { path := section.Key("path").String() @@ -87,11 +121,26 @@ func (scanner *PluginScanner) walker(currentPath string, f os.FileInfo, err erro return nil } -func addStaticRoot(staticRootConfig *StaticRootConfig, currentDir string) { - if staticRootConfig != nil { - staticRootConfig.Path = path.Join(currentDir, staticRootConfig.Path) - StaticRoutes = append(StaticRoutes, staticRootConfig) +func interpolatePluginJson(reader io.Reader, pluginCommon *PluginBase) (io.Reader, error) { + buf := new(bytes.Buffer) + buf.ReadFrom(reader) + jsonStr := buf.String() // + + tmpl, err := template.New("json").Parse(jsonStr) + if err != nil { + return nil, err } + + data := map[string]interface{}{ + "PluginPublicRoot": "public/plugins/" + pluginCommon.Id, + } + + var resultBuffer bytes.Buffer + if err := tmpl.ExecuteTemplate(&resultBuffer, "json", data); err != nil { + return nil, err + } + + return bytes.NewReader(resultBuffer.Bytes()), nil } func (scanner *PluginScanner) loadPluginJson(pluginJsonFilePath string) error { @@ -104,46 +153,29 @@ func (scanner *PluginScanner) loadPluginJson(pluginJsonFilePath string) error { defer reader.Close() jsonParser := json.NewDecoder(reader) - - pluginJson := make(map[string]interface{}) - if err := jsonParser.Decode(&pluginJson); err != nil { + pluginCommon := PluginBase{} + if err := jsonParser.Decode(&pluginCommon); err != nil { return err } - pluginType, exists := pluginJson["pluginType"] - if !exists { - return errors.New("Did not find pluginType property in plugin.json") + if pluginCommon.Id == "" || pluginCommon.Type == "" { + return errors.New("Did not find type and id property in plugin.json") } - if pluginType == "datasource" { - 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[p.Type] = p - addStaticRoot(p.StaticRootConfig, currentDir) + reader.Seek(0, 0) + if newReader, err := interpolatePluginJson(reader, &pluginCommon); err != nil { + return err + } else { + jsonParser = json.NewDecoder(newReader) } - if pluginType == "panel" { - p := PanelPlugin{} - reader.Seek(0, 0) - if err := jsonParser.Decode(&p); err != nil { - return err - } + var loader PluginLoader - if p.Type == "" { - return errors.New("Did not find type property in plugin.json") - } - - Panels[p.Type] = p - addStaticRoot(p.StaticRootConfig, currentDir) + if pluginGoType, exists := PluginTypes[pluginCommon.Type]; !exists { + return errors.New("Unkown plugin type " + pluginCommon.Type) + } else { + loader = reflect.New(reflect.TypeOf(pluginGoType)).Interface().(PluginLoader) } - return nil + return loader.Load(jsonParser, currentDir) } diff --git a/pkg/plugins/plugins_test.go b/pkg/plugins/plugins_test.go index bbeac4bba81..1808f72ec0d 100644 --- a/pkg/plugins/plugins_test.go +++ b/pkg/plugins/plugins_test.go @@ -18,5 +18,22 @@ func TestPluginScans(t *testing.T) { So(err, ShouldBeNil) So(len(DataSources), ShouldBeGreaterThan, 1) + So(len(Panels), ShouldBeGreaterThan, 1) + + Convey("Should set module automatically", func() { + So(DataSources["graphite"].Module, ShouldEqual, "app/plugins/datasource/graphite/module") + }) }) + + Convey("When reading app plugin definition", t, func() { + setting.Cfg = ini.Empty() + sec, _ := setting.Cfg.NewSection("plugin.app-test") + sec.NewKey("path", "../../tests/app-plugin-json") + err := Init() + + So(err, ShouldBeNil) + So(len(Apps), ShouldBeGreaterThan, 0) + So(Apps["app-example"].Info.Logos.Large, ShouldEqual, "public/plugins/app-example/img/logo_large.png") + }) + } diff --git a/pkg/plugins/queries.go b/pkg/plugins/queries.go new file mode 100644 index 00000000000..889cbe654d5 --- /dev/null +++ b/pkg/plugins/queries.go @@ -0,0 +1,75 @@ +package plugins + +import ( + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" +) + +func GetOrgAppSettings(orgId int64) (map[string]*m.AppSettings, error) { + query := m.GetAppSettingsQuery{OrgId: orgId} + + if err := bus.Dispatch(&query); err != nil { + return nil, err + } + + orgAppsMap := make(map[string]*m.AppSettings) + for _, orgApp := range query.Result { + orgAppsMap[orgApp.AppId] = orgApp + } + + return orgAppsMap, nil +} + +func GetEnabledPlugins(orgId int64) (*EnabledPlugins, error) { + enabledPlugins := NewEnabledPlugins() + orgApps, err := GetOrgAppSettings(orgId) + if err != nil { + return nil, err + } + + seenPanels := make(map[string]bool) + seenApi := make(map[string]bool) + + for appId, installedApp := range Apps { + var app AppPlugin + app = *installedApp + + // check if the app is stored in the DB for this org and if so, use the + // state stored there. + if b, ok := orgApps[appId]; ok { + app.Enabled = b.Enabled + app.Pinned = b.Pinned + } + + if app.Enabled { + enabledPlugins.Apps = append(enabledPlugins.Apps, &app) + } + } + + // add all plugins that are not part of an App. + for d, installedDs := range DataSources { + if installedDs.App == "" { + enabledPlugins.DataSources[d] = installedDs + } + } + + for p, panel := range Panels { + if panel.App == "" { + if _, ok := seenPanels[p]; !ok { + seenPanels[p] = true + enabledPlugins.Panels = append(enabledPlugins.Panels, panel) + } + } + } + + for a, api := range ApiPlugins { + if api.App == "" { + if _, ok := seenApi[a]; !ok { + seenApi[a] = true + enabledPlugins.ApiList = append(enabledPlugins.ApiList, api) + } + } + } + + return &enabledPlugins, nil +} diff --git a/pkg/services/sqlstore/app_settings.go b/pkg/services/sqlstore/app_settings.go new file mode 100644 index 00000000000..e9bfbeaa73b --- /dev/null +++ b/pkg/services/sqlstore/app_settings.go @@ -0,0 +1,50 @@ +package sqlstore + +import ( + "time" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" +) + +func init() { + bus.AddHandler("sql", GetAppSettings) + bus.AddHandler("sql", UpdateAppSettings) +} + +func GetAppSettings(query *m.GetAppSettingsQuery) error { + sess := x.Where("org_id=?", query.OrgId) + + query.Result = make([]*m.AppSettings, 0) + return sess.Find(&query.Result) +} + +func UpdateAppSettings(cmd *m.UpdateAppSettingsCmd) error { + return inTransaction2(func(sess *session) error { + var app m.AppSettings + + exists, err := sess.Where("org_id=? and app_id=?", cmd.OrgId, cmd.AppId).Get(&app) + sess.UseBool("enabled") + sess.UseBool("pinned") + if !exists { + app = m.AppSettings{ + AppId: cmd.AppId, + OrgId: cmd.OrgId, + Enabled: cmd.Enabled, + Pinned: cmd.Pinned, + JsonData: cmd.JsonData, + Created: time.Now(), + Updated: time.Now(), + } + _, err = sess.Insert(&app) + return err + } else { + app.Updated = time.Now() + app.Enabled = cmd.Enabled + app.JsonData = cmd.JsonData + app.Pinned = cmd.Pinned + _, err = sess.Id(app.Id).Update(&app) + return err + } + }) +} diff --git a/pkg/services/sqlstore/migrations/plugin_bundle.go b/pkg/services/sqlstore/migrations/app_settings.go similarity index 56% rename from pkg/services/sqlstore/migrations/plugin_bundle.go rename to pkg/services/sqlstore/migrations/app_settings.go index b56ea74a13e..437debbe95b 100644 --- a/pkg/services/sqlstore/migrations/plugin_bundle.go +++ b/pkg/services/sqlstore/migrations/app_settings.go @@ -2,25 +2,27 @@ package migrations import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" -func addPluginBundleMigration(mg *Migrator) { +func addAppSettingsMigration(mg *Migrator) { - var pluginBundleV1 = Table{ - Name: "plugin_bundle", + appSettingsV1 := Table{ + Name: "app_settings", 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: "app_id", Type: DB_NVarchar, Length: 255, Nullable: false}, {Name: "enabled", Type: DB_Bool, Nullable: false}, + {Name: "pinned", 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}, + {Cols: []string{"org_id", "app_id"}, Type: UniqueIndex}, }, } - mg.AddMigration("create plugin_bundle table v1", NewAddTableMigration(pluginBundleV1)) + + mg.AddMigration("create app_settings table v1", NewAddTableMigration(appSettingsV1)) //------- indexes ------------------ - addTableIndicesMigrations(mg, "v1", pluginBundleV1) + addTableIndicesMigrations(mg, "v3", appSettingsV1) } diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index c789db1966a..28ea3035bcd 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -18,7 +18,7 @@ func AddMigrations(mg *Migrator) { addApiKeyMigrations(mg) addDashboardSnapshotMigrations(mg) addQuotaMigration(mg) - addPluginBundleMigration(mg) + addAppSettingsMigration(mg) addSessionMigration(mg) addPlaylistMigrations(mg) } diff --git a/pkg/services/sqlstore/plugin_bundle.go b/pkg/services/sqlstore/plugin_bundle.go deleted file mode 100644 index c15c263a100..00000000000 --- a/pkg/services/sqlstore/plugin_bundle.go +++ /dev/null @@ -1,46 +0,0 @@ -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/public/app/core/controllers/all.js b/public/app/core/controllers/all.js index 0d39cf57d69..d22010cffdc 100644 --- a/public/app/core/controllers/all.js +++ b/public/app/core/controllers/all.js @@ -1,9 +1,3 @@ -// import grafanaCtrl from './grafana_ctrl'; -// -// import * as asd from './sidemenu_ctrl'; -// -// export {grafanaCtrl}; - define([ './grafana_ctrl', './search_ctrl', diff --git a/public/app/core/controllers/sidemenu_ctrl.js b/public/app/core/controllers/sidemenu_ctrl.js index 671e89f7851..5bca63cb1cd 100644 --- a/public/app/core/controllers/sidemenu_ctrl.js +++ b/public/app/core/controllers/sidemenu_ctrl.js @@ -19,7 +19,8 @@ function (angular, _, $, coreModule, config) { $scope.mainLinks.push({ text: item.text, icon: item.icon, - href: $scope.getUrl(item.href) + img: item.img, + url: $scope.getUrl(item.url) }); }); }; diff --git a/public/app/core/directives/misc.js b/public/app/core/directives/misc.js index 97361ed02d3..b3d6de2585d 100644 --- a/public/app/core/directives/misc.js +++ b/public/app/core/directives/misc.js @@ -62,12 +62,13 @@ function (angular, coreModule, kbn) { var label = ''; - var template = '' + ' '; - template = label + template; + template = template + label; elem.replaceWith($compile(angular.element(template))(scope)); } }; diff --git a/public/app/core/routes/all.js b/public/app/core/routes/all.js index 99cd4030bb8..cc4d73ef708 100644 --- a/public/app/core/routes/all.js +++ b/public/app/core/routes/all.js @@ -10,6 +10,7 @@ define([ $locationProvider.html5Mode(true); var loadOrgBundle = new BundleLoader.BundleLoader('app/features/org/all'); + var loadAppsBundle = new BundleLoader.BundleLoader('app/features/apps/all'); $routeProvider .when('/', { @@ -41,17 +42,17 @@ define([ controller : 'DashboardImportCtrl', }) .when('/datasources', { - templateUrl: 'app/features/org/partials/datasources.html', + templateUrl: 'app/features/datasources/partials/list.html', controller : 'DataSourcesCtrl', resolve: loadOrgBundle, }) .when('/datasources/edit/:id', { - templateUrl: 'app/features/org/partials/datasourceEdit.html', + templateUrl: 'app/features/datasources/partials/edit.html', controller : 'DataSourceEditCtrl', resolve: loadOrgBundle, }) .when('/datasources/new', { - templateUrl: 'app/features/org/partials/datasourceEdit.html', + templateUrl: 'app/features/datasources/partials/edit.html', controller : 'DataSourceEditCtrl', resolve: loadOrgBundle, }) @@ -131,15 +132,17 @@ define([ templateUrl: 'app/partials/reset_password.html', controller : 'ResetPasswordCtrl', }) - .when('/plugins', { - templateUrl: 'app/features/org/partials/plugins.html', - controller: 'PluginsCtrl', - resolve: loadOrgBundle, + .when('/apps', { + templateUrl: 'app/features/apps/partials/list.html', + controller: 'AppListCtrl', + controllerAs: 'ctrl', + resolve: loadAppsBundle, }) - .when('/plugins/edit/:type', { - templateUrl: 'app/features/org/partials/pluginEdit.html', - controller: 'PluginEditCtrl', - resolve: loadOrgBundle, + .when('/apps/edit/:appId', { + templateUrl: 'app/features/apps/partials/edit.html', + controller: 'AppEditCtrl', + controllerAs: 'ctrl', + resolve: loadAppsBundle, }) .when('/global-alerts', { templateUrl: 'app/features/dashboard/partials/globalAlerts.html', diff --git a/public/app/core/services/alert_srv.js b/public/app/core/services/alert_srv.js index cecc8eadadf..463be459659 100644 --- a/public/app/core/services/alert_srv.js +++ b/public/app/core/services/alert_srv.js @@ -46,6 +46,10 @@ function (angular, _, coreModule) { }, timeout); } + if (!$rootScope.$$phase) { + $rootScope.$digest(); + } + return(newAlert); }; diff --git a/public/app/core/services/datasource_srv.js b/public/app/core/services/datasource_srv.js index 3811daad168..07e3f004d45 100644 --- a/public/app/core/services/datasource_srv.js +++ b/public/app/core/services/datasource_srv.js @@ -7,7 +7,7 @@ define([ function (angular, _, coreModule, config) { 'use strict'; - coreModule.default.service('datasourceSrv', function($q, $injector) { + coreModule.default.service('datasourceSrv', function($q, $injector, $rootScope) { var self = this; this.init = function() { @@ -58,18 +58,27 @@ function (angular, _, coreModule, config) { } var deferred = $q.defer(); - var pluginDef = dsConfig.meta; - System.import(pluginDef.module).then(function() { - var AngularService = $injector.get(pluginDef.serviceName); - var instance = new AngularService(dsConfig, pluginDef); + System.import(pluginDef.module).then(function(plugin) { + // check if its in cache now + if (self.datasources[name]) { + deferred.resolve(self.datasources[name]); + return; + } + + // plugin module needs to export a constructor function named Datasource + if (!plugin.Datasource) { + throw "Plugin module is missing Datasource constructor"; + } + + var instance = $injector.instantiate(plugin.Datasource, {instanceSettings: dsConfig}); instance.meta = pluginDef; instance.name = name; self.datasources[name] = instance; deferred.resolve(instance); }).catch(function(err) { - console.log('Failed to load data source: ' + err); + $rootScope.appEvent('alert-error', [dsConfig.name + ' plugin failed', err.toString()]); }); return deferred.promise; diff --git a/public/app/features/annotations/partials/editor.html b/public/app/features/annotations/partials/editor.html index 0f244998c17..875f205c1f9 100644 --- a/public/app/features/annotations/partials/editor.html +++ b/public/app/features/annotations/partials/editor.html @@ -44,7 +44,7 @@
| - + {{annotation.name}} | diff --git a/public/app/features/apps/all.ts b/public/app/features/apps/all.ts new file mode 100644 index 00000000000..fcdd27dff4d --- /dev/null +++ b/public/app/features/apps/all.ts @@ -0,0 +1,2 @@ +import './edit_ctrl'; +import './list_ctrl'; diff --git a/public/app/features/apps/app_srv.ts b/public/app/features/apps/app_srv.ts new file mode 100644 index 00000000000..18c6979b388 --- /dev/null +++ b/public/app/features/apps/app_srv.ts @@ -0,0 +1,43 @@ +/// |
| Type | -- | - |
| - - {{p.type}} - | -- - - Edit - - | -- Enabled - - - | -
- This is just a test data source that generates random walk series. If this is your only data source - open the left side menu and navigate to the data sources admin screen and add your data sources. You can change - data source using the button to the left of the Add query button. -
-