diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 148722e5f73..fe0a1d6c548 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -12,7 +12,7 @@ grunt karma:dev ### Run tests for backend assets before commit ``` -test -z "$(gofmt -s -l . | grep -v vendor/src/ | tee /dev/stderr)" +test -z "$(gofmt -s -l . | grep -v -E 'vendor/(github.com|golang.org|gopkg.in)' | tee /dev/stderr)" ``` ### Run tests for frontend assets before commit diff --git a/CHANGELOG.md b/CHANGELOG.md index 505a5f433bb..5baddec644d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ * **Graphite**: Add support for groupByNode, closes [#5613](https://github.com/grafana/grafana/pull/5613) * **Influxdb**: Add support for elapsed(), closes [#5827](https://github.com/grafana/grafana/pull/5827) * **OAuth**: Add support for generic oauth, closes [#4718](https://github.com/grafana/grafana/pull/4718) +* **Cloudwatch**: Add support to expand multi select template variable, closes [#5003](https://github.com/grafana/grafana/pull/5003) +* **Graph Panel**: Now supports flexible lower/upper bounds on Y-Max and Y-Min, PR [#5720](https://github.com/grafana/grafana/pull/5720) +* **Background Tasks**: Now support automatic purging of old snapshots, closes [#4087](https://github.com/grafana/grafana/issues/4087) +* **Background Tasks**: Now support automatic purging of old rendered images, closes [#2172](https://github.com/grafana/grafana/issues/2172) ### Breaking changes * **SystemD**: Change systemd description, closes [#5971](https://github.com/grafana/grafana/pull/5971) @@ -19,6 +23,11 @@ ### Bugfixes * **Table Panel**: Fixed problem when switching to Mixed datasource in metrics tab, fixes [#5999](https://github.com/grafana/grafana/pull/5999) +* **Playlist**: Fixed problem with play order not matching order defined in playlist, fixes [#5467](https://github.com/grafana/grafana/pull/5467) +* **Graph panel**: Fixed problem with auto decimals on y axis when datamin=datamax, fixes [#6070](https://github.com/grafana/grafana/pull/6070) +* **Snapshot**: Can view embedded panels/png rendered panels in snapshots without login, fixes [#3769](https://github.com/grafana/grafana/pull/3769) +* **Elasticsearch**: Fix for query template variable when looking up terms without query, no longer relies on elasticsearch default field, fixes [#3887](https://github.com/grafana/grafana/pull/3887) +* **PNG Rendering**: Fix for server side rendering when using auth proxy, fixes [#5906](https://github.com/grafana/grafana/pull/5906) # 3.1.2 (unreleased) * **Templating**: Fixed issue when combining row & panel repeats, fixes [#5790](https://github.com/grafana/grafana/issues/5790) diff --git a/Gruntfile.js b/Gruntfile.js index 9a0c69b96f9..1f96048746b 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -9,7 +9,6 @@ module.exports = function (grunt) { genDir: 'public_gen', destDir: 'dist', tempDir: 'tmp', - arch: os.arch(), platform: process.platform.replace('win32', 'windows'), }; @@ -17,6 +16,10 @@ module.exports = function (grunt) { config.arch = process.env.hasOwnProperty('ProgramFiles(x86)') ? 'x64' : 'x86'; } + config.arch = grunt.option('arch') || os.arch(); + + config.phjs = grunt.option('phjsToRelease'); + config.pkg.version = grunt.option('pkgVer') || config.pkg.version; console.log('Version', config.pkg.version); diff --git a/README.md b/README.md index 3a5c7dce6d3..98f4b4d3c9d 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ easily the grafana repository you want to build. ```bash go get github.com/*your_account*/grafana mkdir $GOPATH/src/github.com/grafana -ln -s github.com/*your_account*/grafana $GOPATH/src/github.com/grafana/grafana +ln -s $GOPATH/src/github.com/*your_account*/grafana $GOPATH/src/github.com/grafana/grafana ``` ### Building the backend diff --git a/build.go b/build.go index b881645b5fb..202caa1837b 100644 --- a/build.go +++ b/build.go @@ -25,11 +25,16 @@ var ( versionRe = regexp.MustCompile(`-[0-9]{1,3}-g[0-9a-f]{5,10}`) goarch string goos string + gocc string + gocxx string + cgo string + pkgArch string version string = "v1" // deb & rpm does not support semver so have to handle their version a little differently linuxPackageVersion string = "v1" linuxPackageIteration string = "" race bool + phjsToRelease string workingDir string binaries []string = []string{"grafana-server", "grafana-cli"} ) @@ -47,6 +52,11 @@ func main() { flag.StringVar(&goarch, "goarch", runtime.GOARCH, "GOARCH") flag.StringVar(&goos, "goos", runtime.GOOS, "GOOS") + flag.StringVar(&gocc, "cc", "", "CC") + flag.StringVar(&gocxx, "cxx", "", "CXX") + flag.StringVar(&cgo, "cgo-enabled", "", "CGO_ENABLED") + flag.StringVar(&pkgArch, "pkg-arch", "", "PKG ARCH") + flag.StringVar(&phjsToRelease, "phjs", "", "PhantomJS binary") flag.BoolVar(&race, "race", race, "Use race detector") flag.Parse() @@ -73,15 +83,15 @@ func main() { grunt("test") case "package": - grunt("release", fmt.Sprintf("--pkgVer=%v-%v", linuxPackageVersion, linuxPackageIteration)) + grunt(gruntBuildArg("release")...) createLinuxPackages() case "pkg-rpm": - grunt("release") + grunt(gruntBuildArg("release")...) createRpmPackages() case "pkg-deb": - grunt("release") + grunt(gruntBuildArg("release")...) createDebPackages() case "latest": @@ -258,6 +268,10 @@ func createPackage(options linuxPackageOptions) { "-p", "./dist", } + if pkgArch != "" { + args = append(args, "-a", pkgArch) + } + if linuxPackageIteration != "" { args = append(args, "--iteration", linuxPackageIteration) } @@ -307,11 +321,20 @@ func grunt(params ...string) { runPrint("./node_modules/.bin/grunt", params...) } +func gruntBuildArg(task string) []string { + args := []string{task, fmt.Sprintf("--pkgVer=%v-%v", linuxPackageVersion, linuxPackageIteration)} + if pkgArch != "" { + args = append(args, fmt.Sprintf("--arch=%v", pkgArch)) + } + if phjsToRelease != "" { + args = append(args, fmt.Sprintf("--phjsToRelease=%v", phjsToRelease)) + } + return args +} + func setup() { runPrint("go", "get", "-v", "github.com/kardianos/govendor") - runPrint("go", "get", "-v", "github.com/blang/semver") - runPrint("go", "get", "-v", "github.com/mattn/go-sqlite3") - runPrint("go", "install", "-v", "github.com/mattn/go-sqlite3") + runPrint("go", "install", "-v", "./pkg/cmd/grafana-server") } func test(pkg string) { @@ -382,6 +405,15 @@ func setBuildEnv() { if goarch == "386" { os.Setenv("GO386", "387") } + if cgo != "" { + os.Setenv("CGO_ENABLED", cgo) + } + if gocc != "" { + os.Setenv("CC", gocc) + } + if gocxx != "" { + os.Setenv("CXX", gocxx) + } } func getGitSha() string { diff --git a/conf/defaults.ini b/conf/defaults.ini index 53a034bfeaa..39e10280645 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -9,7 +9,7 @@ app_mode = production # instance name, defaults to HOSTNAME environment variable value or hostname if HOSTNAME var is empty instance_name = ${HOSTNAME} -#################################### Paths #################################### +#################################### Paths ############################### [paths] # Path to where grafana can store temp files, sessions, and the sqlite3 db (if that is used) # @@ -23,7 +23,7 @@ logs = data/log # plugins = data/plugins -#################################### Server #################################### +#################################### Server ############################## [server] # Protocol (http or https) protocol = http @@ -57,7 +57,7 @@ enable_gzip = false cert_file = cert_key = -#################################### Database #################################### +#################################### Database ############################ [database] # You can configure the database connection by specifying type, host, name, user and password # as seperate properties or as on string using the url propertie. @@ -84,7 +84,7 @@ server_cert_name = # For "sqlite3" only, path relative to data_path setting path = grafana.db -#################################### Session #################################### +#################################### Session ############################# [session] # Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file" provider = file @@ -112,7 +112,7 @@ cookie_secure = false session_life_time = 86400 gc_interval_time = 86400 -#################################### Analytics #################################### +#################################### Analytics ########################### [analytics] # Server reporting, sends usage counters to stats.grafana.org every 24 hours. # No ip addresses are being tracked, only simple counters to track @@ -133,7 +133,7 @@ google_analytics_ua_id = # Google Tag Manager ID, only enabled if you specify an id here google_tag_manager_id = -#################################### Security #################################### +#################################### Security ############################ [security] # default admin user, created on startup admin_user = admin @@ -161,6 +161,12 @@ external_enabled = true external_snapshot_url = https://snapshots-origin.raintank.io external_snapshot_name = Publish to snapshot.raintank.io +# remove expired snapshot +snapshot_remove_expired = true + +# remove snapshots after 90 days +snapshot_TTL_days = 90 + #################################### Users #################################### [users] # disable user signup / registration @@ -184,10 +190,11 @@ login_hint = email or username # Default UI theme ("dark" or "light") default_theme = dark -# Allow users to sign in using username and password -allow_user_pass_login = true +[auth] +# Set to true to disable (hide) the login form, useful if you use OAuth +disable_login_form = false -#################################### Anonymous Auth ########################## +#################################### Anonymous Auth ###################### [auth.anonymous] # enable anonymous access enabled = false @@ -198,7 +205,7 @@ org_name = Main Org. # specify role for unauthenticated users org_role = Viewer -#################################### Github Auth ########################## +#################################### Github Auth ######################### [auth.github] enabled = false allow_sign_up = false @@ -211,7 +218,7 @@ api_url = https://api.github.com/user team_ids = allowed_organizations = -#################################### Google Auth ########################## +#################################### Google Auth ######################### [auth.google] enabled = false allow_sign_up = false @@ -223,7 +230,16 @@ token_url = https://accounts.google.com/o/oauth2/token api_url = https://www.googleapis.com/oauth2/v1/userinfo allowed_domains = -#################################### Generic OAuth ########################## +#################################### Grafana.net Auth #################### +[auth.grafananet] +enabled = false +allow_sign_up = false +client_id = some_id +client_secret = some_secret +scopes = user:email +allowed_organizations = + +#################################### Generic OAuth ####################### [auth.generic_oauth] enabled = false allow_sign_up = false @@ -247,12 +263,12 @@ header_name = X-WEBAUTH-USER header_property = username auto_sign_up = true -#################################### Auth LDAP ########################## +#################################### Auth LDAP ########################### [auth.ldap] enabled = false config_file = /etc/grafana/ldap.toml -#################################### SMTP / Emailing ########################## +#################################### SMTP / Emailing ##################### [smtp] enabled = false host = localhost:25 @@ -322,18 +338,18 @@ facility = tag = -#################################### AMQP Event Publisher ########################## +#################################### AMQP Event Publisher ################ [event_publisher] enabled = false rabbitmq_url = amqp://localhost/ exchange = grafana_events -#################################### Dashboard JSON files ########################## +#################################### Dashboard JSON files ################ [dashboards.json] enabled = false path = /var/lib/grafana/dashboards -#################################### Usage Quotas ########################## +#################################### Usage Quotas ######################## [quota] enabled = false @@ -368,7 +384,7 @@ global_api_key = -1 # global limit on number of logged in users. global_session = -1 -#################################### Alerting ###################################### +#################################### Alerting ############################ # docs about alerting can be found in /docs/sources/alerting/ # __.-/| # \`o_O' @@ -387,7 +403,7 @@ global_session = -1 [alerting] enabled = true -#################################### Internal Grafana Metrics ########################## +#################################### Internal Grafana Metrics ############ # Metrics available at HTTP API Url /api/metrics [metrics] enabled = true @@ -402,9 +418,9 @@ prefix = prod.grafana.%(instance_name)s. [grafana_net] url = https://grafana.net -#################################### External image storage ########################## +#################################### External Image Storage ############## [external_image_storage] -# You can choose between (s3, webdav or internal) +# You can choose between (s3, webdav) provider = s3 [external_image_storage.s3] diff --git a/conf/sample.ini b/conf/sample.ini index 2c428ea775f..bb575a596b0 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -116,7 +116,7 @@ # in some UI views to notify that grafana or plugin update exists # This option does not cause any auto updates, nor send any information # only a GET request to http://grafana.net to get latest versions -check_for_updates = true +;check_for_updates = true # Google Analytics universal tracking code, only enabled if you specify an id here ;google_analytics_ua_id = @@ -149,6 +149,12 @@ check_for_updates = true ;external_snapshot_url = https://snapshots-origin.raintank.io ;external_snapshot_name = Publish to snapshot.raintank.io +# remove expired snapshot +;snapshot_remove_expired = true + +# remove snapshots after 90 days +;snapshot_TTL_days = 90 + #################################### Users #################################### [users] # disable user signup / registration @@ -169,6 +175,10 @@ check_for_updates = true # Default UI theme ("dark" or "light") ;default_theme = dark +[auth] +# Set to true to disable (hide) the login form, useful if you use OAuth, defaults to false +;disable_login_form = false + #################################### Anonymous Auth ########################## [auth.anonymous] # enable anonymous access @@ -218,6 +228,15 @@ check_for_updates = true ;team_ids = ;allowed_organizations = +#################################### Grafana.net Auth #################### +[auth.grafananet] +;enabled = false +;allow_sign_up = false +;client_id = some_id +;client_secret = some_secret +;scopes = user:email +;allowed_organizations = + #################################### Auth Proxy ########################## [auth.proxy] ;enabled = false diff --git a/docs/sources/datasources/influxdb.md b/docs/sources/datasources/influxdb.md index da8ca08f549..c66d1ef5b69 100644 --- a/docs/sources/datasources/influxdb.md +++ b/docs/sources/datasources/influxdb.md @@ -6,11 +6,7 @@ page_keywords: grafana, influxdb, metrics, query, documentation # InfluxDB -There are currently two separate datasources for InfluxDB in Grafana: InfluxDB 0.8.x and InfluxDB 0.9.x. -The API and capabilities of InfluxDB 0.9.x are completely different from InfluxDB 0.8.x which is why Grafana handles -them as different data sources. - -InfluxDB 0.9 is rapidly evolving and we continue to track its API. InfluxDB 0.8 is no longer maintained by InfluxDB Inc, but we provide support as a convenience to existing users. +Grafana ships with very a feature data source plugin for InfluxDB. Supporting a feature rich query editor, annotation and templating queries. ## Adding the data source ![](/img/v2/add_Influx.jpg) diff --git a/docs/sources/http_api/admin.md b/docs/sources/http_api/admin.md index ba1a0923861..fb1b0659cd9 100644 --- a/docs/sources/http_api/admin.md +++ b/docs/sources/http_api/admin.md @@ -6,6 +6,10 @@ page_keywords: grafana, admin, http, api, documentation # Admin API +The admin http API does not currently work with an api token. Api Token's are currently only linked to an organization and organization role. They cannot given +the permission of server admin, only user's can be given that permission. So in order to use these API calls you will have to use basic auth and Grafana user +with Grafana admin permission. + ## Settings `GET /api/admin/settings` @@ -15,7 +19,6 @@ page_keywords: grafana, admin, http, api, documentation GET /api/admin/settings Accept: application/json Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk **Example Response**: @@ -171,7 +174,6 @@ page_keywords: grafana, admin, http, api, documentation GET /api/admin/stats Accept: application/json Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk **Example Response**: @@ -201,7 +203,6 @@ Create new user POST /api/admin/users HTTP/1.1 Accept: application/json Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk { "name":"User", @@ -228,7 +229,6 @@ Change password for specific user PUT /api/admin/users/2/password HTTP/1.1 Accept: application/json Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk **Example Response**: @@ -246,7 +246,6 @@ Change password for specific user PUT /api/admin/users/2/permissions HTTP/1.1 Accept: application/json Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk **Example Response**: @@ -264,7 +263,6 @@ Change password for specific user DELETE /api/admin/users/2 HTTP/1.1 Accept: application/json Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk **Example Response**: diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 4c7f63d53ae..9a7a480f482 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -238,6 +238,14 @@ options are `Admin` and `Editor` and `Read-Only Editor`.
+## [auth] + +### disable_login_form + +Set to true to disable (hide) the login form, useful if you use OAuth, defaults to false. + +
+ ## [auth.anonymous] ### enabled @@ -484,6 +492,33 @@ Grafana backend index those json dashboards which will make them appear in regul ### path The full path to a directory containing your json dashboards. +## [smtp] +Email server settings. + +### enabled +defaults to false + +### host +defaults to localhost:25 + +### user +In case of SMTP auth, defaults to `empty` + +### password +In case of SMTP auth, defaults to `empty` + +### cert_file +File path to a cert file, defaults to `empty` + +### key_file +File path to a key file, defaults to `empty` + +### skip_verify +Verify SSL for smtp server? defaults to `false` + +### from_address +Address used when sending out emails, defaults to `admin@grafana.localhost` + ## [log] ### mode @@ -525,3 +560,9 @@ Set root url to a Grafana instance where you want to publish external snapshots ### external_snapshot_name Set name for external snapshot button. Defaults to `Publish to snapshot.raintank.io` + +### remove expired snapshot +Enabled to automatically remove expired snapshots + +### remove snapshots after 90 days +Time to live for snapshots. diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index 1233e09d504..460ad56e1ab 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -25,6 +25,25 @@ func ValidateOrgAlert(c *middleware.Context) { } } +func GetAlertStatesForDashboard(c *middleware.Context) Response { + dashboardId := c.QueryInt64("dashboardId") + + if dashboardId == 0 { + return ApiError(400, "Missing query parameter dashboardId", nil) + } + + query := models.GetAlertStatesForDashboardQuery{ + OrgId: c.OrgId, + DashboardId: c.QueryInt64("dashboardId"), + } + + if err := bus.Dispatch(&query); err != nil { + return ApiError(500, "Failed to fetch alert states", err) + } + + return Json(200, query.Result) +} + // GET /api/alerts func GetAlerts(c *middleware.Context) Response { query := models.GetAlertsQuery{ diff --git a/pkg/api/api.go b/pkg/api/api.go index 71331acda9f..deb29d730eb 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -58,6 +58,7 @@ func Register(r *macaron.Macaron) { r.Get("/plugins/:id/page/:page", reqSignedIn, Index) r.Get("/dashboard/*", reqSignedIn, Index) + r.Get("/dashboard-solo/snapshot/*", Index) r.Get("/dashboard-solo/*", reqSignedIn, Index) r.Get("/import/dashboard", reqSignedIn, Index) r.Get("/dashboards/*", reqSignedIn, Index) @@ -202,9 +203,9 @@ func Register(r *macaron.Macaron) { r.Get("/plugins", wrap(GetPluginList)) r.Get("/plugins/:pluginId/settings", wrap(GetPluginSettingById)) + r.Get("/plugins/:pluginId/readme", wrap(GetPluginReadme)) r.Group("/plugins", func() { - r.Get("/:pluginId/readme", wrap(GetPluginReadme)) r.Get("/:pluginId/dashboards/", wrap(GetPluginDashboards)) r.Post("/:pluginId/settings", bind(m.UpdatePluginSettingCmd{}), wrap(UpdatePluginSetting)) }, reqOrgAdmin) @@ -243,7 +244,8 @@ func Register(r *macaron.Macaron) { r.Get("/search/", Search) // metrics - r.Get("/metrics/test", wrap(GetTestMetrics)) + r.Post("/tsdb/query", bind(dtos.MetricRequest{}), wrap(QueryMetrics)) + r.Get("/tsdb/testdata/scenarios", wrap(GetTestDataScenarios)) // metrics r.Get("/metrics", wrap(GetInternalMetrics)) @@ -252,6 +254,7 @@ func Register(r *macaron.Macaron) { r.Post("/test", bind(dtos.AlertTestCommand{}), wrap(AlertTest)) r.Get("/:alertId", ValidateOrgAlert, wrap(GetAlert)) r.Get("/", wrap(GetAlerts)) + r.Get("/states-for-dashboard", wrap(GetAlertStatesForDashboard)) }) r.Get("/alert-notifications", wrap(GetAlertNotifications)) diff --git a/pkg/api/dtos/models.go b/pkg/api/dtos/models.go index 8bfc9f9138d..170a5a868fc 100644 --- a/pkg/api/dtos/models.go +++ b/pkg/api/dtos/models.go @@ -96,13 +96,10 @@ func (slice DataSourceList) Swap(i, j int) { slice[i], slice[j] = slice[j], slice[i] } -type MetricQueryResultDto struct { - Data []MetricQueryResultDataDto `json:"data"` -} - -type MetricQueryResultDataDto struct { - Target string `json:"target"` - DataPoints [][2]float64 `json:"datapoints"` +type MetricRequest struct { + From string `json:"from"` + To string `json:"to"` + Queries []*simplejson.Json `json:"queries"` } type UserStars struct { diff --git a/pkg/api/dtos/playlist.go b/pkg/api/dtos/playlist.go new file mode 100644 index 00000000000..317ff83339a --- /dev/null +++ b/pkg/api/dtos/playlist.go @@ -0,0 +1,23 @@ +package dtos + +type PlaylistDashboard struct { + Id int64 `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Uri string `json:"uri"` + Order int `json:"order"` +} + +type PlaylistDashboardsSlice []PlaylistDashboard + +func (slice PlaylistDashboardsSlice) Len() int { + return len(slice) +} + +func (slice PlaylistDashboardsSlice) Less(i, j int) bool { + return slice[i].Order < slice[j].Order +} + +func (slice PlaylistDashboardsSlice) Swap(i, j int) { + slice[i], slice[j] = slice[j], slice[i] +} diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 3a019e80c49..5a324aa1331 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -38,7 +38,7 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro url := ds.Url if ds.Access == m.DS_ACCESS_PROXY { - url = setting.AppSubUrl + "/api/datasources/proxy/" + strconv.FormatInt(ds.Id, 10) + url = "/api/datasources/proxy/" + strconv.FormatInt(ds.Id, 10) } var dsMap = map[string]interface{}{ diff --git a/pkg/api/index.go b/pkg/api/index.go index e9d784cb652..385810b942e 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -1,6 +1,7 @@ package api import ( + "fmt" "strings" "github.com/grafana/grafana/pkg/api/dtos" @@ -32,6 +33,16 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { locale = parts[0] } + appUrl := setting.AppUrl + appSubUrl := setting.AppSubUrl + + // special case when doing localhost call from phantomjs + if c.IsRenderCall { + appUrl = fmt.Sprintf("%s://localhost:%s", setting.Protocol, setting.HttpPort) + appSubUrl = "" + settings["appSubUrl"] = "" + } + var data = dtos.IndexViewData{ User: &dtos.CurrentUser{ Id: c.UserId, @@ -49,8 +60,8 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { Locale: locale, }, Settings: settings, - AppUrl: setting.AppUrl, - AppSubUrl: setting.AppSubUrl, + AppUrl: appUrl, + AppSubUrl: appSubUrl, GoogleAnalyticsId: setting.GoogleAnalyticsId, GoogleTagManagerId: setting.GoogleTagManagerId, BuildVersion: setting.BuildVersion, @@ -154,7 +165,7 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { } } - if c.OrgRole == m.ROLE_ADMIN { + if len(appLink.Children) > 0 && c.OrgRole == m.ROLE_ADMIN { appLink.Children = append(appLink.Children, &dtos.NavLink{Divider: true}) appLink.Children = append(appLink.Children, &dtos.NavLink{Text: "Plugin Config", Icon: "fa fa-cog", Url: setting.AppSubUrl + "/plugins/" + plugin.Id + "/edit"}) } diff --git a/pkg/api/login.go b/pkg/api/login.go index 789765ee01e..8fbe414c08c 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -25,13 +25,15 @@ func LoginView(c *middleware.Context) { return } - viewData.Settings["googleAuthEnabled"] = setting.OAuthService.Google - viewData.Settings["githubAuthEnabled"] = setting.OAuthService.GitHub - viewData.Settings["genericOAuthEnabled"] = setting.OAuthService.Generic - viewData.Settings["oauthProviderName"] = setting.OAuthService.OAuthProviderName + enabledOAuths := make(map[string]interface{}) + for key, oauth := range setting.OAuthService.OAuthInfos { + enabledOAuths[key] = map[string]string{"name": oauth.Name} + } + + viewData.Settings["oauth"] = enabledOAuths viewData.Settings["disableUserSignUp"] = !setting.AllowUserSignUp viewData.Settings["loginHint"] = setting.LoginHint - viewData.Settings["allowUserPassLogin"] = setting.AllowUserPassLogin + viewData.Settings["disableLoginForm"] = setting.DisableLoginForm if !tryLoginUsingRememberCookie(c) { c.HTML(200, VIEW_INDEX, viewData) diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 6512a827341..bc222361b25 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -3,7 +3,6 @@ package api import ( "errors" "fmt" - "net/url" "golang.org/x/oauth2" @@ -46,9 +45,9 @@ func OAuthLogin(ctx *middleware.Context) { userInfo, err := connect.UserInfo(token) if err != nil { if err == social.ErrMissingTeamMembership { - ctx.Redirect(setting.AppSubUrl + "/login?failedMsg=" + url.QueryEscape("Required Github team membership not fulfilled")) + ctx.Redirect(setting.AppSubUrl + "/login?failCode=1000") } else if err == social.ErrMissingOrganizationMembership { - ctx.Redirect(setting.AppSubUrl + "/login?failedMsg=" + url.QueryEscape("Required Github organization membership not fulfilled")) + ctx.Redirect(setting.AppSubUrl + "/login?failCode=1001") } else { ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err) } @@ -60,7 +59,7 @@ func OAuthLogin(ctx *middleware.Context) { // validate that the email is allowed to login to grafana if !connect.IsEmailAllowed(userInfo.Email) { ctx.Logger.Info("OAuth login attempt with unallowed email", "email", userInfo.Email) - ctx.Redirect(setting.AppSubUrl + "/login?failedMsg=" + url.QueryEscape("Required email domain not fulfilled")) + ctx.Redirect(setting.AppSubUrl + "/login?failCode=1002") return } @@ -83,10 +82,11 @@ func OAuthLogin(ctx *middleware.Context) { return } cmd := m.CreateUserCommand{ - Login: userInfo.Email, - Email: userInfo.Email, - Name: userInfo.Name, - Company: userInfo.Company, + Login: userInfo.Email, + Email: userInfo.Email, + Name: userInfo.Name, + Company: userInfo.Company, + DefaultOrgRole: userInfo.Role, } if err = bus.Dispatch(&cmd); err != nil { diff --git a/pkg/api/metrics.go b/pkg/api/metrics.go index 154f863af53..0fa6003d67a 100644 --- a/pkg/api/metrics.go +++ b/pkg/api/metrics.go @@ -2,39 +2,54 @@ package api import ( "encoding/json" - "math/rand" "net/http" - "strconv" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/tsdb" + "github.com/grafana/grafana/pkg/tsdb/testdata" "github.com/grafana/grafana/pkg/util" ) -func GetTestMetrics(c *middleware.Context) Response { - from := c.QueryInt64("from") - to := c.QueryInt64("to") - maxDataPoints := c.QueryInt64("maxDataPoints") - stepInSeconds := (to - from) / maxDataPoints +// POST /api/tsdb/query +func QueryMetrics(c *middleware.Context, reqDto dtos.MetricRequest) Response { + timeRange := tsdb.NewTimeRange(reqDto.From, reqDto.To) - result := dtos.MetricQueryResultDto{} - result.Data = make([]dtos.MetricQueryResultDataDto, 1) + request := &tsdb.Request{TimeRange: timeRange} - for seriesIndex := range result.Data { - points := make([][2]float64, maxDataPoints) - walker := rand.Float64() * 100 - time := from + for _, query := range reqDto.Queries { + request.Queries = append(request.Queries, &tsdb.Query{ + RefId: query.Get("refId").MustString("A"), + MaxDataPoints: query.Get("maxDataPoints").MustInt64(100), + IntervalMs: query.Get("intervalMs").MustInt64(1000), + Model: query, + DataSource: &tsdb.DataSourceInfo{ + Name: "Grafana TestDataDB", + PluginId: "grafana-testdata-datasource", + }, + }) + } - for i := range points { - points[i][0] = walker - points[i][1] = float64(time) - walker += rand.Float64() - 0.5 - time += stepInSeconds - } + resp, err := tsdb.HandleRequest(request) + if err != nil { + return ApiError(500, "Metric request error", err) + } - result.Data[seriesIndex].Target = "test-series-" + strconv.Itoa(seriesIndex) - result.Data[seriesIndex].DataPoints = points + return Json(200, &resp) +} + +// GET /api/tsdb/testdata/scenarios +func GetTestDataScenarios(c *middleware.Context) Response { + result := make([]interface{}, 0) + + for _, scenario := range testdata.ScenarioRegistry { + result = append(result, map[string]interface{}{ + "id": scenario.Id, + "name": scenario.Name, + "description": scenario.Description, + "stringInput": scenario.StringInput, + }) } return Json(200, &result) diff --git a/pkg/api/playlist_play.go b/pkg/api/playlist_play.go index e4feb3442fb..780767531a8 100644 --- a/pkg/api/playlist_play.go +++ b/pkg/api/playlist_play.go @@ -1,16 +1,18 @@ package api import ( + "sort" "strconv" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" _ "github.com/grafana/grafana/pkg/log" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/search" ) -func populateDashboardsById(dashboardByIds []int64) ([]m.PlaylistDashboardDto, error) { - result := make([]m.PlaylistDashboardDto, 0) +func populateDashboardsById(dashboardByIds []int64, dashboardIdOrder map[int64]int) (dtos.PlaylistDashboardsSlice, error) { + result := make(dtos.PlaylistDashboardsSlice, 0) if len(dashboardByIds) > 0 { dashboardQuery := m.GetDashboardsQuery{DashboardIds: dashboardByIds} @@ -19,11 +21,12 @@ func populateDashboardsById(dashboardByIds []int64) ([]m.PlaylistDashboardDto, e } for _, item := range dashboardQuery.Result { - result = append(result, m.PlaylistDashboardDto{ + result = append(result, dtos.PlaylistDashboard{ Id: item.Id, Slug: item.Slug, Title: item.Title, Uri: "db/" + item.Slug, + Order: dashboardIdOrder[item.Id], }) } } @@ -31,8 +34,8 @@ func populateDashboardsById(dashboardByIds []int64) ([]m.PlaylistDashboardDto, e return result, nil } -func populateDashboardsByTag(orgId, userId int64, dashboardByTag []string) []m.PlaylistDashboardDto { - result := make([]m.PlaylistDashboardDto, 0) +func populateDashboardsByTag(orgId, userId int64, dashboardByTag []string, dashboardTagOrder map[string]int) dtos.PlaylistDashboardsSlice { + result := make(dtos.PlaylistDashboardsSlice, 0) if len(dashboardByTag) > 0 { for _, tag := range dashboardByTag { @@ -47,10 +50,11 @@ func populateDashboardsByTag(orgId, userId int64, dashboardByTag []string) []m.P if err := bus.Dispatch(&searchQuery); err == nil { for _, item := range searchQuery.Result { - result = append(result, m.PlaylistDashboardDto{ + result = append(result, dtos.PlaylistDashboard{ Id: item.Id, Title: item.Title, Uri: item.Uri, + Order: dashboardTagOrder[tag], }) } } @@ -60,28 +64,33 @@ func populateDashboardsByTag(orgId, userId int64, dashboardByTag []string) []m.P return result } -func LoadPlaylistDashboards(orgId, userId, playlistId int64) ([]m.PlaylistDashboardDto, error) { +func LoadPlaylistDashboards(orgId, userId, playlistId int64) (dtos.PlaylistDashboardsSlice, error) { playlistItems, _ := LoadPlaylistItems(playlistId) dashboardByIds := make([]int64, 0) dashboardByTag := make([]string, 0) + dashboardIdOrder := make(map[int64]int) + dashboardTagOrder := make(map[string]int) for _, i := range playlistItems { if i.Type == "dashboard_by_id" { dashboardId, _ := strconv.ParseInt(i.Value, 10, 64) dashboardByIds = append(dashboardByIds, dashboardId) + dashboardIdOrder[dashboardId] = i.Order } if i.Type == "dashboard_by_tag" { dashboardByTag = append(dashboardByTag, i.Value) + dashboardTagOrder[i.Value] = i.Order } } - result := make([]m.PlaylistDashboardDto, 0) + result := make(dtos.PlaylistDashboardsSlice, 0) - var k, _ = populateDashboardsById(dashboardByIds) + var k, _ = populateDashboardsById(dashboardByIds, dashboardIdOrder) result = append(result, k...) - result = append(result, populateDashboardsByTag(orgId, userId, dashboardByTag)...) + result = append(result, populateDashboardsByTag(orgId, userId, dashboardByTag, dashboardTagOrder)...) + sort.Sort(sort.Reverse(result)) return result, nil } diff --git a/pkg/api/render.go b/pkg/api/render.go index 65c1499d0c5..6018656badb 100644 --- a/pkg/api/render.go +++ b/pkg/api/render.go @@ -6,35 +6,21 @@ import ( "github.com/grafana/grafana/pkg/components/renderer" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) func RenderToPng(c *middleware.Context) { queryReader := util.NewUrlQueryReader(c.Req.URL) queryParams := fmt.Sprintf("?%s", c.Req.URL.RawQuery) - sessionId := c.Session.ID() - - // Handle api calls authenticated without session - if sessionId == "" && c.ApiKeyId != 0 { - c.Session.Start(c) - c.Session.Set(middleware.SESS_KEY_APIKEY, c.ApiKeyId) - // release will make sure the new session is persisted before - // we spin up phantomjs - c.Session.Release() - // cleanup session after render is complete - defer func() { c.Session.Destory(c) }() - } renderOpts := &renderer.RenderOpts{ - Url: c.Params("*") + queryParams, - Width: queryReader.Get("width", "800"), - Height: queryReader.Get("height", "400"), - SessionId: c.Session.ID(), - Timeout: queryReader.Get("timeout", "30"), + Path: c.Params("*") + queryParams, + Width: queryReader.Get("width", "800"), + Height: queryReader.Get("height", "400"), + OrgId: c.OrgId, + Timeout: queryReader.Get("timeout", "30"), } - renderOpts.Url = setting.ToAbsUrl(renderOpts.Url) pngPath, err := renderer.RenderToPng(renderOpts) if err != nil { diff --git a/pkg/cmd/grafana-cli/services/services.go b/pkg/cmd/grafana-cli/services/services.go index ed1a69d50a8..e2ccce8418f 100644 --- a/pkg/cmd/grafana-cli/services/services.go +++ b/pkg/cmd/grafana-cli/services/services.go @@ -141,8 +141,6 @@ func createRequest(repoUrl string, subPaths ...string) ([]byte, error) { req, err := http.NewRequest(http.MethodGet, u.String(), nil) - logger.Info("grafanaVersion ", grafanaVersion) - req.Header.Set("grafana-version", grafanaVersion) req.Header.Set("User-Agent", "grafana "+grafanaVersion) diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index d3d291a74bf..6cd063f798f 100644 --- a/pkg/cmd/grafana-server/main.go +++ b/pkg/cmd/grafana-server/main.go @@ -13,16 +13,15 @@ import ( "time" "github.com/grafana/grafana/pkg/log" - "github.com/grafana/grafana/pkg/login" - "github.com/grafana/grafana/pkg/metrics" - "github.com/grafana/grafana/pkg/plugins" - alertingInit "github.com/grafana/grafana/pkg/services/alerting/init" - "github.com/grafana/grafana/pkg/services/eventpublisher" - "github.com/grafana/grafana/pkg/services/notifications" - "github.com/grafana/grafana/pkg/services/search" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/social" + + _ "github.com/grafana/grafana/pkg/services/alerting/conditions" + _ "github.com/grafana/grafana/pkg/services/alerting/notifiers" + _ "github.com/grafana/grafana/pkg/tsdb/graphite" + _ "github.com/grafana/grafana/pkg/tsdb/prometheus" + _ "github.com/grafana/grafana/pkg/tsdb/testdata" ) var version = "3.1.0" @@ -56,26 +55,8 @@ func main() { setting.BuildCommit = commit setting.BuildStamp = buildstampInt64 - go listenToSystemSignels() - - flag.Parse() - writePIDFile() - initRuntime() - metrics.Init() - - search.Init() - login.Init() - social.NewOAuthService() - eventpublisher.Init() - plugins.Init() - alertingInit.Init() - - if err := notifications.Init(); err != nil { - log.Fatal(3, "Notification service failed to initialize", err) - } - - StartServer() - exitChan <- 0 + server := NewGrafanaServer() + server.Start() } func initRuntime() { @@ -93,7 +74,9 @@ func initRuntime() { logger.Info("Starting Grafana", "version", version, "commit", commit, "compiled", time.Unix(setting.BuildStamp, 0)) setting.LogConfigurationInfo() +} +func initSql() { sqlstore.NewEngine() sqlstore.EnsureAdminUser() } @@ -116,7 +99,7 @@ func writePIDFile() { } } -func listenToSystemSignels() { +func listenToSystemSignals(server models.GrafanaServer) { signalChan := make(chan os.Signal, 1) code := 0 @@ -124,16 +107,8 @@ func listenToSystemSignels() { select { case sig := <-signalChan: - log.Info("Received signal %s. shutting down", sig) + server.Shutdown(0, fmt.Sprintf("system signal: %s", sig)) case code = <-exitChan: - switch code { - case 0: - log.Info("Shutting down") - default: - log.Warn("Shutting down") - } + server.Shutdown(code, "startup error") } - - log.Close() - os.Exit(code) } diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go new file mode 100644 index 00000000000..8a2ab0c2b95 --- /dev/null +++ b/pkg/cmd/grafana-server/server.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "time" + + "golang.org/x/sync/errgroup" + + "github.com/grafana/grafana/pkg/api" + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/login" + "github.com/grafana/grafana/pkg/metrics" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/alerting" + "github.com/grafana/grafana/pkg/services/cleanup" + "github.com/grafana/grafana/pkg/services/eventpublisher" + "github.com/grafana/grafana/pkg/services/notifications" + "github.com/grafana/grafana/pkg/services/search" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/social" +) + +func NewGrafanaServer() models.GrafanaServer { + rootCtx, shutdownFn := context.WithCancel(context.Background()) + childRoutines, childCtx := errgroup.WithContext(rootCtx) + + return &GrafanaServerImpl{ + context: childCtx, + shutdownFn: shutdownFn, + childRoutines: childRoutines, + log: log.New("server"), + } +} + +type GrafanaServerImpl struct { + context context.Context + shutdownFn context.CancelFunc + childRoutines *errgroup.Group + log log.Logger +} + +func (g *GrafanaServerImpl) Start() { + go listenToSystemSignals(g) + + writePIDFile() + initRuntime() + initSql() + metrics.Init() + search.Init() + login.Init() + social.NewOAuthService() + eventpublisher.Init() + plugins.Init() + + // init alerting + if setting.AlertingEnabled { + engine := alerting.NewEngine() + g.childRoutines.Go(func() error { return engine.Run(g.context) }) + } + + // cleanup service + cleanUpService := cleanup.NewCleanUpService() + g.childRoutines.Go(func() error { return cleanUpService.Run(g.context) }) + + if err := notifications.Init(); err != nil { + g.log.Error("Notification service failed to initialize", "erro", err) + g.Shutdown(1, "Startup failed") + return + } + + g.startHttpServer() +} + +func (g *GrafanaServerImpl) startHttpServer() { + logger = log.New("http.server") + + var err error + m := newMacaron() + api.Register(m) + + listenAddr := fmt.Sprintf("%s:%s", setting.HttpAddr, setting.HttpPort) + g.log.Info("Initializing HTTP Server", "address", listenAddr, "protocol", setting.Protocol, "subUrl", setting.AppSubUrl) + + switch setting.Protocol { + case setting.HTTP: + err = http.ListenAndServe(listenAddr, m) + case setting.HTTPS: + err = http.ListenAndServeTLS(listenAddr, setting.CertFile, setting.KeyFile, m) + default: + g.log.Error("Invalid protocol", "protocol", setting.Protocol) + g.Shutdown(1, "Startup failed") + } + + if err != nil { + g.log.Error("Fail to start server", "error", err) + g.Shutdown(1, "Startup failed") + return + } +} + +func (g *GrafanaServerImpl) Shutdown(code int, reason string) { + g.log.Info("Shutdown started", "code", code, "reason", reason) + + g.shutdownFn() + err := g.childRoutines.Wait() + + g.log.Info("Shutdown completed", "reason", err) + log.Close() + os.Exit(code) +} + +// implement context.Context +func (g *GrafanaServerImpl) Deadline() (deadline time.Time, ok bool) { + return g.context.Deadline() +} +func (g *GrafanaServerImpl) Done() <-chan struct{} { + return g.context.Done() +} +func (g *GrafanaServerImpl) Err() error { + return g.context.Err() +} +func (g *GrafanaServerImpl) Value(key interface{}) interface{} { + return g.context.Value(key) +} diff --git a/pkg/cmd/grafana-server/web.go b/pkg/cmd/grafana-server/web.go index 4c294a814ca..44177ccc5b4 100644 --- a/pkg/cmd/grafana-server/web.go +++ b/pkg/cmd/grafana-server/web.go @@ -6,7 +6,6 @@ package main import ( "fmt" "net/http" - "os" "path" "gopkg.in/macaron.v1" @@ -79,7 +78,7 @@ func mapStatic(m *macaron.Macaron, rootDir string, dir string, prefix string) { )) } -func StartServer() { +func StartServer() int { logger = log.New("server") var err error @@ -95,11 +94,13 @@ func StartServer() { err = http.ListenAndServeTLS(listenAddr, setting.CertFile, setting.KeyFile, m) default: logger.Error("Invalid protocol", "protocol", setting.Protocol) - os.Exit(1) + return 1 } if err != nil { logger.Error("Fail to start server", "error", err) - os.Exit(1) + return 1 } + + return 0 } diff --git a/pkg/components/renderer/renderer.go b/pkg/components/renderer/renderer.go index ad8f76e03aa..a55ba5e0ab5 100644 --- a/pkg/components/renderer/renderer.go +++ b/pkg/components/renderer/renderer.go @@ -12,36 +12,51 @@ import ( "strconv" "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) type RenderOpts struct { - Url string - Width string - Height string - SessionId string - Timeout string + Path string + Width string + Height string + Timeout string + OrgId int64 } var rendererLog log.Logger = log.New("png-renderer") func RenderToPng(params *RenderOpts) (string, error) { - rendererLog.Info("Rendering", "url", params.Url) + rendererLog.Info("Rendering", "path", params.Path) var executable = "phantomjs" if runtime.GOOS == "windows" { executable = executable + ".exe" } + url := fmt.Sprintf("%s://localhost:%s/%s", setting.Protocol, setting.HttpPort, params.Path) + binPath, _ := filepath.Abs(filepath.Join(setting.PhantomDir, executable)) scriptPath, _ := filepath.Abs(filepath.Join(setting.PhantomDir, "render.js")) pngPath, _ := filepath.Abs(filepath.Join(setting.ImagesDir, util.GetRandomString(20))) pngPath = pngPath + ".png" - cmd := exec.Command(binPath, "--ignore-ssl-errors=true", scriptPath, "url="+params.Url, "width="+params.Width, - "height="+params.Height, "png="+pngPath, "cookiename="+setting.SessionOptions.CookieName, - "domain="+setting.Domain, "sessionid="+params.SessionId) + renderKey := middleware.AddRenderAuthKey(params.OrgId) + defer middleware.RemoveRenderAuthKey(renderKey) + + cmdArgs := []string{ + "--ignore-ssl-errors=true", + scriptPath, + "url=" + url, + "width=" + params.Width, + "height=" + params.Height, + "png=" + pngPath, + "domain=" + setting.Domain, + "renderKey=" + renderKey, + } + + cmd := exec.Command(binPath, cmdArgs...) stdout, err := cmd.StdoutPipe() if err != nil { diff --git a/pkg/log/log.go b/pkg/log/log.go index 34a2aed4762..9b4e8be31d0 100644 --- a/pkg/log/log.go +++ b/pkg/log/log.go @@ -32,11 +32,25 @@ func New(logger string, ctx ...interface{}) Logger { } func Trace(format string, v ...interface{}) { - Root.Debug(fmt.Sprintf(format, v)) + var message string + if len(v) > 0 { + message = fmt.Sprintf(format, v) + } else { + message = format + } + + Root.Debug(message) } func Debug(format string, v ...interface{}) { - Root.Debug(fmt.Sprintf(format, v)) + var message string + if len(v) > 0 { + message = fmt.Sprintf(format, v) + } else { + message = format + } + + Root.Debug(message) } func Debug2(message string, v ...interface{}) { @@ -44,7 +58,14 @@ func Debug2(message string, v ...interface{}) { } func Info(format string, v ...interface{}) { - Root.Info(fmt.Sprintf(format, v)) + var message string + if len(v) > 0 { + message = fmt.Sprintf(format, v) + } else { + message = format + } + + Root.Info(message) } func Info2(message string, v ...interface{}) { @@ -52,7 +73,14 @@ func Info2(message string, v ...interface{}) { } func Warn(format string, v ...interface{}) { - Root.Warn(fmt.Sprintf(format, v)) + var message string + if len(v) > 0 { + message = fmt.Sprintf(format, v) + } else { + message = format + } + + Root.Warn(message) } func Warn2(message string, v ...interface{}) { diff --git a/pkg/metrics/gauge.go b/pkg/metrics/gauge.go index 01cd584cb39..59758aa4ecb 100644 --- a/pkg/metrics/gauge.go +++ b/pkg/metrics/gauge.go @@ -24,10 +24,10 @@ func NewGauge(meta *MetricMeta) Gauge { } } -func RegGauge(meta *MetricMeta) Gauge { - g := NewGauge(meta) - MetricStats.Register(g) - return g +func RegGauge(name string, tagStrings ...string) Gauge { + tr := NewGauge(NewMetricMeta(name, tagStrings)) + MetricStats.Register(tr) + return tr } // GaugeSnapshot is a read-only copy of another Gauge. diff --git a/pkg/metrics/graphite.go b/pkg/metrics/graphite.go index e88df2ebb1b..59c992776de 100644 --- a/pkg/metrics/graphite.go +++ b/pkg/metrics/graphite.go @@ -63,6 +63,8 @@ func (this *GraphitePublisher) Publish(metrics []Metric) { switch metric := m.(type) { case Counter: this.addCount(buf, metricName+".count", metric.Count(), now) + case Gauge: + this.addCount(buf, metricName, metric.Value(), now) case Timer: percentiles := metric.Percentiles([]float64{0.25, 0.75, 0.90, 0.99}) this.addCount(buf, metricName+".count", metric.Count(), now) diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index bbe580de218..002f2369c9b 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -49,6 +49,12 @@ var ( // Timers M_DataSource_ProxyReq_Timer Timer M_Alerting_Exeuction_Time Timer + + // StatTotals + M_StatTotal_Dashboards Gauge + M_StatTotal_Users Gauge + M_StatTotal_Orgs Gauge + M_StatTotal_Playlists Gauge ) func initMetricVars(settings *MetricSettings) { @@ -105,4 +111,10 @@ func initMetricVars(settings *MetricSettings) { // Timers M_DataSource_ProxyReq_Timer = RegTimer("api.dataproxy.request.all") M_Alerting_Exeuction_Time = RegTimer("alerting.execution_time") + + // StatTotals + M_StatTotal_Dashboards = RegGauge("stat_totals", "stat", "dashboards") + M_StatTotal_Users = RegGauge("stat_totals", "stat", "users") + M_StatTotal_Orgs = RegGauge("stat_totals", "stat", "orgs") + M_StatTotal_Playlists = RegGauge("stat_totals", "stat", "playlists") } diff --git a/pkg/metrics/publish.go b/pkg/metrics/publish.go index 9c1de6e05d2..4255481b8d1 100644 --- a/pkg/metrics/publish.go +++ b/pkg/metrics/publish.go @@ -15,6 +15,7 @@ import ( ) var metricsLogger log.Logger = log.New("metrics") +var metricPublishCounter int64 = 0 func Init() { settings := readSettings() @@ -45,12 +46,33 @@ func sendMetrics(settings *MetricSettings) { return } + updateTotalStats() + metrics := MetricStats.GetSnapshots() for _, publisher := range settings.Publishers { publisher.Publish(metrics) } } +func updateTotalStats() { + + // every interval also publish totals + metricPublishCounter++ + if metricPublishCounter%10 == 0 { + // get stats + statsQuery := m.GetSystemStatsQuery{} + if err := bus.Dispatch(&statsQuery); err != nil { + metricsLogger.Error("Failed to get system stats", "error", err) + return + } + + M_StatTotal_Dashboards.Update(statsQuery.Result.DashboardCount) + M_StatTotal_Users.Update(statsQuery.Result.UserCount) + M_StatTotal_Playlists.Update(statsQuery.Result.PlaylistCount) + M_StatTotal_Orgs.Update(statsQuery.Result.OrgCount) + } +} + func sendUsageStats() { if !setting.ReportingEnabled { return diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index df1768e1c3a..cb3f4480821 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -22,6 +22,7 @@ type Context struct { Session SessionStore IsSignedIn bool + IsRenderCall bool AllowAnonymous bool Logger log.Logger } @@ -42,11 +43,11 @@ func GetContextHandler() macaron.Handler { // then init session and look for userId in session // then look for api key in session (special case for render calls via api) // then test if anonymous access is enabled - if initContextWithApiKey(ctx) || + if initContextWithRenderAuth(ctx) || + initContextWithApiKey(ctx) || initContextWithBasicAuth(ctx) || initContextWithAuthProxy(ctx) || initContextWithUserSessionCookie(ctx) || - initContextWithApiKeyFromSession(ctx) || initContextWithAnonymousUser(ctx) { } @@ -176,29 +177,6 @@ func initContextWithBasicAuth(ctx *Context) bool { } } -// special case for panel render calls with api key -func initContextWithApiKeyFromSession(ctx *Context) bool { - keyId := ctx.Session.Get(SESS_KEY_APIKEY) - if keyId == nil { - return false - } - - keyQuery := m.GetApiKeyByIdQuery{ApiKeyId: keyId.(int64)} - if err := bus.Dispatch(&keyQuery); err != nil { - ctx.Logger.Error("Failed to get api key by id", "id", keyId, "error", err) - return false - } else { - apikey := keyQuery.Result - - ctx.IsSignedIn = true - ctx.SignedInUser = &m.SignedInUser{} - ctx.OrgRole = apikey.Role - ctx.ApiKeyId = apikey.Id - ctx.OrgId = apikey.OrgId - return true - } -} - // Handle handles and logs error by given status. func (ctx *Context) Handle(status int, title string, err error) { if err != nil { diff --git a/pkg/middleware/render_auth.go b/pkg/middleware/render_auth.go new file mode 100644 index 00000000000..3a57660c9bf --- /dev/null +++ b/pkg/middleware/render_auth.go @@ -0,0 +1,55 @@ +package middleware + +import ( + "sync" + + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" +) + +var renderKeysLock sync.Mutex +var renderKeys map[string]*m.SignedInUser = make(map[string]*m.SignedInUser) + +func initContextWithRenderAuth(ctx *Context) bool { + key := ctx.GetCookie("renderKey") + if key == "" { + return false + } + + renderKeysLock.Lock() + defer renderKeysLock.Unlock() + + if renderUser, exists := renderKeys[key]; !exists { + ctx.JsonApiErr(401, "Invalid Render Key", nil) + return true + } else { + + ctx.IsSignedIn = true + ctx.SignedInUser = renderUser + ctx.IsRenderCall = true + return true + } +} + +type renderContextFunc func(key string) (string, error) + +func AddRenderAuthKey(orgId int64) string { + renderKeysLock.Lock() + + key := util.GetRandomString(32) + + renderKeys[key] = &m.SignedInUser{ + OrgId: orgId, + OrgRole: m.ROLE_VIEWER, + } + + renderKeysLock.Unlock() + + return key +} + +func RemoveRenderAuthKey(key string) { + renderKeysLock.Lock() + delete(renderKeys, key) + renderKeysLock.Unlock() +} diff --git a/pkg/middleware/session.go b/pkg/middleware/session.go index 583c57b85a5..ee6462be37a 100644 --- a/pkg/middleware/session.go +++ b/pkg/middleware/session.go @@ -13,7 +13,6 @@ import ( const ( SESS_KEY_USERID = "uid" - SESS_KEY_APIKEY = "apikey_id" // used fror render requests with api keys ) var sessionManager *session.Manager diff --git a/pkg/models/alert.go b/pkg/models/alert.go index bf21b8e5ec6..938be8ddbd4 100644 --- a/pkg/models/alert.go +++ b/pkg/models/alert.go @@ -135,3 +135,18 @@ type GetAlertByIdQuery struct { Result *Alert } + +type GetAlertStatesForDashboardQuery struct { + OrgId int64 + DashboardId int64 + + Result []*AlertStateInfoDTO +} + +type AlertStateInfoDTO struct { + Id int64 `json:"id"` + DashboardId int64 `json:"dashboardId"` + PanelId int64 `json:"panelId"` + State AlertStateType `json:"state"` + NewStateDate time.Time `json:"newStateDate"` +} diff --git a/pkg/models/dashboard_snapshot.go b/pkg/models/dashboard_snapshot.go index 4a4d1290b6d..57c5524ace8 100644 --- a/pkg/models/dashboard_snapshot.go +++ b/pkg/models/dashboard_snapshot.go @@ -63,6 +63,9 @@ type DeleteDashboardSnapshotCommand struct { DeleteKey string `json:"-"` } +type DeleteExpiredSnapshotsCommand struct { +} + type GetDashboardSnapshotQuery struct { Key string diff --git a/pkg/models/models.go b/pkg/models/models.go index 5a53cfdabb3..3f4b27ed6ab 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -7,4 +7,5 @@ const ( GOOGLE TWITTER GENERIC + GRAFANANET ) diff --git a/pkg/models/playlist.go b/pkg/models/playlist.go index 4c6eacbb6a6..5c49bb9256c 100644 --- a/pkg/models/playlist.go +++ b/pkg/models/playlist.go @@ -57,17 +57,6 @@ func (this PlaylistDashboard) TableName() string { type Playlists []*Playlist type PlaylistDashboards []*PlaylistDashboard -// -// DTOS -// - -type PlaylistDashboardDto struct { - Id int64 `json:"id"` - Slug string `json:"slug"` - Title string `json:"title"` - Uri string `json:"uri"` -} - // // COMMANDS // diff --git a/pkg/models/server.go b/pkg/models/server.go new file mode 100644 index 00000000000..876fc91dd01 --- /dev/null +++ b/pkg/models/server.go @@ -0,0 +1,10 @@ +package models + +import "context" + +type GrafanaServer interface { + context.Context + + Start() + Shutdown(code int, reason string) +} diff --git a/pkg/models/stats.go b/pkg/models/stats.go index fa9cfdab6e8..067dec763e5 100644 --- a/pkg/models/stats.go +++ b/pkg/models/stats.go @@ -1,10 +1,10 @@ package models type SystemStats struct { - DashboardCount int - UserCount int - OrgCount int - PlaylistCount int + DashboardCount int64 + UserCount int64 + OrgCount int64 + PlaylistCount int64 } type DataSourceStats struct { diff --git a/pkg/models/user.go b/pkg/models/user.go index a231156b7b0..d2dcdf0a5c9 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -44,15 +44,16 @@ func (u *User) NameOrFallback() string { // COMMANDS type CreateUserCommand struct { - Email string - Login string - Name string - Company string - OrgName string - Password string - EmailVerified bool - IsAdmin bool - SkipOrgSetup bool + Email string + Login string + Name string + Company string + OrgName string + Password string + EmailVerified bool + IsAdmin bool + SkipOrgSetup bool + DefaultOrgRole string Result User } diff --git a/pkg/plugins/datasource_plugin.go b/pkg/plugins/datasource_plugin.go index b8c79f22998..aa092c2bc20 100644 --- a/pkg/plugins/datasource_plugin.go +++ b/pkg/plugins/datasource_plugin.go @@ -6,6 +6,7 @@ type DataSourcePlugin struct { FrontendPluginBase Annotations bool `json:"annotations"` Metrics bool `json:"metrics"` + Alerting bool `json:"alerting"` BuiltIn bool `json:"builtIn"` Mixed bool `json:"mixed"` App string `json:"app"` diff --git a/pkg/plugins/frontend_plugin.go b/pkg/plugins/frontend_plugin.go index 974559001d1..8db480f947d 100644 --- a/pkg/plugins/frontend_plugin.go +++ b/pkg/plugins/frontend_plugin.go @@ -43,7 +43,12 @@ func (fp *FrontendPluginBase) setPathsBasedOnApp(app *AppPlugin) { appSubPath := strings.Replace(fp.PluginDir, app.PluginDir, "", 1) fp.IncludedInAppId = app.Id fp.BaseUrl = app.BaseUrl - fp.Module = util.JoinUrlFragments("plugins/"+app.Id, appSubPath) + "/module" + + if isExternalPlugin(app.PluginDir) { + fp.Module = util.JoinUrlFragments("plugins/"+app.Id, appSubPath) + "/module" + } else { + fp.Module = util.JoinUrlFragments("app/plugins/app/"+app.Id, appSubPath) + "/module" + } } func (fp *FrontendPluginBase) handleModuleDefaults() { diff --git a/pkg/plugins/update_checker.go b/pkg/plugins/update_checker.go index ed43398357e..76c566803ac 100644 --- a/pkg/plugins/update_checker.go +++ b/pkg/plugins/update_checker.go @@ -9,6 +9,11 @@ import ( "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/setting" + "github.com/hashicorp/go-version" +) + +var ( + httpClient http.Client = http.Client{Timeout: time.Duration(10 * time.Second)} ) type GrafanaNetPlugin struct { @@ -39,26 +44,23 @@ func StartPluginUpdateChecker() { } func getAllExternalPluginSlugs() string { - str := "" - + var result []string for _, plug := range Plugins { if plug.IsCorePlugin { continue } - str += plug.Id + "," + result = append(result, plug.Id) } - return str + return strings.Join(result, ",") } func checkForUpdates() { log.Trace("Checking for updates") - client := http.Client{Timeout: time.Duration(5 * time.Second)} - pluginSlugs := getAllExternalPluginSlugs() - resp, err := client.Get("https://grafana.net/api/plugins/versioncheck?slugIn=" + pluginSlugs + "&grafanaVersion=" + setting.BuildVersion) + resp, err := httpClient.Get("https://grafana.net/api/plugins/versioncheck?slugIn=" + pluginSlugs + "&grafanaVersion=" + setting.BuildVersion) if err != nil { log.Trace("Failed to get plugins repo from grafana.net, %v", err.Error()) @@ -84,12 +86,20 @@ func checkForUpdates() { for _, gplug := range gNetPlugins { if gplug.Slug == plug.Id { plug.GrafanaNetVersion = gplug.Version - plug.GrafanaNetHasUpdate = plug.Info.Version != plug.GrafanaNetVersion + + plugVersion, err1 := version.NewVersion(plug.Info.Version) + gplugVersion, err2 := version.NewVersion(gplug.Version) + + if err1 != nil || err2 != nil { + plug.GrafanaNetHasUpdate = plug.Info.Version != plug.GrafanaNetVersion + } else { + plug.GrafanaNetHasUpdate = plugVersion.LessThan(gplugVersion) + } } } } - resp2, err := client.Get("https://raw.githubusercontent.com/grafana/grafana/master/latest.json") + resp2, err := httpClient.Get("https://raw.githubusercontent.com/grafana/grafana/master/latest.json") if err != nil { log.Trace("Failed to get latest.json repo from github: %v", err.Error()) return @@ -116,4 +126,11 @@ func checkForUpdates() { GrafanaLatestVersion = githubLatest.Stable GrafanaHasUpdate = githubLatest.Stable != setting.BuildVersion } + + currVersion, err1 := version.NewVersion(setting.BuildVersion) + latestVersion, err2 := version.NewVersion(GrafanaLatestVersion) + + if err1 == nil && err2 == nil { + GrafanaHasUpdate = currVersion.LessThan(latestVersion) + } } diff --git a/pkg/services/alerting/conditions/evaluator.go b/pkg/services/alerting/conditions/evaluator.go index 18a2bf35262..1c154e17ec2 100644 --- a/pkg/services/alerting/conditions/evaluator.go +++ b/pkg/services/alerting/conditions/evaluator.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/services/alerting" + "gopkg.in/guregu/null.v3" ) var ( @@ -13,13 +14,13 @@ var ( ) type AlertEvaluator interface { - Eval(reducedValue *float64) bool + Eval(reducedValue null.Float) bool } type NoDataEvaluator struct{} -func (e *NoDataEvaluator) Eval(reducedValue *float64) bool { - return reducedValue == nil +func (e *NoDataEvaluator) Eval(reducedValue null.Float) bool { + return reducedValue.Valid == false } type ThresholdEvaluator struct { @@ -43,16 +44,16 @@ func newThresholdEvaludator(typ string, model *simplejson.Json) (*ThresholdEvalu return defaultEval, nil } -func (e *ThresholdEvaluator) Eval(reducedValue *float64) bool { - if reducedValue == nil { +func (e *ThresholdEvaluator) Eval(reducedValue null.Float) bool { + if reducedValue.Valid == false { return false } switch e.Type { case "gt": - return *reducedValue > e.Threshold + return reducedValue.Float64 > e.Threshold case "lt": - return *reducedValue < e.Threshold + return reducedValue.Float64 < e.Threshold } return false @@ -86,16 +87,18 @@ func newRangedEvaluator(typ string, model *simplejson.Json) (*RangedEvaluator, e return rangedEval, nil } -func (e *RangedEvaluator) Eval(reducedValue *float64) bool { - if reducedValue == nil { +func (e *RangedEvaluator) Eval(reducedValue null.Float) bool { + if reducedValue.Valid == false { return false } + floatValue := reducedValue.Float64 + switch e.Type { case "within_range": - return (e.Lower < *reducedValue && e.Upper > *reducedValue) || (e.Upper < *reducedValue && e.Lower > *reducedValue) + return (e.Lower < floatValue && e.Upper > floatValue) || (e.Upper < floatValue && e.Lower > floatValue) case "outside_range": - return (e.Upper < *reducedValue && e.Lower < *reducedValue) || (e.Upper > *reducedValue && e.Lower > *reducedValue) + return (e.Upper < floatValue && e.Lower < floatValue) || (e.Upper > floatValue && e.Lower > floatValue) } return false diff --git a/pkg/services/alerting/conditions/evaluator_test.go b/pkg/services/alerting/conditions/evaluator_test.go index d2919f37d9d..24c5cfacea4 100644 --- a/pkg/services/alerting/conditions/evaluator_test.go +++ b/pkg/services/alerting/conditions/evaluator_test.go @@ -3,6 +3,8 @@ package conditions import ( "testing" + "gopkg.in/guregu/null.v3" + "github.com/grafana/grafana/pkg/components/simplejson" . "github.com/smartystreets/goconvey/convey" ) @@ -14,7 +16,7 @@ func evalutorScenario(json string, reducedValue float64, datapoints ...float64) evaluator, err := NewAlertEvaluator(jsonModel) So(err, ShouldBeNil) - return evaluator.Eval(&reducedValue) + return evaluator.Eval(null.FloatFrom(reducedValue)) } func TestEvalutors(t *testing.T) { @@ -51,6 +53,6 @@ func TestEvalutors(t *testing.T) { evaluator, err := NewAlertEvaluator(jsonModel) So(err, ShouldBeNil) - So(evaluator.Eval(nil), ShouldBeTrue) + So(evaluator.Eval(null.FloatFromPtr(nil)), ShouldBeTrue) }) } diff --git a/pkg/services/alerting/conditions/query.go b/pkg/services/alerting/conditions/query.go index f966985f132..b5300a261a3 100644 --- a/pkg/services/alerting/conditions/query.go +++ b/pkg/services/alerting/conditions/query.go @@ -2,6 +2,8 @@ package conditions import ( "fmt" + "strings" + "time" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" @@ -32,7 +34,8 @@ type AlertQuery struct { } func (c *QueryCondition) Eval(context *alerting.EvalContext) { - seriesList, err := c.executeQuery(context) + timeRange := tsdb.NewTimeRange(c.Query.From, c.Query.To) + seriesList, err := c.executeQuery(context, timeRange) if err != nil { context.Error = err return @@ -43,21 +46,21 @@ func (c *QueryCondition) Eval(context *alerting.EvalContext) { reducedValue := c.Reducer.Reduce(series) evalMatch := c.Evaluator.Eval(reducedValue) - if reducedValue == nil { + if reducedValue.Valid == false { emptySerieCount++ continue } if context.IsTestRun { context.Logs = append(context.Logs, &alerting.ResultLogEntry{ - Message: fmt.Sprintf("Condition[%d]: Eval: %v, Metric: %s, Value: %1.3f", c.Index, evalMatch, series.Name, *reducedValue), + Message: fmt.Sprintf("Condition[%d]: Eval: %v, Metric: %s, Value: %1.3f", c.Index, evalMatch, series.Name, reducedValue.Float64), }) } if evalMatch { context.EvalMatches = append(context.EvalMatches, &alerting.EvalMatch{ Metric: series.Name, - Value: *reducedValue, + Value: reducedValue.Float64, }) } } @@ -66,7 +69,7 @@ func (c *QueryCondition) Eval(context *alerting.EvalContext) { context.Firing = len(context.EvalMatches) > 0 } -func (c *QueryCondition) executeQuery(context *alerting.EvalContext) (tsdb.TimeSeriesSlice, error) { +func (c *QueryCondition) executeQuery(context *alerting.EvalContext, timeRange *tsdb.TimeRange) (tsdb.TimeSeriesSlice, error) { getDsInfo := &m.GetDataSourceByIdQuery{ Id: c.Query.DatasourceId, OrgId: context.Rule.OrgId, @@ -76,7 +79,7 @@ func (c *QueryCondition) executeQuery(context *alerting.EvalContext) (tsdb.TimeS return nil, fmt.Errorf("Could not find datasource") } - req := c.getRequestForAlertRule(getDsInfo.Result) + req := c.getRequestForAlertRule(getDsInfo.Result, timeRange) result := make(tsdb.TimeSeriesSlice, 0) resp, err := c.HandleRequest(req) @@ -102,16 +105,13 @@ func (c *QueryCondition) executeQuery(context *alerting.EvalContext) (tsdb.TimeS return result, nil } -func (c *QueryCondition) getRequestForAlertRule(datasource *m.DataSource) *tsdb.Request { +func (c *QueryCondition) getRequestForAlertRule(datasource *m.DataSource, timeRange *tsdb.TimeRange) *tsdb.Request { req := &tsdb.Request{ - TimeRange: tsdb.TimeRange{ - From: c.Query.From, - To: c.Query.To, - }, + TimeRange: timeRange, Queries: []*tsdb.Query{ { RefId: "A", - Query: c.Query.Model.Get("target").MustString(), + Model: c.Query.Model, DataSource: &tsdb.DataSourceInfo{ Id: datasource.Id, Name: datasource.Name, @@ -141,6 +141,15 @@ func NewQueryCondition(model *simplejson.Json, index int) (*QueryCondition, erro condition.Query.Model = queryJson.Get("model") condition.Query.From = queryJson.Get("params").MustArray()[1].(string) condition.Query.To = queryJson.Get("params").MustArray()[2].(string) + + if err := validateFromValue(condition.Query.From); err != nil { + return nil, err + } + + if err := validateToValue(condition.Query.To); err != nil { + return nil, err + } + condition.Query.DatasourceId = queryJson.Get("datasourceId").MustInt64() reducerJson := model.Get("reducer") @@ -155,3 +164,26 @@ func NewQueryCondition(model *simplejson.Json, index int) (*QueryCondition, erro condition.Evaluator = evaluator return &condition, nil } + +func validateFromValue(from string) error { + fromRaw := strings.Replace(from, "now-", "", 1) + + _, err := time.ParseDuration("-" + fromRaw) + return err +} + +func validateToValue(to string) error { + if to == "now" { + return nil + } else if strings.HasPrefix(to, "now-") { + withoutNow := strings.Replace(to, "now-", "", 1) + + _, err := time.ParseDuration("-" + withoutNow) + if err == nil { + return nil + } + } + + _, err := time.ParseDuration(to) + return err +} diff --git a/pkg/services/alerting/conditions/query_test.go b/pkg/services/alerting/conditions/query_test.go index 983e75c4c1b..51c4226f81c 100644 --- a/pkg/services/alerting/conditions/query_test.go +++ b/pkg/services/alerting/conditions/query_test.go @@ -3,6 +3,8 @@ package conditions import ( "testing" + null "gopkg.in/guregu/null.v3" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" m "github.com/grafana/grafana/pkg/models" @@ -41,9 +43,8 @@ func TestQueryCondition(t *testing.T) { }) Convey("should fire when avg is above 100", func() { - one := float64(120) - two := float64(0) - ctx.series = tsdb.TimeSeriesSlice{tsdb.NewTimeSeries("test1", [][2]*float64{{&one, &two}})} + points := tsdb.NewTimeSeriesPointsFromArgs(120, 0) + ctx.series = tsdb.TimeSeriesSlice{tsdb.NewTimeSeries("test1", points)} ctx.exec() So(ctx.result.Error, ShouldBeNil) @@ -51,9 +52,8 @@ func TestQueryCondition(t *testing.T) { }) Convey("Should not fire when avg is below 100", func() { - one := float64(90) - two := float64(0) - ctx.series = tsdb.TimeSeriesSlice{tsdb.NewTimeSeries("test1", [][2]*float64{{&one, &two}})} + points := tsdb.NewTimeSeriesPointsFromArgs(90, 0) + ctx.series = tsdb.TimeSeriesSlice{tsdb.NewTimeSeries("test1", points)} ctx.exec() So(ctx.result.Error, ShouldBeNil) @@ -61,11 +61,9 @@ func TestQueryCondition(t *testing.T) { }) Convey("Should fire if only first serie matches", func() { - one := float64(120) - two := float64(0) ctx.series = tsdb.TimeSeriesSlice{ - tsdb.NewTimeSeries("test1", [][2]*float64{{&one, &two}}), - tsdb.NewTimeSeries("test2", [][2]*float64{{&two, &two}}), + tsdb.NewTimeSeries("test1", tsdb.NewTimeSeriesPointsFromArgs(120, 0)), + tsdb.NewTimeSeries("test2", tsdb.NewTimeSeriesPointsFromArgs(0, 0)), } ctx.exec() @@ -76,8 +74,8 @@ func TestQueryCondition(t *testing.T) { Convey("Empty series", func() { Convey("Should set NoDataFound both series are empty", func() { ctx.series = tsdb.TimeSeriesSlice{ - tsdb.NewTimeSeries("test1", [][2]*float64{}), - tsdb.NewTimeSeries("test2", [][2]*float64{}), + tsdb.NewTimeSeries("test1", tsdb.NewTimeSeriesPointsFromArgs()), + tsdb.NewTimeSeries("test2", tsdb.NewTimeSeriesPointsFromArgs()), } ctx.exec() @@ -86,10 +84,9 @@ func TestQueryCondition(t *testing.T) { }) Convey("Should set NoDataFound both series contains null", func() { - one := float64(120) ctx.series = tsdb.TimeSeriesSlice{ - tsdb.NewTimeSeries("test1", [][2]*float64{{nil, &one}}), - tsdb.NewTimeSeries("test2", [][2]*float64{{nil, &one}}), + tsdb.NewTimeSeries("test1", tsdb.TimeSeriesPoints{tsdb.TimePoint{null.FloatFromPtr(nil), null.FloatFrom(0)}}), + tsdb.NewTimeSeries("test2", tsdb.TimeSeriesPoints{tsdb.TimePoint{null.FloatFromPtr(nil), null.FloatFrom(0)}}), } ctx.exec() @@ -98,11 +95,9 @@ func TestQueryCondition(t *testing.T) { }) Convey("Should not set NoDataFound if one serie is empty", func() { - one := float64(120) - two := float64(0) ctx.series = tsdb.TimeSeriesSlice{ - tsdb.NewTimeSeries("test1", [][2]*float64{}), - tsdb.NewTimeSeries("test2", [][2]*float64{{&one, &two}}), + tsdb.NewTimeSeries("test1", tsdb.NewTimeSeriesPointsFromArgs()), + tsdb.NewTimeSeries("test2", tsdb.NewTimeSeriesPointsFromArgs(120, 0)), } ctx.exec() diff --git a/pkg/services/alerting/conditions/reducer.go b/pkg/services/alerting/conditions/reducer.go index 2bb4cec00be..a982fa63d33 100644 --- a/pkg/services/alerting/conditions/reducer.go +++ b/pkg/services/alerting/conditions/reducer.go @@ -4,19 +4,20 @@ import ( "math" "github.com/grafana/grafana/pkg/tsdb" + "gopkg.in/guregu/null.v3" ) type QueryReducer interface { - Reduce(timeSeries *tsdb.TimeSeries) *float64 + Reduce(timeSeries *tsdb.TimeSeries) null.Float } type SimpleReducer struct { Type string } -func (s *SimpleReducer) Reduce(series *tsdb.TimeSeries) *float64 { +func (s *SimpleReducer) Reduce(series *tsdb.TimeSeries) null.Float { if len(series.Points) == 0 { - return nil + return null.FloatFromPtr(nil) } value := float64(0) @@ -25,36 +26,36 @@ func (s *SimpleReducer) Reduce(series *tsdb.TimeSeries) *float64 { switch s.Type { case "avg": for _, point := range series.Points { - if point[0] != nil { - value += *point[0] + if point[0].Valid { + value += point[0].Float64 allNull = false } } value = value / float64(len(series.Points)) case "sum": for _, point := range series.Points { - if point[0] != nil { - value += *point[0] + if point[0].Valid { + value += point[0].Float64 allNull = false } } case "min": value = math.MaxFloat64 for _, point := range series.Points { - if point[0] != nil { + if point[0].Valid { allNull = false - if value > *point[0] { - value = *point[0] + if value > point[0].Float64 { + value = point[0].Float64 } } } case "max": value = -math.MaxFloat64 for _, point := range series.Points { - if point[0] != nil { + if point[0].Valid { allNull = false - if value < *point[0] { - value = *point[0] + if value < point[0].Float64 { + value = point[0].Float64 } } } @@ -64,10 +65,10 @@ func (s *SimpleReducer) Reduce(series *tsdb.TimeSeries) *float64 { } if allNull { - return nil + return null.FloatFromPtr(nil) } - return &value + return null.FloatFrom(value) } func NewSimpleReducer(typ string) *SimpleReducer { diff --git a/pkg/services/alerting/conditions/reducer_test.go b/pkg/services/alerting/conditions/reducer_test.go index f60154bc98d..67765f9c310 100644 --- a/pkg/services/alerting/conditions/reducer_test.go +++ b/pkg/services/alerting/conditions/reducer_test.go @@ -10,44 +10,41 @@ import ( func TestSimpleReducer(t *testing.T) { Convey("Test simple reducer by calculating", t, func() { Convey("avg", func() { - result := *testReducer("avg", 1, 2, 3) + result := testReducer("avg", 1, 2, 3) So(result, ShouldEqual, float64(2)) }) Convey("sum", func() { - result := *testReducer("sum", 1, 2, 3) + result := testReducer("sum", 1, 2, 3) So(result, ShouldEqual, float64(6)) }) Convey("min", func() { - result := *testReducer("min", 3, 2, 1) + result := testReducer("min", 3, 2, 1) So(result, ShouldEqual, float64(1)) }) Convey("max", func() { - result := *testReducer("max", 1, 2, 3) + result := testReducer("max", 1, 2, 3) So(result, ShouldEqual, float64(3)) }) Convey("count", func() { - result := *testReducer("count", 1, 2, 3000) + result := testReducer("count", 1, 2, 3000) So(result, ShouldEqual, float64(3)) }) }) } -func testReducer(typ string, datapoints ...float64) *float64 { +func testReducer(typ string, datapoints ...float64) float64 { reducer := NewSimpleReducer(typ) - var timeserie [][2]*float64 - dummieTimestamp := float64(521452145) + series := &tsdb.TimeSeries{ + Name: "test time serie", + } for idx := range datapoints { - timeserie = append(timeserie, [2]*float64{&datapoints[idx], &dummieTimestamp}) + series.Points = append(series.Points, tsdb.NewTimePoint(datapoints[idx], 1234134)) } - tsdb := &tsdb.TimeSeries{ - Name: "test time serie", - Points: timeserie, - } - return reducer.Reduce(tsdb) + return reducer.Reduce(series).Float64 } diff --git a/pkg/services/alerting/engine.go b/pkg/services/alerting/engine.go index 7abfe32425c..10b8af64119 100644 --- a/pkg/services/alerting/engine.go +++ b/pkg/services/alerting/engine.go @@ -1,10 +1,12 @@ package alerting import ( + "context" "time" "github.com/benbjohnson/clock" "github.com/grafana/grafana/pkg/log" + "golang.org/x/sync/errgroup" ) type Engine struct { @@ -34,12 +36,19 @@ func NewEngine() *Engine { return e } -func (e *Engine) Start() { - e.log.Info("Starting Alerting Engine") +func (e *Engine) Run(ctx context.Context) error { + e.log.Info("Initializing Alerting") - go e.alertingTicker() - go e.execDispatcher() - go e.resultDispatcher() + g, ctx := errgroup.WithContext(ctx) + + g.Go(func() error { return e.alertingTicker(ctx) }) + g.Go(func() error { return e.execDispatcher(ctx) }) + g.Go(func() error { return e.resultDispatcher(ctx) }) + + err := g.Wait() + + e.log.Info("Stopped Alerting", "reason", err) + return err } func (e *Engine) Stop() { @@ -47,7 +56,7 @@ func (e *Engine) Stop() { close(e.resultQueue) } -func (e *Engine) alertingTicker() { +func (e *Engine) alertingTicker(grafanaCtx context.Context) error { defer func() { if err := recover(); err != nil { e.log.Error("Scheduler Panic: stopping alertingTicker", "error", err, "stack", log.Stack(1)) @@ -58,6 +67,8 @@ func (e *Engine) alertingTicker() { for { select { + case <-grafanaCtx.Done(): + return grafanaCtx.Err() case tick := <-e.ticker.C: // TEMP SOLUTION update rules ever tenth tick if tickIndex%10 == 0 { @@ -70,37 +81,69 @@ func (e *Engine) alertingTicker() { } } -func (e *Engine) execDispatcher() { - for job := range e.execQueue { - e.log.Debug("Starting executing alert rule", "alert id", job.Rule.Id) - go e.executeJob(job) +func (e *Engine) execDispatcher(grafanaCtx context.Context) error { + for { + select { + case <-grafanaCtx.Done(): + close(e.resultQueue) + return grafanaCtx.Err() + case job := <-e.execQueue: + go e.executeJob(grafanaCtx, job) + } } } -func (e *Engine) executeJob(job *Job) { +func (e *Engine) executeJob(grafanaCtx context.Context, job *Job) error { defer func() { if err := recover(); err != nil { e.log.Error("Execute Alert Panic", "error", err, "stack", log.Stack(1)) } }() - job.Running = true - context := NewEvalContext(job.Rule) - e.evalHandler.Eval(context) - job.Running = false + done := make(chan *EvalContext, 1) + go func() { + job.Running = true + context := NewEvalContext(job.Rule) + e.evalHandler.Eval(context) + job.Running = false + done <- context + close(done) + }() - e.resultQueue <- context + select { + + case <-grafanaCtx.Done(): + return grafanaCtx.Err() + case evalContext := <-done: + e.resultQueue <- evalContext + } + + return nil } -func (e *Engine) resultDispatcher() { +func (e *Engine) resultDispatcher(grafanaCtx context.Context) error { + for { + select { + case <-grafanaCtx.Done(): + //handle all responses before shutting down. + for result := range e.resultQueue { + e.handleResponse(result) + } + + return grafanaCtx.Err() + case result := <-e.resultQueue: + e.handleResponse(result) + } + } +} + +func (e *Engine) handleResponse(result *EvalContext) { defer func() { if err := recover(); err != nil { e.log.Error("Panic in resultDispatcher", "error", err, "stack", log.Stack(1)) } }() - for result := range e.resultQueue { - e.log.Debug("Alert Rule Result", "ruleId", result.Rule.Id, "firing", result.Firing) - e.resultHandler.Handle(result) - } + e.log.Debug("Alert Rule Result", "ruleId", result.Rule.Id, "firing", result.Firing) + e.resultHandler.Handle(result) } diff --git a/pkg/services/alerting/eval_context.go b/pkg/services/alerting/eval_context.go index 13067c25f08..a76ed8d519f 100644 --- a/pkg/services/alerting/eval_context.go +++ b/pkg/services/alerting/eval_context.go @@ -71,7 +71,7 @@ func (c *EvalContext) GetNotificationTitle() string { return "[" + c.GetStateModel().Text + "] " + c.Rule.Name } -func (c *EvalContext) getDashboardSlug() (string, error) { +func (c *EvalContext) GetDashboardSlug() (string, error) { if c.dashboardSlug != "" { return c.dashboardSlug, nil } @@ -86,7 +86,7 @@ func (c *EvalContext) getDashboardSlug() (string, error) { } func (c *EvalContext) GetRuleUrl() (string, error) { - if slug, err := c.getDashboardSlug(); err != nil { + if slug, err := c.GetDashboardSlug(); err != nil { return "", err } else { ruleUrl := fmt.Sprintf("%sdashboard/db/%s?fullscreen&edit&tab=alert&panelId=%d", setting.AppUrl, slug, c.Rule.PanelId) @@ -94,15 +94,6 @@ func (c *EvalContext) GetRuleUrl() (string, error) { } } -func (c *EvalContext) GetImageUrl() (string, error) { - if slug, err := c.getDashboardSlug(); err != nil { - return "", err - } else { - ruleUrl := fmt.Sprintf("%sdashboard-solo/db/%s?&panelId=%d", setting.AppUrl, slug, c.Rule.PanelId) - return ruleUrl, nil - } -} - func NewEvalContext(rule *Rule) *EvalContext { return &EvalContext{ StartTime: time.Now(), diff --git a/pkg/services/alerting/eval_handler.go b/pkg/services/alerting/eval_handler.go index ab4c377197b..a5599b96d2c 100644 --- a/pkg/services/alerting/eval_handler.go +++ b/pkg/services/alerting/eval_handler.go @@ -20,7 +20,7 @@ type DefaultEvalHandler struct { func NewEvalHandler() *DefaultEvalHandler { return &DefaultEvalHandler{ log: log.New("alerting.evalHandler"), - alertJobTimeout: time.Second * 10, + alertJobTimeout: time.Second * 15, } } diff --git a/pkg/services/alerting/extractor.go b/pkg/services/alerting/extractor.go index cf516786412..6a8be4f6deb 100644 --- a/pkg/services/alerting/extractor.go +++ b/pkg/services/alerting/extractor.go @@ -74,9 +74,9 @@ func (e *DashAlertExtractor) GetAlerts() ([]*m.Alert, error) { continue } + // backward compatability check, can be removed later enabled, hasEnabled := jsonAlert.CheckGet("enabled") - - if !hasEnabled || !enabled.MustBool() { + if hasEnabled && enabled.MustBool() == false { continue } diff --git a/pkg/services/alerting/extractor_test.go b/pkg/services/alerting/extractor_test.go index b25ce313b54..f82210f1b1a 100644 --- a/pkg/services/alerting/extractor_test.go +++ b/pkg/services/alerting/extractor_test.go @@ -42,7 +42,6 @@ func TestAlertRuleExtraction(t *testing.T) { "name": "name1", "message": "desc1", "handler": 1, - "enabled": true, "frequency": "60s", "conditions": [ { @@ -66,7 +65,6 @@ func TestAlertRuleExtraction(t *testing.T) { "name": "name2", "message": "desc2", "handler": 0, - "enabled": true, "frequency": "60s", "severity": "warning", "conditions": [ diff --git a/pkg/services/alerting/init/init.go b/pkg/services/alerting/init/init.go deleted file mode 100644 index b6627a359e6..00000000000 --- a/pkg/services/alerting/init/init.go +++ /dev/null @@ -1,20 +0,0 @@ -package init - -import ( - "github.com/grafana/grafana/pkg/services/alerting" - _ "github.com/grafana/grafana/pkg/services/alerting/conditions" - _ "github.com/grafana/grafana/pkg/services/alerting/notifiers" - "github.com/grafana/grafana/pkg/setting" - _ "github.com/grafana/grafana/pkg/tsdb/graphite" -) - -var engine *alerting.Engine - -func Init() { - if !setting.AlertingEnabled { - return - } - - engine = alerting.NewEngine() - engine.Start() -} diff --git a/pkg/services/alerting/notifier.go b/pkg/services/alerting/notifier.go index bb48f71cdcd..39569a94bf1 100644 --- a/pkg/services/alerting/notifier.go +++ b/pkg/services/alerting/notifier.go @@ -2,6 +2,7 @@ package alerting import ( "errors" + "fmt" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/imguploader" @@ -60,20 +61,23 @@ func (n *RootNotifier) sendNotifications(notifiers []Notifier, context *EvalCont } } -func (n *RootNotifier) uploadImage(context *EvalContext) error { - uploader, _ := imguploader.NewImageUploader() - - imageUrl, err := context.GetImageUrl() +func (n *RootNotifier) uploadImage(context *EvalContext) (err error) { + uploader, err := imguploader.NewImageUploader() if err != nil { return err } renderOpts := &renderer.RenderOpts{ - Url: imageUrl, - Width: "800", - Height: "400", - SessionId: "123", - Timeout: "10", + Width: "800", + Height: "400", + Timeout: "30", + OrgId: context.Rule.OrgId, + } + + if slug, err := context.GetDashboardSlug(); err != nil { + return err + } else { + renderOpts.Path = fmt.Sprintf("dashboard-solo/db/%s?&panelId=%d", slug, context.Rule.PanelId) } if imagePath, err := renderer.RenderToPng(renderOpts); err != nil { diff --git a/pkg/services/alerting/notifiers/webhook.go b/pkg/services/alerting/notifiers/webhook.go index 7e28b35cd0a..320f273eddc 100644 --- a/pkg/services/alerting/notifiers/webhook.go +++ b/pkg/services/alerting/notifiers/webhook.go @@ -52,9 +52,8 @@ func (this *WebhookNotifier) Notify(context *alerting.EvalContext) { bodyJSON.Set("rule_url", ruleUrl) } - imageUrl, err := context.GetImageUrl() - if err == nil { - bodyJSON.Set("image_url", imageUrl) + if context.ImagePublicUrl != "" { + bodyJSON.Set("image_url", context.ImagePublicUrl) } body, _ := bodyJSON.MarshalJSON() diff --git a/pkg/services/cleanup/cleanup.go b/pkg/services/cleanup/cleanup.go new file mode 100644 index 00000000000..ffaa75de9cc --- /dev/null +++ b/pkg/services/cleanup/cleanup.go @@ -0,0 +1,85 @@ +package cleanup + +import ( + "context" + "io/ioutil" + "os" + "path" + "time" + + "golang.org/x/sync/errgroup" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/log" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" +) + +type CleanUpService struct { + log log.Logger +} + +func NewCleanUpService() *CleanUpService { + return &CleanUpService{ + log: log.New("cleanup"), + } +} + +func (service *CleanUpService) Run(ctx context.Context) error { + service.log.Info("Initializing CleanUpService") + + g, _ := errgroup.WithContext(ctx) + g.Go(func() error { return service.start(ctx) }) + + err := g.Wait() + service.log.Info("Stopped CleanUpService", "reason", err) + return err +} + +func (service *CleanUpService) start(ctx context.Context) error { + service.cleanUpTmpFiles() + + ticker := time.NewTicker(time.Hour * 1) + for { + select { + case <-ticker.C: + service.cleanUpTmpFiles() + service.deleteExpiredSnapshots() + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func (service *CleanUpService) cleanUpTmpFiles() { + if _, err := os.Stat(setting.ImagesDir); os.IsNotExist(err) { + return + } + + files, err := ioutil.ReadDir(setting.ImagesDir) + if err != nil { + service.log.Error("Problem reading image dir", "error", err) + return + } + + var toDelete []os.FileInfo + for _, file := range files { + if file.ModTime().AddDate(0, 0, 1).Before(time.Now()) { + toDelete = append(toDelete, file) + } + } + + for _, file := range toDelete { + fullPath := path.Join(setting.ImagesDir, file.Name()) + err := os.Remove(fullPath) + if err != nil { + service.log.Error("Failed to delete temp file", "file", file.Name(), "error", err) + } + } + + service.log.Debug("Found old rendered image to delete", "deleted", len(toDelete), "keept", len(files)) +} + +func (service *CleanUpService) deleteExpiredSnapshots() { + bus.Dispatch(&m.DeleteExpiredSnapshotsCommand{}) +} diff --git a/pkg/services/notifications/mailer.go b/pkg/services/notifications/mailer.go index 309436cb7d9..91c75a1889e 100644 --- a/pkg/services/notifications/mailer.go +++ b/pkg/services/notifications/mailer.go @@ -12,6 +12,7 @@ import ( "net/smtp" "os" "strings" + "time" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/setting" @@ -66,7 +67,7 @@ func sendToSmtpServer(recipients []string, msgContent []byte) error { tlsconfig.Certificates = []tls.Certificate{cert} } - conn, err := net.Dial("tcp", net.JoinHostPort(host, port)) + conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, port), time.Second*10) if err != nil { return err } diff --git a/pkg/services/notifications/webhook.go b/pkg/services/notifications/webhook.go index 31f00baebd3..67ffa43900a 100644 --- a/pkg/services/notifications/webhook.go +++ b/pkg/services/notifications/webhook.go @@ -44,7 +44,7 @@ func sendWebRequest(webhook *Webhook) error { webhookLog.Debug("Sending webhook", "url", webhook.Url) client := http.Client{ - Timeout: time.Duration(3 * time.Second), + Timeout: time.Duration(10 * time.Second), } request, err := http.NewRequest("POST", webhook.Url, bytes.NewReader([]byte(webhook.Body))) diff --git a/pkg/services/sqlstore/alert.go b/pkg/services/sqlstore/alert.go index 5a430238823..61397017943 100644 --- a/pkg/services/sqlstore/alert.go +++ b/pkg/services/sqlstore/alert.go @@ -17,6 +17,7 @@ func init() { bus.AddHandler("sql", DeleteAlertById) bus.AddHandler("sql", GetAllAlertQueryHandler) bus.AddHandler("sql", SetAlertState) + bus.AddHandler("sql", GetAlertStatesForDashboard) } func GetAlertById(query *m.GetAlertByIdQuery) error { @@ -92,7 +93,7 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error { params = append(params, query.Limit) } - sql.WriteString("ORDER BY name ASC") + sql.WriteString(" ORDER BY name ASC") alerts := make([]*m.Alert, 0) if err := x.Sql(sql.String(), params...).Find(&alerts); err != nil { @@ -241,3 +242,19 @@ func SetAlertState(cmd *m.SetAlertStateCommand) error { return nil }) } + +func GetAlertStatesForDashboard(query *m.GetAlertStatesForDashboardQuery) error { + var rawSql = `SELECT + id, + dashboard_id, + panel_id, + state, + new_state_date + FROM alert + WHERE org_id = ? AND dashboard_id = ?` + + query.Result = make([]*m.AlertStateInfoDTO, 0) + err := x.Sql(rawSql, query.OrgId, query.DashboardId).Find(&query.Result) + + return err +} diff --git a/pkg/services/sqlstore/alert_notification.go b/pkg/services/sqlstore/alert_notification.go index 5105ca39eff..5acb53c3c09 100644 --- a/pkg/services/sqlstore/alert_notification.go +++ b/pkg/services/sqlstore/alert_notification.go @@ -66,7 +66,8 @@ func GetAlertNotificationsToSend(query *m.GetAlertNotificationsToSendQuery) erro sql.WriteString(` WHERE alert_notification.org_id = ?`) params = append(params, query.OrgId) - sql.WriteString(` AND ((alert_notification.is_default = 1)`) + sql.WriteString(` AND ((alert_notification.is_default = ?)`) + params = append(params, dialect.BooleanStr(true)) if len(query.Ids) > 0 { sql.WriteString(` OR alert_notification.id IN (?` + strings.Repeat(",?", len(query.Ids)-1) + ")") for _, v := range query.Ids { diff --git a/pkg/services/sqlstore/annotation.go b/pkg/services/sqlstore/annotation.go index 1b0f02fce09..3ea8647d3fa 100644 --- a/pkg/services/sqlstore/annotation.go +++ b/pkg/services/sqlstore/annotation.go @@ -75,7 +75,7 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I query.Limit = 10 } - sql.WriteString(fmt.Sprintf("ORDER BY epoch DESC LIMIT %v", query.Limit)) + sql.WriteString(fmt.Sprintf(" ORDER BY epoch DESC LIMIT %v", query.Limit)) items := make([]*annotations.Item, 0) if err := x.Sql(sql.String(), params...).Find(&items); err != nil { diff --git a/pkg/services/sqlstore/dashboard_snapshot.go b/pkg/services/sqlstore/dashboard_snapshot.go index fc94a91cce5..bb3a4f8f57e 100644 --- a/pkg/services/sqlstore/dashboard_snapshot.go +++ b/pkg/services/sqlstore/dashboard_snapshot.go @@ -6,6 +6,7 @@ import ( "github.com/go-xorm/xorm" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" ) func init() { @@ -13,6 +14,25 @@ func init() { bus.AddHandler("sql", GetDashboardSnapshot) bus.AddHandler("sql", DeleteDashboardSnapshot) bus.AddHandler("sql", SearchDashboardSnapshots) + bus.AddHandler("sql", DeleteExpiredSnapshots) +} + +func DeleteExpiredSnapshots(cmd *m.DeleteExpiredSnapshotsCommand) error { + return inTransaction(func(sess *xorm.Session) error { + var expiredCount int64 = 0 + + if setting.SnapShotRemoveExpired { + deleteExpiredSql := "DELETE FROM dashboard_snapshot WHERE expires < ?" + expiredResponse, err := x.Exec(deleteExpiredSql, time.Now) + if err != nil { + return err + } + expiredCount, _ = expiredResponse.RowsAffected() + } + + sqlog.Debug("Deleted old/expired snaphots", "expired", expiredCount) + return nil + }) } func CreateDashboardSnapshot(cmd *m.CreateDashboardSnapshotCommand) error { diff --git a/pkg/services/sqlstore/migrations/dashboard_mig.go b/pkg/services/sqlstore/migrations/dashboard_mig.go index 4f286dce68a..283501d366f 100644 --- a/pkg/services/sqlstore/migrations/dashboard_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_mig.go @@ -120,4 +120,9 @@ func addDashboardMigration(mg *Migrator) { mg.AddMigration("Add index for plugin_id in dashboard", NewAddIndexMigration(dashboardV2, &Index{ Cols: []string{"org_id", "plugin_id"}, Type: IndexType, })) + + // dashboard_id index for dashboard_tag table + mg.AddMigration("Add index for dashboard_id in dashboard_tag", NewAddIndexMigration(dashboardTagV1, &Index{ + Cols: []string{"dashboard_id"}, Type: IndexType, + })) } diff --git a/pkg/services/sqlstore/migrator/dialect.go b/pkg/services/sqlstore/migrator/dialect.go index 0c94eb82234..4473b560428 100644 --- a/pkg/services/sqlstore/migrator/dialect.go +++ b/pkg/services/sqlstore/migrator/dialect.go @@ -18,6 +18,7 @@ type Dialect interface { SupportEngine() bool LikeStr() string Default(col *Column) string + BooleanStr(bool) string CreateIndexSql(tableName string, index *Index) string CreateTableSql(table *Table) string diff --git a/pkg/services/sqlstore/migrator/migrator.go b/pkg/services/sqlstore/migrator/migrator.go index e704826bed3..64831ee46b4 100644 --- a/pkg/services/sqlstore/migrator/migrator.go +++ b/pkg/services/sqlstore/migrator/migrator.go @@ -92,44 +92,45 @@ func (mg *Migrator) Start() error { mg.Logger.Debug("Executing", "sql", sql) - if err := mg.exec(m); err != nil { - mg.Logger.Error("Exec failed", "error", err, "sql", sql) - record.Error = err.Error() - mg.x.Insert(&record) + err := mg.inTransaction(func(sess *xorm.Session) error { + + if err := mg.exec(m, sess); err != nil { + mg.Logger.Error("Exec failed", "error", err, "sql", sql) + record.Error = err.Error() + sess.Insert(&record) + return err + } else { + record.Success = true + sess.Insert(&record) + } + + return nil + }) + + if err != nil { return err - } else { - record.Success = true - mg.x.Insert(&record) } } return nil } -func (mg *Migrator) exec(m Migration) error { +func (mg *Migrator) exec(m Migration, sess *xorm.Session) error { mg.Logger.Info("Executing migration", "id", m.Id()) - err := mg.inTransaction(func(sess *xorm.Session) error { - - condition := m.GetCondition() - if condition != nil { - sql, args := condition.Sql(mg.dialect) - results, err := sess.Query(sql, args...) - if err != nil || len(results) == 0 { - mg.Logger.Info("Skipping migration condition not fulfilled", "id", m.Id()) - return sess.Rollback() - } + condition := m.GetCondition() + if condition != nil { + sql, args := condition.Sql(mg.dialect) + results, err := sess.Query(sql, args...) + if err != nil || len(results) == 0 { + mg.Logger.Info("Skipping migration condition not fulfilled", "id", m.Id()) + return sess.Rollback() } + } - _, err := sess.Exec(m.Sql(mg.dialect)) - if err != nil { - mg.Logger.Error("Executing migration failed", "id", m.Id(), "error", err) - return err - } - return nil - }) - + _, err := sess.Exec(m.Sql(mg.dialect)) if err != nil { + mg.Logger.Error("Executing migration failed", "id", m.Id(), "error", err) return err } diff --git a/pkg/services/sqlstore/migrator/mysql_dialect.go b/pkg/services/sqlstore/migrator/mysql_dialect.go index 195d52d1934..fc64842bd07 100644 --- a/pkg/services/sqlstore/migrator/mysql_dialect.go +++ b/pkg/services/sqlstore/migrator/mysql_dialect.go @@ -29,6 +29,10 @@ func (db *Mysql) AutoIncrStr() string { return "AUTO_INCREMENT" } +func (db *Mysql) BooleanStr(value bool) string { + return strconv.FormatBool(value) +} + func (db *Mysql) SqlType(c *Column) string { var res string switch c.Type { diff --git a/pkg/services/sqlstore/migrator/postgres_dialect.go b/pkg/services/sqlstore/migrator/postgres_dialect.go index 826a00a1410..5500b9f1684 100644 --- a/pkg/services/sqlstore/migrator/postgres_dialect.go +++ b/pkg/services/sqlstore/migrator/postgres_dialect.go @@ -36,6 +36,10 @@ func (db *Postgres) AutoIncrStr() string { return "" } +func (db *Postgres) BooleanStr(value bool) string { + return strconv.FormatBool(value) +} + func (b *Postgres) Default(col *Column) string { if col.Type == DB_Bool { if col.Default == "0" { diff --git a/pkg/services/sqlstore/migrator/sqlite_dialect.go b/pkg/services/sqlstore/migrator/sqlite_dialect.go index 8555754ab92..fe1e781c8df 100644 --- a/pkg/services/sqlstore/migrator/sqlite_dialect.go +++ b/pkg/services/sqlstore/migrator/sqlite_dialect.go @@ -29,6 +29,13 @@ func (db *Sqlite3) AutoIncrStr() string { return "AUTOINCREMENT" } +func (db *Sqlite3) BooleanStr(value bool) string { + if value { + return "1" + } + return "0" +} + func (db *Sqlite3) SqlType(c *Column) string { switch c.Type { case DB_Date, DB_DateTime, DB_TimeStamp, DB_Time: diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 3dc685cd7e5..bbf21296519 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -128,7 +128,11 @@ func CreateUser(cmd *m.CreateUserCommand) error { } if setting.AutoAssignOrg && !user.IsAdmin { - orgUser.Role = m.RoleType(setting.AutoAssignOrgRole) + if len(cmd.DefaultOrgRole) > 0 { + orgUser.Role = m.RoleType(cmd.DefaultOrgRole) + } else { + orgUser.Role = m.RoleType(setting.AutoAssignOrgRole) + } } if _, err = sess.Insert(&orgUser); err != nil { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 33b026713a1..46f70215c1f 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -78,9 +78,11 @@ var ( DataProxyWhiteList map[string]bool // Snapshots - ExternalSnapshotUrl string - ExternalSnapshotName string - ExternalEnabled bool + ExternalSnapshotUrl string + ExternalSnapshotName string + ExternalEnabled bool + SnapShotTTLDays int + SnapShotRemoveExpired bool // User settings AllowUserSignUp bool @@ -90,7 +92,7 @@ var ( VerifyEmailEnabled bool LoginHint string DefaultTheme string - AllowUserPassLogin bool + DisableLoginForm bool // Http auth AdminUser string @@ -495,6 +497,8 @@ func NewConfigContext(args *CommandLineArgs) error { ExternalSnapshotUrl = snapshots.Key("external_snapshot_url").String() ExternalSnapshotName = snapshots.Key("external_snapshot_name").String() ExternalEnabled = snapshots.Key("external_enabled").MustBool(true) + SnapShotRemoveExpired = snapshots.Key("snapshot_remove_expired").MustBool(true) + SnapShotTTLDays = snapshots.Key("snapshot_TTL_days").MustInt(90) // read data source proxy white list DataProxyWhiteList = make(map[string]bool) @@ -514,7 +518,10 @@ func NewConfigContext(args *CommandLineArgs) error { VerifyEmailEnabled = users.Key("verify_email_enabled").MustBool(false) LoginHint = users.Key("login_hint").String() DefaultTheme = users.Key("default_theme").String() - AllowUserPassLogin = users.Key("allow_user_pass_login").MustBool(true) + + // auth + auth := Cfg.Section("auth") + DisableLoginForm = auth.Key("disable_login_form").MustBool(false) // anonymous access AnonymousEnabled = Cfg.Section("auth.anonymous").Key("enabled").MustBool(false) @@ -556,7 +563,7 @@ func NewConfigContext(args *CommandLineArgs) error { log.Warn("require_email_validation is enabled but smpt is disabled") } - GrafanaNetUrl = Cfg.Section("grafana.net").Key("url").MustString("https://grafana.net") + GrafanaNetUrl = Cfg.Section("grafana_net").Key("url").MustString("https://grafana.net") imageUploadingSection := Cfg.Section("external_image_storage") ImageUploadProvider = imageUploadingSection.Key("provider").MustString("internal") diff --git a/pkg/setting/setting_oauth.go b/pkg/setting/setting_oauth.go index 71c4ade1468..8d51343e635 100644 --- a/pkg/setting/setting_oauth.go +++ b/pkg/setting/setting_oauth.go @@ -8,12 +8,11 @@ type OAuthInfo struct { AllowedDomains []string ApiUrl string AllowSignup bool + Name string } type OAuther struct { - GitHub, Google, Twitter, Generic bool - OAuthInfos map[string]*OAuthInfo - OAuthProviderName string + OAuthInfos map[string]*OAuthInfo } var OAuthService *OAuther diff --git a/pkg/social/grafananet_oauth.go b/pkg/social/grafananet_oauth.go new file mode 100644 index 00000000000..80c1aaedb45 --- /dev/null +++ b/pkg/social/grafananet_oauth.go @@ -0,0 +1,114 @@ +package social + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/grafana/grafana/pkg/models" + + "golang.org/x/oauth2" +) + +type SocialGrafanaNet struct { + *oauth2.Config + url string + allowedOrganizations []string + allowSignup bool +} + +func (s *SocialGrafanaNet) Type() int { + return int(models.GRAFANANET) +} + +func (s *SocialGrafanaNet) IsEmailAllowed(email string) bool { + return true +} + +func (s *SocialGrafanaNet) IsSignupAllowed() bool { + return s.allowSignup +} + +func (s *SocialGrafanaNet) IsOrganizationMember(client *http.Client) bool { + if len(s.allowedOrganizations) == 0 { + return true + } + + organizations, err := s.FetchOrganizations(client) + if err != nil { + return false + } + + for _, allowedOrganization := range s.allowedOrganizations { + for _, organization := range organizations { + if organization == allowedOrganization { + return true + } + } + } + + return false +} + +func (s *SocialGrafanaNet) FetchOrganizations(client *http.Client) ([]string, error) { + type Record struct { + Login string `json:"login"` + } + + url := fmt.Sprintf(s.url + "/api/oauth2/user/orgs") + r, err := client.Get(url) + if err != nil { + return nil, err + } + + defer r.Body.Close() + + var records []Record + + if err = json.NewDecoder(r.Body).Decode(&records); err != nil { + return nil, err + } + + var logins = make([]string, len(records)) + for i, record := range records { + logins[i] = record.Login + } + + return logins, nil +} + +func (s *SocialGrafanaNet) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { + var data struct { + Id int `json:"id"` + Name string `json:"login"` + Email string `json:"email"` + Role string `json:"role"` + } + + var err error + client := s.Client(oauth2.NoContext, token) + r, err := client.Get(s.url + "/api/oauth2/user") + if err != nil { + return nil, err + } + + defer r.Body.Close() + + if err = json.NewDecoder(r.Body).Decode(&data); err != nil { + return nil, err + } + + userInfo := &BasicUserInfo{ + Identity: strconv.Itoa(data.Id), + Name: data.Name, + Email: data.Email, + Role: data.Role, + } + + if !s.IsOrganizationMember(client) { + return nil, ErrMissingOrganizationMembership + } + + return userInfo, nil +} diff --git a/pkg/social/social.go b/pkg/social/social.go index 66d0f5fa778..4dbc70d71a9 100644 --- a/pkg/social/social.go +++ b/pkg/social/social.go @@ -15,6 +15,7 @@ type BasicUserInfo struct { Email string Login string Company string + Role string } type SocialConnector interface { @@ -36,7 +37,7 @@ func NewOAuthService() { setting.OAuthService = &setting.OAuther{} setting.OAuthService.OAuthInfos = make(map[string]*setting.OAuthInfo) - allOauthes := []string{"github", "google", "generic_oauth"} + allOauthes := []string{"github", "google", "generic_oauth", "grafananet"} for _, name := range allOauthes { sec := setting.Cfg.Section("auth." + name) @@ -50,6 +51,7 @@ func NewOAuthService() { Enabled: sec.Key("enabled").MustBool(), AllowedDomains: sec.Key("allowed_domains").Strings(" "), AllowSignup: sec.Key("allow_sign_up").MustBool(), + Name: sec.Key("name").MustString(name), } if !info.Enabled { @@ -70,22 +72,18 @@ func NewOAuthService() { // GitHub. if name == "github" { - setting.OAuthService.GitHub = true - teamIds := sec.Key("team_ids").Ints(",") - allowedOrganizations := sec.Key("allowed_organizations").Strings(" ") SocialMap["github"] = &SocialGithub{ Config: &config, allowedDomains: info.AllowedDomains, apiUrl: info.ApiUrl, allowSignup: info.AllowSignup, - teamIds: teamIds, - allowedOrganizations: allowedOrganizations, + teamIds: sec.Key("team_ids").Ints(","), + allowedOrganizations: sec.Key("allowed_organizations").Strings(" "), } } // Google. if name == "google" { - setting.OAuthService.Google = true SocialMap["google"] = &SocialGoogle{ Config: &config, allowedDomains: info.AllowedDomains, apiUrl: info.ApiUrl, @@ -95,17 +93,33 @@ func NewOAuthService() { // Generic - Uses the same scheme as Github. if name == "generic_oauth" { - setting.OAuthService.Generic = true - setting.OAuthService.OAuthProviderName = sec.Key("oauth_provider_name").String() - teamIds := sec.Key("team_ids").Ints(",") - allowedOrganizations := sec.Key("allowed_organizations").Strings(" ") SocialMap["generic_oauth"] = &GenericOAuth{ Config: &config, allowedDomains: info.AllowedDomains, apiUrl: info.ApiUrl, allowSignup: info.AllowSignup, - teamIds: teamIds, - allowedOrganizations: allowedOrganizations, + teamIds: sec.Key("team_ids").Ints(","), + allowedOrganizations: sec.Key("allowed_organizations").Strings(" "), + } + } + + if name == "grafananet" { + config := oauth2.Config{ + ClientID: info.ClientId, + ClientSecret: info.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: setting.GrafanaNetUrl + "/oauth2/authorize", + TokenURL: setting.GrafanaNetUrl + "/api/oauth2/token", + }, + RedirectURL: strings.TrimSuffix(setting.AppUrl, "/") + SocialBaseUrl + name, + Scopes: info.Scopes, + } + + SocialMap["grafananet"] = &SocialGrafanaNet{ + Config: &config, + url: setting.GrafanaNetUrl, + allowSignup: info.AllowSignup, + allowedOrganizations: sec.Key("allowed_organizations").Strings(" "), } } } diff --git a/pkg/tsdb/batch.go b/pkg/tsdb/batch.go index bc16ed1e75a..4dee7b31c86 100644 --- a/pkg/tsdb/batch.go +++ b/pkg/tsdb/batch.go @@ -26,7 +26,7 @@ func (bg *Batch) process(context *QueryContext) { if executor == nil { bg.Done = true result := &BatchResult{ - Error: errors.New("Could not find executor for data source type " + bg.Queries[0].DataSource.PluginId), + Error: errors.New("Could not find executor for data source type: " + bg.Queries[0].DataSource.PluginId), QueryResults: make(map[string]*QueryResult), } for _, query := range bg.Queries { diff --git a/pkg/tsdb/graphite/graphite.go b/pkg/tsdb/graphite/graphite.go index 4042702378c..78685d52371 100644 --- a/pkg/tsdb/graphite/graphite.go +++ b/pkg/tsdb/graphite/graphite.go @@ -38,7 +38,7 @@ func init() { } HttpClient = http.Client{ - Timeout: time.Duration(10 * time.Second), + Timeout: time.Duration(15 * time.Second), Transport: tr, } } @@ -54,7 +54,7 @@ func (e *GraphiteExecutor) Execute(queries tsdb.QuerySlice, context *tsdb.QueryC } for _, query := range queries { - formData["target"] = []string{query.Query} + formData["target"] = []string{query.Model.Get("target").MustString()} } if setting.Env == setting.DEV { @@ -79,7 +79,8 @@ func (e *GraphiteExecutor) Execute(queries tsdb.QuerySlice, context *tsdb.QueryC } result.QueryResults = make(map[string]*tsdb.QueryResult) - queryRes := &tsdb.QueryResult{} + queryRes := tsdb.NewQueryResult() + for _, series := range data { queryRes.Series = append(queryRes.Series, &tsdb.TimeSeries{ Name: series.Target, @@ -102,9 +103,9 @@ func (e *GraphiteExecutor) parseResponse(res *http.Response) ([]TargetResponseDT return nil, err } - if res.StatusCode == http.StatusUnauthorized { - glog.Info("Request is Unauthorized", "status", res.Status, "body", string(body)) - return nil, fmt.Errorf("Request is Unauthorized status: %v body: %s", res.Status, string(body)) + if res.StatusCode/100 != 2 { + glog.Info("Request failed", "status", res.Status, "body", string(body)) + return nil, fmt.Errorf("Request failed status: %v", res.Status) } var data []TargetResponseDTO diff --git a/pkg/tsdb/graphite/graphite_test.go b/pkg/tsdb/graphite/graphite_test.go index 59007b43ed5..7a3bba9035b 100644 --- a/pkg/tsdb/graphite/graphite_test.go +++ b/pkg/tsdb/graphite/graphite_test.go @@ -1,23 +1 @@ package graphite - -// func TestGraphite(t *testing.T) { -// -// Convey("When executing graphite query", t, func() { -// executor := NewGraphiteExecutor(&tsdb.DataSourceInfo{ -// Url: "http://localhost:8080", -// }) -// -// queries := tsdb.QuerySlice{ -// &tsdb.Query{Query: "{\"target\": \"apps.backend.*.counters.requests.count\"}"}, -// } -// -// context := tsdb.NewQueryContext(queries, tsdb.TimeRange{}) -// result := executor.Execute(queries, context) -// So(result.Error, ShouldBeNil) -// -// Convey("Should return series", func() { -// So(result.QueryResults, ShouldNotBeEmpty) -// }) -// }) -// -// } diff --git a/pkg/tsdb/graphite/types.go b/pkg/tsdb/graphite/types.go index 085b1fb2b94..8bd13aec4f2 100644 --- a/pkg/tsdb/graphite/types.go +++ b/pkg/tsdb/graphite/types.go @@ -1,6 +1,8 @@ package graphite +import "github.com/grafana/grafana/pkg/tsdb" + type TargetResponseDTO struct { - Target string `json:"target"` - DataPoints [][2]*float64 `json:"datapoints"` + Target string `json:"target"` + DataPoints tsdb.TimeSeriesPoints `json:"datapoints"` } diff --git a/pkg/tsdb/models.go b/pkg/tsdb/models.go index 05a8b13ef84..bbf7bba7ac7 100644 --- a/pkg/tsdb/models.go +++ b/pkg/tsdb/models.go @@ -1,19 +1,31 @@ package tsdb -type TimeRange struct { - From string - To string +import ( + "github.com/grafana/grafana/pkg/components/simplejson" + "gopkg.in/guregu/null.v3" +) + +type Query struct { + RefId string + Model *simplejson.Json + Depends []string + DataSource *DataSourceInfo + Results []*TimeSeries + Exclude bool + MaxDataPoints int64 + IntervalMs int64 } +type QuerySlice []*Query + type Request struct { - TimeRange TimeRange - MaxDataPoints int - Queries QuerySlice + TimeRange *TimeRange + Queries QuerySlice } type Response struct { - BatchTimings []*BatchTiming - Results map[string]*QueryResult + BatchTimings []*BatchTiming `json:"timings"` + Results map[string]*QueryResult `json:"results"` } type DataSourceInfo struct { @@ -40,19 +52,41 @@ type BatchResult struct { } type QueryResult struct { - Error error - RefId string - Series TimeSeriesSlice + Error error `json:"error"` + RefId string `json:"refId"` + Series TimeSeriesSlice `json:"series"` } type TimeSeries struct { - Name string `json:"name"` - Points [][2]*float64 `json:"points"` + Name string `json:"name"` + Points TimeSeriesPoints `json:"points"` } +type TimePoint [2]null.Float +type TimeSeriesPoints []TimePoint type TimeSeriesSlice []*TimeSeries -func NewTimeSeries(name string, points [][2]*float64) *TimeSeries { +func NewQueryResult() *QueryResult { + return &QueryResult{ + Series: make(TimeSeriesSlice, 0), + } +} + +func NewTimePoint(value float64, timestamp float64) TimePoint { + return TimePoint{null.FloatFrom(value), null.FloatFrom(timestamp)} +} + +func NewTimeSeriesPointsFromArgs(values ...float64) TimeSeriesPoints { + points := make(TimeSeriesPoints, 0) + + for i := 0; i < len(values); i += 2 { + points = append(points, NewTimePoint(values[i], values[i+1])) + } + + return points +} + +func NewTimeSeries(name string, points TimeSeriesPoints) *TimeSeries { return &TimeSeries{ Name: name, Points: points, diff --git a/pkg/tsdb/prometheus/prometheus.go b/pkg/tsdb/prometheus/prometheus.go new file mode 100644 index 00000000000..f7e68662efa --- /dev/null +++ b/pkg/tsdb/prometheus/prometheus.go @@ -0,0 +1,161 @@ +package prometheus + +import ( + "fmt" + "net/http" + "regexp" + "strings" + "time" + + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/tsdb" + "github.com/prometheus/client_golang/api/prometheus" + pmodel "github.com/prometheus/common/model" + "golang.org/x/net/context" +) + +type PrometheusExecutor struct { + *tsdb.DataSourceInfo +} + +func NewPrometheusExecutor(dsInfo *tsdb.DataSourceInfo) tsdb.Executor { + return &PrometheusExecutor{dsInfo} +} + +var ( + plog log.Logger + HttpClient http.Client +) + +func init() { + plog = log.New("tsdb.prometheus") + tsdb.RegisterExecutor("prometheus", NewPrometheusExecutor) +} + +func (e *PrometheusExecutor) getClient() (prometheus.QueryAPI, error) { + cfg := prometheus.Config{ + Address: e.DataSourceInfo.Url, + } + + client, err := prometheus.New(cfg) + if err != nil { + return nil, err + } + + return prometheus.NewQueryAPI(client), nil +} + +func (e *PrometheusExecutor) Execute(queries tsdb.QuerySlice, queryContext *tsdb.QueryContext) *tsdb.BatchResult { + result := &tsdb.BatchResult{} + + client, err := e.getClient() + if err != nil { + return resultWithError(result, err) + } + + query, err := parseQuery(queries, queryContext) + if err != nil { + return resultWithError(result, err) + } + + timeRange := prometheus.Range{ + Start: query.Start, + End: query.End, + Step: query.Step, + } + + value, err := client.QueryRange(context.Background(), query.Expr, timeRange) + + if err != nil { + return resultWithError(result, err) + } + + queryResult, err := parseResponse(value, query) + if err != nil { + return resultWithError(result, err) + } + result.QueryResults = queryResult + return result +} + +func formatLegend(metric pmodel.Metric, query *PrometheusQuery) string { + reg, _ := regexp.Compile(`\{\{\s*(.+?)\s*\}\}`) + + result := reg.ReplaceAllFunc([]byte(query.LegendFormat), func(in []byte) []byte { + ind := strings.Replace(strings.Replace(string(in), "{{", "", 1), "}}", "", 1) + if val, exists := metric[pmodel.LabelName(ind)]; exists { + return []byte(val) + } + + return in + }) + + return string(result) +} + +func parseQuery(queries tsdb.QuerySlice, queryContext *tsdb.QueryContext) (*PrometheusQuery, error) { + queryModel := queries[0] + + expr, err := queryModel.Model.Get("expr").String() + if err != nil { + return nil, err + } + + step, err := queryModel.Model.Get("step").Int64() + if err != nil { + return nil, err + } + + format, err := queryModel.Model.Get("legendFormat").String() + if err != nil { + return nil, err + } + + start, err := queryContext.TimeRange.ParseFrom() + if err != nil { + return nil, err + } + + end, err := queryContext.TimeRange.ParseTo() + if err != nil { + return nil, err + } + + return &PrometheusQuery{ + Expr: expr, + Step: time.Second * time.Duration(step), + LegendFormat: format, + Start: start, + End: end, + }, nil +} + +func parseResponse(value pmodel.Value, query *PrometheusQuery) (map[string]*tsdb.QueryResult, error) { + queryResults := make(map[string]*tsdb.QueryResult) + queryRes := tsdb.NewQueryResult() + + data, ok := value.(pmodel.Matrix) + if !ok { + return queryResults, fmt.Errorf("Unsupported result format: %s", value.Type().String()) + } + + for _, v := range data { + series := tsdb.TimeSeries{ + Name: formatLegend(v.Metric, query), + } + + for _, k := range v.Values { + series.Points = append(series.Points, tsdb.NewTimePoint(float64(k.Value), float64(k.Timestamp.Unix()*1000))) + } + + queryRes.Series = append(queryRes.Series, &series) + } + + queryResults["A"] = queryRes + return queryResults, nil +} + +func resultWithError(result *tsdb.BatchResult, err error) *tsdb.BatchResult { + result.Error = err + return result +} diff --git a/pkg/tsdb/prometheus/prometheus_test.go b/pkg/tsdb/prometheus/prometheus_test.go new file mode 100644 index 00000000000..f7489ae9afc --- /dev/null +++ b/pkg/tsdb/prometheus/prometheus_test.go @@ -0,0 +1,26 @@ +package prometheus + +import ( + "testing" + + p "github.com/prometheus/common/model" + . "github.com/smartystreets/goconvey/convey" +) + +func TestPrometheus(t *testing.T) { + Convey("Prometheus", t, func() { + + Convey("converting metric name", func() { + metric := map[p.LabelName]p.LabelValue{ + p.LabelName("app"): p.LabelValue("backend"), + p.LabelName("device"): p.LabelValue("mobile"), + } + + query := &PrometheusQuery{ + LegendFormat: "legend {{app}} {{device}} {{broken}}", + } + + So(formatLegend(metric, query), ShouldEqual, "legend backend mobile {{broken}}") + }) + }) +} diff --git a/pkg/tsdb/prometheus/types.go b/pkg/tsdb/prometheus/types.go new file mode 100644 index 00000000000..8ed665d0123 --- /dev/null +++ b/pkg/tsdb/prometheus/types.go @@ -0,0 +1,11 @@ +package prometheus + +import "time" + +type PrometheusQuery struct { + Expr string + Step time.Duration + LegendFormat string + Start time.Time + End time.Time +} diff --git a/pkg/tsdb/query.go b/pkg/tsdb/query.go deleted file mode 100644 index bcead660450..00000000000 --- a/pkg/tsdb/query.go +++ /dev/null @@ -1,12 +0,0 @@ -package tsdb - -type Query struct { - RefId string - Query string - Depends []string - DataSource *DataSourceInfo - Results []*TimeSeries - Exclude bool -} - -type QuerySlice []*Query diff --git a/pkg/tsdb/query_context.go b/pkg/tsdb/query_context.go index a1fc4c9bcb5..db40ba6253c 100644 --- a/pkg/tsdb/query_context.go +++ b/pkg/tsdb/query_context.go @@ -3,7 +3,7 @@ package tsdb import "sync" type QueryContext struct { - TimeRange TimeRange + TimeRange *TimeRange Queries QuerySlice Results map[string]*QueryResult ResultsChan chan *BatchResult @@ -11,7 +11,7 @@ type QueryContext struct { BatchWaits sync.WaitGroup } -func NewQueryContext(queries QuerySlice, timeRange TimeRange) *QueryContext { +func NewQueryContext(queries QuerySlice, timeRange *TimeRange) *QueryContext { return &QueryContext{ TimeRange: timeRange, Queries: queries, diff --git a/pkg/tsdb/testdata/scenarios.go b/pkg/tsdb/testdata/scenarios.go new file mode 100644 index 00000000000..e90b0d4df79 --- /dev/null +++ b/pkg/tsdb/testdata/scenarios.go @@ -0,0 +1,130 @@ +package testdata + +import ( + "math/rand" + "strconv" + "strings" + "time" + + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/tsdb" +) + +type ScenarioHandler func(query *tsdb.Query, context *tsdb.QueryContext) *tsdb.QueryResult + +type Scenario struct { + Id string `json:"id"` + Name string `json:"name"` + StringInput string `json:"stringOption"` + Description string `json:"description"` + Handler ScenarioHandler `json:"-"` +} + +var ScenarioRegistry map[string]*Scenario + +func init() { + ScenarioRegistry = make(map[string]*Scenario) + logger := log.New("tsdb.testdata") + + logger.Debug("Initializing TestData Scenario") + + registerScenario(&Scenario{ + Id: "random_walk", + Name: "Random Walk", + + Handler: func(query *tsdb.Query, context *tsdb.QueryContext) *tsdb.QueryResult { + timeWalkerMs := context.TimeRange.GetFromAsMsEpoch() + to := context.TimeRange.GetToAsMsEpoch() + + series := newSeriesForQuery(query) + + points := make(tsdb.TimeSeriesPoints, 0) + walker := rand.Float64() * 100 + + for i := int64(0); i < 10000 && timeWalkerMs < to; i++ { + points = append(points, tsdb.NewTimePoint(walker, float64(timeWalkerMs))) + + walker += rand.Float64() - 0.5 + timeWalkerMs += query.IntervalMs + } + + series.Points = points + + queryRes := tsdb.NewQueryResult() + queryRes.Series = append(queryRes.Series, series) + return queryRes + }, + }) + + registerScenario(&Scenario{ + Id: "no_data_points", + Name: "No Data Points", + Handler: func(query *tsdb.Query, context *tsdb.QueryContext) *tsdb.QueryResult { + return tsdb.NewQueryResult() + }, + }) + + registerScenario(&Scenario{ + Id: "datapoints_outside_range", + Name: "Datapoints Outside Range", + Handler: func(query *tsdb.Query, context *tsdb.QueryContext) *tsdb.QueryResult { + queryRes := tsdb.NewQueryResult() + + series := newSeriesForQuery(query) + outsideTime := context.TimeRange.MustGetFrom().Add(-1*time.Hour).Unix() * 1000 + + series.Points = append(series.Points, tsdb.NewTimePoint(10, float64(outsideTime))) + queryRes.Series = append(queryRes.Series, series) + + return queryRes + }, + }) + + registerScenario(&Scenario{ + Id: "csv_metric_values", + Name: "CSV Metric Values", + StringInput: "1,20,90,30,5,0", + Handler: func(query *tsdb.Query, context *tsdb.QueryContext) *tsdb.QueryResult { + queryRes := tsdb.NewQueryResult() + + stringInput := query.Model.Get("stringInput").MustString() + values := []float64{} + for _, strVal := range strings.Split(stringInput, ",") { + if val, err := strconv.ParseFloat(strVal, 64); err == nil { + values = append(values, val) + } + } + + if len(values) == 0 { + return queryRes + } + + series := newSeriesForQuery(query) + startTime := context.TimeRange.GetFromAsMsEpoch() + endTime := context.TimeRange.GetToAsMsEpoch() + step := (endTime - startTime) / int64(len(values)-1) + + for _, val := range values { + series.Points = append(series.Points, tsdb.NewTimePoint(val, float64(startTime))) + startTime += step + } + + queryRes.Series = append(queryRes.Series, series) + + return queryRes + }, + }) +} + +func registerScenario(scenario *Scenario) { + ScenarioRegistry[scenario.Id] = scenario +} + +func newSeriesForQuery(query *tsdb.Query) *tsdb.TimeSeries { + alias := query.Model.Get("alias").MustString("") + if alias == "" { + alias = query.RefId + "-series" + } + + return &tsdb.TimeSeries{Name: alias} +} diff --git a/pkg/tsdb/testdata/testdata.go b/pkg/tsdb/testdata/testdata.go new file mode 100644 index 00000000000..5b40bb6de5a --- /dev/null +++ b/pkg/tsdb/testdata/testdata.go @@ -0,0 +1,39 @@ +package testdata + +import ( + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/tsdb" +) + +type TestDataExecutor struct { + *tsdb.DataSourceInfo + log log.Logger +} + +func NewTestDataExecutor(dsInfo *tsdb.DataSourceInfo) tsdb.Executor { + return &TestDataExecutor{ + DataSourceInfo: dsInfo, + log: log.New("tsdb.testdata"), + } +} + +func init() { + tsdb.RegisterExecutor("grafana-testdata-datasource", NewTestDataExecutor) +} + +func (e *TestDataExecutor) Execute(queries tsdb.QuerySlice, context *tsdb.QueryContext) *tsdb.BatchResult { + result := &tsdb.BatchResult{} + result.QueryResults = make(map[string]*tsdb.QueryResult) + + for _, query := range queries { + scenarioId := query.Model.Get("scenarioId").MustString("random_walk") + if scenario, exist := ScenarioRegistry[scenarioId]; exist { + result.QueryResults[query.RefId] = scenario.Handler(query, context) + result.QueryResults[query.RefId].RefId = query.RefId + } else { + e.log.Error("Scenario not found", "scenarioId", scenarioId) + } + } + + return result +} diff --git a/pkg/tsdb/time_range.go b/pkg/tsdb/time_range.go new file mode 100644 index 00000000000..cf6bc6a5048 --- /dev/null +++ b/pkg/tsdb/time_range.go @@ -0,0 +1,90 @@ +package tsdb + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +func NewTimeRange(from, to string) *TimeRange { + return &TimeRange{ + From: from, + To: to, + Now: time.Now(), + } +} + +type TimeRange struct { + From string + To string + Now time.Time +} + +func (tr *TimeRange) GetFromAsMsEpoch() int64 { + return tr.MustGetFrom().UnixNano() / int64(time.Millisecond) +} + +func (tr *TimeRange) GetToAsMsEpoch() int64 { + return tr.MustGetTo().UnixNano() / int64(time.Millisecond) +} + +func (tr *TimeRange) MustGetFrom() time.Time { + if res, err := tr.ParseFrom(); err != nil { + return time.Unix(0, 0) + } else { + return res + } +} + +func (tr *TimeRange) MustGetTo() time.Time { + if res, err := tr.ParseTo(); err != nil { + return time.Unix(0, 0) + } else { + return res + } +} + +func tryParseUnixMsEpoch(val string) (time.Time, bool) { + if val, err := strconv.ParseInt(val, 10, 64); err == nil { + seconds := val / 1000 + nano := (val - seconds*1000) * 1000000 + return time.Unix(seconds, nano), true + } + return time.Time{}, false +} + +func (tr *TimeRange) ParseFrom() (time.Time, error) { + if res, ok := tryParseUnixMsEpoch(tr.From); ok { + return res, nil + } + + fromRaw := strings.Replace(tr.From, "now-", "", 1) + diff, err := time.ParseDuration("-" + fromRaw) + if err != nil { + return time.Time{}, err + } + + return tr.Now.Add(diff), nil +} + +func (tr *TimeRange) ParseTo() (time.Time, error) { + if tr.To == "now" { + return tr.Now, nil + } else if strings.HasPrefix(tr.To, "now-") { + withoutNow := strings.Replace(tr.To, "now-", "", 1) + + diff, err := time.ParseDuration("-" + withoutNow) + if err != nil { + return time.Time{}, nil + } + + return tr.Now.Add(diff), nil + } + + if res, ok := tryParseUnixMsEpoch(tr.To); ok { + return res, nil + } + + return time.Time{}, fmt.Errorf("cannot parse to value %s", tr.To) +} diff --git a/pkg/tsdb/time_range_test.go b/pkg/tsdb/time_range_test.go new file mode 100644 index 00000000000..5412d0d05f3 --- /dev/null +++ b/pkg/tsdb/time_range_test.go @@ -0,0 +1,95 @@ +package tsdb + +import ( + "testing" + "time" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestTimeRange(t *testing.T) { + Convey("Time range", t, func() { + + now := time.Now() + + Convey("Can parse 5m, now", func() { + tr := TimeRange{ + From: "5m", + To: "now", + Now: now, + } + + Convey("5m ago ", func() { + fiveMinAgo, _ := time.ParseDuration("-5m") + expected := now.Add(fiveMinAgo) + + res, err := tr.ParseFrom() + So(err, ShouldBeNil) + So(res.Unix(), ShouldEqual, expected.Unix()) + }) + + Convey("now ", func() { + res, err := tr.ParseTo() + So(err, ShouldBeNil) + So(res.Unix(), ShouldEqual, now.Unix()) + }) + }) + + Convey("Can parse 5h, now-10m", func() { + tr := TimeRange{ + From: "5h", + To: "now-10m", + Now: now, + } + + Convey("5h ago ", func() { + fiveHourAgo, _ := time.ParseDuration("-5h") + expected := now.Add(fiveHourAgo) + + res, err := tr.ParseFrom() + So(err, ShouldBeNil) + So(res.Unix(), ShouldEqual, expected.Unix()) + }) + + Convey("now-10m ", func() { + fiveMinAgo, _ := time.ParseDuration("-10m") + expected := now.Add(fiveMinAgo) + res, err := tr.ParseTo() + So(err, ShouldBeNil) + So(res.Unix(), ShouldEqual, expected.Unix()) + }) + }) + + Convey("can parse unix epocs", func() { + var err error + tr := TimeRange{ + From: "1474973725473", + To: "1474975757930", + Now: now, + } + + res, err := tr.ParseFrom() + So(err, ShouldBeNil) + So(res.UnixNano()/int64(time.Millisecond), ShouldEqual, 1474973725473) + + res, err = tr.ParseTo() + So(err, ShouldBeNil) + So(res.UnixNano()/int64(time.Millisecond), ShouldEqual, 1474975757930) + }) + + Convey("Cannot parse asdf", func() { + var err error + tr := TimeRange{ + From: "asdf", + To: "asdf", + Now: now, + } + + _, err = tr.ParseFrom() + So(err, ShouldNotBeNil) + + _, err = tr.ParseTo() + So(err, ShouldNotBeNil) + }) + }) +} diff --git a/pkg/tsdb/tsdb_test.go b/pkg/tsdb/tsdb_test.go index 24d84a27c74..429dd01d6ba 100644 --- a/pkg/tsdb/tsdb_test.go +++ b/pkg/tsdb/tsdb_test.go @@ -14,9 +14,9 @@ func TestMetricQuery(t *testing.T) { Convey("Given 3 queries for 2 data sources", func() { request := &Request{ Queries: QuerySlice{ - {RefId: "A", Query: "asd", DataSource: &DataSourceInfo{Id: 1}}, - {RefId: "B", Query: "asd", DataSource: &DataSourceInfo{Id: 1}}, - {RefId: "C", Query: "asd", DataSource: &DataSourceInfo{Id: 2}}, + {RefId: "A", DataSource: &DataSourceInfo{Id: 1}}, + {RefId: "B", DataSource: &DataSourceInfo{Id: 1}}, + {RefId: "C", DataSource: &DataSourceInfo{Id: 2}}, }, } @@ -31,9 +31,9 @@ func TestMetricQuery(t *testing.T) { Convey("Given query 2 depends on query 1", func() { request := &Request{ Queries: QuerySlice{ - {RefId: "A", Query: "asd", DataSource: &DataSourceInfo{Id: 1}}, - {RefId: "B", Query: "asd", DataSource: &DataSourceInfo{Id: 2}}, - {RefId: "C", Query: "#A / #B", DataSource: &DataSourceInfo{Id: 3}, Depends: []string{"A", "B"}}, + {RefId: "A", DataSource: &DataSourceInfo{Id: 1}}, + {RefId: "B", DataSource: &DataSourceInfo{Id: 2}}, + {RefId: "C", DataSource: &DataSourceInfo{Id: 3}, Depends: []string{"A", "B"}}, }, } @@ -55,7 +55,7 @@ func TestMetricQuery(t *testing.T) { Convey("When executing request with one query", t, func() { req := &Request{ Queries: QuerySlice{ - {RefId: "A", Query: "asd", DataSource: &DataSourceInfo{Id: 1, PluginId: "test"}}, + {RefId: "A", DataSource: &DataSourceInfo{Id: 1, PluginId: "test"}}, }, } @@ -74,8 +74,8 @@ func TestMetricQuery(t *testing.T) { Convey("When executing one request with two queries from same data source", t, func() { req := &Request{ Queries: QuerySlice{ - {RefId: "A", Query: "asd", DataSource: &DataSourceInfo{Id: 1, PluginId: "test"}}, - {RefId: "B", Query: "asd", DataSource: &DataSourceInfo{Id: 1, PluginId: "test"}}, + {RefId: "A", DataSource: &DataSourceInfo{Id: 1, PluginId: "test"}}, + {RefId: "B", DataSource: &DataSourceInfo{Id: 1, PluginId: "test"}}, }, } @@ -100,9 +100,9 @@ func TestMetricQuery(t *testing.T) { Convey("When executing one request with three queries from different datasources", t, func() { req := &Request{ Queries: QuerySlice{ - {RefId: "A", Query: "asd", DataSource: &DataSourceInfo{Id: 1, PluginId: "test"}}, - {RefId: "B", Query: "asd", DataSource: &DataSourceInfo{Id: 1, PluginId: "test"}}, - {RefId: "C", Query: "asd", DataSource: &DataSourceInfo{Id: 2, PluginId: "test"}}, + {RefId: "A", DataSource: &DataSourceInfo{Id: 1, PluginId: "test"}}, + {RefId: "B", DataSource: &DataSourceInfo{Id: 1, PluginId: "test"}}, + {RefId: "C", DataSource: &DataSourceInfo{Id: 2, PluginId: "test"}}, }, } @@ -117,7 +117,7 @@ func TestMetricQuery(t *testing.T) { Convey("When query uses data source of unknown type", t, func() { req := &Request{ Queries: QuerySlice{ - {RefId: "A", Query: "asd", DataSource: &DataSourceInfo{Id: 1, PluginId: "asdasdas"}}, + {RefId: "A", DataSource: &DataSourceInfo{Id: 1, PluginId: "asdasdas"}}, }, } @@ -129,10 +129,10 @@ func TestMetricQuery(t *testing.T) { req := &Request{ Queries: QuerySlice{ { - RefId: "A", Query: "asd", DataSource: &DataSourceInfo{Id: 1, PluginId: "test"}, + RefId: "A", DataSource: &DataSourceInfo{Id: 1, PluginId: "test"}, }, { - RefId: "B", Query: "#A / 2", DataSource: &DataSourceInfo{Id: 2, PluginId: "test"}, Depends: []string{"A"}, + RefId: "B", DataSource: &DataSourceInfo{Id: 2, PluginId: "test"}, Depends: []string{"A"}, }, }, } diff --git a/public/app/core/controllers/login_ctrl.js b/public/app/core/controllers/login_ctrl.js index 3f31407f454..4b03dddf973 100644 --- a/public/app/core/controllers/login_ctrl.js +++ b/public/app/core/controllers/login_ctrl.js @@ -1,11 +1,18 @@ define([ 'angular', + 'lodash', '../core_module', 'app/core/config', ], -function (angular, coreModule, config) { +function (angular, _, coreModule, config) { 'use strict'; + var failCodes = { + "1000": "Required team membership not fulfilled", + "1001": "Required organization membership not fulfilled", + "1002": "Required email domain not fulfilled", + }; + coreModule.default.controller('LoginCtrl', function($scope, backendSrv, contextSrv, $location) { $scope.formModel = { user: '', @@ -15,12 +22,10 @@ function (angular, coreModule, config) { contextSrv.sidemenu = false; - $scope.googleAuthEnabled = config.googleAuthEnabled; - $scope.githubAuthEnabled = config.githubAuthEnabled; - $scope.oauthEnabled = config.githubAuthEnabled || config.googleAuthEnabled || config.genericOAuthEnabled; - $scope.allowUserPassLogin = config.allowUserPassLogin; - $scope.genericOAuthEnabled = config.genericOAuthEnabled; - $scope.oauthProviderName = config.oauthProviderName; + $scope.oauth = config.oauth; + $scope.oauthEnabled = _.keys(config.oauth).length > 0; + + $scope.disableLoginForm = config.disableLoginForm; $scope.disableUserSignUp = config.disableUserSignUp; $scope.loginHint = config.loginHint; @@ -31,8 +36,8 @@ function (angular, coreModule, config) { $scope.$watch("loginMode", $scope.loginModeChanged); var params = $location.search(); - if (params.failedMsg) { - $scope.appEvent('alert-warning', ['Login Failed', params.failedMsg]); + if (params.failCode) { + $scope.appEvent('alert-warning', ['Login Failed', failCodes[params.failCode]]); delete params.failedMsg; $location.search(params); } diff --git a/public/app/core/core.ts b/public/app/core/core.ts index 1174e267f4e..d44cbf4dbfb 100644 --- a/public/app/core/core.ts +++ b/public/app/core/core.ts @@ -41,6 +41,7 @@ import 'app/core/routes/routes'; import './filters/filters'; import coreModule from './core_module'; import appEvents from './app_events'; +import colors from './utils/colors'; export { @@ -60,4 +61,5 @@ export { dashboardSelector, queryPartEditorDirective, WizardFlow, + colors, }; diff --git a/public/app/core/directives/metric_segment.js b/public/app/core/directives/metric_segment.js index 9257f3c6a79..cc662aa67ca 100644 --- a/public/app/core/directives/metric_segment.js +++ b/public/app/core/directives/metric_segment.js @@ -23,10 +23,10 @@ function (_, $, coreModule) { getOptions: "&", onChange: "&", }, - link: function($scope, elem, attrs) { + link: function($scope, elem) { var $input = $(inputTemplate); - var $button = $(attrs.styleMode === 'select' ? selectTemplate : linkTemplate); var segment = $scope.segment; + var $button = $(segment.selectMode ? selectTemplate : linkTemplate); var options = null; var cancelBlur = null; var linkMode = true; @@ -136,7 +136,7 @@ function (_, $, coreModule) { $button.click(function() { options = null; - $input.css('width', ($button.width() + 16) + 'px'); + $input.css('width', (Math.max($button.width(), 80) + 16) + 'px'); $button.hide(); $input.show(); @@ -170,6 +170,7 @@ function (_, $, coreModule) { }, link: { pre: function postLink($scope, elem, attrs) { + var cachedOptions; $scope.valueToSegment = function(value) { var option = _.find($scope.options, {value: value}); @@ -177,7 +178,9 @@ function (_, $, coreModule) { cssClass: attrs.cssClass, custom: attrs.custom, value: option ? option.text : value, + selectMode: attrs.selectMode, }; + return uiSegmentSrv.newSegment(segment); }; @@ -188,13 +191,20 @@ function (_, $, coreModule) { }); return $q.when(optionSegments); } else { - return $scope.getOptions(); + return $scope.getOptions().then(function(options) { + cachedOptions = options; + return _.map(options, function(option) { + return uiSegmentSrv.newSegment({value: option.text}); + }); + }); } }; $scope.onSegmentChange = function() { - if ($scope.options) { - var option = _.find($scope.options, {text: $scope.segment.value}); + var options = $scope.options || cachedOptions; + + if (options) { + var option = _.find(options, {text: $scope.segment.value}); if (option && option.value !== $scope.property) { $scope.property = option.value; } else if (attrs.custom !== 'false') { diff --git a/public/app/core/directives/value_select_dropdown.js b/public/app/core/directives/value_select_dropdown.js index d97124f3406..7f3c8ccc7bb 100644 --- a/public/app/core/directives/value_select_dropdown.js +++ b/public/app/core/directives/value_select_dropdown.js @@ -236,7 +236,7 @@ function (angular, _, coreModule) { var inputEl = elem.find('input'); function openDropdown() { - inputEl.css('width', Math.max(linkEl.width(), 30) + 'px'); + inputEl.css('width', Math.max(linkEl.width(), 80) + 'px'); inputEl.show(); linkEl.hide(); diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index fdc2b6cb974..1e620e88216 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -114,6 +114,10 @@ export class BackendSrv { var requestIsLocal = options.url.indexOf('/') === 0; var firstAttempt = options.retry === 0; + if (requestIsLocal && !options.hasSubUrl && options.retry === 0) { + options.url = config.appSubUrl + options.url; + } + if (requestIsLocal && options.headers && options.headers.Authorization) { options.headers['X-DS-Authorization'] = options.headers.Authorization; delete options.headers.Authorization; diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts index 41d99c3198f..ac00528db20 100644 --- a/public/app/core/services/context_srv.ts +++ b/public/app/core/services/context_srv.ts @@ -9,6 +9,7 @@ export class User { isGrafanaAdmin: any; isSignedIn: any; orgRole: any; + timezone: string; constructor() { if (config.bootData.user) { diff --git a/public/app/core/services/segment_srv.js b/public/app/core/services/segment_srv.js index d05a2bb011f..9d13e8e27e3 100644 --- a/public/app/core/services/segment_srv.js +++ b/public/app/core/services/segment_srv.js @@ -28,6 +28,7 @@ function (angular, _, coreModule) { this.type = options.type; this.fake = options.fake; this.value = options.value; + this.selectMode = options.selectMode; this.type = options.type; this.expandable = options.expandable; this.html = options.html || $sce.trustAsHtml(templateSrv.highlightVariablesAsHtml(this.value)); diff --git a/public/app/core/time_series2.ts b/public/app/core/time_series2.ts index dfae26fb48b..d672e0dd0dc 100644 --- a/public/app/core/time_series2.ts +++ b/public/app/core/time_series2.ts @@ -31,6 +31,8 @@ export default class TimeSeries { allIsZero: boolean; decimals: number; scaledDecimals: number; + hasMsResolution: boolean; + isOutsideRange: boolean; lines: any; bars: any; @@ -54,6 +56,7 @@ export default class TimeSeries { this.stats = {}; this.legend = true; this.unit = opts.unit; + this.hasMsResolution = this.isMsResolutionNeeded(); } applySeriesOverrides(overrides) { diff --git a/public/app/core/utils/colors.ts b/public/app/core/utils/colors.ts new file mode 100644 index 00000000000..bd774ea02ea --- /dev/null +++ b/public/app/core/utils/colors.ts @@ -0,0 +1,12 @@ + + +export default [ + "#7EB26D","#EAB839","#6ED0E0","#EF843C","#E24D42","#1F78C1","#BA43A9","#705DA0", + "#508642","#CCA300","#447EBC","#C15C17","#890F02","#0A437C","#6D1F62","#584477", + "#B7DBAB","#F4D598","#70DBED","#F9BA8F","#F29191","#82B5D8","#E5A8E2","#AEA2E0", + "#629E51","#E5AC0E","#64B0C8","#E0752D","#BF1B00","#0A50A1","#962D82","#614D93", + "#9AC48A","#F2C96D","#65C5DB","#F9934E","#EA6460","#5195CE","#D683CE","#806EB7", + "#3F6833","#967302","#2F575E","#99440A","#58140C","#052B51","#511749","#3F2B5B", + "#E0F9D7","#FCEACA","#CFFAFF","#F9E2D2","#FCE2DE","#BADFF4","#F9D9F9","#DEDAF7" +]; + diff --git a/public/app/core/utils/kbn.js b/public/app/core/utils/kbn.js index c0d162b0ece..a807a249235 100644 --- a/public/app/core/utils/kbn.js +++ b/public/app/core/utils/kbn.js @@ -9,6 +9,10 @@ function($, _, moment) { var kbn = {}; kbn.valueFormats = {}; + kbn.regexEscape = function(value) { + return value.replace(/[\\^$*+?.()|[\]{}\/]/g, '\\$&'); + }; + ///// HELPER FUNCTIONS ///// kbn.round_interval = function(interval) { @@ -170,7 +174,10 @@ function($, _, moment) { lowLimitMs = kbn.interval_to_ms(lowLimitInterval); } else { - return userInterval; + return { + intervalMs: kbn.interval_to_ms(userInterval), + interval: userInterval, + }; } } @@ -179,7 +186,10 @@ function($, _, moment) { intervalMs = lowLimitMs; } - return kbn.secondsToHms(intervalMs / 1000); + return { + intervalMs: intervalMs, + interval: kbn.secondsToHms(intervalMs / 1000), + }; }; kbn.describe_interval = function (string) { diff --git a/public/app/features/alerting/alert_tab_ctrl.ts b/public/app/features/alerting/alert_tab_ctrl.ts index 01cacaf9740..0ea5d5fd804 100644 --- a/public/app/features/alerting/alert_tab_ctrl.ts +++ b/public/app/features/alerting/alert_tab_ctrl.ts @@ -6,6 +6,7 @@ import {QueryPart} from 'app/core/components/query_part/query_part'; import alertDef from './alert_def'; import config from 'app/core/config'; import moment from 'moment'; +import appEvents from 'app/core/app_events'; export class AlertTabCtrl { panel: any; @@ -47,19 +48,18 @@ export class AlertTabCtrl { $onInit() { this.addNotificationSegment = this.uiSegmentSrv.newPlusButton(); - this.initModel(); - this.validateModel(); + // subscribe to graph threshold handle changes + var thresholdChangedEventHandler = this.graphThresholdChanged.bind(this); + this.panelCtrl.events.on('threshold-changed', thresholdChangedEventHandler); - // set panel alert edit mode + // set panel alert edit mode this.$scope.$on("$destroy", () => { + this.panelCtrl.events.off("threshold-changed", thresholdChangedEventHandler); this.panelCtrl.editingThresholds = false; this.panelCtrl.render(); }); - // subscribe to graph threshold handle changes - this.panelCtrl.events.on('threshold-changed', this.graphThresholdChanged.bind(this)); - - // build notification model + // build notification model this.notifications = []; this.alertNotifications = []; this.alertHistory = []; @@ -67,21 +67,8 @@ export class AlertTabCtrl { return this.backendSrv.get('/api/alert-notifications').then(res => { this.notifications = res; - _.each(this.alert.notifications, item => { - var model = _.find(this.notifications, {id: item.id}); - if (model) { - model.iconClass = this.getNotificationIcon(model.type); - this.alertNotifications.push(model); - } - }); - - _.each(this.notifications, item => { - if (item.isDefault) { - item.iconClass = this.getNotificationIcon(item.type); - item.bgColor = "#00678b"; - this.alertNotifications.push(item); - } - }); + this.initModel(); + this.validateModel(); }); } @@ -142,9 +129,8 @@ export class AlertTabCtrl { } initModel() { - var alert = this.alert = this.panel.alert = this.panel.alert || {enabled: false}; - - if (!this.alert.enabled) { + var alert = this.alert = this.panel.alert; + if (!alert) { return; } @@ -168,6 +154,22 @@ export class AlertTabCtrl { ThresholdMapper.alertToGraphThresholds(this.panel); + for (let addedNotification of alert.notifications) { + var model = _.find(this.notifications, {id: addedNotification.id}); + if (model) { + model.iconClass = this.getNotificationIcon(model.type); + this.alertNotifications.push(model); + } + } + + for (let notification of this.notifications) { + if (notification.isDefault) { + notification.iconClass = this.getNotificationIcon(notification.type); + notification.bgColor = "#00678b"; + this.alertNotifications.push(notification); + } + } + this.panelCtrl.editingThresholds = true; this.panelCtrl.render(); } @@ -192,7 +194,7 @@ export class AlertTabCtrl { } validateModel() { - if (!this.alert.enabled) { + if (!this.alert) { return; } @@ -227,8 +229,8 @@ export class AlertTabCtrl { var datasourceName = foundTarget.datasource || this.panel.datasource; this.datasourceSrv.get(datasourceName).then(ds => { - if (ds.meta.id !== 'graphite') { - this.error = 'Currently the alerting backend only supports Graphite queries'; + if (!ds.meta.alerting) { + this.error = 'The datasource does not support alerting queries'; } else if (this.templateSrv.variableExists(foundTarget.target)) { this.error = 'Template variables are not supported in alert queries'; } else { @@ -302,14 +304,24 @@ export class AlertTabCtrl { } delete() { - this.alert = this.panel.alert = {enabled: false}; - this.panel.thresholds = []; - this.conditionModels = []; - this.panelCtrl.render(); + appEvents.emit('confirm-modal', { + title: 'Delete Alert', + text: 'Are you sure you want to delete this alert rule?', + text2: 'You need to save dashboard for the delete to take effect', + icon: 'fa-trash', + yesText: 'Delete', + onConfirm: () => { + delete this.panel.alert; + this.alert = null; + this.panel.thresholds = []; + this.conditionModels = []; + this.panelCtrl.render(); + } + }); } enable() { - this.alert.enabled = true; + this.panel.alert = {}; this.initModel(); } diff --git a/public/app/features/alerting/partials/alert_tab.html b/public/app/features/alerting/partials/alert_tab.html index 3d6e183d93f..bb6fe7547b2 100644 --- a/public/app/features/alerting/partials/alert_tab.html +++ b/public/app/features/alerting/partials/alert_tab.html @@ -1,4 +1,4 @@ -
+
-
+