Merge branch 'external-plugins'

This commit is contained in:
Torkel Ödegaard 2015-12-15 10:10:48 +01:00
commit 2ec5bc77d7
85 changed files with 1123 additions and 143 deletions

View File

@ -13,7 +13,7 @@ func Register(r *macaron.Macaron) {
reqSignedIn := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true}) reqSignedIn := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true})
reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true}) reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true})
reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN) reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)
regOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN) reqOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN)
quota := middleware.Quota quota := middleware.Quota
bind := binding.Bind bind := binding.Bind
@ -41,6 +41,9 @@ func Register(r *macaron.Macaron) {
r.Get("/admin/orgs", reqGrafanaAdmin, Index) r.Get("/admin/orgs", reqGrafanaAdmin, Index)
r.Get("/admin/orgs/edit/:id", 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/*", reqSignedIn, Index)
r.Get("/dashboard-solo/*", reqSignedIn, Index) r.Get("/dashboard-solo/*", reqSignedIn, Index)
@ -114,7 +117,7 @@ func Register(r *macaron.Macaron) {
r.Get("/invites", wrap(GetPendingOrgInvites)) r.Get("/invites", wrap(GetPendingOrgInvites))
r.Post("/invites", quota("user"), bind(dtos.AddInviteForm{}), wrap(AddOrgInvite)) r.Post("/invites", quota("user"), bind(dtos.AddInviteForm{}), wrap(AddOrgInvite))
r.Patch("/invites/:code/revoke", wrap(RevokeInvite)) r.Patch("/invites/:code/revoke", wrap(RevokeInvite))
}, regOrgAdmin) }, reqOrgAdmin)
// create new org // create new org
r.Post("/orgs", quota("org"), bind(m.CreateOrgCommand{}), wrap(CreateOrg)) r.Post("/orgs", quota("org"), bind(m.CreateOrgCommand{}), wrap(CreateOrg))
@ -141,7 +144,7 @@ func Register(r *macaron.Macaron) {
r.Get("/", wrap(GetApiKeys)) r.Get("/", wrap(GetApiKeys))
r.Post("/", quota("api_key"), bind(m.AddApiKeyCommand{}), wrap(AddApiKey)) r.Post("/", quota("api_key"), bind(m.AddApiKeyCommand{}), wrap(AddApiKey))
r.Delete("/:id", wrap(DeleteApiKey)) r.Delete("/:id", wrap(DeleteApiKey))
}, regOrgAdmin) }, reqOrgAdmin)
// Data sources // Data sources
r.Group("/datasources", func() { r.Group("/datasources", func() {
@ -151,7 +154,13 @@ func Register(r *macaron.Macaron) {
r.Delete("/:id", DeleteDataSource) r.Delete("/:id", DeleteDataSource)
r.Get("/:id", wrap(GetDataSourceById)) r.Get("/:id", wrap(GetDataSourceById))
r.Get("/plugins", GetDataSourcePlugins) 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.Get("/frontend/settings/", GetFrontendSettings)
r.Any("/datasources/proxy/:id/*", reqSignedIn, ProxyDataSourceRequest) r.Any("/datasources/proxy/:id/*", reqSignedIn, ProxyDataSourceRequest)
@ -188,5 +197,7 @@ func Register(r *macaron.Macaron) {
// rendering // rendering
r.Get("/render/*", reqSignedIn, RenderToPng) r.Get("/render/*", reqSignedIn, RenderToPng)
InitExternalPluginRoutes(r)
r.NotFound(NotFoundHandler) r.NotFound(NotFoundHandler)
} }

View File

@ -117,8 +117,15 @@ func UpdateDataSource(c *middleware.Context, cmd m.UpdateDataSourceCommand) {
func GetDataSourcePlugins(c *middleware.Context) { func GetDataSourcePlugins(c *middleware.Context) {
dsList := make(map[string]interface{}) dsList := make(map[string]interface{})
for key, value := range plugins.DataSources { orgBundles := m.GetPluginBundlesQuery{OrgId: c.OrgId}
if value.(map[string]interface{})["builtIn"] == nil { 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 dsList[key] = value
} }
} }

25
pkg/api/dtos/index.go Normal file
View File

@ -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"`
}

View File

@ -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"`
}

75
pkg/api/externalplugin.go Normal file
View File

@ -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}
}

View File

@ -29,6 +29,13 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro
datasources := make(map[string]interface{}) datasources := make(map[string]interface{})
var defaultDatasource string 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 { for _, ds := range orgDataSources {
url := ds.Url url := ds.Url
@ -42,7 +49,7 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro
"url": url, "url": url,
} }
meta, exists := plugins.DataSources[ds.Type] meta, exists := enabledPlugins.DataSourcePlugins[ds.Type]
if !exists { if !exists {
log.Error(3, "Could not find plugin definition for data source: %v", ds.Type) log.Error(3, "Could not find plugin definition for data source: %v", ds.Type)
continue continue
@ -109,9 +116,18 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro
defaultDatasource = "-- Grafana --" 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{}{ jsonObj := map[string]interface{}{
"defaultDatasource": defaultDatasource, "defaultDatasource": defaultDatasource,
"datasources": datasources, "datasources": datasources,
"panels": panels,
"appSubUrl": setting.AppSubUrl, "appSubUrl": setting.AppSubUrl,
"allowOrgCreate": (setting.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin, "allowOrgCreate": (setting.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin,
"authProxyEnabled": setting.AuthProxyEnabled, "authProxyEnabled": setting.AuthProxyEnabled,

View File

@ -2,66 +2,121 @@ package api
import ( import (
"github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/middleware" "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" "github.com/grafana/grafana/pkg/setting"
) )
func setIndexViewData(c *middleware.Context) error { func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
settings, err := getFrontendSettingsMap(c) settings, err := getFrontendSettingsMap(c)
if err != nil { if err != nil {
return err return nil, err
} }
currentUser := &dtos.CurrentUser{ var data = dtos.IndexViewData{
Id: c.UserId, User: &dtos.CurrentUser{
IsSignedIn: c.IsSignedIn, Id: c.UserId,
Login: c.Login, IsSignedIn: c.IsSignedIn,
Email: c.Email, Login: c.Login,
Name: c.Name, Email: c.Email,
LightTheme: c.Theme == "light", Name: c.Name,
OrgId: c.OrgId, LightTheme: c.Theme == "light",
OrgName: c.OrgName, OrgId: c.OrgId,
OrgRole: c.OrgRole, OrgName: c.OrgName,
GravatarUrl: dtos.GetGravatarUrl(c.Email), OrgRole: c.OrgRole,
IsGrafanaAdmin: c.IsGrafanaAdmin, 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 { 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 { if len(data.User.Name) == 0 {
currentUser.Name = currentUser.Login data.User.Name = data.User.Login
} }
themeUrlParam := c.Query("theme") themeUrlParam := c.Query("theme")
if themeUrlParam == "light" { if themeUrlParam == "light" {
currentUser.LightTheme = true data.User.LightTheme = true
} }
c.Data["User"] = currentUser data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{
c.Data["Settings"] = settings Text: "Dashboards",
c.Data["AppUrl"] = setting.AppUrl Icon: "fa fa-fw fa-th-large",
c.Data["AppSubUrl"] = setting.AppSubUrl Href: "/",
})
if setting.GoogleAnalyticsId != "" { if c.OrgRole == m.ROLE_ADMIN {
c.Data["GoogleAnalyticsId"] = setting.GoogleAnalyticsId 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 != "" { orgBundles := m.GetPluginBundlesQuery{OrgId: c.OrgId}
c.Data["GoogleTagManagerId"] = setting.GoogleTagManagerId 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) { 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) c.Handle(500, "Failed to get settings", err)
return return
} else {
c.HTML(200, "index", data)
} }
c.HTML(200, "index")
} }
func NotFoundHandler(c *middleware.Context) { func NotFoundHandler(c *middleware.Context) {
@ -70,10 +125,10 @@ func NotFoundHandler(c *middleware.Context) {
return return
} }
if err := setIndexViewData(c); err != nil { if data, err := setIndexViewData(c); err != nil {
c.Handle(500, "Failed to get settings", err) c.Handle(500, "Failed to get settings", err)
return return
} else {
c.HTML(404, "index", data)
} }
c.HTML(404, "index")
} }

View File

@ -19,19 +19,19 @@ const (
) )
func LoginView(c *middleware.Context) { 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) c.Handle(500, "Failed to get settings", err)
return return
} }
settings := c.Data["Settings"].(map[string]interface{}) viewData.Settings["googleAuthEnabled"] = setting.OAuthService.Google
settings["googleAuthEnabled"] = setting.OAuthService.Google viewData.Settings["githubAuthEnabled"] = setting.OAuthService.GitHub
settings["githubAuthEnabled"] = setting.OAuthService.GitHub viewData.Settings["disableUserSignUp"] = !setting.AllowUserSignUp
settings["disableUserSignUp"] = !setting.AllowUserSignUp viewData.Settings["loginHint"] = setting.LoginHint
settings["loginHint"] = setting.LoginHint
if !tryLoginUsingRememberCookie(c) { if !tryLoginUsingRememberCookie(c) {
c.HTML(200, VIEW_INDEX) c.HTML(200, VIEW_INDEX, viewData)
return return
} }

65
pkg/api/plugin_bundle.go Normal file
View File

@ -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")
}

View File

@ -14,6 +14,7 @@ import (
"github.com/grafana/grafana/pkg/api/static" "github.com/grafana/grafana/pkg/api/static"
"github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
) )
@ -28,12 +29,18 @@ func newMacaron() *macaron.Macaron {
m.Use(middleware.Gziper()) m.Use(middleware.Gziper())
} }
mapStatic(m, "", "public") for _, route := range plugins.StaticRoutes {
mapStatic(m, "app", "app") pluginRoute := path.Join("/public/plugins/", route.Url)
mapStatic(m, "css", "css") log.Info("Plugin: Adding static route %s -> %s", pluginRoute, route.Path)
mapStatic(m, "img", "img") mapStatic(m, route.Path, "", pluginRoute)
mapStatic(m, "fonts", "fonts") }
mapStatic(m, "robots.txt", "robots.txt")
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{ m.Use(macaron.Renderer(macaron.RenderOptions{
Directory: path.Join(setting.StaticRootPath, "views"), Directory: path.Join(setting.StaticRootPath, "views"),
@ -51,7 +58,7 @@ func newMacaron() *macaron.Macaron {
return m 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) { headers := func(c *macaron.Context) {
c.Resp.Header().Set("Cache-Control", "public, max-age=3600") 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( m.Use(httpstatic.Static(
path.Join(setting.StaticRootPath, dir), path.Join(rootDir, dir),
httpstatic.StaticOptions{ httpstatic.StaticOptions{
SkipLogging: true, SkipLogging: true,
Prefix: prefix, Prefix: prefix,

View File

@ -131,8 +131,8 @@ func (a *ldapAuther) getGrafanaUserFor(ldapUser *ldapUserInfo) (*m.User, error)
} }
return userQuery.Result, nil return userQuery.Result, nil
}
}
func (a *ldapAuther) createGrafanaUser(ldapUser *ldapUserInfo) (*m.User, error) { func (a *ldapAuther) createGrafanaUser(ldapUser *ldapUserInfo) (*m.User, error) {
cmd := m.CreateUserCommand{ cmd := m.CreateUserCommand{
Login: ldapUser.Username, Login: ldapUser.Username,

View File

@ -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
}

85
pkg/plugins/models.go Normal file
View File

@ -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),
}
}

View File

@ -6,18 +6,19 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"strings"
"github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
) )
type PluginMeta struct {
Type string `json:"type"`
Name string `json:"name"`
}
var ( 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 { type PluginScanner struct {
@ -25,13 +26,53 @@ type PluginScanner struct {
errors []error 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")) 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 { func scan(pluginDir string) error {
DataSources = make(map[string]interface{})
scanner := &PluginScanner{ scanner := &PluginScanner{
pluginPath: pluginDir, pluginPath: pluginDir,
} }
@ -47,7 +88,7 @@ func scan(pluginDir string) error {
return nil 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 { if err != nil {
return err return err
} }
@ -57,17 +98,25 @@ func (scanner *PluginScanner) walker(path string, f os.FileInfo, err error) erro
} }
if f.Name() == "plugin.json" { if f.Name() == "plugin.json" {
err := scanner.loadPluginJson(path) err := scanner.loadPluginJson(currentPath)
if err != nil { 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) scanner.errors = append(scanner.errors, err)
} }
} }
return nil return nil
} }
func (scanner *PluginScanner) loadPluginJson(path string) error { func addStaticRoot(staticRootConfig *StaticRootConfig, currentDir string) {
reader, err := os.Open(path) 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 { if err != nil {
return err return err
} }
@ -87,12 +136,95 @@ func (scanner *PluginScanner) loadPluginJson(path string) error {
} }
if pluginType == "datasource" { if pluginType == "datasource" {
datasourceType, exists := pluginJson["type"] p := DataSourcePlugin{}
if !exists { 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") 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 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
}

View File

@ -4,14 +4,17 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/grafana/grafana/pkg/setting"
. "github.com/smartystreets/goconvey/convey" . "github.com/smartystreets/goconvey/convey"
"gopkg.in/ini.v1"
) )
func TestPluginScans(t *testing.T) { func TestPluginScans(t *testing.T) {
Convey("When scaning for plugins", t, func() { Convey("When scaning for plugins", t, func() {
path, _ := filepath.Abs("../../public/app/plugins") setting.StaticRootPath, _ = filepath.Abs("../../public/")
err := scan(path) setting.Cfg = ini.Empty()
err := Init()
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(len(DataSources), ShouldBeGreaterThan, 1) So(len(DataSources), ShouldBeGreaterThan, 1)

View File

@ -18,6 +18,7 @@ func AddMigrations(mg *Migrator) {
addApiKeyMigrations(mg) addApiKeyMigrations(mg)
addDashboardSnapshotMigrations(mg) addDashboardSnapshotMigrations(mg)
addQuotaMigration(mg) addQuotaMigration(mg)
addPluginBundleMigration(mg)
} }
func addMigrationLogMigrations(mg *Migrator) { func addMigrationLogMigrations(mg *Migrator) {

View File

@ -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)
}

View File

@ -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
}
})
}

View File

@ -287,13 +287,11 @@ func loadSpecifedConfigFile(configFile string) {
defaultSec, err := Cfg.GetSection(section.Name()) defaultSec, err := Cfg.GetSection(section.Name())
if err != nil { if err != nil {
log.Error(3, "Unknown config section %s defined in %s", section.Name(), configFile) defaultSec, _ = Cfg.NewSection(section.Name())
continue
} }
defaultKey, err := defaultSec.GetKey(key.Name()) defaultKey, err := defaultSec.GetKey(key.Name())
if err != nil { if err != nil {
log.Error(3, "Unknown config key %s defined in section %s, in file %s", key.Name(), section.Name(), configFile) defaultKey, _ = defaultSec.NewKey(key.Name(), key.Value())
continue
} }
defaultKey.SetValue(key.Value()) defaultKey.SetValue(key.Value())
} }

View File

@ -2,6 +2,7 @@ define([
'angular', 'angular',
'jquery', 'jquery',
'lodash', 'lodash',
'app/core/config',
'require', 'require',
'bootstrap', 'bootstrap',
'angular-route', 'angular-route',
@ -12,7 +13,7 @@ define([
'bindonce', 'bindonce',
'app/core/core', 'app/core/core',
], ],
function (angular, $, _, appLevelRequire) { function (angular, $, _, config, appLevelRequire) {
"use strict"; "use strict";
var app = angular.module('grafana', []); var app = angular.module('grafana', []);
@ -35,6 +36,8 @@ function (angular, $, _, appLevelRequire) {
} else { } else {
_.extend(module, register_fns); _.extend(module, register_fns);
} }
// push it into the apps dependencies
apps_deps.push(module.name);
return module; return module;
}; };
@ -64,13 +67,15 @@ function (angular, $, _, appLevelRequire) {
var module_name = 'grafana.'+type; var module_name = 'grafana.'+type;
// create the module // create the module
app.useModule(angular.module(module_name, [])); app.useModule(angular.module(module_name, []));
// push it into the apps dependencies
apps_deps.push(module_name);
}); });
var preBootRequires = [ var preBootRequires = ['app/features/all'];
'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() { app.boot = function() {
require(preBootRequires, function () { require(preBootRequires, function () {

View File

@ -6,6 +6,7 @@ function (Settings) {
var bootData = window.grafanaBootData || { settings: {} }; var bootData = window.grafanaBootData || { settings: {} };
var options = bootData.settings; var options = bootData.settings;
options.bootData = bootData;
return new Settings(options); return new Settings(options);

View File

@ -15,19 +15,13 @@ function (angular, _, $, coreModule, config) {
}; };
$scope.setupMainNav = function() { $scope.setupMainNav = function() {
$scope.mainLinks.push({ _.each(config.bootData.mainNavLinks, function(item) {
text: "Dashboards",
icon: "fa fa-fw fa-th-large",
href: $scope.getUrl("/"),
});
if (contextSrv.hasRole('Admin')) {
$scope.mainLinks.push({ $scope.mainLinks.push({
text: "Data Sources", text: item.text,
icon: "fa fa-fw fa-database", icon: item.icon,
href: $scope.getUrl("/datasources"), href: $scope.getUrl(item.href)
}); });
} });
}; };
$scope.loadOrgs = function() { $scope.loadOrgs = function() {

View File

@ -131,6 +131,16 @@ define([
templateUrl: 'app/partials/reset_password.html', templateUrl: 'app/partials/reset_password.html',
controller : 'ResetPasswordCtrl', 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', { .when('/global-alerts', {
templateUrl: 'app/features/dashboard/partials/globalAlerts.html', templateUrl: 'app/features/dashboard/partials/globalAlerts.html',
}) })

View File

@ -12,8 +12,8 @@ function (angular, _, coreModule, store, config) {
var self = this; var self = this;
function User() { function User() {
if (window.grafanaBootData.user) { if (config.bootData.user) {
_.extend(this, window.grafanaBootData.user); _.extend(this, config.bootData.user);
} }
} }

View File

@ -8,15 +8,8 @@ function (_) {
var defaults = { var defaults = {
datasources : {}, datasources : {},
window_title_prefix : 'Grafana - ', window_title_prefix : 'Grafana - ',
panels : { 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' },
},
new_panel_title: 'Panel Title', new_panel_title: 'Panel Title',
plugins: {},
playlist_timespan: "1m", playlist_timespan: "1m",
unsaved_changes_warning: true, unsaved_changes_warning: true,
appSubUrl: "" appSubUrl: ""

View File

@ -6,4 +6,8 @@ define([
'./userInviteCtrl', './userInviteCtrl',
'./orgApiKeysCtrl', './orgApiKeysCtrl',
'./orgDetailsCtrl', './orgDetailsCtrl',
'./pluginsCtrl',
'./pluginEditCtrl',
'./plugin_srv',
'./plugin_directive',
], function () {}); ], function () {});

View File

@ -0,0 +1,3 @@
<div>
{{current.type}} plugin does not have any additional config.
</div>

View File

@ -0,0 +1,42 @@
<topnav title="Plugins" icon="fa fa-fw fa-cubes" subnav="true">
<ul class="nav">
<li ><a href="plugins">Overview</a></li>
<li class="active" ><a href="plugins/edit/{{current.type}}">Edit</a></li>
</ul>
</topnav>
<div class="page-container">
<div class="page">
<h2>Edit Plugin</h2>
<form name="editForm">
<div class="tight-form">
<ul class="tight-form-list">
<li class="tight-form-item" style="width: 80px">
Type
</li>
<li>
<li>
<input type="text" disabled="disabled" class="input-xlarge tight-form-input" ng-model="current.type">
</li>
</li>
<li class="tight-form-item">
Default&nbsp;
<input class="cr1" id="current.enabled" type="checkbox" ng-model="current.enabled" ng-checked="current.enabled">
<label for="current.enabled" class="cr1"></label>
</li>
</ul>
<div class="clearfix"></div>
</div>
<br>
<plugin-config-loader plugin="current"></plugin-config-loader>
<div class="pull-right" style="margin-top: 35px">
<button type="submit" class="btn btn-success" ng-click="update()">Save</button>
<a class="btn btn-inverse" href="plugins">Cancel</a>
</div>
<br>
</form>
</div>
</div>

View File

@ -0,0 +1,41 @@
<topnav title="Plugins" icon="fa fa-fw fa-cubes" subnav="true">
<ul class="nav">
<li class="active" ><a href="plugins">Overview</a></li>
</ul>
</topnav>
<div class="page-container">
<div class="page">
<h2>Plugins</h2>
<div ng-if="!plugins">
<em>No plugins defined</em>
</div>
<table class="grafana-options-table" ng-if="plugins">
<tr>
<td><strong>Type</strong></td>
<td></td>
<td></td>
</tr>
<tr ng-repeat="(type, p) in plugins">
<td style="width:1%">
<i class="fa fa-cubes"></i> &nbsp;
{{p.type}}
</td>
<td style="width: 1%">
<a href="plugins/edit/{{p.type}}" class="btn btn-inverse btn-mini">
<i class="fa fa-edit"></i>
Edit
</a>
</td>
<td style="width: 1%">
Enabled&nbsp;
<input id="p.enabled" type="checkbox" ng-model="p.enabled" ng-checked="p.enabled" ng-change="update(p)">
<label for="p.enabled"></label>
</td>
</tr>
</table>
</div>
</div>

View File

@ -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();
});
});

View File

@ -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();
};
}
};
});
});

View File

@ -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();
});
});

View File

@ -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();
});
});

View File

@ -13,9 +13,9 @@ function (angular, $, config) {
restrict: 'E', restrict: 'E',
link: function(scope, elem, attr) { link: function(scope, elem, attr) {
var getter = $parse(attr.type), panelType = getter(scope); 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)); var panelEl = angular.element(document.createElement('grafana-panel-' + panelType));
elem.append(panelEl); elem.append(panelEl);
$compile(panelEl)(scope); $compile(panelEl)(scope);

View File

@ -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 = /<the_path_were_you_cloned_it>/grafana-plugin-example
```

View File

@ -14,7 +14,7 @@ function (angular, app, _, config, PanelMeta) {
module.directive('grafanaPanelDashlist', function() { module.directive('grafanaPanelDashlist', function() {
return { return {
controller: 'DashListPanelCtrl', 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, fullscreen: true,
}); });
$scope.panelMeta.addEditorTab('Options', 'app/panels/dashlist/editor.html'); $scope.panelMeta.addEditorTab('Options', 'app/plugins/panels/dashlist/editor.html');
var defaults = { var defaults = {
mode: 'starred', mode: 'starred',

View File

@ -0,0 +1,8 @@
{
"pluginType": "panel",
"name": "Dashboard list",
"type": "dashlist",
"module": "app/plugins/panels/dashlist/module"
}

View File

@ -45,7 +45,7 @@ function (angular, _, $) {
popoverScope.series = seriesInfo; popoverScope.series = seriesInfo;
popoverSrv.show({ popoverSrv.show({
element: el, element: el,
templateUrl: 'app/panels/graph/legend.popover.html', templateUrl: 'app/plugins/panels/graph/legend.popover.html',
scope: popoverScope scope: popoverScope
}); });
} }

View File

@ -17,7 +17,7 @@ function (angular, _, moment, kbn, TimeSeries, PanelMeta) {
module.directive('grafanaPanelGraph', function() { module.directive('grafanaPanelGraph', function() {
return { return {
controller: 'GraphCtrl', 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, metricsEditor: true,
}); });
$scope.panelMeta.addEditorTab('Axes & Grid', 'app/panels/graph/axisEditor.html'); $scope.panelMeta.addEditorTab('Axes & Grid', 'app/plugins/panels/graph/axisEditor.html');
$scope.panelMeta.addEditorTab('Display Styles', 'app/panels/graph/styleEditor.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.addEditorTab('Time range', 'app/features/panel/partials/panelTime.html');
$scope.panelMeta.addExtendedMenuItem('Export CSV', '', 'exportCsv()'); $scope.panelMeta.addExtendedMenuItem('Export CSV', '', 'exportCsv()');

View File

@ -0,0 +1,8 @@
{
"pluginType": "panel",
"name": "Graph",
"type": "graph",
"module": "app/plugins/panels/graph/module"
}

View File

@ -16,7 +16,7 @@ function (angular, app, _, kbn, TimeSeries, PanelMeta) {
module.directive('grafanaPanelSinglestat', function() { module.directive('grafanaPanelSinglestat', function() {
return { return {
controller: 'SingleStatCtrl', 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.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'); $scope.panelMeta.addEditorTab('Time range', 'app/features/panel/partials/panelTime.html');
// Set and populate defaults // Set and populate defaults

View File

@ -0,0 +1,8 @@
{
"pluginType": "panel",
"name": "Singlestat",
"type": "singlestat",
"module": "app/plugins/panels/singlestat/module"
}

View File

@ -1,4 +1,4 @@
///<reference path="../../headers/common.d.ts" /> ///<reference path="../../../headers/common.d.ts" />
import angular = require('angular'); import angular = require('angular');
import _ = require('lodash'); import _ = require('lodash');
@ -21,7 +21,7 @@ export class TablePanelCtrl {
metricsEditor: true, 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'); $scope.panelMeta.addEditorTab('Time range', 'app/features/panel/partials/panelTime.html');
var panelDefaults = { var panelDefaults = {

View File

@ -1,5 +1,4 @@
///<reference path="../../headers/common.d.ts" /> ///<reference path="../../../headers/common.d.ts" />
import angular = require('angular'); import angular = require('angular');
import $ = require('jquery'); import $ = require('jquery');
@ -122,4 +121,3 @@ export function tablePanelEditor($q, uiSegmentSrv) {
controller: TablePanelEditorCtrl, controller: TablePanelEditorCtrl,
}; };
} }

View File

@ -1,4 +1,4 @@
///<reference path="../../headers/common.d.ts" /> ///<reference path="../../../headers/common.d.ts" />
import angular = require('angular'); import angular = require('angular');
import $ = require('jquery'); import $ = require('jquery');
@ -14,7 +14,7 @@ export function tablePanel() {
'use strict'; 'use strict';
return { return {
restrict: 'E', restrict: 'E',
templateUrl: 'app/panels/table/module.html', templateUrl: 'app/plugins/panels/table/module.html',
controller: TablePanelCtrl, controller: TablePanelCtrl,
link: function(scope, elem) { link: function(scope, elem) {
var data; var data;

View File

@ -0,0 +1,8 @@
{
"pluginType": "panel",
"name": "Table",
"type": "table",
"module": "app/plugins/panels/table/module"
}

View File

@ -1,4 +1,4 @@
///<reference path="../../headers/common.d.ts" /> ///<reference path="../../../headers/common.d.ts" />
import _ = require('lodash'); import _ = require('lodash');
import kbn = require('app/core/utils/kbn'); import kbn = require('app/core/utils/kbn');

View File

@ -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;
}
}

View File

@ -1,4 +1,4 @@
///<reference path="../../headers/common.d.ts" /> ///<reference path="../../../headers/common.d.ts" />
import moment = require('moment'); import moment = require('moment');
import _ = require('lodash'); import _ = require('lodash');

View File

@ -16,7 +16,7 @@ function (angular, app, _, require, PanelMeta) {
module.directive('grafanaPanelText', function() { module.directive('grafanaPanelText', function() {
return { return {
controller: 'TextPanelCtrl', 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, 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 // Set and populate defaults
var _d = { var _d = {
@ -84,7 +84,7 @@ function (angular, app, _, require, PanelMeta) {
$scope.updateContent(converter.makeHtml(text)); $scope.updateContent(converter.makeHtml(text));
} }
else { else {
require(['./lib/showdown'], function (Showdown) { require(['vendor/showdown'], function (Showdown) {
converter = new Showdown.converter(); converter = new Showdown.converter();
$scope.updateContent(converter.makeHtml(text)); $scope.updateContent(converter.makeHtml(text));
}); });

View File

@ -0,0 +1,8 @@
{
"pluginType": "panel",
"name": "Text",
"type": "text",
"module": "app/plugins/panels/text/module"
}

View File

@ -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": []
}

View File

@ -2,7 +2,7 @@ define([
'./helpers', './helpers',
'app/features/panel/panel_srv', 'app/features/panel/panel_srv',
'app/features/panel/panel_helper', 'app/features/panel/panel_helper',
'app/panels/graph/module' 'app/plugins/panels/graph/module'
], function(helpers) { ], function(helpers) {
'use strict'; 'use strict';

View File

@ -3,7 +3,7 @@ define([
'angular', 'angular',
'jquery', 'jquery',
'app/core/time_series', 'app/core/time_series',
'app/panels/graph/graph' 'app/plugins/panels/graph/graph'
], function(helpers, angular, $, TimeSeries) { ], function(helpers, angular, $, TimeSeries) {
'use strict'; 'use strict';

View File

@ -1,6 +1,6 @@
define([ define([
'jquery', 'jquery',
'app/panels/graph/graph.tooltip' 'app/plugins/panels/graph/graph.tooltip'
], function($, GraphTooltip) { ], function($, GraphTooltip) {
'use strict'; 'use strict';

View File

@ -1,6 +1,6 @@
define([ define([
'./helpers', './helpers',
'app/panels/graph/seriesOverridesCtrl' 'app/plugins/panels/graph/seriesOverridesCtrl'
], function(helpers) { ], function(helpers) {
'use strict'; 'use strict';

View File

@ -2,7 +2,7 @@ define([
'./helpers', './helpers',
'app/features/panel/panel_srv', 'app/features/panel/panel_srv',
'app/features/panel/panel_helper', 'app/features/panel/panel_helper',
'app/panels/singlestat/module' 'app/plugins/panels/singlestat/module'
], function(helpers) { ], function(helpers) {
'use strict'; 'use strict';

View File

@ -95,6 +95,8 @@ function file2moduleName(filePath) {
.replace(/\.\w*$/, ''); .replace(/\.\w*$/, '');
} }
window.grafanaBootData = {settings: {}};
require([ require([
'lodash', 'lodash',
'angular', 'angular',

View File

@ -10,10 +10,18 @@
[[if .User.LightTheme]] [[if .User.LightTheme]]
<link rel="stylesheet" href="[[.AppSubUrl]]/public/css/grafana.light.min.css"> <link rel="stylesheet" href="[[.AppSubUrl]]/public/css/grafana.light.min.css">
[[ range $css := .PluginCss ]]
<link rel="stylesheet" href="[[$.AppSubUrl]]/[[ $css.Light ]]">
[[ end ]]
[[else]] [[else]]
<link rel="stylesheet" href="[[.AppSubUrl]]/public/css/grafana.dark.min.css"> <link rel="stylesheet" href="[[.AppSubUrl]]/public/css/grafana.dark.min.css">
[[ range $css := .PluginCss ]]
<link rel="stylesheet" href="[[$.AppSubUrl]]/[[ $css.Dark ]]">
[[ end ]]
[[end]] [[end]]
<link rel="icon" type="image/png" href="[[.AppSubUrl]]/public/img/fav32.png"> <link rel="icon" type="image/png" href="[[.AppSubUrl]]/public/img/fav32.png">
<base href="[[.AppSubUrl]]/" /> <base href="[[.AppSubUrl]]/" />
@ -50,10 +58,12 @@
window.grafanaBootData = { window.grafanaBootData = {
user:[[.User]], user:[[.User]],
settings: [[.Settings]], settings: [[.Settings]],
pluginModules: [[.PluginJs]],
mainNavLinks: [[.MainNavLinks]]
}; };
require(['app/app'], function (app) { require(['app/app'], function (app) {
app.boot(); app.boot();
}) })
</script> </script>

View File

@ -8,9 +8,7 @@ module.exports = function(config) {
expand: true, expand: true,
cwd: '<%= genDir %>', cwd: '<%= genDir %>',
src: [ src: [
//'index.html', 'app/**/*.html',
'app/panels/**/*.html',
'app/partials/**/*.html'
], ],
dest: '<%= genDir %>' dest: '<%= genDir %>'
} }

View File

@ -4,7 +4,6 @@ module.exports = function(config) {
'Gruntfile.js', 'Gruntfile.js',
'<%= srcDir %>/app/**/*.js', '<%= srcDir %>/app/**/*.js',
'<%= srcDir %>/plugins/**/*.js', '<%= srcDir %>/plugins/**/*.js',
'!<%= srcDir %>/app/panels/*/{lib,leaflet}/*',
'!<%= srcDir %>/app/dashboards/*' '!<%= srcDir %>/app/dashboards/*'
], ],
options: { options: {

View File

@ -18,7 +18,6 @@ module.exports = function(config) {
'dist/*', 'dist/*',
'sample/*', 'sample/*',
'<%= srcDir %>/vendor/*', '<%= srcDir %>/vendor/*',
'<%= srcDir %>/app/panels/*/{lib,leaflet}/*',
'<%= srcDir %>/app/dashboards/*' '<%= srcDir %>/app/dashboards/*'
] ]
} }

View File

@ -62,11 +62,11 @@ module.exports = function(config,grunt) {
]; ];
var fs = require('fs'); 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/ // create a module for each directory in public/app/panels/
fs.readdirSync(panelPath).forEach(function (panelName) { 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 }; return { options: options };