diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 98f6b6fe928..57fca7f44cb 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,5 +1,10 @@ -Please prefix your title with [Bug] or [Feature request] -For question please check [Support Options](http://grafana.org/support/). **Do not** open a github issue +Read before posting: + +- Questions should be posted to https://community.grafana.com. Please search there and here on GitHub for similar issues before creating a new issue. +- Checkout FAQ: https://community.grafana.com/c/howto/faq +- Checkout How to troubleshoot metric query issues: https://community.grafana.com/t/how-to-troubleshoot-metric-query-issues/50 + +Please prefix your title with [Bug] or [Feature request]. Please include this information: - What Grafana version are you using? @@ -8,11 +13,6 @@ Please include this information: - What did you do? - What was the expected result? - What happened instead? - -**IMPORTANT** -If it relates to *metric data viz*: -- An image or text representation of your metric query -- The raw query and response for the network request (check this in chrome dev tools network tab, here you can see metric requests and other request, please include the request body and request response) - -If it relates to *alerting* -- An image of the test execution data fully expanded. +- If related to metric query / data viz: + - Include raw network request & response: get by opening Chrome Dev Tools (F12, Ctrl+Shift+I on windows, Cmd+Opt+I on Mac), go the network tab. + diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9cb6d797e..82b3ba2037e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,21 +1,86 @@ -# 4.2.0 (unreleased) +# 4.3.0 (unreleased) ## Enhancements -* **Alerting**: Added Telegram alert notifier [#7098](https://github.com/grafana/grafana/pull/7098), thx [@leonoff](https://github.com/leonoff) + +* **InfluxDB**: influxdb query builder support for ORDER BY and LIMIT (allows TOPN queries) [#6065](https://github.com/grafana/grafana/issues/6065) Support influxdb's SLIMIT Feature [#7232](https://github.com/grafana/grafana/issues/7232) thx [@thuck](https://github.com/thuck) +* **InfluxDB**: Small fix for the "glow" when focus the field for LIMIT and SLIMIT [#7799](https://github.com/grafana/grafana/pull/7799) thx [@thuck](https://github.com/thuck) +* **Panels**: Delay loading & Lazy load panels as they become visible (scrolled into view) [#5216](https://github.com/grafana/grafana/issues/5216) thx [@jifwin](https://github.com/jifwin) +* **Graph**: Support auto grid min/max when using log scale [#3090](https://github.com/grafana/grafana/issues/3090), thx [@bigbenhur](https://github.com/bigbenhur) +* **Elasticsearch**: Support histogram aggregations [#3164](https://github.com/grafana/grafana/issues/3164) + +## Minor Enchancements + +* **Prometheus**: Make Prometheus query field a textarea [#7663](https://github.com/grafana/grafana/issues/7663), thx [@hagen1778](https://github.com/hagen1778) +* **Templating**: Should not be possible to create self-referencing (recursive) template variable definitions [#7614](https://github.com/grafana/grafana/issues/7614) thx [@thuck](https://github.com/thuck) +* **Cloudwatch**: Correctly obtain IAM roles within ECS container tasks [#7892](https://github.com/grafana/grafana/issues/7892) thx [@gomlgs](https://github.com/gomlgs) +* **Units**: New number format: Scientific notation [#7781](https://github.com/grafana/grafana/issues/7781) thx [@cadnce](https://github.com/cadnce) +* **Oauth**: Add common type for oauth authorization errors [#6428](https://github.com/grafana/grafana/issues/6428) thx [@amenzhinsky](https://github.com/amenzhinsky) + +# 4.2.0 (2017-03-22) +## Minor Enhancements +* **Templates**: Prevent use of the prefix `__` for templates in web UI [#7678](https://github.com/grafana/grafana/issues/7678) +* **Threema**: Add emoji to Threema alert notifications [#7676](https://github.com/grafana/grafana/pull/7676) thx [@dbrgn](https://github.com/dbrgn) +* **Panels**: Support dm3 unit [#7695](https://github.com/grafana/grafana/issues/7695) thx [@mitjaziv](https://github.com/mitjaziv) +* **Docs**: Added some details about Sessions in Postgres [#7694](https://github.com/grafana/grafana/pull/7694) thx [@rickard-von-essen](https://github.com/rickard-von-essen) +* **Influxdb**: Allow commas in template variables [#7681](https://github.com/grafana/grafana/issues/7681) thx [@thuck](https://github.com/thuck) +* **Cloudwatch**: stop using deprecated session.New() [#7736](https://github.com/grafana/grafana/issues/7736) thx [@mtanda](https://github.com/mtanda) +* **OpenTSDB**: Pass dropcounter rate option if no max counter and no reset value or reset value as 0 is specified [#7743](https://github.com/grafana/grafana/pull/7743) thx [@r4um](https://github.com/r4um) +* **Templating**: support full resolution for $interval variable [#7696](https://github.com/grafana/grafana/pull/7696) thx [@mtanda](https://github.com/mtanda) +* **Elasticsearch**: Unique Count on string fields in ElasticSearch [#3536](https://github.com/grafana/grafana/issues/3536), thx [@pyro2927](https://github.com/pyro2927) +* **Templating**: Data source template variable that refers to other variable in regex filter [#6365](https://github.com/grafana/grafana/issues/6365) thx [@rlodge](https://github.com/rlodge) +* **Admin**: Global User List: add search and pagination [#7469](https://github.com/grafana/grafana/issues/7469) +* **User Management**: Invite UI is now disabled when login form is disabled [#7875](https://github.com/grafana/grafana/issues/7875) + +## Bugfixes +* **Webhook**: Use proxy settings from environment variables [#7710](https://github.com/grafana/grafana/issues/7710) +* **Panels**: Deleting a dashboard with unsaved changes raises an error message [#7591](https://github.com/grafana/grafana/issues/7591) thx [@thuck](https://github.com/thuck) +* **Influxdb**: Query builder detects regex to easily for measurement [#7276](https://github.com/grafana/grafana/issues/7276) thx [@thuck](https://github.com/thuck) +* **Docs**: router_logging not documented [#7723](https://github.com/grafana/grafana/issues/7723) +* **Alerting**: Spelling mistake [#7739](https://github.com/grafana/grafana/pull/7739) thx [@woutersmit](https://github.com/woutersmit) +* **Alerting**: Graph legend scrolls to top when an alias is toggled/clicked [#7680](https://github.com/grafana/grafana/issues/7680) thx [@p4ddy1](https://github.com/p4ddy1) +* **Panels**: Fixed panel tooltip description after scrolling down [#7708](https://github.com/grafana/grafana/issues/7708) thx [@askomorokhov](https://github.com/askomorokhov) + +# 4.2.0-beta1 (2017-02-27) + +## Enhancements +* **Telegram**: Added Telegram alert notifier [#7098](https://github.com/grafana/grafana/pull/7098), thx [@leonoff](https://github.com/leonoff) * **Templating**: Make $__interval and $__interval_ms global built in variables that can be used in by any datasource (in panel queries), closes [#7190](https://github.com/grafana/grafana/issues/7190), closes [#6582](https://github.com/grafana/grafana/issues/6582) * **S3 Image Store**: External s3 image store (used in alert notifications) now support AWS IAM Roles, closes [#6985](https://github.com/grafana/grafana/issues/6985), [#7058](https://github.com/grafana/grafana/issues/7058) thx [@mtanda](https://github.com/mtanda) -* **Optimzation**: Never issue refresh event when Grafana tab is not visible [#7218](https://github.com/grafana/grafana/issues/7218), thx [@mtanda](https://github.com/mtanda) -* **Browser History**: Browser back/forward now works time ranges / zoom, [#7259](https://github.com/grafana/grafana/issues/7259) * **SingleStat**: Implements diff aggregation method for singlestat [#7234](https://github.com/grafana/grafana/issues/7234), thx [@oliverpool](https://github.com/oliverpool) * **Dataproxy**: Added setting to enable more verbose logging in dataproxy [#7209](https://github.com/grafana/grafana/pull/7209), thx [@Ricky-N](https://github.com/Ricky-N) * **Alerting**: Better information about why an alert triggered [#7035](https://github.com/grafana/grafana/issues/7035) -* **LINE**: Add LINE as alerting notification channel [#7301](https://github.com/grafana/grafana/pull/7301), thx [#huydx](https://github.com/huydx) -* **Elasticsearch**: Support for Min Doc Count options in Terms aggregation [#7324](https://github.com/grafana/grafana/pull/7324), thx [#lpic10](https://github.com/lpic10) -* **Elasticsearch**: Term aggregation limit can now be changed in template queries [#7112](https://github.com/grafana/grafana/issues/7112), thx [#FFalcon](https://github.com/FFalcon) +* **LINE**: Add LINE as alerting notification channel [#7301](https://github.com/grafana/grafana/pull/7301), thx [@huydx](https://github.com/huydx) +* **LINE**: Adds image to notification message [#7417](https://github.com/grafana/grafana/pull/7417), thx [@Erliz](https://github.com/Erliz) +* **Hipchat**: Adds support for sending alert notifications to hipchat [#6451](https://github.com/grafana/grafana/issues/6451), thx [@jregovic](https://github.com/jregovic) +* **Alerting**: Uploading images for alert notifications is now optional [#7419](https://github.com/grafana/grafana/issues/7419) +* **Dashboard**: Adds shortcut for collapsing/expanding all rows [#552](https://github.com/grafana/grafana/issues/552), thx [@mtanda](https://github.com/mtanda) +* **Alerting**: Adds de duping of alert notifications [#7632](https://github.com/grafana/grafana/pull/7632) +* **Orgs**: Sharing dashboards using Grafana share feature will now redirect to correct org. [#1613](https://github.com/grafana/grafana/issues/1613) +* **Pushover**: Add Pushover alert notifications [#7526](https://github.com/grafana/grafana/pull/7526) thx [@devkid](https://github.com/devkid) +* **Threema**: Add Threema Gateway alert notification integration [#7482](https://github.com/grafana/grafana/pull/7482) thx [@dbrgn](https://github.com/dbrgn) + +## Minor Enhancements +* **Optimzation**: Never issue refresh event when Grafana tab is not visible [#7218](https://github.com/grafana/grafana/issues/7218), thx [@mtanda](https://github.com/mtanda) +* **Browser History**: Browser back/forward now works time ranges / zoom, [#7259](https://github.com/grafana/grafana/issues/7259) +* **Elasticsearch**: Support for Min Doc Count options in Terms aggregation [#7324](https://github.com/grafana/grafana/pull/7324), thx [@lpic10](https://github.com/lpic10) +* **Elasticsearch**: Term aggregation limit can now be changed in template queries [#7112](https://github.com/grafana/grafana/issues/7112), thx [@FFalcon](https://github.com/FFalcon) +* **Elasticsearch**: Ad-hoc filters now support all operators [#7612](https://github.com/grafana/grafana/issues/7612), thx [@tamayika](https://github.com/tamayika) +* **Graph**: Add full series name as title for legends. [#7493](https://github.com/grafana/grafana/pull/7493), thx [@kolobaev](https://github.com/kolobaev) +* **Table**: Add a message when queries returns no data. [#6109](https://github.com/grafana/grafana/issues/6109), thx [@xginn8](https://github.com/xginn8) +* **Graph**: Set max width for series names in legend tables. [#2385](https://github.com/grafana/grafana/issues/2385), thx [@kolobaev](https://github.com/kolobaev) +* **Database**: Allow max db connection pool configuration [#7427](https://github.com/grafana/grafana/issues/7427), thx [@huydx](https://github.com/huydx) +* **Datasources** Delete datsource by name [#7476](https://github.com/grafana/grafana/issues/7476), thx [@huydx](https://github.com/huydx) +* **Dataproxy**: Only allow get that begins with api/ to access Prometheus [#7459](https://github.com/grafana/grafana/pull/7459), thx [@mtanda](https://github.com/mtanda) +* **Snapshot**: Make timeout for snapshot creation configurable [#7449](https://github.com/grafana/grafana/pull/7449) thx [@ryu1-sakai](https://github.com/ryu1-sakai) +* **Panels**: Add more physics units [#7554](https://github.com/grafana/grafana/pull/7554) thx [@ryantxu](https://github.com/ryantxu) +* **Email**: Add sender's name on email [#2131](https://github.com/grafana/grafana/issues/2131) thx [@jacobbednarz](https://github.com/jacobbednarz) +* **HTTPS**: Set tls 1.2 as lowest tls version. [#7347](https://github.com/grafana/grafana/pull/7347) thx [@roman-vynar](https://github.com/roman-vynar) +* **Table**: Added suppressing of empty results to table plugin. [#7602](https://github.com/grafana/grafana/pull/7602) thx [@LLIyRiK](https://github.com/LLIyRiK) ## Tech * **Library Upgrade**: Upgraded angularjs from 1.5.8 to 1.6.1 [#7274](https://github.com/grafana/grafana/issues/7274) +* **Backend**: Grafana is now built using golang 1.8 ## Bugfixes * **Alerting**: Fixes missing support for no_data and execution error when testing alerts [#7149](https://github.com/grafana/grafana/issues/7149) @@ -23,11 +88,18 @@ * **Alertlist**: Only show scrollbar when required [#7269](https://github.com/grafana/grafana/issues/7269) * **SMTP**: Set LocalName to hostname [#7223](https://github.com/grafana/grafana/issues/7223) * **Sidemenu**: Disable sign out in sidemenu for AuthProxyEnabled [#7377](https://github.com/grafana/grafana/pull/7377), thx [@solugebefola](https://github.com/solugebefola) +* **Prometheus**: Add support for basic auth in Prometheus tsdb package [#6799](https://github.com/grafana/grafana/issues/6799), thx [@hagen1778](https://github.com/hagen1778) +* **OAuth**: Redirect to original page when logging in with OAuth [#7513](https://github.com/grafana/grafana/issues/7513) +* **Annotations**: Wrap text in annotations tooltip [#7542](https://github.com/grafana/grafana/pull/7542), thx [@xginn8](https://github.com/xginn8) +* **Templating**: Fixes error when using numeric sort on empty strings [#7382](https://github.com/grafana/grafana/issues/7382) +* **Templating**: Fixed issue detecting template variable dependency [#7354](https://github.com/grafana/grafana/issues/7354) -# 4.1.2 (unreleased) +# 4.1.2 (2017-02-13) ### Bugfixes * **Table**: Fixes broken annotation rendering mode in the table panel [#7268](https://github.com/grafana/grafana/issues/7268) +* **Data Sources**: Sorting for lists of data sources in UI is now case insensitive [#7491](https://github.com/grafana/grafana/issues/7491) +* **Admin**: Support more then 1000 users in global users list [#7469](https://github.com/grafana/grafana/issues/7469) # 4.1.1 (2017-01-11) @@ -730,7 +802,7 @@ Grafana 2.x is fundamentally different from 1.x; it now ships with an integrated # 1.8.0 (2014-09-22) -Read this [blog post](http://grafana.org/blog/2014/09/11/grafana-1-8-0-rc1-released.html) for an overview of all improvements. +Read this [blog post](https://grafana.com/blog/2014/09/11/grafana-1.8.0-rc1-released) for an overview of all improvements. **Fixes** - [Issue #802](https://github.com/grafana/grafana/issues/802). Annotations: Fix when using InfluxDB datasource diff --git a/Makefile b/Makefile index dbb345473ec..d003cda6dd4 100644 --- a/Makefile +++ b/Makefile @@ -4,9 +4,9 @@ deps-go: go run build.go setup deps-js: - yarn install --pure-lockfile + yarn install --pure-lockfile --no-progress -deps: deps-go deps-js +deps: deps-js build-go: go run build.go build diff --git a/README.md b/README.md index 14f5669d846..35bfad353fc 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,13 @@ -[Grafana](http://grafana.org) [![Circle CI](https://circleci.com/gh/grafana/grafana.svg?style=svg)](https://circleci.com/gh/grafana/grafana) +[Grafana](https://grafana.com) [![Circle CI](https://circleci.com/gh/grafana/grafana.svg?style=svg)](https://circleci.com/gh/grafana/grafana) ================ -[Website](http://grafana.org) | +[Website](https://grafana.com) | [Twitter](https://twitter.com/grafana) | -[IRC](https://webchat.freenode.net/?channels=grafana) | -[![Slack](https://brandfolder.com/api/favicon/icon?size=16&domain=www.slack.com)](http://slack.raintank.io) -[Slack](http://slack.raintank.io) | -[Email](mailto:contact@grafana.org) +[Community & Forum](https://community.grafana.com) Grafana is an open source, feature rich metrics dashboard and graph editor for Graphite, Elasticsearch, OpenTSDB, Prometheus and InfluxDB. -![](http://grafana.org/assets/img/features/dashboard_ex1.png) +![](http://docs.grafana.org/assets/img/features/dashboard_ex1.png) - [Install instructions](http://docs.grafana.org/installation/) - [What's New in Grafana 2.0](http://docs.grafana.org/guides/whats-new-in-v2/) @@ -19,6 +16,7 @@ Graphite, Elasticsearch, OpenTSDB, Prometheus and InfluxDB. - [What's New in Grafana 3.0](http://docs.grafana.org/guides/whats-new-in-v3/) - [What's New in Grafana 4.0](http://docs.grafana.org/guides/whats-new-in-v4/) - [What's New in Grafana 4.1](http://docs.grafana.org/guides/whats-new-in-v4-1/) +- [What's New in Grafana 4.2](http://docs.grafana.org/guides/whats-new-in-v4-2/) ## Features ### Graphite Target Editor @@ -66,7 +64,7 @@ There are no dependencies except an external time series data store. For dashboa database (sqlite3) or you can use an external SQL data base like MySQL or Postgres. ## Installation -Head to [grafana.org](http://docs.grafana.org/installation/) and [download](http://grafana.org/download/) +Head to [grafana.org](http://docs.grafana.org/installation/) and [download](https://grafana.com/get) the latest release. If you have any problems please read the [troubleshooting guide](http://docs.grafana.org/installation/troubleshooting/). @@ -76,11 +74,11 @@ Be sure to read the [getting started guide](http://docs.grafana.org/guides/getti ## Run from master If you want to build a package yourself, or contribute. Here is a guide for how to do that. You can always find -the latest master builds [here](http://grafana.org/builds) +the latest master builds [here](https://grafana.com/grafana/download) ### Dependencies -- Go 1.7.3 +- Go 1.8 - NodeJS v4+ ### Get Code @@ -110,7 +108,7 @@ go run build.go build ### Building frontend assets -To build less to css for the frontend you will need a recent version of **node (v4+)**, +To build less to css for the frontend you will need a recent version of **node (v6+)**, npm (v2.5.0) and grunt (v0.4.5). Run the following: ```bash diff --git a/appveyor.yml b/appveyor.yml index 8b4a16d19cb..303c3abca9e 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -7,13 +7,17 @@ clone_folder: c:\gopath\src\github.com\grafana\grafana environment: nodejs_version: "6" GOPATH: c:\gopath + GOVERSION: 1.8 install: + - rmdir c:\go /s /q # install nodejs and npm - ps: Install-Product node $env:nodejs_version - - npm install -g yarn - - yarn install --pure-lockfile + - npm install -g yarn --silent + - yarn install --pure-lockfile --no-progress - npm install -g grunt-cli + - appveyor DownloadFile https://storage.googleapis.com/golang/go%GOVERSION%.windows-amd64.zip + - 7z x go%GOVERSION%.windows-amd64.zip -y -oC:\ > NUL # install gcc (needed for sqlite3) - choco install -y --limit-output mingw - set PATH=C:\tools\mingw64\bin;%PATH% @@ -21,18 +25,28 @@ install: - echo %GOPATH% - go version - go env - - go run build.go setup + # - go run build.go setup build_script: - go run build.go build - grunt release - - go run build.go sha1-dist + - go run build.go sha-dist - cp dist/* . artifacts: - path: grafana-*windows-*.* name: binzip + type: zip deploy: - provider: Environment - name: GrafanaBuildsS3 + name: GrafanaReleaseMaster + on: + buildType: master + + - provider: Environment + name: GrafanaReleaseRelease + on: + buildType: release + + diff --git a/build.go b/build.go index 78f73816ead..a706842dfec 100644 --- a/build.go +++ b/build.go @@ -5,7 +5,7 @@ package main import ( "bytes" "crypto/md5" - "crypto/sha1" + "crypto/sha256" "encoding/json" "flag" "fmt" @@ -14,6 +14,7 @@ import ( "log" "os" "os/exec" + "path" "path/filepath" "regexp" "runtime" @@ -38,10 +39,11 @@ var ( phjsToRelease string workingDir string includeBuildNumber bool = true + buildNumber int = 0 binaries []string = []string{"grafana-server", "grafana-cli"} ) -const minGoVersion = 1.7 +const minGoVersion = 1.8 func main() { log.SetOutput(os.Stdout) @@ -58,6 +60,7 @@ func main() { flag.StringVar(&phjsToRelease, "phjs", "", "PhantomJS binary") flag.BoolVar(&race, "race", race, "Use race detector") flag.BoolVar(&includeBuildNumber, "includeBuildNumber", includeBuildNumber, "IncludeBuildNumber in package name") + flag.IntVar(&buildNumber, "buildNumber", 0, "Build number from CI system") flag.Parse() readVersionFromPackageJson() @@ -102,8 +105,8 @@ func main() { grunt(gruntBuildArg("release")...) createDebPackages() - case "sha1-dist": - sha1FilesInDist() + case "sha-dist": + shaFilesInDist() case "latest": makeLatestDistCopies() @@ -118,14 +121,24 @@ func main() { } func makeLatestDistCopies() { - rpmIteration := "-1" - if linuxPackageIteration != "" { - rpmIteration = linuxPackageIteration + files, err := ioutil.ReadDir("dist") + if err != nil { + log.Fatalf("failed to create latest copies. Cannot read from /dist") } - runError("cp", fmt.Sprintf("dist/grafana_%v-%v_amd64.deb", linuxPackageVersion, linuxPackageIteration), "dist/grafana_latest_amd64.deb") - runError("cp", fmt.Sprintf("dist/grafana-%v-%v.x86_64.rpm", linuxPackageVersion, rpmIteration), "dist/grafana-latest-1.x86_64.rpm") - runError("cp", fmt.Sprintf("dist/grafana-%v-%v.linux-x64.tar.gz", linuxPackageVersion, linuxPackageIteration), "dist/grafana-latest.linux-x64.tar.gz") + latestMapping := map[string]string{ + ".deb": "dist/grafana_latest_amd64.deb", + ".rpm": "dist/grafana-latest-1.x86_64.rpm", + ".tar.gz": "dist/grafana-latest.linux-x64.tar.gz", + } + + for _, file := range files { + for extension, fullName := range latestMapping { + if strings.HasSuffix(file.Name(), extension) { + runError("cp", path.Join("dist", file.Name()), fullName) + } + } + } } func readVersionFromPackageJson() { @@ -157,7 +170,11 @@ func readVersionFromPackageJson() { // add timestamp to iteration if includeBuildNumber { - linuxPackageIteration = fmt.Sprintf("%d%s", time.Now().Unix(), linuxPackageIteration) + if buildNumber != 0 { + linuxPackageIteration = fmt.Sprintf("%d%s", buildNumber, linuxPackageIteration) + } else { + linuxPackageIteration = fmt.Sprintf("%d%s", time.Now().Unix(), linuxPackageIteration) + } } } @@ -258,9 +275,9 @@ func createPackage(options linuxPackageOptions) { "--description", "Grafana", "-C", packageRoot, "--vendor", "Grafana", - "--url", "http://grafana.org", + "--url", "https://grafana.com", "--license", "\"Apache 2.0\"", - "--maintainer", "contact@grafana.org", + "--maintainer", "contact@grafana.com", "--config-files", options.initdScriptFilePath, "--config-files", options.etcDefaultFilePath, "--config-files", options.systemdServiceFilePath, @@ -270,6 +287,14 @@ func createPackage(options linuxPackageOptions) { "-p", "./dist", } + if options.packageType == "rpm" { + args = append(args, "--rpm-posttrans", "packaging/rpm/control/posttrans") + } + + if options.packageType == "deb" { + args = append(args, "--deb-no-default-config-files") + } + if pkgArch != "" { args = append(args, "-a", pkgArch) } @@ -328,7 +353,7 @@ func gruntBuildArg(task string) []string { if includeBuildNumber { args = append(args, fmt.Sprintf("--pkgVer=%v-%v", linuxPackageVersion, linuxPackageIteration)) } else { - args = append(args, fmt.Sprintf("--pkgVer=%v", linuxPackageVersion)) + args = append(args, fmt.Sprintf("--pkgVer=%v", version)) } if pkgArch != "" { args = append(args, fmt.Sprintf("--arch=%v", pkgArch)) @@ -505,14 +530,14 @@ func md5File(file string) error { return out.Close() } -func sha1FilesInDist() { +func shaFilesInDist() { filepath.Walk("./dist", func(path string, f os.FileInfo, err error) error { if path == "./dist" { return nil } - if strings.Contains(path, ".sha1") == false { - err := sha1File(path) + if strings.Contains(path, ".sha256") == false { + err := shaFile(path) if err != nil { log.Printf("Failed to create sha file. error: %v\n", err) } @@ -521,20 +546,20 @@ func sha1FilesInDist() { }) } -func sha1File(file string) error { +func shaFile(file string) error { fd, err := os.Open(file) if err != nil { return err } defer fd.Close() - h := sha1.New() + h := sha256.New() _, err = io.Copy(h, fd) if err != nil { return err } - out, err := os.Create(file + ".sha1") + out, err := os.Create(file + ".sha256") if err != nil { return err } diff --git a/circle.yml b/circle.yml index daa90305720..b8767b79b8b 100644 --- a/circle.yml +++ b/circle.yml @@ -9,7 +9,7 @@ machine: GOPATH: "/home/ubuntu/.go_workspace" ORG_PATH: "github.com/grafana" REPO_PATH: "${ORG_PATH}/grafana" - GODIST: "go1.7.4.linux-amd64.tar.gz" + GODIST: "go1.8.linux-amd64.tar.gz" post: - mkdir -p ~/download - mkdir -p ~/docker @@ -33,7 +33,7 @@ dependencies: test: override: - - bash scripts/circle-test.sh + - bash scripts/circle-test.sh deployment: gh_branch: @@ -41,15 +41,17 @@ deployment: commands: - ./scripts/build/deploy.sh - ./scripts/build/sign_packages.sh - - go run build.go sha1-dist + - go run build.go sha-dist - aws s3 sync ./dist s3://$BUCKET_NAME/master - #- ./scripts/trigger_grafana_docker_build.sh ${TRIGGER_GRAFANA_DOCKER_CIRCLECI_TOKEN} + - ./scripts/trigger_windows_build.sh ${APPVEYOR_TOKEN} ${CIRCLE_SHA1} master + - ./scripts/trigger_docker_build.sh ${TRIGGER_GRAFANA_PACKER_CIRCLECI_TOKEN} + - go run ./scripts/build/publish.go -apiKey ${GRAFANA_COM_API_KEY} gh_tag: tag: /^v[0-9]+(\.[0-9]+){2}(-.+|[^-.]*)$/ commands: - ./scripts/build/deploy.sh - ./scripts/build/sign_packages.sh - - go run build.go sha1-dist + - go run build.go sha-dist - aws s3 sync ./dist s3://$BUCKET_NAME/release - #- ./scripts/trigger_grafana_docker_build.sh ${TRIGGER_GRAFANA_DOCKER_CIRCLECI_TOKEN} - + - ./scripts/trigger_windows_build.sh ${APPVEYOR_TOKEN} ${CIRCLE_SHA1} release + - ./scripts/trigger_docker_build.sh ${TRIGGER_GRAFANA_PACKER_CIRCLECI_TOKEN} ${CIRCLE_TAG} diff --git a/conf/defaults.ini b/conf/defaults.ini index 358847724ab..5c12937f4f8 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -73,6 +73,10 @@ password = # Example: mysql://user:secret@host:port/database url = +# Max conn setting default is 0 (mean not set) +max_idle_conn = +max_open_conn = + # For "postgres", use either "disable", "require" or "verify-full" # For "mysql", use either "true", "false", or "skip-verify". ssl_mode = disable @@ -127,11 +131,11 @@ logging = false # Change this option to false to disable reporting. reporting_enabled = true -# Set to false to disable all checks to https://grafana.net +# Set to false to disable all checks to https://grafana.com # for new vesions (grafana itself and plugins), check is used # 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 +# only a GET request to https://grafana.com to get latest versions check_for_updates = true # Google Analytics universal tracking code, only enabled if you specify an id here @@ -201,6 +205,9 @@ default_theme = dark # Set to true to disable (hide) the login form, useful if you use OAuth disable_login_form = false +# Set to true to disable the signout link in the side menu. useful if you use auth.proxy +disable_signout_menu = false + #################################### Anonymous Auth ###################### [auth.anonymous] # enable anonymous access @@ -238,7 +245,7 @@ api_url = https://www.googleapis.com/oauth2/v1/userinfo allowed_domains = hosted_domain = -#################################### Grafana.net Auth #################### +#################################### Grafana.com Auth #################### [auth.grafananet] enabled = false allow_sign_up = true @@ -291,6 +298,7 @@ cert_file = key_file = skip_verify = false from_address = admin@grafana.localhost +from_name = Grafana [emails] welcome_email_on_sign_up = false @@ -420,7 +428,7 @@ address = prefix = prod.grafana.%(instance_name)s. [grafana_net] -url = https://grafana.net +url = https://grafana.com #################################### External Image Storage ############## [external_image_storage] diff --git a/conf/sample.ini b/conf/sample.ini index ce9344e1d4f..87505221790 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -82,6 +82,11 @@ # For "sqlite3" only, path relative to data_path setting ;path = grafana.db +# Max conn setting default is 0 (mean not set) +;max_idle_conn = +;max_open_conn = + + #################################### Session #################################### [session] # Either "memory", "file", "redis", "mysql", "postgres", default is "file" @@ -123,7 +128,7 @@ # for new vesions (grafana itself and plugins), check is used # 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 +# only a GET request to http://grafana.com to get latest versions ;check_for_updates = true # Google Analytics universal tracking code, only enabled if you specify an id here @@ -187,6 +192,9 @@ # Set to true to disable (hide) the login form, useful if you use OAuth, defaults to false ;disable_login_form = false +# Set to true to disable the signout link in the side menu. useful if you use auth.proxy, defaults to false +;disable_signout_menu = false + #################################### Anonymous Auth ########################## [auth.anonymous] # enable anonymous access @@ -237,7 +245,7 @@ ;team_ids = ;allowed_organizations = -#################################### Grafana.net Auth #################### +#################################### Grafana.com Auth #################### [auth.grafananet] ;enabled = false ;allow_sign_up = true @@ -276,6 +284,7 @@ ;key_file = ;skip_verify = false ;from_address = admin@grafana.localhost +;from_name = Grafana [emails] ;welcome_email_on_sign_up = false @@ -372,10 +381,10 @@ ;address = ;prefix = prod.grafana.%(instance_name)s. -#################################### Internal Grafana Metrics ########################## -# Url used to to import dashboards directly from Grafana.net +#################################### Grafana.com integration ########################## +# Url used to to import dashboards directly from Grafana.com [grafana_net] -;url = https://grafana.net +;url = https://grafana.com #################################### External image storage ########################## [external_image_storage] diff --git a/docker/blocks/prometheus/fig b/docker/blocks/prometheus/fig index ec4dd8e4b16..1c70c14fc37 100644 --- a/docker/blocks/prometheus/fig +++ b/docker/blocks/prometheus/fig @@ -3,8 +3,6 @@ prometheus: net: host ports: - "9090:9090" - volumes: - - /var/docker/prometheus:/prometheus-data node_exporter: image: prom/node-exporter diff --git a/docs/Dockerfile b/docs/Dockerfile index 7652cbd3e3f..7679f2b7e4b 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -3,10 +3,11 @@ FROM grafana/docs-base:latest # to get the git info for this repo # COPY config.toml /site -RUN rm -rf /site/content/* +# RUN rm -rf /site/content/* -COPY ./sources /site/content/ +# COPY ./sources /site/content/docs/ +COPY config.toml /site COPY awsconfig /site VOLUME ["/site/content"] diff --git a/docs/Makefile b/docs/Makefile index 718c4b52be7..e5c555f8019 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,50 +1,33 @@ .PHONY: all default docs docs-build docs-shell shell test -# to allow `make DOCSDIR=1 docs-shell` (to create a bind mount in docs) -DOCS_MOUNT := $(if $(DOCSDIR),-v $(CURDIR):/docs/content/grafana/) # to allow `make DOCSPORT=9000 docs` DOCSPORT := 3004 -# Get the IP ADDRESS -DOCKER_IP=$(shell python -c "import urlparse ; print urlparse.urlparse('$(DOCKER_HOST)').hostname or ''") -HUGO_BASE_URL=$(shell test -z "$(DOCKER_IP)" && echo localhost || echo "$(DOCKER_IP)") -HUGO_BIND_IP=0.0.0.0 - -GIT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD 2>/dev/null) -GIT_BRANCH_CLEAN := $(shell echo $(GIT_BRANCH) | sed -e "s/[^[:alnum:]]/-/g") DOCKER_DOCS_IMAGE := grafana/grafana-docs -DOCKER_RUN_DOCS := docker run --rm -it $(DOCS_MOUNT) -e AWS_S3_BUCKET -e NOCACHE SOURCES_HOST_DIR := "$(shell pwd)/sources" -# for some docs workarounds (see below in "docs-build" target) -GITCOMMIT := $(shell git rev-parse --short HEAD 2>/dev/null) +DOCS_MOUNT := -v $(SOURCES_HOST_DIR):/site/content + +DOCKER_RUN_DOCS := docker run --rm -it $(DOCS_MOUNT) -e NOCACHE -p 3004:3004 -p 3005:3005 + default: docs docs: docs-build - $(DOCKER_RUN_DOCS) -p 3004:3004 -p 3005:3005 -e DOCKERHOST "$(DOCKER_DOCS_IMAGE)" /bin/bash -c "grunt && grunt connect --port=3004" - -docs-watch: docs-build - $(DOCKER_RUN_DOCS) -p 3004:3004 -p 3005:3005 -v $(SOURCES_HOST_DIR):/site/content -e DOCKERHOST "$(DOCKER_DOCS_IMAGE)" /bin/bash -c "grunt --env=dev-docs && grunt connect --port=3004 & grunt watch --port=3004 --env=dev-docs" - -docs-watch-mac: docs-build - $(DOCKER_RUN_DOCS) -p 3004:3004 -p 3005:3005 -v $(SOURCES_HOST_DIR):/site/content -e DOCKERHOST "$(DOCKER_DOCS_IMAGE)" /bin/bash -c "grunt --env=dev-docs-mac && grunt connect --port=3004 & grunt watch --port=3004 --env=dev-docs-mac" - -publish: docs-build - $(DOCKER_RUN_DOCS) "$(DOCKER_DOCS_IMAGE)" /bin/bash -c "./publish.sh staging-docs v3.1" - -publish-prod: docs-build - $(DOCKER_RUN_DOCS) "$(DOCKER_DOCS_IMAGE)" /bin/bash -c "./publish.sh prod-docs root" - -docs-draft: docs-build - $(DOCKER_RUN_DOCS) -p $(if $(DOCSPORT),$(DOCSPORT):)8000 -e DOCKERHOST "$(DOCKER_DOCS_IMAGE)" hugo server --buildDrafts="true" --port=$(DOCSPORT) --baseUrl=$(HUGO_BASE_URL) --bind=$(HUGO_BIND_IP) - -docs-shell: docs-build - $(DOCKER_RUN_DOCS) -p $(if $(DOCSPORT),$(DOCSPORT):)8000 "$(DOCKER_DOCS_IMAGE)" bash + $(DOCKER_RUN_DOCS) $(DOCS_MOUNT) -e DOCKERHOST "$(DOCKER_DOCS_IMAGE)" /bin/bash -c "grunt --env=dev-docs && grunt connect --port=3004" test: docs-build - $(DOCKER_RUN_DOCS) -p $(if $(DOCSPORT),$(DOCSPORT):)8000 "$(DOCKER_DOCS_IMAGE)" + $(DOCKER_RUN_DOCS) $(DOCS_MOUNT) -e DOCKERHOST "$(DOCKER_DOCS_IMAGE)" /bin/bash -c "ls -la /site/content" + +docs-watch: docs-build + $(DOCKER_RUN_DOCS) $(DOCS_MOUNT) -e DOCKERHOST "$(DOCKER_DOCS_IMAGE)" /bin/bash -c "grunt --env=dev-docs && grunt connect --port=3004 & grunt watch --port=3004 --env=dev-docs" + +publish: docs-build + $(DOCKER_RUN_DOCS) $(DOCS_MOUNT) -e DOCKERHOST "$(DOCKER_DOCS_IMAGE)" /bin/bash -c "./publish.sh staging-docs root" + +publish-prod: docs-build + $(DOCKER_RUN_DOCS) $(DOCS_MOUNT) -e DOCKERHOST "$(DOCKER_DOCS_IMAGE)" /bin/bash -c "./publish.sh prod-docs root" docs-build: - docker build -t "$(DOCKER_DOCS_IMAGE)" . + docker build -t "$(DOCKER_DOCS_IMAGE)" --no-cache . diff --git a/docs/README.md b/docs/README.md index 65bd5714615..ea3f8394ace 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,14 +6,13 @@ and the [mkdocs](http://www.mkdocs.org/) tool. **Prepare the Docker Image**: -Build the `grafana/docs-base:latest` image. Run these commands in the -same directory this file is in. **Note** that you may require ``sudo`` +Git clone `grafana/grafana.org` repo. Run these commands in the root of that repo. **Note** that you may require ``sudo`` when running ``make docs-build`` depending on how your system's docker service is configured): ``` -$ git clone https://github.com/grafana/docs-base -$ cd docs-base +$ git clone https://github.com/grafana/grafana.org +$ cd grafana.org $ make docs-build ``` @@ -31,31 +30,6 @@ This command will not return control of the shell to the user. Instead the command is now running a new docker container built from the image we created in the previous step. -Open [localhost:8180](http://localhost:8180) to view the docs. - -**Note** that after running ``make docs`` you may notice a message -like this in the console output - -> Running at: http://0.0.0.0:8000/ - -This is misleading. That is **not** the port the documentation is -served from. You must browse to port **8180** to view the new -documentation. +Open [localhost:3004](http://localhost:3004) to view the docs. -# Adding a New Page - -Adding a new page requires updating the ``mkdocs.yml`` file which is -located in this directory. - -For example, if you are adding documentation for a new HTTP API called -``preferences`` you would: - -1. Create the file ``docs/sources/http_api/preferences.md`` -1. Add a reference to it in ``docs/sources/http_api/overview.md`` -1. Update the list under the **pages** key in the ``docs/mkdocs.yml`` file with a reference to your new page: - - -```yaml -- ['http_api/preferences.md', 'API', 'Preferences API'] -``` diff --git a/docs/config.toml b/docs/config.toml index c8d093a81d7..8ace86392ea 100644 --- a/docs/config.toml +++ b/docs/config.toml @@ -1,70 +1,20 @@ + baseurl = "http://localhost:3002/" languageCode = "en-us" -title = "Grafana Docs" -canonifyurls = false +title = "Grafana Documentation" +canonifyurls = true relativeURLs = false verbose = true enableRobotsTXT = true disableSitemap = false -disableRSS = true - -[[menu.top]] - name = "Docs" - url = "" - weight = 1 - -[[menu.top]] - name = "Community" - url = "/community" - weight = 2 - -[[menu.top]] - name = "Support" - url = "/support" - weight = 3 - -[[menu.top]] - name = "Plugins" - url = "https://grafana.net/plugins" - weight = 4 - -[[menu.top]] - name = "Dashboards" - url = "https://grafana.net/dashboards" - weight = 5 - -[[menu.top]] - name = "Hosting" - url = "/hosting" - weight = 6 - -[[menu.top]] - name = "Github" - url = "https://github.com/grafana/grafana" - weight = 7 - -## Main -[[menu.main]] - name = "Feature Gallery" - url = "/features" - weight = 1 - -[[menu.main]] - name = "Live Demo" - url = "http://play.grafana.org" - weight = 2 - -[[menu.main]] - name = "Download" - url = "/download" - weight = 3 - -[[menu.main]] - name = "Blog" - url = "/blog" - weight = 4 - - +disableRSS = false +preservetaxonomynames = true +metaDataFormat = "yaml" +[taxonomies] +tag = "tags" +category = "categories" +[permalinks] + blog = ":year/:month/:day/:title/" diff --git a/docs/sources/administration/cli.md b/docs/sources/administration/cli.md index 99446fb5f1b..8c7755506e8 100644 --- a/docs/sources/administration/cli.md +++ b/docs/sources/administration/cli.md @@ -10,11 +10,13 @@ weight = 8 # Grafana CLI -Grafana cli is a small executable that is bundled with grafana server and is suppose to be executed on the same machine as grafana runs. +Grafana cli is a small executable that is bundled with grafana server and is suppose to be executed on the same machine as grafana runs. ## Plugins -The CLI helps you install, upgrade and manage your plugins on the same machine it CLI is running. You can find more information about how to install and manage your plugins at the [plugin page] ({{< relref "/installation.md" >}}) +The CLI helps you install, upgrade and manage your plugins on the same machine it CLI is running. +You can find more information about how to install and manage your plugins at the +[plugin page]({{< relref "plugins/installation.md" >}}). ## Admin diff --git a/docs/sources/alerting/metrics.md b/docs/sources/alerting/metrics.md index b0bf5c9f4fc..02fee6a718a 100644 --- a/docs/sources/alerting/metrics.md +++ b/docs/sources/alerting/metrics.md @@ -13,7 +13,7 @@ weight = 2 > Alerting is only available in Grafana v4.0 and above. -The alert engine publish some internal metrics about itself. You can read more about how Grafana published [interal metrics](/installation/configuration/#metrics) +The alert engine publishes some internal metrics about itself. You can read more about how Grafana published [internal metrics](/installation/configuration/#metrics). Description | Type | Metric name ---------- | ----------- | ---------- diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index 9f8d3c36120..d0dd19cbcdc 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -101,4 +101,9 @@ config file. This is an optional requirement, you can get slack and email notifications without setting this up. +# Configure the link back to Grafana from alert notifications + +All alert notifications contains a link back to the triggered alert in the Grafana instance. +This url is based on the [domain](/installation/configuration/#domain) setting in Grafana. + diff --git a/docs/sources/alerting/rules.md b/docs/sources/alerting/rules.md index af3cfecfdb8..a2688de3100 100644 --- a/docs/sources/alerting/rules.md +++ b/docs/sources/alerting/rules.md @@ -33,7 +33,7 @@ of core Grafana. Only some data soures are supported right now. They include `Gr ### Clustering We have not implemented clustering yet. So if you run multiple instances of grafana-server -you have to make sure [execute_alerts]({{< relref "/installation/configuration.md#alerting" >}}) +you have to make sure [execute_alerts]({{< relref "installation/configuration.md#alerting" >}}) is true on only one instance or otherwise you will get duplicated notifications.
diff --git a/docs/sources/contribute/cla.md b/docs/sources/contribute/cla.md new file mode 100644 index 00000000000..b990187d809 --- /dev/null +++ b/docs/sources/contribute/cla.md @@ -0,0 +1,104 @@ ++++ +title = "Contributor Licence Agreement (CLA)" +description = "Contributer Licence Agreement (CLA)" +type = "docs" +aliases = ["/project/cla", "docs/contributing/cla.html"] +[menu.docs] +parent = "contribute" +weight = 1 ++++ + +# Grafana Labs Contributor License Agreement + +Thank you for your interest in contributing to Grafana Labs ("We" or "Us"). + +This contributor agreement ("Agreement") documents the rights granted by contributors to Us. +To make this document effective, please sign it following the instructions at +[cla-assistant](https://cla-assistant.io/grafana/grafana). This is a legally binding document, +so please read it carefully before agreeing to it. The Agreement may cover more than +one software project managed by Us. + +## 1. Definitions + +"You" (Individual) means the individual who Submits a Contribution to Us. + +"You" (Entity) means any Legal Entity on behalf of whom a Contribution has been received by Us. "Legal Entity" means an entity which is not a natural person. "Affiliates" means other Legal Entities that control, are controlled by, or under common control with that Legal Entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such Legal Entity, whether by contract or otherwise, (ii) ownership of fifty percent (50%) or more of the outstanding shares or securities which vote to elect the management or other persons who direct such Legal Entity or (iii) beneficial ownership of such entity. + +"Contribution" means any work of authorship that is Submitted by You to Us in which You own or assert ownership of the Copyright. If You do not own the Copyright in the entire work of authorship, you must notify us at contact@grafana.com before Submitting such a Contribution. + +"Copyright" means all rights protecting works of authorship owned or controlled by You [or Your Affiliates], including copyright, moral and neighboring rights, as appropriate, for the full term of their existence including any extensions by You. + +"Material" means the work of authorship which is made available by Us to third parties. When this Agreement covers more than one software project, the Material means the work of authorship to which the Contribution was Submitted. After You Submit the Contribution, it may be included in the Material. + +"Submit" means any form of electronic, verbal, or written communication sent to Us or our representatives, including but not limited to electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Us for the purpose of discussing and improving the Material, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." + +"Submission Date" means the date on which You Submit a Contribution to Us. + +"Effective Date" means the date You execute this Agreement or the date You first Submit a Contribution to Us, whichever is earlier. + +"Media" means any portion of a Contribution which is not software. + +## 2. Grant of Rights + +### 2.1 Copyright License + +(a) You retain ownership of the Copyright in Your Contribution and have the same rights to use or license the Contribution which You would have had without entering into the Agreement. + +(b) To the maximum extent permitted by the relevant law, You grant to Us a perpetual, worldwide, non-exclusive, transferable, royalty-free, irrevocable license under the Copyright covering the Contribution, with the right to sublicense such rights through multiple tiers of sublicensees, to reproduce, modify, display, perform and distribute the Contribution as part of the Material; provided that this license is conditioned upon compliance with Section 2.3. + +### 2.2 Patent License + +For patent claims including, without limitation, method, process, and apparatus claims which You [or Your Affiliates] own, control or have the right to grant, now or in the future, You grant to Us a perpetual, worldwide, non-exclusive, transferable, royalty-free, irrevocable patent license, with the right to sublicense these rights to multiple tiers of sublicensees, to make, have made, use, sell, offer for sale, import and otherwise transfer the Contribution and the Contribution in combination with the Material (and portions of such combination). This license is granted only to the extent that the exercise of the licensed rights infringes such patent claims; and provided that this license is conditioned upon compliance with Section 2.3. + +### 2.3 Outbound License + +As a condition on the grant of rights in Sections 2.1 and 2.2, We agree to license the Contribution only under the terms of the license or licenses which We are using on the Submission Date for the Material or any licenses which are approved by the Open Source Initiative on or after the Effective Date, including both permissive and copyleft licenses, whether or not such licenses are subsequently disapproved (including any right to adopt any future version of a license if permitted). + +In addition, We may use the following licenses for Media in the Contribution: GNU Free Documentation License v1.3, Creative Commons Attribution 3.0, or Creative Commons Attribution No Derivatives 3.0 (including any right to adopt any future version of a license if permitted). + +2.4 Moral Rights. If moral rights apply to the Contribution, to the maximum extent permitted by law, You waive and agree not to assert such moral rights against Us or our successors in interest, or any of our licensees, either direct or indirect. + +2.5 Our Rights. You acknowledge that We are not obligated to use Your Contribution as part of the Material and may decide to include any Contribution We consider appropriate. + +2.6 Reservation of Rights. Any rights not expressly [assigned or] licensed under this section are expressly reserved by You. + +## 3. Agreement + +You confirm that: + +(a) You have the legal authority to enter into this Agreement. + +(b) You [or Your Affiliates] own the Copyright and patent claims covering the Contribution which are required to grant the rights under Section 2. + +\(c)(Individual) The grant of rights under Section 2 does not violate any grant of rights which You have made to third parties, including Your employer. If You are an employee, You have had Your employer approve this Agreement or sign the Entity version of this document. If You are less than eighteen years old, please have Your parents or guardian sign the Agreement. + +\(c)(Entity) The grant of rights under Section 2 does not violate any grant of rights which You or Your Affiliates have made to third parties. + +## 4. Disclaimer + +EXCEPT FOR THE EXPRESS WARRANTIES IN SECTION 3, THE CONTRIBUTION IS PROVIDED "AS IS". MORE PARTICULARLY, ALL EXPRESS OR IMPLIED WARRANTIES INCLUDING, WITHOUT LIMITATION, ANY IMPLIED WARRANTY OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT ARE EXPRESSLY DISCLAIMED BY YOU TO US [AND BY US TO YOU]. TO THE EXTENT THAT ANY SUCH WARRANTIES CANNOT BE DISCLAIMED, SUCH WARRANTY IS LIMITED IN DURATION TO THE MINIMUM PERIOD PERMITTED BY LAW. + +## 5. Consequential Damage Waiver + +TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT WILL YOU [OR US] BE LIABLE FOR ANY LOSS OF PROFITS, LOSS OF ANTICIPATED SAVINGS, LOSS OF DATA, INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL AND EXEMPLARY DAMAGES ARISING OUT OF THIS AGREEMENT REGARDLESS OF THE LEGAL OR EQUITABLE THEORY (CONTRACT, TORT OR OTHERWISE) UPON WHICH THE CLAIM IS BASED. + +## 6. Miscellaneous + +6.1 This Agreement will be governed by and construed in accordance with the laws of New York State, United States excluding its conflicts of law provisions. Under certain circumstances, the governing law in this section might be superseded by the United Nations Convention on Contracts for the International Sale of Goods ("UN Convention") and the parties intend to avoid the application of the UN Convention to this Agreement and, thus, exclude the application of the UN Convention in its entirety to this Agreement. + +6.2 This Agreement sets out the entire agreement between You and Us for Your Contributions to Us and overrides all other agreements or understandings. + +6.3 If You or We assign the rights or obligations received through this Agreement to a third party, as a condition of the assignment, that third party must agree in writing to abide by all the rights and obligations in the Agreement. + +6.4 The failure of either party to require performance by the other party of any provision of this Agreement in one situation shall not affect the right of a party to require such performance at any time in the future. A waiver of performance under a provision in one situation shall not be considered a waiver of the performance of the provision in the future or a waiver of the provision in its entirety. + +6.5 If any provision of this Agreement is found void and unenforceable, such provision will be replaced to the extent possible with a provision that comes closest to the meaning of the original provision and which is enforceable. The terms and conditions set forth in this Agreement shall apply notwithstanding any failure of essential purpose of this Agreement or any limited remedy to the maximum extent possible under law. + +### Sign using CLA-Assistant + +[https://cla-assistant.io/grafana/grafana](https://cla-assistant.io/grafana/grafana) + +
+
+
+This CLA aggreement is based on the [Harmony Contributor Aggrement Template (combined)](http://www.harmonyagreements.org/agreements.html), [Creative Commons Attribution 3.0 Unported License](https://creativecommons.org/licenses/by/3.0/) diff --git a/docs/sources/contribute/index.md b/docs/sources/contribute/index.md new file mode 100644 index 00000000000..6a8d47ddf3f --- /dev/null +++ b/docs/sources/contribute/index.md @@ -0,0 +1,12 @@ ++++ +title = "Contribute" +description = "Contribute" +type = "docs" +[menu.docs] +name = "Contribute" +identifier = "contribute" +weight = 20 ++++ + +### Contribute info + diff --git a/docs/sources/features/datasources/cloudwatch.md b/docs/sources/features/datasources/cloudwatch.md index b4ad0b3288e..c2896c5b172 100644 --- a/docs/sources/features/datasources/cloudwatch.md +++ b/docs/sources/features/datasources/cloudwatch.md @@ -3,6 +3,7 @@ title = "AWS CloudWatch" description = "Guide for using CloudWatch in Grafana" keywords = ["grafana", "cloudwatch", "guide"] type = "docs" +aliases = ["/datasources/cloudwatch"] [menu.docs] name = "AWS Cloudwatch" identifier = "cloudwatch" diff --git a/docs/sources/features/datasources/elasticsearch.md b/docs/sources/features/datasources/elasticsearch.md index 345f68a9220..4f8bfaf968c 100644 --- a/docs/sources/features/datasources/elasticsearch.md +++ b/docs/sources/features/datasources/elasticsearch.md @@ -3,6 +3,7 @@ title = "Using Elasticsearch in Grafana" description = "Guide for using Elasticsearch in Grafana" keywords = ["grafana", "elasticsearch", "guide"] type = "docs" +aliases = ["/datasources/elasticsearch"] [menu.docs] name = "Elasticsearch" parent = "datasources" diff --git a/docs/sources/features/datasources/graphite.md b/docs/sources/features/datasources/graphite.md index 44a4dff3761..11e15ab503e 100644 --- a/docs/sources/features/datasources/graphite.md +++ b/docs/sources/features/datasources/graphite.md @@ -3,6 +3,7 @@ title = "Using Graphite in Grafana" description = "Guide for using graphite in Grafana" keywords = ["grafana", "graphite", "guide"] type = "docs" +aliases = ["/datasources/graphite"] [menu.docs] name = "Graphite" identifier = "graphite" diff --git a/docs/sources/features/datasources/index.md b/docs/sources/features/datasources/index.md index af0062ddc51..16a8578ed3e 100644 --- a/docs/sources/features/datasources/index.md +++ b/docs/sources/features/datasources/index.md @@ -9,13 +9,16 @@ weight = 5 +++ # Data Source Overview + Grafana supports many different storage backends for your time series data (Data Source). Each Data Source has a specific Query Editor that is customized for the features and capabilities that the particular Data Source exposes. ## Querying + The query language and capabilities of each Data Source are obviously very different. You can combine data from multiple Data Sources onto a single Dashboard, but each Panel is tied to a specific Data Source that belongs to a particular Organization. ## Supported Data Sources + The following datasources are officially supported: * [Graphite]({{< relref "graphite.md" >}}) @@ -27,5 +30,5 @@ The following datasources are officially supported: ## Data source plugins -Since grafana 3.0 you can install data sources as plugins. Checkout [Grafana.net](https://grafana.net/plugins) for more data sources. +Since grafana 3.0 you can install data sources as plugins. Checkout [Grafana.net](https://grafana.com/plugins) for more data sources. diff --git a/docs/sources/features/datasources/influxdb.md b/docs/sources/features/datasources/influxdb.md index c6afdfdc8d4..03dc26bcc26 100644 --- a/docs/sources/features/datasources/influxdb.md +++ b/docs/sources/features/datasources/influxdb.md @@ -3,6 +3,7 @@ title = "Using InfluxDB in Grafana" description = "Guide for using InfluxDB in Grafana" keywords = ["grafana", "influxdb", "guide"] type = "docs" +aliases = ["/datasources/influxdb"] [menu.docs] name = "InfluxDB" parent = "datasources" @@ -22,7 +23,6 @@ Grafana ships with very feature rich data source plugin for InfluxDB. Supporting > NOTE: If this link is missing in the side menu it means that your current user does not have the `Admin` role for the current organization. 3. Click the `Add new` link in the top header. -4. Select `InfluxDB 0.9.x` or `InfluxDB 0.8.x` from the dropdown. Name | Description ------------ | ------------- diff --git a/docs/sources/features/datasources/kairosdb.md b/docs/sources/features/datasources/kairosdb.md deleted file mode 100644 index d27105d0ee1..00000000000 --- a/docs/sources/features/datasources/kairosdb.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -page_title: KairosDB Guide -page_description: KairosDB guide for Grafana -page_keywords: grafana, kairosdb, documentation ---- - -# KairosDB Guide -Grafana v2.1 brings initial support for KairosDB Datasources. While the process of adding the datasource is similar to adding a Graphite or OpenTSDB datasource type, Kairos DB does have a few different options for building queries. - -## Adding the data source to Grafana -![](/img/v2/add_KairosDB.jpg) - -1. Open the side menu by clicking the the Grafana icon in the top header. -2. In the side menu under the `Dashboards` link you should find a link named `Data Sources`. - - > NOTE: If this link is missing in the side menu it means that your current user does not have the `Admin` role for the current organization. - -3. Click the `Add new` link in the top header. -4. Select `KairosDB` from the dropdown. - - - -Name | Description ------------- | ------------- -Name | The data source name, important that this is the same as in Grafana v1.x if you plan to import old dashboards. -Default | Default data source means that it will be pre-selected for new panels. -Url | The http protocol, ip and port of your kairosdb server (default port is usually 8080) -Access | Proxy = access via Grafana backend, Direct = access directly from browser. - -## Query editor -Open a graph in edit mode by click the title. - -![](/img/v2/kairos_query_editor.jpg) - -For details on KairosDB metric queries checkout the official. -- [Query Metrics - KairosDB 0.9.4 documentation](http://kairosdb.github.io/kairosdocs/restapi/QueryMetrics.html). - -## Templated queries -KairosDB Datasource Plugin provides following functions in `Variables values query` field in Templating Editor to query `metric names`, `tag names`, and `tag values` to kairosdb server. - -Name | Description -| ------- | --------| -`metrics(query)` | Returns a list of metric names matching `query`. If nothing is given, returns a list of all metric names. -`tag_names(query)` | Returns a list of tag names matching `query`. If nothing is given, returns a list of all tag names. -`tag_values(metric,tag)` | Returns a list of values for `tag` from the given `metric`. - -For details of `metric names`, `tag names`, and `tag values`, please refer to the KairosDB documentations. - -- [List Metric Names - KairosDB 0.9.4 documentation](http://kairosdb.github.io/kairosdocs/restapi/ListMetricNames.html) -- [List Tag Names - KairosDB 0.9.4 documentation](http://kairosdb.github.io/kairosdocs/restapi/ListTagNames.html) -- [List Tag Values - KairosDB 0.9.4 documentation](http://kairosdb.github.io/kairosdocs/restapi/ListTagValues.html) -- [Query Metrics - KairosDB 0.9.4 documentation](http://kairosdb.github.io/kairosdocs/restapi/QueryMetrics.html). diff --git a/docs/sources/features/datasources/opentsdb.md b/docs/sources/features/datasources/opentsdb.md index 7e81ebe1483..2b1c7e5c3f5 100644 --- a/docs/sources/features/datasources/opentsdb.md +++ b/docs/sources/features/datasources/opentsdb.md @@ -3,6 +3,7 @@ title = "Using OpenTSDB in Grafana" description = "Guide for using OpenTSDB in Grafana" keywords = ["grafana", "opentsdb", "guide"] type = "docs" +aliases = ["/datasources/opentsdb", "docs/features/opentsdb"] [menu.docs] name = "OpenTSDB" parent = "datasources" diff --git a/docs/sources/features/datasources/plugin_api.md b/docs/sources/features/datasources/plugin_api.md deleted file mode 100644 index 2e2121ed21a..00000000000 --- a/docs/sources/features/datasources/plugin_api.md +++ /dev/null @@ -1,34 +0,0 @@ ----- -page_title: Data source Plugin API -page_description: Data Source Plugin Description -page_keywords: grafana, data source, plugin, api, docs ---- - -# Data source plugin API - -All data sources in Grafana are implemented as plugins. - -## Breaking change in 2.2 - -In Grafana 2.2 a breaking change was introduced for how data source query editors -are structured, defined and loaded. This was in order to support mixing multiple data sources -in the same panel. - -In Grafana 2.2, the query editor is no longer defined using the partials section in -`plugin.json`, but defined via an angular directive named using convention naming -scheme like `metricQueryEditor`. For example - -Graphite defines a directive like this: - -```javascript -module.directive('metricQueryEditorGraphite', function() { - return {controller: 'GraphiteQueryCtrl', templateUrl: 'app/plugins/datasource/graphite/partials/query.editor.html'}; -}); -``` - -Even though the data source type name is with lowercase `g`, the directive uses capital `G` in `Graphite` because -that is how angular directives needs to be named in order to match an element with name ``. -You also specify the query controller here instead of in the query.editor.html partial like before. - - - diff --git a/docs/sources/features/datasources/prometheus.md b/docs/sources/features/datasources/prometheus.md index 2a35bb615cb..947d7426230 100644 --- a/docs/sources/features/datasources/prometheus.md +++ b/docs/sources/features/datasources/prometheus.md @@ -3,6 +3,7 @@ title = "Using Prometheus in Grafana" description = "Guide for using Prometheus in Grafana" keywords = ["grafana", "prometheus", "guide"] type = "docs" +aliases = ["/datasources/prometheus"] [menu.docs] name = "Prometheus" parent = "datasources" @@ -15,7 +16,8 @@ weight = 2 Grafana includes support for Prometheus Datasources. While the process of adding the datasource is similar to adding a Graphite or OpenTSDB datasource type, Prometheus does have a few different options for building queries. ## Adding the data source to Grafana -![](/img/v2/add_Prometheus.png) + +![](/img/docs/v2/add_Prometheus.png) 1. Open the side menu by clicking the the Grafana icon in the top header. 2. In the side menu under the `Dashboards` link you should find a link named `Data Sources`. @@ -48,6 +50,7 @@ For details on Prometheus metric queries check out the Prometheus documentation - [Query Metrics - Prometheus documentation](http://prometheus.io/docs/querying/basics/). ## Templated queries + Prometheus Datasource Plugin provides the following functions in `Variables values query` field in Templating Editor to query `metric names` and `labels names` on the Prometheus server. Name | Description @@ -64,8 +67,8 @@ For details of `metric names` & `label names`, and `label values`, please refer You can create a template variable in Grafana and have that variable filled with values from any Prometheus metric exploration query. You can then use this variable in your Prometheus metric queries. -For example you can have a variable that contains all values for label `hostname` if you specify a query like this -in the templating edit view. +For example you can have a variable that contains all values for label `hostname` if you specify a query like this in the templating edit view. + ```sql label_values(hostname) ``` @@ -74,7 +77,7 @@ You can also use raw queries & regular expressions to extract anything you might ### Using templated variables in queries -When the `Include All` option or `Multi-Value` option is enabled, Grafana converts the labels from plain text to a regex compatible string. +When the `Include All` option or `Multi-Value` option is enabled, Grafana converts the labels from plain text to a regex compatible string. Which means you have to use `=~` instead of `=` in your Prometheus queries. For example `ALERTS{instance=~$instance}` instead of `ALERTS{instance=$instance}`. -![](/img/v2/prometheus_templating.png) +![](/img/docs/v2/prometheus_templating.png) diff --git a/docs/sources/features/datasources/testdata.md b/docs/sources/features/datasources/testdata.md index 02e99f7dd8a..3e3f0909700 100644 --- a/docs/sources/features/datasources/testdata.md +++ b/docs/sources/features/datasources/testdata.md @@ -1,23 +1,23 @@ +++ -title = "Grafana TestData" +title = "TestData" keywords = ["grafana", "dashboard", "documentation", "panels", "testdata"] type = "docs" [menu.docs] -name = "Grafana TestData" +name = "TestData" parent = "datasources" -weight = 2 +weight = 20 +++ -# Grafana TestData +# Grafana TestData - > NOTE: This plugin is disable by default. + > NOTE: This plugin is disable by default. -The purpose of this data sources is to make it easier to create fake data for any panel. -Using `Grafana TestData` you can build your own time series and have any panel render it. -This make is much easier to verify functionally since the data can be shared very +The purpose of this data sources is to make it easier to create fake data for any panel. +Using `Grafana TestData` you can build your own time series and have any panel render it. +This make is much easier to verify functionally since the data can be shared very -## Enable +## Enable `Grafana TestData` is not enabled by default. To enable it you have to go to `/plugins/testdata/edit` and click the enable button to enable it for each server. @@ -33,8 +33,8 @@ You can now choose different scenario that you want rendered in the drop down me ## CSV -The comma separated values scenario is the most powerful one since it lets you create any kind of graph you like. -Once you provided the numbers `Grafana TestData` will distribute them evenly based on the time range of your query. +The comma separated values scenario is the most powerful one since it lets you create any kind of graph you like. +Once you provided the numbers `Grafana TestData` will distribute them evenly based on the time range of your query. ![](/img/docs/v41/test_data_csv_example.png) @@ -45,10 +45,10 @@ Once you provided the numbers `Grafana TestData` will distribute them evenly bas ### Commit updates to the dashboards -If you want to submit a change to one of the current dashboards bundled with `Grafana TestData` you have to update the revision property. +If you want to submit a change to one of the current dashboards bundled with `Grafana TestData` you have to update the revision property. Otherwise the dashboard will not be updated automatically for other Grafana users. ## Using test data in issues -If you post an issue on github regarding time series data or rendering of time series data we strongly advice you to use this data source to replicate the data. -That makes it much easier for the developers to replicate and solve the issue you have. +If you post an issue on github regarding time series data or rendering of time series data we strongly advice you to use this data source to replicate the data. +That makes it much easier for the developers to replicate and solve the issue you have. diff --git a/docs/sources/guides/basic_concepts.md b/docs/sources/guides/basic_concepts.md index bbb1cb53728..cb64b105349 100644 --- a/docs/sources/guides/basic_concepts.md +++ b/docs/sources/guides/basic_concepts.md @@ -14,13 +14,15 @@ parent = "guides" This document is a “bottom up” introduction to basic concepts in Grafana, and can be used as a starting point to get familiar with core features. ### Data Source + Grafana supports many different storage backends for your time series data (Data Source). Each Data Source has a specific Query Editor that is customized for the features and capabilities that the particular Data Source exposes. -The following datasources are officially supported: [Graphite](/datasources/graphite/), [InfluxDB](/datasources/influxdb/), [OpenTSDB](/datasources/opentsdb/), [Prometheus](/datasources/prometheus/), [Elasticsearch](/datasources/elasticsearch/), [CloudWatch](/datasources/cloudwatch/), and [KairosDB](/datasources/kairosdb) +The following datasources are officially supported: [Graphite]({{< relref "features/datasources/graphite.md" >}}), [InfluxDB]({{< relref "features/datasources/influxdb.md" >}}), [OpenTSDB]({{< relref "features/datasources/opentsdb.md" >}}), [Prometheus]({{< relref "features/datasources/prometheus.md" >}}), [Elasticsearch]({{< relref "features/datasources/elasticsearch.md" >}}), [CloudWatch]({{< relref "features/datasources/cloudwatch.md" >}}). The query language and capabilities of each Data Source are obviously very different. You can combine data from multiple Data Sources onto a single Dashboard, but each Panel is tied to a specific Data Source that belongs to a particular Organization. ### Organization + Grafana supports multiple organizations in order to support a wide variety of deployment models, including using a single Grafana instance to provide service to multiple potentially untrusted Organizations. In many cases, Grafana will be deployed with a single Organization. @@ -34,6 +36,7 @@ All Dashboards are owned by a particular Organization. For more details on the user model for Grafana, please refer to [Admin](/reference/admin/) ### User + A User is a named account in Grafana. A user can belong to one or more Organizations, and can be assigned different levels of privileges through roles. Grafana supports a wide variety of internal and external ways for Users to authenticate themselves. These include from its own integrated database, from an external SQL server, or from an external LDAP server. diff --git a/docs/sources/guides/getting_started.md b/docs/sources/guides/getting_started.md index 3f039f97ae4..f4c9bb9647f 100644 --- a/docs/sources/guides/getting_started.md +++ b/docs/sources/guides/getting_started.md @@ -3,6 +3,7 @@ title = "Getting Started" description = "Getting started with Grafana guide" keywords = ["grafana", "intro", "guide", "started"] type = "docs" +aliases = ["/guides/gettingstarted"] [menu.docs] name = "Getting Started" identifier = "getting_started_guide" @@ -10,12 +11,15 @@ parent = "guides" +++ # Getting started -This guide will help you get started and acquainted with Grafana. It assumes you have a working Grafana server up and running and have added at least one [Data Source](/datasources/overview). + +This guide will help you get started and acquainted with Grafana. It assumes you have a working Grafana server up and running and have added at least one [Data Source](/features/datasources/). ## Beginner guides + Watch the 10min [beginners guide to building dashboards](https://www.youtube.com/watch?v=sKNZMtoSHN4&index=7&list=PLDGkOdUX1Ujo3wHw9-z5Vo12YLqXRjzg2) to get a quick intro to setting up Dashboards and Panels. ## Basic Concepts + Read the [Basic Concepts](/guides/basic_concepts) document to get a crash course in key Grafana concepts. ### Top header @@ -34,7 +38,8 @@ The image above shows you the top header for a Dashboard. 6. Settings: Manage Dashboard settings and features such as Templating and Annotations. ## Dashboards, Panels, Rows, the building blocks of Grafana... -Dashboards are at the core of what Grafana is all about. Dashboards are composed of individual Panels arranged on a number of Rows. Grafana ships with a variety of Panels. Grafana makes it easy to construct the right queries, and customize the display properties so that you can create the perfect Dashboard for your need. Each Panel can interact with data from any configured Grafana Data Source (currently InfluxDB, Graphite, OpenTSDB, and KairosDB). The [Basic Concepts](/guides/basic_concepts) guide explores these key ideas in detail. + +Dashboards are at the core of what Grafana is all about. Dashboards are composed of individual Panels arranged on a number of Rows. Grafana ships with a variety of Panels. Grafana makes it easy to construct the right queries, and customize the display properties so that you can create the perfect Dashboard for your need. Each Panel can interact with data from any configured Grafana Data Source (currently InfluxDB, Graphite, OpenTSDB, Prometheus and Cloudwatch). The [Basic Concepts](/guides/basic_concepts) guide explores these key ideas in detail. ## Adding & Editing Graphs and Panels diff --git a/docs/sources/guides/whats-new-in-v2-5.md b/docs/sources/guides/whats-new-in-v2-5.md index 93cdd59cb50..ff80ec1f4f4 100644 --- a/docs/sources/guides/whats-new-in-v2-5.md +++ b/docs/sources/guides/whats-new-in-v2-5.md @@ -107,5 +107,5 @@ view the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG - - - -### Download Grafana 2.5 now +### Download Grafana 2.5 now diff --git a/docs/sources/guides/whats-new-in-v3-1.md b/docs/sources/guides/whats-new-in-v3-1.md index 3f1c25eb937..1e8ef87297b 100644 --- a/docs/sources/guides/whats-new-in-v3-1.md +++ b/docs/sources/guides/whats-new-in-v3-1.md @@ -26,7 +26,7 @@ select data source and optional metrix prefix options. The above screenshot shows the new import modal that gives you 3 options for how to import a dashboard. -One notable new addition here is the ability to import directly from Dashboards shared on [Grafana.net](https://grafana.net). +One notable new addition here is the ability to import directly from Dashboards shared on [Grafana.com](https://grafana.com). The next step in the import process: @@ -37,9 +37,9 @@ shows a CollectD dashboard for Graphite that requires a metric prefix be specifi ## Discover Dashboards -On [Grafana.net](https://grafana.net) you can now browse & search for dashboards. We have already added a few but +On [Grafana.com](https://grafana.com) you can now browse & search for dashboards. We have already added a few but more are being uploaded every day. To import a dashboard just copy the dashboard url and head back to Grafana, -then Dashboard Search -> Import -> Paste Grafana.net Dashboard URL. +then Dashboard Search -> Import -> Paste Grafana.com Dashboard URL. diff --git a/docs/sources/guides/whats-new-in-v3.md b/docs/sources/guides/whats-new-in-v3.md index fd4d1e40a18..d82a833ec90 100644 --- a/docs/sources/guides/whats-new-in-v3.md +++ b/docs/sources/guides/whats-new-in-v3.md @@ -14,7 +14,7 @@ weight = 6 ## Commercial Support -Commercial Support subscriptions for Grafana are now [generally available](https://grafana.net/support/plans/). +Commercial Support subscriptions for Grafana are now [generally available](https://grafana.com/support/plans/). Raintank is committed to a 100% open-source strategy for Grafana. We do not want to go down the “open core” route. If your organization @@ -41,15 +41,15 @@ two new plugin types: Dashboards, and Grafana **Pages**. Apps are a great way to provide an entire experience right within Grafana. -## Grafana.net +## Grafana.com -[Grafana.net](https://grafana.net) offers a central repository where the community can come together to discover, create and +[Grafana.com](https://grafana.com) offers a central repository where the community can come together to discover, create and share plugins (data sources, panels, apps) and dashboards. We are also working on a hosted Graphite-compatible data source that will be optimized for use with Grafana. -It’ll be easy to combine your existing data source(s) with this OpenSaaS option. Finally, Grafana.net can +It’ll be easy to combine your existing data source(s) with this OpenSaaS option. Finally, Grafana.com can also be a hub to manage all your Grafana instances. You’ll be able to monitor their health and availability, perform dashboard backups, and more. @@ -57,12 +57,12 @@ We are also working on a hosted Graphite-compatible Data Source that will be optimized for use with Grafana. It’ll be easy to combine your existing Data Source(s) with this OpenSaaS option. -Finally, Grafana.net will also be a hub to manage all your Grafana +Finally, Grafana.com will also be a hub to manage all your Grafana instances. You’ll be able to monitor their health and availability, perform Dashboard backups, and more. Grafana.net will officially launch along with the stable version of -Grafana 3.0, but check out the preview +Grafana 3.0, but go to and check out the preview and sign up for an account in the meantime. @@ -189,37 +189,37 @@ variable is interpolated. data source (or panel) they need to be updated as well. * InfluxDB 0.8: This data source is no longer included in releases, -you can still install manually from [Grafana.net](http://grafana.net) +you can still install manually from [Grafana.com](https://grafana.com) * KairosDB: This data source has also no longer shipped with Grafana, -you can install it manually from [Grafana.net](http://grafana.net) +you can install it manually from [Grafana.com](https://grafana.com) ## Plugin showcase -Discovering and installing plugins is very quick and easy with Grafana 3.0 and [Grafana.net](https://grafana.net). Here +Discovering and installing plugins is very quick and easy with Grafana 3.0 and [Grafana.com](https://grafana.com). Here are a couple that I incurage you try! -#### [Clock Panel](https://grafana.net/plugins/grafana-clock-panel) +#### [Clock Panel](https://grafana.com/plugins/grafana-clock-panel) Support's both current time and count down mode. -#### [Pie Chart Panel](https://grafana.net/plugins/grafana-piechart-panel) +#### [Pie Chart Panel](https://grafana.com/plugins/grafana-piechart-panel) A simple pie chart panel is now available as an external plugin. -#### [WorldPing App](https://grafana.net/plugins/raintank-worldping-app) +#### [WorldPing App](https://grafana.com/plugins/raintank-worldping-app) This is full blown Grafana App that adds new panels, data sources and pages to give feature rich global performance monitoring directly from your on-prem Grafana. -#### [Zabbix App](https://grafana.net/plugins/alexanderzobnin-zabbix-app) +#### [Zabbix App](https://grafana.com/plugins/alexanderzobnin-zabbix-app) This app contains the already very pouplar Zabbix data source plugin, 2 dashboards and a triggers panel. It is created and maintained by [Alexander Zobnin](https://github.com/alexanderzobnin/grafana-zabbix). -Checkout the full list of plugins on [Grafana.net](https://grafana.net/plugins) +Checkout the full list of plugins on [Grafana.com](https://grafana.com/plugins) ## CHANGELOG diff --git a/docs/sources/guides/whats-new-in-v4-1.md b/docs/sources/guides/whats-new-in-v4-1.md index 59e6236af40..bd2b0f1b75f 100644 --- a/docs/sources/guides/whats-new-in-v4-1.md +++ b/docs/sources/guides/whats-new-in-v4-1.md @@ -7,7 +7,7 @@ type = "docs" name = "Version 4.1" identifier = "v4.1" parent = "whatsnew" -weight = -1 +weight = 3 +++ diff --git a/docs/sources/guides/whats-new-in-v4-2.md b/docs/sources/guides/whats-new-in-v4-2.md new file mode 100644 index 00000000000..44aa3a45dc0 --- /dev/null +++ b/docs/sources/guides/whats-new-in-v4-2.md @@ -0,0 +1,88 @@ ++++ +title = "What's New in Grafana v4.2" +description = "Feature & improvement highlights for Grafana v4.2" +keywords = ["grafana", "new", "documentation", "4.2.0"] +type = "docs" +[menu.docs] +name = "Version 4.2" +identifier = "v4.2" +parent = "whatsnew" +weight = -1 ++++ + +## Whats new in Grafana v4.2 + +Grafana v4.2 Beta is now [available for download](/download/4_2_0/). +Just like the last release this one contains lots bug fixes and minor improvements. +We are very happy to say that 27 of 40 issues was closed by pull requests from the community. +Big thumbs up! + +## Release Highlights + +- **Hipchat**: Adds support for sending alert notifications to hipchat [#6451](https://github.com/grafana/grafana/issues/6451), thx [@jregovic](https://github.com/jregovic) +- **Telegram**: Added Telegram alert notifier [#7098](https://github.com/grafana/grafana/pull/7098), thx [@leonoff](https://github.com/leonoff) +- **LINE**: Add LINE as alerting notification channel [#7301](https://github.com/grafana/grafana/pull/7301), thx [@huydx](https://github.com/huydx) +- **Templating**: Make $__interval and $__interval_ms global built in variables that can be used in by any datasource (in panel queries), closes [#7190](https://github.com/grafana/grafana/issues/7190), closes [#6582](https://github.com/grafana/grafana/issues/6582) +- **Alerting**: Adds deduping of alert notifications [#7632](https://github.com/grafana/grafana/pull/7632) +- **Alerting**: Better information about why an alert triggered [#7035](https://github.com/grafana/grafana/issues/7035) +- **Orgs**: Sharing dashboards using Grafana share feature will now redirect to correct org. [#6948](https://github.com/grafana/grafana/issues/6948) +- [Full changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) + +### New alert notification channels + +This release adds **five** new alert notifications channels, all of them contributed by the community. + +* Hipchat +* Telegram +* LINE +* Pushover +* Threema + +### Templating + +We added two new global built in variables in grafana. `$__interval` and `$__interval_ms` are now reserved template names in grafana and can be used by any datasource. +We might add more global built in variables in the future and if we do we will prefix them with `$__`. So please avoid using that in your template variables. + +### Dedupe alert notifications when running multiple servers + +In this release we will dedupe alert notificiations when you are running multiple servers. +This makes it possible to run alerting on multiple servers and only get one notification. + +We currently solve this with sql transactions which puts some limitations for how many servers you can use to execute the same rules. +3-5 servers should not be a problem but as always, it depends on how many alerts you have and how frequently they execute. + +Next up for a better HA situation is to add support for workload balancing between Grafana servers. + +### Alerting more info + +You can now see the reason why an alert triggered in the alert history. Its also easier to detect when an alert is set to `alerting` due to the `no_data` option. + +### Improved support for multi-org setup + +When loading dashboards we now set an query parameter called orgId. So we can detect from which org an user shared a dashboard. +This makes it possible for users to share dashboards between orgs without changing org first. + +We aim to introduce [dashboard groups](https://github.com/grafana/grafana/issues/1611) sometime in the future which will introduce access control and user groups within one org. +Making it possible to have users in multiple groups and have detailed access control. + +## Upgrade & Breaking changes + +If your using https in grafana we now force you to use tls 1.2 and the most secure ciphers. +We think its better to be secure by default rather then making it configurable. +If you want to run https with lower versions of tls we suggest you put a reserve proxy in front of grafana. + +If you have template variables name `$__interval` or `$__interval_ms` they will no longer work since these keywords +are reserved as global built in variables. We might add more global built in variables in the future and if we do, we will prefix them with `$__`. So please avoid using that in your template variables. + +## Changelog + +Checkout the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list +of new features, changes, and bug fixes. + +## Download + +Head to [v4.2-beta download page](/download/4_2_0/) for download links & instructions. + +## Thanks + +A big thanks to all the Grafana users who contribute by submitting PRs, bug reports & feedback! diff --git a/docs/sources/guides/whats-new-in-v4.md b/docs/sources/guides/whats-new-in-v4.md index cfd454cdc23..68a40ed7a1e 100644 --- a/docs/sources/guides/whats-new-in-v4.md +++ b/docs/sources/guides/whats-new-in-v4.md @@ -27,7 +27,7 @@ notifications will be sent out when the rule conditions are met. This feature has been worked on for over a year with many iterations and rewrites just to make sure the foundations are really solid. We are really proud to finally release it! -Since the alerting execution is processed in the backend all data source plugins are not supported. +Since the alerting execution is processed in the backend not all data source plugins are supported. Right now Graphite, Prometheus, InfluxDB and OpenTSDB are supported. Elasticsearch is being worked on but will be not ready for v4 release. @@ -70,8 +70,8 @@ to add graph comments in the form of annotations directly from within Grafana in {{< imgbox max-width="30%" img="/img/docs/v4/alert_list_panel.png" caption="Alert List Panel" >}} -This new panel allows you to show alert rules or a history of alert rule state changes. You can filter based on states your -interested in. Very useful panel for overview style dashboards. +This new panel allows you to show alert rules or a history of alert rule state changes. You can filter based on states you are +interested in. This panel is very useful for overview style dashboards.
@@ -79,8 +79,8 @@ interested in. Very useful panel for overview style dashboards. {{< imgbox max-width="30%" img="/img/docs/v4/adhoc_filters.gif" caption="Ad-hoc filters variable" >}} -This is a new and very different type of template variable. It will allow you to create new key/value filters on the fly. -With autocomplete for both key and values. The filter condition will be automatically applied to all +This is a new and very different type of template variable. It will allow you to create new key/value filters on the fly +with autocomplete for both key and values. The filter condition will be automatically applied to all queries that use that data source. This feature opens up more exploratory dashboards. In the gif animation to the right you have a dashboard for Elasticsearch log data. It uses one query variable that allow you to quickly change how the data is grouped, and an interval variable for controlling the granularity of the time buckets. What was missing @@ -126,15 +126,15 @@ We always try to bring some UX/UI refinements & polish in every release. {{< imgbox max-width="50%" img="/img/docs/v4/add_panel.gif" caption="Add Panel flow" >}} -We spent a lot of time improving the dashboard building experience. Trying to make it both +We spent a lot of time improving the dashboard building experience to make it both more efficient and easier for beginners. After many good but not great experiments with a `build mode` we eventually decided to just improve the green row menu and continue work on a `build mode` for a future release. The new row menu automatically slides out when you mouse over the edge of the row. You no longer need -to hover over the small green icon and the click it to expand the row menu. +to hover over the small green icon and then click it to expand the row menu. -There is some minor improvements to drag and drop behaviour. Now when dragging a panel from one row +There are some minor improvements to drag and drop behaviour. Now when dragging a panel from one row to another you will insert the panel and Grafana will automatically make room for it. When you drag a panel within a row you will simply reorder the panels. @@ -150,7 +150,7 @@ We plan to further improve dashboard building in the future with a more rich gri {{< imgbox max-width="40%" img="/img/docs/v4/shortcuts.png" caption="Shortcuts" >}} Grafana v4 introduces a number of really powerful keyboard shortcuts. You can now focus a panel -by hovering over it with your mouse. With a panel focused you can simple hit `e` to toggle panel +by hovering over it with your mouse. With a panel focused you can simply hit `e` to toggle panel edit mode, or `v` to toggle fullscreen mode. `p r` removes the panel. `p s` opens share modal. @@ -164,10 +164,10 @@ Some nice navigation shortcuts are: ## Upgrade & Breaking changes -There are no breaking changes. Old dashboards and features should work the same. Grafana-server will automatically upgrade it's db +There are no breaking changes. Old dashboards and features should work the same. Grafana-server will automatically upgrade its db schema on restart. It's advisable to do a backup of Grafana's database before updating. -If your are using plugins make sure to update your plugins as some might not work perfectly v4. +If you are using plugins make sure to update your plugins as some might not work perfectly v4. You can update plugins using grafana-cli diff --git a/docs/sources/http_api/admin.md b/docs/sources/http_api/admin.md index 8aa00d08618..950ea4c8202 100644 --- a/docs/sources/http_api/admin.md +++ b/docs/sources/http_api/admin.md @@ -158,6 +158,7 @@ with Grafana admin permission. "cert_file":"", "enabled":"false", "from_address":"admin@grafana.localhost", + "from_name":"Grafana", "host":"localhost:25", "key_file":"", "password":"************", @@ -292,4 +293,4 @@ Change password for specific user HTTP/1.1 200 Content-Type: application/json - {state: "new state", message: "alerts pause/un paused", "alertsAffected": 100} + {state: "new state", message: "alerts pause/un paused", "alertsAffected": 100} diff --git a/docs/sources/http_api/alerting.md b/docs/sources/http_api/alerting.md index b58a177fecd..cc7ce4c2650 100644 --- a/docs/sources/http_api/alerting.md +++ b/docs/sources/http_api/alerting.md @@ -87,7 +87,7 @@ This API can also be used to create, update and delete alert notifications. { "alertId": 1, - "paused: true + "paused": true } **Example Response**: diff --git a/docs/sources/http_api/auth.md b/docs/sources/http_api/auth.md index aaeda1105b1..ef62f271715 100644 --- a/docs/sources/http_api/auth.md +++ b/docs/sources/http_api/auth.md @@ -30,7 +30,7 @@ curl example: Open the sidemenu and click the organization dropdown and select the `API Keys` option. -![](/img/v2/orgdropdown_api_keys.png) +![](/img/docs/v2/orgdropdown_api_keys.png) You use the token in all requests in the `Authorization` header, like this: diff --git a/docs/sources/http_api/data_source.md b/docs/sources/http_api/data_source.md index dd8cbc9aa74..f15411e5eff 100644 --- a/docs/sources/http_api/data_source.md +++ b/docs/sources/http_api/data_source.md @@ -195,7 +195,7 @@ parent = "http_api" {"message":"Datasource updated", "id": 1, "name": "test_datasource"} -## Delete an existing data source +## Delete an existing data source by id `DELETE /api/datasources/:datasourceId` @@ -213,6 +213,24 @@ parent = "http_api" {"message":"Data source deleted"} +## Delete an existing data source by name + +`DELETE /api/datasources/name/:datasourceName` + +**Example Request**: + + DELETE /api/datasources/name/test_datasource HTTP/1.1 + Accept: application/json + Content-Type: application/json + Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + +**Example Response**: + + HTTP/1.1 200 + Content-Type: application/json + + {"message":"Data source deleted"} + ## Data source proxy calls `GET /api/datasources/proxy/:datasourceId/*` diff --git a/docs/sources/http_api/user.md b/docs/sources/http_api/user.md index 2309b56fa2a..fa20fd3e9b3 100644 --- a/docs/sources/http_api/user.md +++ b/docs/sources/http_api/user.md @@ -13,7 +13,7 @@ parent = "http_api" ## Search Users -`GET /api/users` +`GET /api/users?perpage=10&page=1` **Example Request**: @@ -22,6 +22,8 @@ parent = "http_api" Content-Type: application/json Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +Default value for the `perpage` parameter is `1000` and for the `page` parameter is `1`. + **Example Response**: HTTP/1.1 200 @@ -44,6 +46,45 @@ parent = "http_api" } ] +## Search Users with Paging + +`GET /api/users/search?perpage=10&page=1&query=mygraf` + +**Example Request**: + + GET /api/users/search?perpage=10&page=1&query=mygraf HTTP/1.1 + Accept: application/json + Content-Type: application/json + Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + +Default value for the `perpage` parameter is `1000` and for the `page` parameter is `1`. The `totalCount` field in the response can be used for pagination of the user list E.g. if `totalCount` is equal to 100 users and the `perpage` parameter is set to 10 then there are 10 pages of users. The `query` parameter is optional and it will return results where the query value is contained in one of the `name`, `login` or `email` fields. Query values with spaces need to be url encoded e.g. `query=Jane%20Doe`. + +**Example Response**: + + HTTP/1.1 200 + Content-Type: application/json + { + "totalCount": 2, + "users": [ + { + "id": 1, + "name": "Admin", + "login": "admin", + "email": "admin@mygraf.com", + "isAdmin": true + }, + { + "id": 2, + "name": "User", + "login": "user", + "email": "user@mygraf.com", + "isAdmin": false + } + ], + "page": 1, + "perPage": 10 + } + ## Get single user by Id `GET /api/users/:id` @@ -71,36 +112,35 @@ parent = "http_api" ## Get single user by Username(login) or Email - `GET /api/users/lookup` +`GET /api/users/lookup?loginOrEmail=user@mygraf.com` - **Parameter:** `loginOrEmail` +**Example Request using the email as option**: - **Example Request using the email as option**: + GET /api/users/lookup?loginOrEmail=user@mygraf.com HTTP/1.1 + Accept: application/json + Content-Type: application/json + Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - GET /api/users/lookup?loginOrEmail=user@mygraf.com HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +**Example Request using the username as option**: + + GET /api/users/lookup?loginOrEmail=admin HTTP/1.1 + Accept: application/json + Content-Type: application/json + Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - **Example Request using the username as option**: - GET /api/users/lookup?loginOrEmail=admin HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +**Example Response**: - **Example Response**: + HTTP/1.1 200 + Content-Type: application/json - HTTP/1.1 200 - Content-Type: application/json - - { - "email": "user@mygraf.com" - "name": "admin", - "login": "admin", - "theme": "light", - "orgId": 1, - "isGrafanaAdmin": true - } + { + "email": "user@mygraf.com" + "name": "admin", + "login": "admin", + "theme": "light", + "orgId": 1, + "isGrafanaAdmin": true + } ## User Update diff --git a/docs/sources/index.md b/docs/sources/index.md index 0324bf888ad..f22ee82cc5c 100644 --- a/docs/sources/index.md +++ b/docs/sources/index.md @@ -1,8 +1,9 @@ +++ -title = "Grafana Documentation Site" +title = "Docs Home" description = "Install guide for Grafana" keywords = ["grafana", "installation", "documentation"] type = "docs" +aliases = ["v1.1", "guides/reference/admin"] [menu.docs] name = "Welcome to the Docs" identifier = "root" @@ -22,7 +23,7 @@ other domains including industrial sensors, home automation, weather, and proces - [Installing on Windows](installation/windows) - [Installing on Docker](installation/docker) - [Installing using Provisioning (Chef, Puppet, Salt, Ansible, etc)](installation/provisioning) -- [Nightly Builds](http://grafana.org/builds) +- [Nightly Builds](https://grafana.com/grafana/download) For other platforms Read the [build from source]({{< relref "project/building_from_source.md" >}}) instructions for more information. @@ -30,21 +31,21 @@ instructions for more information. ## Configuring Grafana The back-end web server has a number of configuration options. Go the -[Configuration](/installation/configuration) page for details on all +[Configuration]({{< relref "installation/configuration.md" >}}) page for details on all those options. ## Getting started -- [Getting Started](guides/getting_started) -- [Basic Concepts](guides/basic_concepts) -- [Screencasts](tutorials/screencasts) +- [Getting Started]({{< relref "guides/getting_started.md" >}}) +- [Basic Concepts]({{< relref "guides/basic_concepts.md" >}}) +- [Screencasts]({{< relref "tutorials/screencasts.md" >}}) ## Data sources guides -- [Graphite](datasources/graphite) -- [Elasticsearch](datasources/elasticsearch) -- [InfluxDB](datasources/influxdb) -- [OpenTSDB](datasources/opentsdb) +- [Graphite]({{< relref "features/datasources/graphite.md" >}}) +- [Elasticsearch]({{< relref "features/datasources/elasticsearch.md" >}}) +- [InfluxDB]({{< relref "features/datasources/influxdb.md" >}}) +- [OpenTSDB]({{< relref "features/datasources/opentsdb.md" >}}) diff --git a/docs/sources/installation/behind_proxy.md b/docs/sources/installation/behind_proxy.md index 46b70b5dc85..0caa31bbfb5 100644 --- a/docs/sources/installation/behind_proxy.md +++ b/docs/sources/installation/behind_proxy.md @@ -23,16 +23,18 @@ domain = foo.bar To use sub *path* ex `http://foo.bar/grafana` make sure to include `/grafana` in the end of root_url. Otherwise Grafana will not behave correctly. See example below. -# Examples +## Examples Here are some example configurations for running Grafana behind a reverse proxy. -## Grafana configuration (ex http://foo.bar.com) +### Grafana configuration (ex http://foo.bar.com) + ``` [server] domain = foo.bar ``` -## Nginx configuration +### Nginx configuration + ``` server { listen 80; @@ -45,16 +47,16 @@ server { } ``` -# Examples with **sub path** (ex http://foo.bar.com/grafana) +### Examples with **sub path** (ex http://foo.bar.com/grafana) -## Grafana configuration with sub path +#### Grafana configuration with sub path ``` [server] domain = foo.bar root_url = %(protocol)s://%(domain)s:/grafana ``` -## Nginx configuration with sub path +#### Nginx configuration with sub path ``` server { listen 80; diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index e222f8b5f4f..176f843e86b 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -55,6 +55,7 @@ Then you can override them using:
## instance_name + Set the name of the grafana-server instance. Used in logging and internal metrics and in clustering info. Defaults to: `${HOSTNAME}`, which will be replaced with environment variable `HOSTNAME`, if that is empty or does not exist Grafana will try to use @@ -134,6 +135,10 @@ Path to the certificate file (if `protocol` is set to `https`). Path to the certificate key file (if `protocol` is set to `https`). +### router_logging + +Set to true for Grafana to log all HTTP requests (not just errors). These are logged as Info level events +to grafana log.

@@ -145,6 +150,7 @@ things). By default it is configured to use `sqlite3` which is an embedded database (included in the main Grafana binary). ### url + Use either URL or or the other fields below to configure the database Example: `mysql://user:secret@host:port/database` @@ -231,7 +237,7 @@ Default is `false`. Set to `false` to prohibit users from being able to sign up / create user accounts. Defaults to `true`. The admin user can still create -users from the [Grafana Admin Pages](../reference/admin.md) +users from the [Grafana Admin Pages](../../reference/admin) ### allow_org_create @@ -261,6 +267,10 @@ options are `Admin` and `Editor` and `Read Only Editor`. e.g. : Set to true to disable (hide) the login form, useful if you use OAuth, defaults to false. +### disable_signout_menu + +Set to true to disable the signout link in the side menu. useful if you use auth.proxy, defaults to false. +
## [auth.anonymous] @@ -404,7 +414,7 @@ browser to access Grafana, but with the prefix path of `/login/generic_oauth`. allowed_domains = mycompany.com mycompany.org allow_sign_up = true -Set api_url to the resource that returns basic user info. +Set api_url to the resource that returns [OpenID UserInfo](https://connect2id.com/products/server/docs/api/userinfo) compatible information.
@@ -421,6 +431,11 @@ Set to `true` to enable LDAP integration (default: `false`) ### config_file Path to the LDAP specific configuration file (default: `/etc/grafana/ldap.toml`) +### allow_sign_up + +Allow sign up should almost always be true (default) to allow new Grafana users to be created (if ldap authentication is ok). If set to +false only pre-existing Grafana users will be able to login (if ldap authentication is ok). + > For details on LDAP Configuration, go to the [LDAP Integration]({{< relref "ldap.md" >}}) page.
@@ -455,7 +470,7 @@ session provider you have configured. - **file:** session file path, e.g. `data/sessions` - **mysql:** go-sql-driver/mysql dsn config string, e.g. `user:password@tcp(127.0.0.1:3306)/database_name` -- **postgres:** ex: user=a password=b host=localhost port=5432 dbname=c sslmode=disable +- **postgres:** ex: user=a password=b host=localhost port=5432 dbname=c sslmode=require - **memcache:** ex: 127.0.0.1:11211 - **redis:** ex: `addr=127.0.0.1:6379,pool_size=100,prefix=grafana` @@ -471,6 +486,17 @@ Mysql Example: PRIMARY KEY (`key`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8; +Postgres Example: + + CREATE TABLE session ( + key CHAR(16) NOT NULL, + data BYTEA, + expiry INTEGER NOT NULL, + PRIMARY KEY (key) + ); + +Postgres valid `sslmode` are `disable`, `require` (default), `verify-ca`, and `verify-full`. + ### cookie_name The name of the Grafana session cookie. @@ -540,6 +566,9 @@ Verify SSL for smtp server? defaults to `false` ### from_address Address used when sending out emails, defaults to `admin@grafana.localhost` +### from_name +Name to be used when sending out emails, defaults to `Grafana` + ## [log] ### mode @@ -597,13 +626,18 @@ You can choose between (s3, webdav). If left empty Grafana will ignore the uploa ## [external_image_storage.s3] ### bucket_url -bucket url for s3. ex http://grafana.s3.amazonaws.com/ +Bucket URL for S3. AWS region can be specified within URL or defaults to 'us-east-1', e.g. +- http://grafana.s3.amazonaws.com/ +- https://grafana.s3-ap-southeast-2.amazonaws.com/ +- https://grafana.s3-cn-north-1.amazonaws.com.cn ### access_key -access key. ex AAAAAAAAAAAAAAAAAAAA +Access key. e.g. AAAAAAAAAAAAAAAAAAAA + +Access key requires permissions to the S3 bucket for the 's3:PutObject' and 's3:PutObjectAcl' actions. ### secret_key -secret key. ex AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +Secret key. e.g. AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ## [external_image_storage.webdav] diff --git a/docs/sources/installation/debian.md b/docs/sources/installation/debian.md index 088f7d14461..c82b7f1c0e5 100644 --- a/docs/sources/installation/debian.md +++ b/docs/sources/installation/debian.md @@ -15,14 +15,14 @@ weight = 1 Description | Download ------------ | ------------- -Stable for Debian-based Linux | [4.1.1 (x86-64 deb)](https://grafanarel.s3.amazonaws.com/builds/grafana_4.1.1-1484211277_amd64.deb) +Stable for Debian-based Linux | [4.2.0 (x86-64 deb)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.2.0_amd64.deb) ## Install Stable ``` -$ wget https://grafanarel.s3.amazonaws.com/builds/grafana_4.1.1-1484211277_amd64.deb +$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.2.0_amd64.deb $ sudo apt-get install -y adduser libfontconfig -$ sudo dpkg -i grafana_4.1.1-1484211277_amd64.deb +$ sudo dpkg -i grafana_4.2.0_amd64.deb ``` ## APT Repository @@ -114,14 +114,14 @@ those options. ### Adding data sources -- [Graphite]({{< relref "datasources/graphite.md" >}}) -- [InfluxDB]({{< relref "datasources/influxdb.md" >}}) -- [OpenTSDB]({{< relref "datasources/opentsdb.md" >}}) -- [Prometheus]({{< relref "datasources/prometheus.md" >}}) +- [Graphite]({{< relref "features/datasources/graphite.md" >}}) +- [InfluxDB]({{< relref "features/datasources/influxdb.md" >}}) +- [OpenTSDB]({{< relref "features/datasources/opentsdb.md" >}}) +- [Prometheus]({{< relref "features/datasources/prometheus.md" >}}) ## Installing from binary tar file -Download [the latest `.tar.gz` file](http://grafana.org/download) and +Download [the latest `.tar.gz` file](https://grafana.com/get) and extract it. This will extract into a folder named after the version you downloaded. This folder contains all files required to run Grafana. There are no init scripts or install scripts in this package. diff --git a/docs/sources/installation/index.md b/docs/sources/installation/index.md index dc7fd9597a2..47d52817b64 100644 --- a/docs/sources/installation/index.md +++ b/docs/sources/installation/index.md @@ -3,7 +3,7 @@ title = "Installation" description = "Install guide for Grafana" keywords = ["grafana", "installation", "documentation"] type = "docs" -aliases = ["installation/installation/"] +aliases = ["installation/installation/", "v2.1/installation/install/"] [menu.docs] name = "Installation" identifier = "installation" @@ -17,6 +17,6 @@ identifier = "installation" - [Installing on Windows](windows) - [Installing on Docker](docker) - [Installing using Provisioning (Chef, Puppet, Salt, Ansible, etc)](provisioning) -- [Nightly Builds](http://grafana.org/builds) +- [Nightly Builds](https://grafana.com/grafana/download) diff --git a/docs/sources/installation/provisioning.md b/docs/sources/installation/provisioning.md index 513b469d4f9..c3969bcf32d 100644 --- a/docs/sources/installation/provisioning.md +++ b/docs/sources/installation/provisioning.md @@ -3,6 +3,7 @@ title = "Installing via provisioning tools" description = "Guide to install Grafana via provisioning tools like puppet & chef" keywords = ["grafana", "provisioning", "documentation", "puppet", "chef", "ansible"] type = "docs" +aliases = ["docs/provisioning"] [menu.docs] parent = "installation" weight = 8 diff --git a/docs/sources/installation/rpm.md b/docs/sources/installation/rpm.md index 8be68232011..c0952328cbe 100644 --- a/docs/sources/installation/rpm.md +++ b/docs/sources/installation/rpm.md @@ -15,24 +15,25 @@ weight = 2 Description | Download ------------ | ------------- -Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.1.1 (x86-64 rpm)](https://grafanarel.s3.amazonaws.com/builds/grafana-4.1.1-1484211277.x86_64.rpm) +Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.2.0 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.2.0-1.x86_64.rpm) ## Install Stable You can install Grafana using Yum directly. - $ sudo yum install https://grafanarel.s3.amazonaws.com/builds/grafana-4.1.1-1484211277.x86_64.rpm + $ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.2.0-1.x86_64.rpm Or install manually using `rpm`. #### On CentOS / Fedora / Redhat: + $ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.2.0-1.x86_64.rpm $ sudo yum install initscripts fontconfig - $ sudo rpm -Uvh grafana-4.1.1-1484211277.x86_64.rpm + $ sudo rpm -Uvh grafana-4.2.0-1.x86_64.rpm #### On OpenSuse: - $ sudo rpm -i --nodeps grafana-4.1.1-1484211277.x86_64.rpm + $ sudo rpm -i --nodeps grafana-4.2.0-1.x86_64.rpm ## Install via YUM Repository @@ -121,10 +122,10 @@ those options. ### Adding data sources -- [Graphite]({{< relref "datasources/graphite.md" >}}) -- [InfluxDB]({{< relref "datasources/influxdb.md" >}}) -- [OpenTSDB]({{< relref "datasources/opentsdb.md" >}}) -- [Prometheus]({{< relref "datasources/prometheus.md" >}}) +- [Graphite]({{< relref "features/datasources/graphite.md" >}}) +- [InfluxDB]({{< relref "features/datasources/influxdb.md" >}}) +- [OpenTSDB]({{< relref "features/datasources/opentsdb.md" >}}) +- [Prometheus]({{< relref "features/datasources/prometheus.md" >}}) ### Server side image rendering @@ -141,7 +142,7 @@ yum install urw-fonts ## Installing from binary tar file -Download [the latest `.tar.gz` file](http://grafana.org/download) and +Download [the latest `.tar.gz` file](https://grafana.com/get) and extract it. This will extract into a folder named after the version you downloaded. This folder contains all files required to run Grafana. There are no init scripts or install scripts in this package. diff --git a/docs/sources/installation/windows.md b/docs/sources/installation/windows.md index 0036a50694d..229654ef58e 100644 --- a/docs/sources/installation/windows.md +++ b/docs/sources/installation/windows.md @@ -13,7 +13,7 @@ weight = 3 Description | Download ------------ | ------------- -Latest stable package for Windows | [grafana.4.1.1.windows-x64.zip](https://grafanarel.s3.amazonaws.com/builds/grafana-4.1.1.windows-x64.zip) +Latest stable package for Windows | [grafana.4.2.0.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.2.0.windows-x64.zip) ## Configure diff --git a/docs/sources/plugins/apps.md b/docs/sources/plugins/apps.md deleted file mode 100644 index 74038a9feb9..00000000000 --- a/docs/sources/plugins/apps.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -page_title: App plugin -page_description: App plugin for Grafana -page_keywords: grafana, plugins, documentation ---- - - -# Apps - -App plugins is a new kind of grafana plugin that can bundle datasource and panel plugins within one package. It also enable the plugin author to create custom pages within grafana. The custom pages enables the plugin author to include things like documentation, sign up forms or controlling other services using HTTP requests. - -Datasource and panel plugins will show up like normal plugins. The app pages will be available in the main menu. - - - -## Enabling app plugins -After installing an app it have to be enabled before it show up as an datasource or panel. You can do that on the app page in the config tab. - -### Develop your own App - -> Our goal is not to have a very extensive documentation but rather have actual -> code that people can look at. An example implementation of an app can be found -> in this [example app repo](https://github.com/grafana/example-app) - diff --git a/docs/sources/plugins/developing/apps.md b/docs/sources/plugins/developing/apps.md new file mode 100644 index 00000000000..a3fc35066f6 --- /dev/null +++ b/docs/sources/plugins/developing/apps.md @@ -0,0 +1,63 @@ ++++ +title = "Developing App Plugins" +keywords = ["grafana", "plugins", "documentation"] +type = "docs" +[menu.docs] +name = "Developing App Plugins" +parent = "developing" +weight = 6 ++++ + +# Grafana Apps + +App plugins are a new kind of grafana plugin that can bundle datasource and panel plugins within one package. It also enable the plugin author to create custom pages within grafana. The custom pages enable the plugin author to include things like documentation, sign up forms or controlling other services using HTTP requests. + +Datasource and panel plugins will show up like normal plugins. The app pages will be available in the main menu. + +{{< imgbox img="/img/docs/v3/app-in-main-menu.png" caption="App in Main Menu" >}} + +## Enabling app plugins + +After installing an app, it has to be enabled before it shows up as a datasource or panel. You can do that on the app page in the config tab. + +## Developing an App Plugin + +An App is a bundle of panels, dashboards and/or data source(s). There is nothing different about developing panels and data sources for an app. + +Apps have to be enabled in Grafana and should import any included dashboards when the user enables it. A ConfigCtrl class should be created and the dashboards imported in the postUpdate hook. See example below: + +```javascript +export class ConfigCtrl { + /** @ngInject */ + constructor($scope, $injector, $q) { + this.$q = $q; + this.enabled = false; + this.appEditCtrl.setPostUpdateHook(this.postUpdate.bind(this)); + } + + postUpdate() { + if (!this.appModel.enabled) { + return this.$q.resolve(); + } + return this.appEditCtrl.importDashboards().then(() => { + this.enabled = true; + return { + url: "plugins/raintank-kubernetes-app/page/clusters", + message: "Kubernetes App enabled!" + }; + }); + } +} +ConfigCtrl.templateUrl = 'components/config/config.html'; +``` + +If possible a link to a dashboard or custom page should be shown after enabling the app to guide the user to the appropriate place. + +{{< imgbox img="/img/docs/app_plugin_after_enable.png" caption="After enabling" >}} + +### Develop your own App + +> Our goal is not to have a very extensive documentation but rather have actual +> code that people can look at. An example implementation of an app can be found +> in this [example app repo](https://github.com/grafana/example-app) + diff --git a/docs/sources/plugins/developing/code-styleguide.md b/docs/sources/plugins/developing/code-styleguide.md new file mode 100644 index 00000000000..44379c4d4e5 --- /dev/null +++ b/docs/sources/plugins/developing/code-styleguide.md @@ -0,0 +1,182 @@ ++++ +title = "Plugin Code Styleguide" +type = "docs" +[menu.docs] +name = "Plugin Code Styleguide" +parent = "developing" +weight = 2 ++++ + +# Grafana Plugin Code Styleguide + +This guide has two parts. The first part describes the metadata and the second part is a styleguide for HTML/CSS and JavaScript in Grafana plugins and applies if you are using ES6 in your plugin. If using TypeScript then the [Angular TypeScript styleguide](https://angular.io/styleguide) is recommended. + +## Metadata + +The plugin metadata consists of a plugin.json file and the README.md file. These two files are used by Grafana and Grafana.com. + +### Plugin.json (mandatory) + +The plugin.json file is the same concept as the package.json file for an npm package. When Grafana starts it will scan the plugin folders and mount every folder that contains a plugin.json file unless the folder contains a subfolder named `dist`. In that case grafana will mount the `dist` folder instead. + +The most important fields are the first three, especially the id. The convention for the plugin id is **[github username/org]-[plugin name]-[datasource|app|panel]** and it has to be unique. + +Examples: + +``` +raintank-worldping-app +grafana-simple-json-datasource +grafana-piechart-panel +mtanda-histogram-panel +``` + +The full file format for plugin.json is described [here]({{< relref "plugin.json.md" >}}). + +Minimal plugin.json: + +```javascript +{ + "type": "panel", + "name": "Clock", + "id": "yourorg-clock-panel", + + "info": { + "description": "Clock panel for grafana", + "author": { + "name": "Raintank Inc.", + "url": "http://raintank.io" + }, + "keywords": ["clock", "panel"], + "version": "1.0.0", + "updated": "2015-03-24" + }, + + "dependencies": { + "grafanaVersion": "3.x.x", + "plugins": [ ] + } +} +``` + +### README.md + +The README.md file is rendered both on Grafana.net and in the plugins section in Grafana. The only difference from how GitHub renders markdown is that html is not allowed. + +## File and Directory Structure Conventions + +Here is a typical directory structure for a plugin. + +``` +johnnyb-awesome-datasource +|-- dist +|-- spec +| |-- datasource_spec.js +| |-- query_ctrl_spec.js +| |-- test-main.js +|-- src +| |-- img +| | |-- logo.svg +| |-- partials +| | |-- annotations.editor.html +| | |-- config.html +| | |-- query.editor.html +| |-- datasource.js +| |-- module.js +| |-- plugin.json +| |-- query_ctrl.js +|-- Gruntfile.js +|-- LICENSE +|-- package.json +|-- README.md +``` + +Most JavaScript projects have a build step and most Grafana plugins are built using Babel and ES6. The generated JavaScript should be placed in the `dist` directory and the source code in the `src` directory. We recommend that the plugin.json file be placed in the src directory and then copied over to the dist directory when building. The `README.md` can be placed in the root or in the dist directory. + +Directories: + +- `src/` contains plugin source files. +- `src/partials` contains html templates. +- `src/img` contains plugin logos and other images. +- `spec/` contains tests (optional). +- `dist/` contains built content. + +## HTML and CSS + +For the HTML on editor tabs, we recommend using the inbuilt Grafana styles rather than defining your own. This makes plugins feel like a more natural part of Grafana. If done correctly, the html will also be responsive and adapt to smaller screens. The `gf-form` css classes should be used for labels and inputs. + +Below is a minimal example of an editor row with one form group and two fields, a dropdown and a text input: + +```html +
+
+
My Plugin Options
+
+ +
+ +
+
+ + +
+
+
+
+``` + +Use the `width-x` and `max-width-x` classes to control the width of your labels and input fields. Try to get labels and input fields to line up neatly by having the same width for all the labels in a group and the same width for all inputs in a group if possible. + +## Build Scripts + +Our recommendation is to use whatever you usually use - Grunt, Gulp or npm scripts. Most plugins seems to use Grunt so that is probably the easiest to get started with if you do not have a preferred build system. The only requirement is that it supports systemjs which is required by Grafana to load plugins. + +## Linting + +We recommend that you use a linter for your JavaScript. For ES6, the standard linter is [eslint](http://eslint.org/). Rules for linting are described in an .eslintrc that is placed in the root directory. [Here is an example](https://github.com/grafana/worldmap-panel/blob/master/.eslintrc) of linting rules in a plugin. + +### ES6 features + +1. Use `const` if a variable is not going to be reassigned. +2. Prefer to use `let` instead `var` ([Exploring ES6](http://exploringjs.com/es6/ch_core-features.html#_from-var-to-letconst)) +3. Use arrow functions, which don’t shadow `this` ([Exploring ES6](http://exploringjs.com/es6/ch_core-features.html#_from-function-expressions-to-arrow-functions)): + + ```js + testDatasource() { + return this.getServerStatus() + .then(status => { + return this.doSomething(status); + }) + } + ``` + + better than + + ```js + testDatasource() { + var self = this; + return this.getServerStatus() + .then(function(status) { + return self.doSomething(status); + }) + } + ``` +4. Use native _Promise_ object: + + ```js + metricFindQuery(query) { + if (!query) { + return Promise.resolve([]); + } + } + ``` + + better than + + ```js + metricFindQuery(query) { + if (!query) { + return this.$q.when([]); + } + } + ``` +5. If using Lodash, then be consequent and prefer that to the native ES6 array functions. diff --git a/docs/sources/plugins/datasources.md b/docs/sources/plugins/developing/datasources.md similarity index 69% rename from docs/sources/plugins/datasources.md rename to docs/sources/plugins/developing/datasources.md index 332629aa5d8..612a0786976 100644 --- a/docs/sources/plugins/datasources.md +++ b/docs/sources/plugins/developing/datasources.md @@ -1,3 +1,12 @@ ++++ +title = "Developing Datasource Plugins" +keywords = ["grafana", "plugins", "documentation"] +type = "docs" +[menu.docs] +name = "Developing Datasource Plugins" +parent = "developing" +weight = 6 ++++ # Datasources @@ -31,9 +40,11 @@ There are two datasource specific settings for the plugin.json These settings indicates what kind of data the plugin can deliver. At least one of them have to be true ## Datasource + The javascript object that communicates with the database and transforms data to times series. -The Datasource should contain the following functions. +The Datasource should contain the following functions: + ``` query(options) //used by panels to get data testDatasource() //used by datasource configuration page to make sure the connection is working @@ -41,9 +52,14 @@ annotationQuery(options) // used by dashboards to get annotations metricFindQuery(options) // used by query editor to get metric suggestions. ``` +### testDatasource + +When a user clicks on the *Save & Test* button when adding a new data source, the details are first saved to the database and then the `testDatasource` function that is defined in your data source plugin will be called. It is recommended that this function makes a query to the data source that will also test that the authentication details are correct. This is so the data source is correctly configured when the user tries to write a query in a new dashboard. + ### Query -Request object passed to datasource.query function +Request object passed to datasource.query function: + ```json { "range": { "from": "2015-12-22T03:06:13.851Z", "to": "2015-12-22T06:48:24.137Z" }, @@ -57,11 +73,12 @@ Request object passed to datasource.query function } ``` -There are two different kind of results for datasources. -Time series and table. Time series is the most common format and is supported by all datasources and panels. Table format is only support by the Influxdb datasource and table panel. But we might see more of this in the future. +There are two different kinds of results for datasources; +time series and table. Time series is the most common format and is supported by all datasources and panels. Table format is only supported by the InfluxDB datasource and table panel. But we might see more of this in the future. + +Time series response from datasource.query. +An array of: -Time series response from datasource.query -An array of ```json [ { @@ -81,8 +98,9 @@ An array of ] ``` -Table response from datasource.query -An array of +Table response from datasource.query. +An array of: + ```json [ { @@ -119,7 +137,8 @@ An array of ### Annotation Query -Request object passed to datasource.annotationQuery function +Request object passed to datasource.annotationQuery function: + ```json { "range": { "from": "2016-03-04T04:07:55.144Z", "to": "2016-03-04T07:07:55.144Z" }, @@ -132,7 +151,8 @@ Request object passed to datasource.annotationQuery function } ``` -Expected result from datasource.annotationQuery +Expected result from datasource.annotationQuery: + ```json [ { @@ -152,24 +172,24 @@ Expected result from datasource.annotationQuery ## QueryCtrl -A javascript class that will be instantiated and treated as an Angular controller when the user edits metrics in a panel. This class have to inherit from the app/plugins/sdk.QueryCtrl class. +A JavaScript class that will be instantiated and treated as an Angular controller when the user edits metrics in a panel. This class has to inherit from the app/plugins/sdk.QueryCtrl class. Requires a static template or templateUrl variable which will be rendered as the view for this controller. ## ConfigCtrl -A javascript class that will be instantiated and treated as an Angular controller when a user tries to edit or create a new datasource of this type. +A JavaScript class that will be instantiated and treated as an Angular controller when a user tries to edit or create a new datasource of this type. Requires a static template or templateUrl variable which will be rendered as the view for this controller. ## QueryOptionsCtrl -A javascript class that will be instantiated and treated as an Angular controller when the user edits metrics in a panel. This controller is responsible for handling panel wide settings for the datasource. Such as interval, rate and aggregations if needed. +A JavaScript class that will be instantiated and treated as an Angular controller when the user edits metrics in a panel. This controller is responsible for handling panel wide settings for the datasource, such as interval, rate and aggregations if needed. Requires a static template or templateUrl variable which will be rendered as the view for this controller. ## AnnotationsQueryCtrl -A javascript class that will be instantiated and treated as an Angular controller when the user choose this type of datasource in the templating menu in the dashboard. +A JavaScript class that will be instantiated and treated as an Angular controller when the user choose this type of datasource in the templating menu in the dashboard. -Requires a static template or templateUrl variable which will be rendered as the view for this controller. The fields that are bound to this controller is then sent to the Database objects annotationQuery function. +Requires a static template or templateUrl variable which will be rendered as the view for this controller. The fields that are bound to this controller are then sent to the Database objects annotationQuery function. diff --git a/docs/sources/plugins/developing/defaults-and-editor-mode.md b/docs/sources/plugins/developing/defaults-and-editor-mode.md new file mode 100644 index 00000000000..f40aaa90396 --- /dev/null +++ b/docs/sources/plugins/developing/defaults-and-editor-mode.md @@ -0,0 +1,128 @@ ++++ +title = "Plugin Defaults and Editor Mode" +type = "docs" +[menu.docs] +name = "Plugin Defaults and Editor Mode" +parent = "developing" +weight = 3 ++++ + +# Plugin Defaults and Editor Mode + +Most plugins allow users to customize the behavior by changing settings on an editor tab. These setting fields are saved in the dashboard json. + +## Defaults + +We define fields to be saved in Grafana by creating values on the panel object of the controller. You can see these values for any panel by choosing View JSON from the settings menu in Grafana. Here is an excerpt from the clock panel json (with some fields removed), the panel data is saved in the panels array: + +```json +{ + "id": 4, + "title": "Clock", +... + "rows": [ + { +... + "panels": [ + { + "bgColor": "rgb(132, 151, 130)", + "clockType": "24 hour", +``` + +You can define panel data by first creating a variable with default values for the fields and then setting them on the panel object: + +```javascript +const panelDefaults = { + clockType: '24 hour', + fontSize: '60px', + fontWeight: 'normal', + bgColor: null +}; + +constructor($scope, $injector) { + super($scope, $injector); + _.defaults(this.panel, panelDefaults); + + this.updateClock(); +} +``` + +The Lodash function [defaults](https://lodash.com/docs/4.17.4#defaults), which is called in the code above: `_.defaults`, sets a default value only if the value is not already set. This way values that have been changed by the user will not be overwritten. + +These panel fields can be used in the controller or module.html template: + +```html +

{{ctrl.time}}

+``` + +If you want your users to be able to change these panel values then you need to expose them in the Grafana editor. + +## Editor Mode + +Editor mode is when a user clicks Edit on a panel. Every panel has a general tab where you change the title and width and some panels have more inbuilt tabs like the Metrics tab or Time Range tab. A panel plugin can add its own tab(s) so that a user can customize the panel. + +Grafana conventions mean all you need to do is to hook up an Angular template with input fields and Grafana will automatically save the values to the dashboard json and load them on dashboard load. + +## Using Events + +To add an editor tab you need to hook into the event model so that the tab is added when the *init-edit-mode* event is triggered. The following code should be added to the constructor of the plugin Ctrl class: + +```javascript +this.events.on('init-edit-mode', this.onInitEditMode.bind(this)); +``` + +Then you need to create a handler function that is bound to the event. In the example above, the handler is called onInitEditMode. The tab is added by calling the controller function, *addEditorTab*. This function has three parameters; the tab name, the path to a html template for the new editor tab and the tab number. It can be a bit tricky to figure out the path, the path name will be based on the id that is specified in the plugin.json file - for example **grafana-clock-panel**. The code below hooks up an Angular template called editor.html that is located in the `src/partials` directory. + +```javascript +onInitEditMode() { + this.addEditorTab('Options', 'public/plugins/grafana-clock-panel/editor.html', 2); +} +``` + +## Editor HTML and CSS + +For editor tabs html, it is best to use Grafana css styles rather than custom styles. This is to preserve the look and feel of other tabs in Grafana. + +Most editor tabs should use the [gf-form css class](https://github.com/grafana/grafana/blob/master/public/sass/components/_gf-form.scss) from Grafana. The example below has one row with a couple of columns and each column is wrapped in a div like this: + +```html +
+ ``` + +Then each pair, label and field is wrapped in a div with a gf-form class. + +```html +
+ + +
+``` + +Note that there are some Angular attributes here. *ng-model* will update the panel data. *ng-change* will render the panel when you change the value. This change will occur on the onblur event due to the *ng-model-onblur* attribute. This means you can see the effect of your changes on the panel while editing. + +{{< imgbox img="/assets/img/blog/clock-panel-editor.png" caption="Panel Editor" >}} + +On the editor tab we use a drop down for 12/24 hour clock, an input field for font size and a color picker for the background color. + +The drop down/select has its own *gf-form-select-wrapper* css class and looks like this: + +```html +
+ +
+ +
+
+``` + +The color picker (or spectrum picker) is a component that already exists in Grafana. We use it like this for the background color: + +```html + +``` + +## Editor Tab Finished + +To reiterate, this all ties together quite neatly. We specify properties and panel defaults in the constructor for the panel controller and these can then be changed in the editor. Grafana takes care of saving the changes. + +One thing to be aware of is that panel defaults are used the first time a panel is created to set the initial values of the panel properties. After the panel is saved then the saved value will be used instead. So beware if you update panel defaults they will not automatically update the property in an existing panel. For example, if you set the default font size to 60px first and then in version 2 of the plugin change it to 50px, existing panels will still have 60px and only new panels will get the new 50px value. diff --git a/docs/sources/plugins/developing/development.md b/docs/sources/plugins/developing/development.md new file mode 100644 index 00000000000..1f8db99424b --- /dev/null +++ b/docs/sources/plugins/developing/development.md @@ -0,0 +1,120 @@ ++++ +title = "Developer Guide" +type = "docs" +aliases = ["/plugins/development/", "/plugins/datasources/", "/plugins/apps/", "/plugins/panels/"] +[menu.docs] +name = "Developer Guide" +parent = "developing" +weight = 1 ++++ + +# Developer Guide + +From grafana 3.0 it's very easy to develop your own plugins and share them with other grafana users. + +There are two blog posts about authoring a plugin that might also be of interest to any plugin authors, [Timing is Everything. Writing the Clock Panel Plugin for Grafana 3.0- part 1](https://grafana.com/blog/2016/04/08/timing-is-everything.-writing-the-clock-panel-plugin-for-grafana-3.0/) and [Timing is Everything. Editor Mode in Grafana 3.0 for the Clock Panel Plugin](https://grafana.com/blog/2016/04/15/timing-is-everything.-editor-mode-in-grafana-3.0-for-the-clock-panel-plugin/). + +## Short version + +1. [Setup grafana](http://docs.grafana.org/project/building_from_source/) +2. Clone an example plugin into ```/var/lib/grafana/plugins``` or `data/plugins` (relative to grafana git repo if your running development version from source dir) +3. Code away! + +## What languages? + +Since everything turns into javascript it's up to you to choose which language you want. That said it's probably a good idea to choose es6 or typescript since we use es6 classes in Grafana. So it's easier to get inspiration from the Grafana repo is you choose one of those languages. + +## Buildscript + +You can use any build system you like that support systemjs. All the built content should end up in a folder named ```dist``` and committed to the repository.By committing the dist folder the person who installs your plugin does not have to run any buildscript. + +All our example plugins have build scripted configured. + +## Metadata + +See the [coding styleguide]({{< relref "code-styleguide.md" >}}) for details on the metadata. + +## module.(js|ts) + +This is the entry point for every plugin. This is the place where you should export +your plugin implementation. Depending on what kind of plugin you are developing you +will be expected to export different things. You can find what's expected for [datasource]({{< relref "datasources.md" >}}), [panels]({{< relref "panels.md" >}}) +and [apps]({{< relref "apps.md" >}}) plugins in the documentation. + +The Grafana SDK is quite small so far and can be found here: + +- [SDK file in Grafana](https://github.com/grafana/grafana/blob/master/public/app/plugins/sdk.ts) +- [SDK Readme](https://github.com/grafana/grafana/blob/master/public/app/plugins/plugin_api.md) + +The SDK contains three different plugin classes: PanelCtrl, MetricsPanelCtrl and QueryCtrl. For plugins of the panel type, the module.js file should export one of these. There are some extra classes for [data sources]({{< relref "datasources.md" >}}). + +Example: + +```javascript +import {ClockCtrl} from './clock_ctrl'; + +export { + ClockCtrl as PanelCtrl +}; +``` + +The module class is also where css for the dark and light themes is imported: + +```javascript +import {loadPluginCss} from 'app/plugins/sdk'; +import WorldmapCtrl from './worldmap_ctrl'; + +loadPluginCss({ + dark: 'plugins/grafana-worldmap-panel/css/worldmap.dark.css', + light: 'plugins/grafana-worldmap-panel/css/worldmap.light.css' +}); + +export { + WorldmapCtrl as PanelCtrl +}; +``` + +## Start developing your plugin + +There are three ways that you can start developing a Grafana plugin. + +1. Setup a Grafana development environment. [(described here)](http://docs.grafana.org/project/building_from_source/) and place your plugin in the ```data/plugins``` folder. +2. Install Grafana and place your plugin in the plugins directory which is set in your [config file](/installation/configuration). By default this is `/var/lib/grafana/plugins` on Linux systems. +3. Place your plugin directory anywhere you like and specify it grafana.ini. + +We encourage people to setup the full Grafana environment so that you can get inspiration from the rest of grafana code base. + +When Grafana starts it will scan the plugin folders and mount every folder that contains a plugin.json file unless +the folder contains a subfolder named dist. In that case grafana will mount the dist folder instead. +This makes it possible to have both built and src content in the same plugin git repo. + +## Grafana Events + +There are a number of Grafana events that a plugin can hook into: + +- `init-edit-mode` can be used to add tabs when editing a panel +- `panel-teardown` can be used for clean up +- `data-received` is an event in that is triggered on data refresh and can be hooked into +- `data-snapshot-load` is an event triggered to load data when in snapshot mode. +- `data-error` is used to handle errors on dashboard refresh. + +If a panel receives data and hooks into the `data-received` event then it should handle snapshot mode too. Otherwise the panel will not work if saved as a snapshot. [Getting Plugins to work in Snapshot Mode]({{< relref "snapshot-mode.md" >}}) describes how to add support for this. + +## Examples + +We currently have three different examples that you can fork/download to get started developing your grafana plugin. + + - [simple-json-datasource](https://github.com/grafana/simple-json-datasource) (small datasource plugin for querying json data from backends) + - [example-app](https://github.com/grafana/example-app) + - [clock-panel](https://github.com/grafana/clock-panel) + - [singlestat-panel](https://github.com/grafana/grafana/blob/master/public/app/plugins/panel/singlestat/module.ts) + - [piechart-panel](https://github.com/grafana/piechart-panel) + +## Other Articles + +- [Getting Plugins to work in Snapshot Mode]({{< relref "snapshot-mode.md" >}}) +- [Plugin Defaults and Editor Mode]({{< relref "defaults-and-editor-mode.md" >}}) +- [Grafana Plugin Code Styleguide]({{< relref "code-styleguide.md" >}}) +- [Grafana Apps]({{< relref "apps.md" >}}) +- [Grafana Datasources]({{< relref "datasources.md" >}}) +- [plugin.json Schema]({{< relref "plugin.json.md" >}}) diff --git a/docs/sources/plugins/developing/index.md b/docs/sources/plugins/developing/index.md new file mode 100644 index 00000000000..c9a78fbb39a --- /dev/null +++ b/docs/sources/plugins/developing/index.md @@ -0,0 +1,8 @@ ++++ +title = "Developing Plugins" +type = "docs" +[menu.docs] +parent = "plugins" +identifier = "developing" +weight = 3 ++++ diff --git a/docs/sources/plugins/panels.md b/docs/sources/plugins/developing/panels.md similarity index 95% rename from docs/sources/plugins/panels.md rename to docs/sources/plugins/developing/panels.md index 168d1320ca5..26db69c7c94 100644 --- a/docs/sources/plugins/panels.md +++ b/docs/sources/plugins/developing/panels.md @@ -9,7 +9,7 @@ page_keywords: grafana, plugins, documentation title = "Installing Plugins" type = "docs" [menu.docs] -parent = "plugins" +parent = "developing" weight = 1 +++ diff --git a/docs/sources/plugins/developing/plugin.json.md b/docs/sources/plugins/developing/plugin.json.md new file mode 100644 index 00000000000..7de5e91986f --- /dev/null +++ b/docs/sources/plugins/developing/plugin.json.md @@ -0,0 +1,28 @@ ++++ +title = "plugin.json Schema" +keywords = ["grafana", "plugins", "documentation"] +type = "docs" +[menu.docs] +name = "plugin.json Schema" +parent = "developing" +weight = 6 ++++ + +# Plugin.json + +The plugin.json file is mandatory for all plugins. When Grafana starts it will scan the plugin folders and mount every folder that contains a plugin.json file unless the folder contains a subfolder named `dist`. In that case grafana will mount the `dist` folder instead. + +## Plugin JSON Schema + +| Property | Description | +| ------------- |-------------| +| id | unique name of the plugin - [conventions described in styleguide]({{< relref "code-styleguide.md" >}}) | +| type | panel/datasource/app | +| name | Human readable name of the plugin | +| info.description | Description of plugin. Used for searching grafana.com plugins | +| info.author | | +| info.keywords | plugin keywords. Used for search on grafana net| +| info.logos | link to project logos | +| info.version | project version of this commit. Must be semver | +| dependencies.grafanaVersion | Required grafana backend version for this plugin | +| dependencies.plugins | required plugins for this plugin. | diff --git a/docs/sources/plugins/developing/snapshot-mode.md b/docs/sources/plugins/developing/snapshot-mode.md new file mode 100644 index 00000000000..dd47b93851a --- /dev/null +++ b/docs/sources/plugins/developing/snapshot-mode.md @@ -0,0 +1,79 @@ ++++ +title = "Snapshot Mode" +type = "docs" +[menu.docs] +name = "Snapshot Mode" +parent = "developing" +weight = 6 ++++ + +# Getting Plugins to work in Snapshot Mode + +{{< imgbox img="/img/docs/Grafana-snapshot-example.png" caption="A dashboard using snapshot data and not live data." >}} + +Grafana has this great feature where you can [save a snapshot of your dashboard](http://docs.grafana.org/reference/sharing/). Instead of sending a screenshot of a dashboard to someone, you can send them a working, interactive Grafana dashboard with the snapshot data embedded inside it. The snapshot can be saved on your Grafana server and is available to all your co-workers. Raintank also hosts a [snapshot server](http://snapshot.raintank.io/) if you want to send the snapshot to someone who does not have access to your Grafana server. + +{{< imgbox img="/img/docs/animated_gifs/snapshots.gif" caption="Selecting a snapshot" >}} + +This all works because Grafana saves a snapshot of the current data in the dashboard json instead of fetching the data from a data source. However, if you are building a custom panel plugin then this will not work straight out of the box. You will need to make some small (and easy!) changes first. + +## Enabling support for loading snapshot data + +Grafana automatically saves data from data sources in the dashboard json when the snapshot is created so we do not have to write any code for that. Enabling snapshot support for reading time series data is very simple. First in the constructor, we need to add an event handler for `data-snapshot-load`. This event is triggered by Grafana when the snapshot data is loaded from the dashboard json. + +```javascript +constructor($scope, $injector, contextSrv) { + super($scope, $injector); + ... + this.events.on('init-edit-mode', this.onInitEditMode.bind(this)); + this.events.on('data-received', this.onDataReceived.bind(this)); + this.events.on('panel-teardown', this.onPanelTeardown.bind(this)); + this.events.on('data-snapshot-load', this.onDataSnapshotLoad.bind(this)); +``` + +Then we need to create a simple event handler that just forwards the data on to our regular `data-received` handler: + +```javascript +onDataSnapshotLoad(snapshotData) { + this.onDataReceived(snapshotData); +} +``` + +This will cover most use cases for snapshot support. Sometimes you will want to save data that is not time series data from a Grafana data source and then you have to do a bit more work to get snapshot support. + +## Saving custom data for snapshots + +Data that is not time series data from a Grafana data source is not saved automatically by Grafana. Saving custom data for snapshot mode has to be done manually. + +{{< imgbox img="/img/docs/Grafana-save-snapshot.png" caption="Save snapshot" >}} + +Grafana gives us a chance to save data to the dashboard json when it is creating a snapshot. In the 'data-received' event handler, you can check the snapshot flag on the dashboard object. If this is true, then Grafana is creating a snapshot and you can manually save custom data to the panel json. In the example, a new field called snapshotLocationData in the panel json is initialized with a snapshot of the custom data. + +```javascript +onDataReceived(dataList) { + if (!dataList) return; + + if (this.dashboard.snapshot && this.locations) { + this.panel.snapshotLocationData = this.locations; + } +``` + +Now the location data is saved in the dashboard json but we will have to load it manually as well. + +## Loading custom data for snapshots + +The example below shows a function that loads the custom data. The data source for the custom data (an external api in this case) is not available in snapshot mode so a guard check is made to see if there is any snapshot data available first. If there is, then the snapshot data is used instead of trying to load the data from the external api. + +```javascript +loadLocationDataFromFile(reload) { + if (this.map && !reload) return; + + if (this.panel.snapshotLocationData) { + this.locations = this.panel.snapshotLocationData; + return; + } +``` + +It is really easy to forget to add this support but it enables a great feature and can be used to demo your panel. + +If there is a panel plugin that you would like to be installed on the Raintank Snapshot server then please contact us via [Slack](https://raintank.slack.com) or [GitHub](https://github.com/grafana/grafana). diff --git a/docs/sources/plugins/development.md b/docs/sources/plugins/development.md deleted file mode 100644 index 9b2a3fb8668..00000000000 --- a/docs/sources/plugins/development.md +++ /dev/null @@ -1,60 +0,0 @@ -+++ -title = "Developer Guide" -type = "docs" -aliases = ["/plugins/datasources/", "/plugins/apps/", "/plugins/panels/"] -[menu.docs] -name = "Developer Guide" -parent = "plugins" -weight = 5 -+++ - -# Developer Guide - -From grafana 3.0 it's very easy to develop your own plugins and share them with other grafana users. - -## Short version - -1. [Setup grafana](http://docs.grafana.org/project/building_from_source/) -2. Clone an example plugin into ```/var/lib/grafana/plugins``` or `data/plugins` (relative to grafana git repo if your running development version from source dir) -3. Code away! - -## What languages? - -Since everything turns into javascript it's up to you to choose which language you want. That said it's probably a good idea to choose es6 or typescript since we use es6 classes in Grafana. So it's easier to get inspiration from the Grafana repo is you choose one of those languages. - -## Buildscript - -You can use any build system you like that support systemjs. All the built content should end up in a folder named ```dist``` and committed to the repository.By committing the dist folder the person who installs your plugin does not have to run any buildscript. - -All our example plugins have build scripted configured. - -## module.(js|ts) - -This is the entry point for every plugin. This is the place where you should export -your plugin implementation. Depending on what kind of plugin you are developing you -will be expected to export different things. You can find what's expected for [datasource](./datasources.md), [panels](./panels.md) -and [apps](./apps.md) plugins in the documentation. - -## Start developing your plugin - -There are three ways that you can start developing a Grafana plugin. - -1. Setup a Grafana development environment. [(described here)](http://docs.grafana.org/project/building_from_source/) and place your plugin in the ```data/plugins``` folder. -2. Install Grafana and place your plugin in the plugins directory which is set in your [config file](../installation/configuration.md). By default this is `/var/lib/grafana/plugins` on Linux systems. -3. Place your plugin directory anywhere you like and specify it grafana.ini. - -We encourage people to setup the full Grafana environment so that you can get inspiration from the rest of grafana code base. - -When Grafana starts it will scan the plugin folders and mount every folder that contains a plugin.json file unless -the folder contains a subfolder named dist. In that case grafana will mount the dist folder instead. -This makes it possible to have both built and src content in the same plugin git repo. - -## Examples - -We currently have three different examples that you can fork/download to get started developing your grafana plugin. - - - [simple-json-datasource](https://github.com/grafana/simple-json-datasource) (small datasource plugin for querying json data from backends) - - [example-app](https://github.com/grafana/example-app) - - [clock-panel](https://github.com/grafana/clock-panel) - - [singlestat-panel](https://github.com/grafana/grafana/blob/master/public/app/plugins/panel/singlestat/module.ts) - - [piechart-panel](https://github.com/grafana/piechart-panel) diff --git a/docs/sources/plugins/index.md b/docs/sources/plugins/index.md index 217a01f9160..8108ce33805 100644 --- a/docs/sources/plugins/index.md +++ b/docs/sources/plugins/index.md @@ -1,5 +1,6 @@ +++ title = "Plugins" +type = "docs" [menu.docs] name = "Plugins" identifier = "plugins" @@ -9,16 +10,11 @@ weight = 8 # Plugins -From Grafana 3.0 not only datasource plugins are supported but also panel plugins and apps. -Having panels as plugins make it easy to create and add any kind of panel, to show your data -or improve your favorite dashboards. Apps is something new in Grafana that enables -bundling of datasources, panels, dashboards and Grafana pages into a cohesive experience. +Plugins make it easy to create and add any kind of panel, data source or complete +application, to show your data or improve your favorite dashboards. -Grafana already have a strong community of contributors and plugin developers. +Grafana has a strong community of contributors and plugin developers. By making it easier to develop and install plugins we hope that the community can grow even stronger and develop new plugins that we would never think about. -You can discover available plugins on [Grafana.net](https://grafana.net) - - - +You can discover available plugins on [Grafana.com](https://grafana.com/plugins) diff --git a/docs/sources/plugins/installation.md b/docs/sources/plugins/installation.md index 5880d18c0e3..745d7ce2304 100644 --- a/docs/sources/plugins/installation.md +++ b/docs/sources/plugins/installation.md @@ -17,7 +17,7 @@ Grafana already have a strong community of contributors and plugin developers. By making it easier to develop and install plugins we hope that the community can grow even stronger and develop new plugins that we would never think about. -To discover plugins checkout the official [Plugin Repository](https://grafana.net/plugins). +To discover plugins checkout the official [Plugin Repository](https://grafana.com/plugins). # Installing plugins diff --git a/docs/sources/plugins/plugin.json.md b/docs/sources/plugins/plugin.json.md deleted file mode 100644 index dd4f2f58560..00000000000 --- a/docs/sources/plugins/plugin.json.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -page_title: Plugin json file -page_description: Plugin json for Grafana -page_keywords: grafana, plugins, documentation ---- - -# Plugin.json - -TODO - diff --git a/docs/sources/project/building_from_source.md b/docs/sources/project/building_from_source.md index 195c71d99a8..4b5291dc887 100644 --- a/docs/sources/project/building_from_source.md +++ b/docs/sources/project/building_from_source.md @@ -13,7 +13,7 @@ dev environment. Grafana ships with its own required backend server; also comple ## Dependencies -- [Go 1.7.3](https://golang.org/dl/) +- [Go 1.8](https://golang.org/dl/) - [NodeJS](https://nodejs.org/download/) ## Get Code diff --git a/docs/sources/project/cla.md b/docs/sources/project/cla.md deleted file mode 100644 index 4554300249d..00000000000 --- a/docs/sources/project/cla.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -page_title: Grafana CLA -page_description: Grafana Contributor License Agreement -page_keywords: grafana, cla, contribute, documentation ---- - -# Grafana Contributor License Agreement - -## Why is this agreement necessary? -We very much appreciate your wanting to contribute to Grafana, -but we need to add you to the contributors list first. - - -Note that the following agreement is not a transfer of copyright ownership, -this simply is a license agreement for contributions. You also do not change -your rights to use your own contributions for any other purpose. - - -For some background on why contributor license agreements are necessary, -you can read FAQs from many other open source projects: - -- [Django's excellent CLA FAQ](https://www.djangoproject.com/foundation/cla/faq/) -- [A well-written chapter from Karl Fogel's Producing Open Source Software on CLAs](http://producingoss.com/en/copyright-assignment.html) -- [The Wikipedia article on CLAs](http://en.wikipedia.org/wiki/Contributor_license_agreement) - -This is part of the legal framework of the open-source ecosystem that adds some red tape, -but protects both the contributor and the company / foundation behind the project. -It also gives us the option to relicense the code with a more permissive license in the future. - - -If you have more questions, shoot us an [email](mailto:torkel@grafana.org) or drop by #grafana on IRC (freenode). - -Many thanks to [RethinkDB](http://rethinkdb.com) for permission to re-use their CLA! - -## Terms of the Agreement - -
- -This Contributor License Agreement (“Agreement”) is entered into between Coding Instinct AB, -a Swedish corporation (“Grafana,” “we” or “us” etc.) and you (as defined and further identified below). -Accordingly, you hereby agree to the following terms for your past, present and future contributions -submitted to Grafana: - - -**1. Definitions:** -(a) "You" (or "your") shall mean the contribution copyright owner (whether an individual or organization) or legal entity authorized by the copyright owner that is making this Agreement with Grafana. - - -(b) "Contribution(s)" shall mean the code, documentation or other original works of authorship, including any modifications or additions to an existing work, submitted by you to Grafana for inclusion in, or documentation of, any of the products or projects owned or managed by Grafana (the "work(s)"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to Grafana or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Grafana for the purpose of discussing and/or improving the work, but excluding communication that is conspicuously marked or otherwise designated in writing by you as "Not a Contribution." - -**2. Grant of Copyright License.** -You hereby grant to Grafana and to recipients of software distributed by Grafana a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute your contributions and such derivative works. - - -**3. Grant of Patent License.** -With respect to any patents you own, or that you can license without payment to any third party, you hereby grant Grafana a perpetual, irrevocable, non-exclusive, worldwide, no-charge, royalty-free license to: (i) make, have made, use, sell, offer to sell, import, and otherwise distribute and exploit your contributions in whole or in part, alone or in combination with or included in any product, work or materials arising out of or relating to the Works to which your contributions were submitted; and (ii) sublicense these same rights to third parties through multiple levels of sublicensees or other licensing arrangements. - - -**4. Rights.** -Except as set out above, you keep all right, title, and interest in your contribution. The rights that you grant to us under this agreement are effective on the date you first submitted a contribution to us, even if your submission took place before the date you entered this agreement. - - -**5. You represent and warrant that:** -(i) the contributions are an original work and that you can legally grant the rights set out in this agreement; - - -(ii) the contributions and Grafana’s exercise of any license rights granted hereunder, does not and will not, infringe the rights of any third party; - - -(iii) you are not aware of any pending or threatened claims, suits, actions, or charges pertaining to the contributions, including without limitation any claims or allegations that any or all of the contributions infringes, violates, or misappropriate the intellectual property rights of any third party (you further agree that you will notify Grafana immediately if you become aware of any such actual or potential claims, suits, actions, allegations or charges). - - -**6. Employer Rights.** -If your employer(s) has rights to intellectual property that you create that includes your contributions, you represent and warrant that your employer has waived such rights for your contributions to Grafana, or that you have received permission to make contributions on behalf of that employer and that you are authorized to execute this agreement on behalf of your employer. - - -**7. Support.** -You are not expected to provide support for your contributions, except to the extent you desire to provide support. You may provide support for free, for a fee, or not at all. Except as set forth herein, and unless required by applicable law or agreed to in writing, you provide your contributions on an "as is" basis, without warranties or conditions of any kind. - - -**8. Enforcement.** -The failure of either party to enforce its rights under this agreement for any period shall not be construed as a waiver of such rights. No changes or modifications or waivers to this Agreement will be effective unless in writing and signed by both parties. In the event that any provision of this agreement shall be determined to be illegal or unenforceable, that provision will be limited or eliminated to the minimum extent necessary so that this agreement shall otherwise remain in full force and effect and enforceable. This agreement shall be governed by and construed in accordance with the laws of the State of California in the United States without regard to the conflicts of laws provisions thereof. In any action or proceeding to enforce rights under this agreement, the prevailing party will be entitled to recover costs and attorneys’ fees. - - - \ No newline at end of file diff --git a/docs/sources/project/index.md b/docs/sources/project/index.md new file mode 100644 index 00000000000..f64918b2f02 --- /dev/null +++ b/docs/sources/project/index.md @@ -0,0 +1,8 @@ +++ +title = "Project" +type = "docs" +identifier = "project" +weight = 6 ++++ + +# Welcome to the grafana project diff --git a/docs/sources/reference/admin.md b/docs/sources/reference/admin.md index df396fed212..a6863b4ea71 100644 --- a/docs/sources/reference/admin.md +++ b/docs/sources/reference/admin.md @@ -27,7 +27,7 @@ modify Organization details and options. ## Grafana Administrators - + As a Grafana Administrator, you have complete access to any Organization or User in that instance of Grafana. When performing actions as a Grafana admin, the sidebar will change it's appearance as below to indicate you are performing global server administration. diff --git a/docs/sources/reference/dashboard.md b/docs/sources/reference/dashboard.md index c1c574ce917..595a10e5081 100644 --- a/docs/sources/reference/dashboard.md +++ b/docs/sources/reference/dashboard.md @@ -66,11 +66,11 @@ Each field in the dashboard JSON is explained below with its usage: | **editable** | whether a dashboard is editable or not | | **hideControls** | whether row controls on the left in green are hidden or not | | **graphTooltip** | TODO | -| **rows** | row metadata, see [rows section](/docs/sources/reference/dashboard.md/#rows) for details | +| **rows** | row metadata, see [rows section](#rows) for details | | **time** | time range for dashboard, i.e. last 6 hours, last 7 days, etc | -| **timepicker** | timepicker metadata, see [timepicker section](/docs/sources/reference/dashboard.md/#timepicker) for details | -| **templating** | templating metadata, see [templating section](/docs/sources/reference/dashboard.md/#templating) for details | -| **annotations** | annotations metadata, see [annotations section](/docs/sources/reference/dashboard.md/#annotations) for details | +| **timepicker** | timepicker metadata, see [timepicker section](#timepicker) for details | +| **templating** | templating metadata, see [templating section](#templating) for details | +| **annotations** | annotations metadata, see [annotations section](#annotations) for details | | **schemaVersion** | TODO | | **version** | TODO | | **links** | TODO | @@ -79,7 +79,7 @@ Each field in the dashboard JSON is explained below with its usage: `rows` field consists of an array of JSON object representing each row in a dashboard, such as shown below: -``` +```json "rows": [ { "collapse": false, @@ -105,14 +105,14 @@ Usage of the fields is explained below: | **collapse** | whether row is collapsed or not | | **editable** | whether a row is editable or not | | **height** | height of the row in pixels | -| **panels** | panels metadata, see [panels section](/docs/sources/reference/dashboard.md/#panels) for details | +| **panels** | panels metadata, see [panels section](#panels) for details | | **title** | title of row | #### panels Panels are the building blocks a dashboard. It consists of datasource queries, type of graphs, aliases, etc. Panel JSON consists of an array of JSON objects, each representing a different panel in a row. Most of the fields are common for all panels but some fields depends on the panel type. Following is an example of panel JSON representing a `graph` panel type: -``` +```json "panels": [ { "aliasColors": {}, @@ -276,7 +276,7 @@ Usage of each field is explained below: Description: TODO -``` +```json "timepicker": { "collapse": false, "enable": true, @@ -330,7 +330,7 @@ Usage of the fields is explained below: `templating` fields contains array of template variables with their saved values along with some other metadata, for example: -``` +```json "templating": { "enable": true, "list": [ diff --git a/docs/sources/reference/export_import.md b/docs/sources/reference/export_import.md index bc05795c030..4c2d5faa3d3 100644 --- a/docs/sources/reference/export_import.md +++ b/docs/sources/reference/export_import.md @@ -33,7 +33,7 @@ To import a dashboard open dashboard search and then hit the import button. -From here you can upload a dashboard json file, paste a [Grafana.net](https://grafana.net) dashboard +From here you can upload a dashboard json file, paste a [Grafana.com](https://grafana.com) dashboard url or paste dashboard json text directly into the text area. @@ -41,15 +41,15 @@ url or paste dashboard json text directly into the text area. In step 2 of the import process Grafana will let you change the name of the dashboard, pick what data source you want the dashboard to use and specify any metric prefixes (if the dashboard use any). -## Discover dashboards on Grafana.net +## Discover dashboards on Grafana.com -Find dashboards for common server applications at [Grafana.net/dashboards](https://grafana.net/dashboards). +Find dashboards for common server applications at [Grafana.com/dashboards](https://grafana.com/dashboards). ## Import & Sharing with Grafana 2.x or 3.0 -Dashboards on Grafana.net use a new feature in Grafana 3.1 that allows the import process +Dashboards on Grafana.com use a new feature in Grafana 3.1 that allows the import process to update each panel so that they are using a data source of your choosing. If you are running a Grafana version older than 3.1 then you might need to do some manual steps either before or after import in order for the dashboard to work properly. @@ -99,10 +99,10 @@ These are then referenced in the dashboard panels like this: ``` These inputs and their usage in data source properties are automatically added during export in Grafana 3.1. -If you run an older version of Grafana and want to share a dashboard on Grafana.net you need to manually +If you run an older version of Grafana and want to share a dashboard on Grafana.com you need to manually add the inputs and templatize the datasource properties like above. -If you want to import a dashboard from Grafana.net into an older version of Grafana then you can either import +If you want to import a dashboard from Grafana.com into an older version of Grafana then you can either import it as usual and then update the data source option in the metrics tab so that the panel is using the correct data source. Another alternative is to open the json file in a a text editor and update the data source properties to value that matches a name of your data source. diff --git a/docs/sources/reference/index.md b/docs/sources/reference/index.md new file mode 100644 index 00000000000..aaaabe89b4b --- /dev/null +++ b/docs/sources/reference/index.md @@ -0,0 +1,7 @@ +--- +title: Reference Index +description: Grafana docs reference +type: docs +--- + +# Documentation diff --git a/docs/sources/reference/playlist.md b/docs/sources/reference/playlist.md index 359a5d8f3bd..e2d59bc322d 100644 --- a/docs/sources/reference/playlist.md +++ b/docs/sources/reference/playlist.md @@ -16,7 +16,7 @@ Since Grafana automatically scales Dashboards to any resolution they're perfect ## Creating a Playlist -{{< docs-imagebox img="img/docs/v3/playlist.png" max-width="25rem" >}} +{{< docs-imagebox img="/img/docs/v3/playlist.png" max-width="25rem" >}} The Playlist feature can be accessed from Grafana's sidemenu, in the Dashboard submenu. diff --git a/docs/sources/tutorials/ha_setup.md b/docs/sources/tutorials/ha_setup.md new file mode 100644 index 00000000000..998b382a43f --- /dev/null +++ b/docs/sources/tutorials/ha_setup.md @@ -0,0 +1,40 @@ ++++ +title = "Setup Grafana for High availability" +type = "docs" +keywords = ["grafana", "tutorials", "HA", "high availability"] +[menu.docs] +parent = "tutorials" +weight = 10 ++++ + +# How to setup Grafana for high availability + +> Alerting does not support high availability yet. + +Setting up Grafana for high availability is fairly simple. It comes down to two things: + + * Use a shared database for multiple grafana instances. + * Consider how user sessions are stored. + +## Configure multiple servers to use the same database + +First you need to do is to setup mysql or postgres on another server and configure Grafana to use that database. +You can find the configuration for doing that in the [[database]]({{< relref "configuration.md" >}}#database) section in the grafana config. +Grafana will now persist all long term data in the database. +It also worth considering how to setup the database for high availability but thats outside the scope of this guide. + +## User sessions + +The second thing to consider is how to deal with user sessions and how to balance the load between servers. +By default Grafana stores user sessions on disk which works fine if you use `sticky sessions` in your load balancer. +Grafana also supports storing the session data in the database, redis or memcache which makes it possible to use round robin in your load balancer. +If you use mysql/postgres for session storage you first need a table to store the session data in. More details about that in [[sessions]]({{< relref "configuration.md" >}}#session) + +For Grafana itself it doesn't really matter if you store your sessions on disk or database/redis/memcache. +But we suggest that you store the session in redis/memcache since it makes it easier to add/remote instances from the group. + +## Alerting + +Currently alerting does not support high availability. But this is something that we will be working on in the future. + + diff --git a/docs/sources/tutorials/hubot_howto.md b/docs/sources/tutorials/hubot_howto.md index 17658fbaecd..2c886b94f29 100644 --- a/docs/sources/tutorials/hubot_howto.md +++ b/docs/sources/tutorials/hubot_howto.md @@ -22,15 +22,17 @@ take you to the graph. > is so Hipchat and Slack can show them reliably (they require the image to be publicly available).
- +
## What is Hubot? + [Hubot](https://hubot.github.com/) is an universal and extensible chat bot that can be used with many chat services and has a huge library of third party plugins that allow you to automate anything from your chat rooms. ## Install Hubot + Hubot is very easy to install and host. If you do not already have a bot up and running please read the official [Getting Started With Hubot](https://hubot.github.com/docs/) guide. @@ -63,6 +65,7 @@ The `hubot-grafana` plugin requires a number of environment variables to be set export HUBOT_GRAFANA_S3_REGION=us-standard ### Grafana server side rendering + The hubot plugin will take advantage of the Grafana server side rendering feature that can render any panel on the server using phantomjs. Grafana ships with a phantomjs binary (linux only). @@ -70,11 +73,13 @@ To verify that this feature works try the `Direct link to rendered image` link i If you do not get an image when opening this link verify that the required font packages are installed for phantomjs to work. ### Grafana API Key - + + You need to set the environment variable `HUBOT_GRAFANA_API_KEY` to a Grafana API Key. You can add these from the API Keys page which you find in the Organization dropdown. ### Amazon S3 + The `S3` options are optional but for the images to work properly in services like Slack and Hipchat they need to publicly available. By specifying the `S3` options the hubot-grafana script will publish the rendered panel to `S3` and it will use that URL when it posts to Slack or Hipchat. @@ -99,6 +104,7 @@ panel to `S3` and it will use that URL when it posts to Slack or Hipchat. - Get a templated dashboard with the `$host` parameter set to `carbon-a` ## Aliases + Some of the hubot commands above can lengthy and you might have to remember the dashboard slug (url id). If you have a few favorite graphs you want to be able check up on often (let's say from your mobile) you can create hubot command aliases with the hubot script `hubot-alias`. @@ -115,7 +121,7 @@ Now you can add an alias like this:
Using the alias:
- +
## Summary diff --git a/package.json b/package.json index dc3970bfe69..d38de899bf5 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "company": "Coding Instinct AB" }, "name": "grafana", - "version": "4.2.0-pre1", + "version": "4.3.0-pre1", "repository": { "type": "git", "url": "http://github.com/grafana/grafana.git" diff --git a/packaging/deb/systemd/grafana-server.service b/packaging/deb/systemd/grafana-server.service index ea7193c55a6..a2eb6f1880a 100644 --- a/packaging/deb/systemd/grafana-server.service +++ b/packaging/deb/systemd/grafana-server.service @@ -3,6 +3,7 @@ Description=Grafana instance Documentation=http://docs.grafana.org Wants=network-online.target After=network-online.target +After=postgresql.service mariadb.service mysql.service [Service] EnvironmentFile=/etc/default/grafana-server diff --git a/packaging/publish/publish_both.sh b/packaging/publish/publish_both.sh index 2f24ff6b3ea..5edf679b481 100755 --- a/packaging/publish/publish_both.sh +++ b/packaging/publish/publish_both.sh @@ -1,19 +1,18 @@ #! /usr/bin/env bash -deb_ver=4.1.0-1484127817 -rpm_ver=4.1.0-1484127817 +version=4.2.0 -wget https://grafanarel.s3.amazonaws.com/builds/grafana_${deb_ver}_amd64.deb +wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${version}_amd64.deb -package_cloud push grafana/stable/debian/jessie grafana_${deb_ver}_amd64.deb -package_cloud push grafana/stable/debian/wheezy grafana_${deb_ver}_amd64.deb +package_cloud push grafana/stable/debian/jessie grafana_${version}_amd64.deb +package_cloud push grafana/stable/debian/wheezy grafana_${version}_amd64.deb -package_cloud push grafana/testing/debian/jessie grafana_${deb_ver}_amd64.deb -package_cloud push grafana/testing/debian/wheezy grafana_${deb_ver}_amd64.deb +package_cloud push grafana/testing/debian/jessie grafana_${version}_amd64.deb +package_cloud push grafana/testing/debian/wheezy grafana_${version}_amd64.deb -wget https://grafanarel.s3.amazonaws.com/builds/grafana-${rpm_ver}.x86_64.rpm +wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-${version}-1.x86_64.rpm -package_cloud push grafana/testing/el/6 grafana-${rpm_ver}.x86_64.rpm -package_cloud push grafana/testing/el/7 grafana-${rpm_ver}.x86_64.rpm +package_cloud push grafana/testing/el/6 grafana-${version}-1.x86_64.rpm +package_cloud push grafana/testing/el/7 grafana-${version}-1.x86_64.rpm -package_cloud push grafana/stable/el/7 grafana-${rpm_ver}.x86_64.rpm -package_cloud push grafana/stable/el/6 grafana-${rpm_ver}.x86_64.rpm +package_cloud push grafana/stable/el/7 grafana-${version}-1.x86_64.rpm +package_cloud push grafana/stable/el/6 grafana-${version}-1.x86_64.rpm diff --git a/packaging/publish/publish_testing.sh b/packaging/publish/publish_testing.sh index 1e39d65b7e5..76419eb7a7e 100755 --- a/packaging/publish/publish_testing.sh +++ b/packaging/publish/publish_testing.sh @@ -1,13 +1,13 @@ #! /usr/bin/env bash -deb_ver=4.1.0-1482230757beta1 -rpm_ver=4.1.0-1482230757beta1 +deb_ver=4.2.0-beta1 +rpm_ver=4.2.0-beta1 -wget https://grafanarel.s3.amazonaws.com/builds/grafana_${deb_ver}_amd64.deb +wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${deb_ver}_amd64.deb package_cloud push grafana/testing/debian/jessie grafana_${deb_ver}_amd64.deb package_cloud push grafana/testing/debian/wheezy grafana_${deb_ver}_amd64.deb -wget https://grafanarel.s3.amazonaws.com/builds/grafana-${rpm_ver}.x86_64.rpm +wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-${rpm_ver}.x86_64.rpm package_cloud push grafana/testing/el/6 grafana-${rpm_ver}.x86_64.rpm package_cloud push grafana/testing/el/7 grafana-${rpm_ver}.x86_64.rpm diff --git a/packaging/rpm/control/postinst b/packaging/rpm/control/postinst old mode 100644 new mode 100755 index be3d7692a5f..cf31112679d --- a/packaging/rpm/control/postinst +++ b/packaging/rpm/control/postinst @@ -68,13 +68,6 @@ if [ $1 -eq 1 ] ; then echo " sudo service grafana-server start" fi elif [ $1 -ge 2 ] ; then - if [ -x /opt/grafana/ ]; then - echo "### Upgrading Notice ### " - echo "-- New grafana install home is /usr/share/grafana" - echo "-- Please move sqlite3 database to /var/lib/grafana/" - echo "-- Notice: service name && binary changed to grafana-server" - fi - if [ "$RESTART_ON_UPGRADE" == "true" ]; then stopGrafana startGrafana diff --git a/packaging/rpm/control/posttrans b/packaging/rpm/control/posttrans new file mode 100755 index 00000000000..0afe4561a93 --- /dev/null +++ b/packaging/rpm/control/posttrans @@ -0,0 +1,32 @@ +#!/bin/sh + +set -e + +echo "POSTTRANS: Running script" + +[ -f /etc/sysconfig/grafana-server ] && . /etc/sysconfig/grafana-server + +# copy config files if missing +if [ ! -f /etc/grafana/grafana.ini ]; then + echo "POSTTRANS: Config file not found" + + if [ -f /etc/grafana/grafana.ini.rpmsave ]; then + echo "POSTTRANS: /etc/grafana/grafana.ini.rpmsave config file found." + mv /etc/grafana/grafana.ini.rpmsave /etc/grafana/grafana.ini + echo "POSTTRANS: /etc/grafana/grafana.ini restored" + + if [ -f /etc/grafana/ldap.toml.rpmsave ]; then + echo "POSTTRANS: /etc/grafana/ldap.toml.rpmsave found" + mv /etc/grafana/ldap.toml.rpmsave /etc/grafana/ldap.toml + echo "POSTTRANS: /etc/grafana/ldap.toml restored" + fi + + echo "POSTTRANS: Restoring config file permissions" + chown -Rh root:$GRAFANA_GROUP /etc/grafana/* + chmod 755 /etc/grafana + find /etc/grafana -type f -exec chmod 640 {} ';' + find /etc/grafana -type d -exec chmod 755 {} ';' + fi +fi + + diff --git a/packaging/rpm/systemd/grafana-server.service b/packaging/rpm/systemd/grafana-server.service index ec0ef7e804e..a6e02073d46 100644 --- a/packaging/rpm/systemd/grafana-server.service +++ b/packaging/rpm/systemd/grafana-server.service @@ -3,6 +3,7 @@ Description=Grafana instance Documentation=http://docs.grafana.org Wants=network-online.target After=network-online.target +After=postgresql.service mariadb.service mysql.service [Service] EnvironmentFile=/etc/sysconfig/grafana-server diff --git a/pkg/api/api.go b/pkg/api/api.go index 7d3a5563892..026bae2d894 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -123,6 +123,7 @@ func (hs *HttpServer) registerRoutes() { // users (admin permission required) r.Group("/users", func() { r.Get("/", wrap(SearchUsers)) + r.Get("/search", wrap(SearchUsersWithPaging)) r.Get("/:id", wrap(GetUserById)) r.Get("/:id/orgs", wrap(GetUserOrgList)) // query parameters /users/lookup?loginOrEmail=admin@example.com @@ -195,10 +196,11 @@ func (hs *HttpServer) registerRoutes() { // Data sources r.Group("/datasources", func() { - r.Get("/", GetDataSources) + r.Get("/", wrap(GetDataSources)) r.Post("/", quota("data_source"), bind(m.AddDataSourceCommand{}), AddDataSource) r.Put("/:id", bind(m.UpdateDataSourceCommand{}), wrap(UpdateDataSource)) - r.Delete("/:id", DeleteDataSource) + r.Delete("/:id", DeleteDataSourceById) + r.Delete("/name/:name", DeleteDataSourceByName) r.Get("/:id", wrap(GetDataSourceById)) r.Get("/name/:name", wrap(GetDataSourceByName)) }, reqOrgAdmin) diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go deleted file mode 100644 index ea3588b0e67..00000000000 --- a/pkg/api/api_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package api - -import ( - "testing" -) - -func TestHttpApi(t *testing.T) { - - // Convey("Given the grafana api", t, func() { - // ConveyApiScenario("Can sign up", func(c apiTestContext) { - // c.PostJson() - // So(c.Resp, ShouldEqualJsonApiResponse, "User created and logged in") - // }) - // - // m := macaron.New() - // m.Use(middleware.GetContextHandler()) - // m.Use(middleware.Sessioner(&session.Options{})) - // Register(m) - // - // var context *middleware.Context - // m.Get("/", func(c *middleware.Context) { - // context = c - // }) - // - // resp := httptest.NewRecorder() - // req, err := http.NewRequest("GET", "/", nil) - // So(err, ShouldBeNil) - // - // m.ServeHTTP(resp, req) - // - // Convey("should red 200", func() { - // So(resp.Code, ShouldEqual, 200) - // }) - // }) -} diff --git a/pkg/api/cloudwatch/cloudwatch.go b/pkg/api/cloudwatch/cloudwatch.go index 1b796be874e..9f7bf01a884 100644 --- a/pkg/api/cloudwatch/cloudwatch.go +++ b/pkg/api/cloudwatch/cloudwatch.go @@ -3,7 +3,9 @@ package cloudwatch import ( "encoding/json" "errors" + "fmt" "io/ioutil" + "os" "strings" "sync" "time" @@ -12,6 +14,7 @@ import ( "github.com/aws/aws-sdk-go/aws/awsutil" "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds" + "github.com/aws/aws-sdk-go/aws/credentials/endpointcreds" "github.com/aws/aws-sdk-go/aws/ec2metadata" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/cloudwatch" @@ -114,19 +117,26 @@ func getCredentials(dsInfo *datasourceInfo) (*credentials.Credentials, error) { DurationSeconds: aws.Int64(900), } - stsSess := session.New() + stsSess, err := session.NewSession() + if err != nil { + return nil, err + } stsCreds := credentials.NewChainCredentials( []credentials.Provider{ &credentials.EnvProvider{}, &credentials.SharedCredentialsProvider{Filename: "", Profile: dsInfo.Profile}, - &ec2rolecreds.EC2RoleProvider{Client: ec2metadata.New(stsSess), ExpiryWindow: 5 * time.Minute}, + remoteCredProvider(stsSess), }) stsConfig := &aws.Config{ Region: aws.String(dsInfo.Region), Credentials: stsCreds, } - svc := sts.New(session.New(stsConfig), stsConfig) + sess, err := session.NewSession(stsConfig) + if err != nil { + return nil, err + } + svc := sts.New(sess, stsConfig) resp, err := svc.AssumeRole(params) if err != nil { return nil, err @@ -139,7 +149,10 @@ func getCredentials(dsInfo *datasourceInfo) (*credentials.Credentials, error) { } } - sess := session.New() + sess, err := session.NewSession() + if err != nil { + return nil, err + } creds := credentials.NewChainCredentials( []credentials.Provider{ &credentials.StaticProvider{Value: credentials.Value{ @@ -166,6 +179,30 @@ func getCredentials(dsInfo *datasourceInfo) (*credentials.Credentials, error) { return creds, nil } +func remoteCredProvider(sess *session.Session) credentials.Provider { + ecsCredURI := os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") + + if len(ecsCredURI) > 0 { + return ecsCredProvider(sess, ecsCredURI) + } + return ec2RoleProvider(sess) +} + +func ecsCredProvider(sess *session.Session, uri string) credentials.Provider { + const host = `169.254.170.2` + + c := ec2metadata.New(sess) + return endpointcreds.NewProviderClient( + c.Client.Config, + c.Client.Handlers, + fmt.Sprintf("http://%s%s", host, uri), + func(p *endpointcreds.Provider) { p.ExpiryWindow = 5 * time.Minute }) +} + +func ec2RoleProvider(sess *session.Session) credentials.Provider { + return &ec2rolecreds.EC2RoleProvider{Client: ec2metadata.New(sess), ExpiryWindow: 5 * time.Minute} +} + func getAwsConfig(req *cwRequest) (*aws.Config, error) { creds, err := getCredentials(req.GetDatasourceInfo()) if err != nil { @@ -185,7 +222,12 @@ func handleGetMetricStatistics(req *cwRequest, c *middleware.Context) { c.JsonApiErr(500, "Unable to call AWS API", err) return } - svc := cloudwatch.New(session.New(cfg), cfg) + sess, err := session.NewSession(cfg) + if err != nil { + c.JsonApiErr(500, "Unable to call AWS API", err) + return + } + svc := cloudwatch.New(sess, cfg) reqParam := &struct { Parameters struct { @@ -232,7 +274,12 @@ func handleListMetrics(req *cwRequest, c *middleware.Context) { c.JsonApiErr(500, "Unable to call AWS API", err) return } - svc := cloudwatch.New(session.New(cfg), cfg) + sess, err := session.NewSession(cfg) + if err != nil { + c.JsonApiErr(500, "Unable to call AWS API", err) + return + } + svc := cloudwatch.New(sess, cfg) reqParam := &struct { Parameters struct { @@ -273,7 +320,12 @@ func handleDescribeAlarms(req *cwRequest, c *middleware.Context) { c.JsonApiErr(500, "Unable to call AWS API", err) return } - svc := cloudwatch.New(session.New(cfg), cfg) + sess, err := session.NewSession(cfg) + if err != nil { + c.JsonApiErr(500, "Unable to call AWS API", err) + return + } + svc := cloudwatch.New(sess, cfg) reqParam := &struct { Parameters struct { @@ -316,7 +368,12 @@ func handleDescribeAlarmsForMetric(req *cwRequest, c *middleware.Context) { c.JsonApiErr(500, "Unable to call AWS API", err) return } - svc := cloudwatch.New(session.New(cfg), cfg) + sess, err := session.NewSession(cfg) + if err != nil { + c.JsonApiErr(500, "Unable to call AWS API", err) + return + } + svc := cloudwatch.New(sess, cfg) reqParam := &struct { Parameters struct { @@ -360,7 +417,12 @@ func handleDescribeAlarmHistory(req *cwRequest, c *middleware.Context) { c.JsonApiErr(500, "Unable to call AWS API", err) return } - svc := cloudwatch.New(session.New(cfg), cfg) + sess, err := session.NewSession(cfg) + if err != nil { + c.JsonApiErr(500, "Unable to call AWS API", err) + return + } + svc := cloudwatch.New(sess, cfg) reqParam := &struct { Parameters struct { @@ -396,7 +458,12 @@ func handleDescribeInstances(req *cwRequest, c *middleware.Context) { c.JsonApiErr(500, "Unable to call AWS API", err) return } - svc := ec2.New(session.New(cfg), cfg) + sess, err := session.NewSession(cfg) + if err != nil { + c.JsonApiErr(500, "Unable to call AWS API", err) + return + } + svc := ec2.New(sess, cfg) reqParam := &struct { Parameters struct { diff --git a/pkg/api/cloudwatch/cloudwatch_test.go b/pkg/api/cloudwatch/cloudwatch_test.go new file mode 100644 index 00000000000..41050c102ff --- /dev/null +++ b/pkg/api/cloudwatch/cloudwatch_test.go @@ -0,0 +1,41 @@ +package cloudwatch + +import ( + "fmt" + "os" + "testing" + + "github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds" + "github.com/aws/aws-sdk-go/aws/credentials/endpointcreds" + "github.com/aws/aws-sdk-go/aws/session" + . "github.com/smartystreets/goconvey/convey" +) + +func TestECSCredProvider(t *testing.T) { + Convey("Running in an ECS container task", t, func() { + defer os.Clearenv() + os.Setenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "/abc/123") + + provider := remoteCredProvider(&session.Session{}) + + So(provider, ShouldNotBeNil) + + ecsProvider, ok := provider.(*endpointcreds.Provider) + So(ecsProvider, ShouldNotBeNil) + So(ok, ShouldBeTrue) + + So(ecsProvider.Client.Endpoint, ShouldEqual, fmt.Sprintf("http://169.254.170.2/abc/123")) + }) +} + +func TestDefaultEC2RoleProvider(t *testing.T) { + Convey("Running outside an ECS container task", t, func() { + provider := remoteCredProvider(&session.Session{}) + + So(provider, ShouldNotBeNil) + + ec2Provider, ok := provider.(*ec2rolecreds.EC2RoleProvider) + So(ec2Provider, ShouldNotBeNil) + So(ok, ShouldBeTrue) + }) +} diff --git a/pkg/api/cloudwatch/metrics.go b/pkg/api/cloudwatch/metrics.go index 0df8eaa089c..67f35a60aa7 100644 --- a/pkg/api/cloudwatch/metrics.go +++ b/pkg/api/cloudwatch/metrics.go @@ -81,7 +81,8 @@ func init() { "AWS/Redshift": {"CPUUtilization", "DatabaseConnections", "HealthStatus", "MaintenanceMode", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "PercentageDiskSpaceUsed", "ReadIOPS", "ReadLatency", "ReadThroughput", "WriteIOPS", "WriteLatency", "WriteThroughput"}, "AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "CommitLatency", "CommitThroughput", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "FailedSqlStatements", "FreeableMemory", "FreeStorageSpace", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "SwapUsage", "TotalConnections", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"}, "AWS/Route53": {"HealthCheckStatus", "HealthCheckPercentageHealthy", "ConnectionTime", "SSLHandshakeTime", "TimeToFirstByte"}, - "AWS/S3": {"BucketSizeBytes", "NumberOfObjects"}, + "AWS/S3": {"BucketSizeBytes", "NumberOfObjects", "AllRequests", "GetRequests", "PutRequests", "DeleteRequests", "HeadRequests", "PostRequests", "ListRequests", "BytesDownloaded", "BytesUploaded", "4xxErrors", "5xxErrors", "FirstByteLatency", "TotalRequestLatency"}, + "AWS/SES": {"Bounce", "Complaint", "Delivery", "Reject", "Send"}, "AWS/SNS": {"NumberOfMessagesPublished", "PublishSize", "NumberOfNotificationsDelivered", "NumberOfNotificationsFailed"}, "AWS/SQS": {"NumberOfMessagesSent", "SentMessageSize", "NumberOfMessagesReceived", "NumberOfEmptyReceives", "NumberOfMessagesDeleted", "ApproximateNumberOfMessagesDelayed", "ApproximateNumberOfMessagesVisible", "ApproximateNumberOfMessagesNotVisible"}, "AWS/StorageGateway": {"CacheHitPercent", "CachePercentUsed", "CachePercentDirty", "CloudBytesDownloaded", "CloudDownloadLatency", "CloudBytesUploaded", "UploadBufferFree", "UploadBufferPercentUsed", "UploadBufferUsed", "QueuedWrites", "ReadBytes", "ReadTime", "TotalCacheSize", "WriteBytes", "WriteTime", "TimeSinceLastRecoveryPoint", "WorkingStorageFree", "WorkingStoragePercentUsed", "WorkingStorageUsed", @@ -122,7 +123,8 @@ func init() { "AWS/Redshift": {"NodeID", "ClusterIdentifier"}, "AWS/RDS": {"DBInstanceIdentifier", "DBClusterIdentifier", "DatabaseClass", "EngineName"}, "AWS/Route53": {"HealthCheckId"}, - "AWS/S3": {"BucketName", "StorageType"}, + "AWS/S3": {"BucketName", "StorageType", "FilterId"}, + "AWS/SES": {}, "AWS/SNS": {"Application", "Platform", "TopicName"}, "AWS/SQS": {"QueueName"}, "AWS/StorageGateway": {"GatewayId", "GatewayName", "VolumeId"}, @@ -256,8 +258,11 @@ func getAllMetrics(cwData *datasourceInfo) (cloudwatch.ListMetricsOutput, error) Region: aws.String(cwData.Region), Credentials: creds, } - - svc := cloudwatch.New(session.New(cfg), cfg) + sess, err := session.NewSession(cfg) + if err != nil { + return cloudwatch.ListMetricsOutput{}, err + } + svc := cloudwatch.New(sess, cfg) params := &cloudwatch.ListMetricsInput{ Namespace: aws.String(cwData.Namespace), diff --git a/pkg/api/dataproxy.go b/pkg/api/dataproxy.go index 26a1e05e813..17730b42a76 100644 --- a/pkg/api/dataproxy.go +++ b/pkg/api/dataproxy.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httputil" "net/url" + "strings" "time" "github.com/grafana/grafana/pkg/api/cloudwatch" @@ -115,6 +116,13 @@ func ProxyDataSourceRequest(c *middleware.Context) { proxyPath := c.Params("*") + if ds.Type == m.DS_PROMETHEUS { + if c.Req.Request.Method != http.MethodGet || !strings.HasPrefix(proxyPath, "api/") { + c.JsonApiErr(403, "GET is only allowed on proxied Prometheus datasource", nil) + return + } + } + if ds.Type == m.DS_ES { if c.Req.Request.Method == "DELETE" { c.JsonApiErr(403, "Deletes not allowed on proxied Elasticsearch datasource", nil) diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index 43f4d308ed6..ddf8681c3c8 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -11,12 +11,11 @@ import ( "github.com/grafana/grafana/pkg/util" ) -func GetDataSources(c *middleware.Context) { +func GetDataSources(c *middleware.Context) Response { query := m.GetDataSourcesQuery{OrgId: c.OrgId} if err := bus.Dispatch(&query); err != nil { - c.JsonApiErr(500, "Failed to query datasources", err) - return + return ApiError(500, "Failed to query datasources", err) } result := make(dtos.DataSourceList, 0) @@ -46,7 +45,8 @@ func GetDataSources(c *middleware.Context) { } sort.Sort(result) - c.JSON(200, result) + + return Json(200, &result) } func GetDataSourceById(c *middleware.Context) Response { @@ -68,7 +68,7 @@ func GetDataSourceById(c *middleware.Context) Response { return Json(200, &dtos) } -func DeleteDataSource(c *middleware.Context) { +func DeleteDataSourceById(c *middleware.Context) { id := c.ParamsInt64(":id") if id <= 0 { @@ -76,7 +76,26 @@ func DeleteDataSource(c *middleware.Context) { return } - cmd := &m.DeleteDataSourceCommand{Id: id, OrgId: c.OrgId} + cmd := &m.DeleteDataSourceByIdCommand{Id: id, OrgId: c.OrgId} + + err := bus.Dispatch(cmd) + if err != nil { + c.JsonApiErr(500, "Failed to delete datasource", err) + return + } + + c.JsonOK("Data source deleted") +} + +func DeleteDataSourceByName(c *middleware.Context) { + name := c.Params(":name") + + if name == "" { + c.JsonApiErr(400, "Missing valid datasource name", nil) + return + } + + cmd := &m.DeleteDataSourceByNameCommand{Name: name, OrgId: c.OrgId} err := bus.Dispatch(cmd) if err != nil { diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go new file mode 100644 index 00000000000..5ae752bea91 --- /dev/null +++ b/pkg/api/datasources_test.go @@ -0,0 +1,132 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/grafana/grafana/pkg/models" + macaron "gopkg.in/macaron.v1" + + "github.com/go-macaron/session" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + . "github.com/smartystreets/goconvey/convey" +) + +const ( + TestOrgID = 1 + TestUserID = 1 +) + +func TestDataSourcesProxy(t *testing.T) { + Convey("Given a user is logged in", t, func() { + loggedInUserScenario("When calling GET on", "/api/datasources/", func(sc *scenarioContext) { + + // Stubs the database query + bus.AddHandler("test", func(query *models.GetDataSourcesQuery) error { + So(query.OrgId, ShouldEqual, TestOrgID) + query.Result = []*models.DataSource{ + {Name: "mmm"}, + {Name: "ZZZ"}, + {Name: "BBB"}, + {Name: "aaa"}, + } + return nil + }) + + // handler func being tested + sc.handlerFunc = GetDataSources + sc.fakeReq("GET", "/api/datasources").exec() + + respJSON := []map[string]interface{}{} + err := json.NewDecoder(sc.resp.Body).Decode(&respJSON) + So(err, ShouldBeNil) + + Convey("should return list of datasources for org sorted alphabetically and case insensitively", func() { + So(respJSON[0]["name"], ShouldEqual, "aaa") + So(respJSON[1]["name"], ShouldEqual, "BBB") + So(respJSON[2]["name"], ShouldEqual, "mmm") + So(respJSON[3]["name"], ShouldEqual, "ZZZ") + }) + }) + }) +} + +func loggedInUserScenario(desc string, url string, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := &scenarioContext{ + url: url, + } + viewsPath, _ := filepath.Abs("../../public/views") + + sc.m = macaron.New() + sc.m.Use(macaron.Renderer(macaron.RenderOptions{ + Directory: viewsPath, + Delims: macaron.Delims{Left: "[[", Right: "]]"}, + })) + + sc.m.Use(middleware.GetContextHandler()) + sc.m.Use(middleware.Sessioner(&session.Options{})) + + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = models.ROLE_EDITOR + if sc.handlerFunc != nil { + return sc.handlerFunc(sc.context) + } + + return nil + }) + + sc.m.Get(url, sc.defaultHandler) + + fn(sc) + }) +} + +func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext { + sc.resp = httptest.NewRecorder() + req, err := http.NewRequest(method, url, nil) + So(err, ShouldBeNil) + sc.req = req + + return sc +} + +func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext { + sc.resp = httptest.NewRecorder() + req, err := http.NewRequest(method, url, nil) + q := req.URL.Query() + for k, v := range queryParams { + q.Add(k, v) + } + req.URL.RawQuery = q.Encode() + So(err, ShouldBeNil) + sc.req = req + + return sc +} + +type scenarioContext struct { + m *macaron.Macaron + context *middleware.Context + resp *httptest.ResponseRecorder + handlerFunc handlerFunc + defaultHandler macaron.Handler + req *http.Request + url string +} + +func (sc *scenarioContext) exec() { + sc.m.ServeHTTP(sc.resp, sc.req) +} + +type scenarioFunc func(c *scenarioContext) +type handlerFunc func(c *middleware.Context) Response diff --git a/pkg/api/dtos/models.go b/pkg/api/dtos/models.go index 13dbe4ea32a..564d86a25bf 100644 --- a/pkg/api/dtos/models.go +++ b/pkg/api/dtos/models.go @@ -91,7 +91,7 @@ func (slice DataSourceList) Len() int { } func (slice DataSourceList) Less(i, j int) bool { - return slice[i].Name < slice[j].Name + return strings.ToLower(slice[i].Name) < strings.ToLower(slice[j].Name) } func (slice DataSourceList) Swap(i, j int) { diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 3690784375d..11e70dccc0d 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -133,14 +133,17 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro } jsonObj := map[string]interface{}{ - "defaultDatasource": defaultDatasource, - "datasources": datasources, - "panels": panels, - "appSubUrl": setting.AppSubUrl, - "allowOrgCreate": (setting.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin, - "authProxyEnabled": setting.AuthProxyEnabled, - "ldapEnabled": setting.LdapEnabled, - "alertingEnabled": setting.AlertingEnabled, + "defaultDatasource": defaultDatasource, + "datasources": datasources, + "panels": panels, + "appSubUrl": setting.AppSubUrl, + "allowOrgCreate": (setting.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin, + "authProxyEnabled": setting.AuthProxyEnabled, + "ldapEnabled": setting.LdapEnabled, + "alertingEnabled": setting.AlertingEnabled, + "googleAnalyticsId": setting.GoogleAnalyticsId, + "disableLoginForm": setting.DisableLoginForm, + "disableSignoutMenu": setting.DisableSignoutMenu, "buildInfo": map[string]interface{}{ "version": setting.BuildVersion, "commit": setting.BuildCommit, diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 442eae60e02..fe7ca21f402 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -2,6 +2,7 @@ package api import ( "context" + "crypto/tls" "errors" "fmt" "net/http" @@ -24,6 +25,8 @@ type HttpServer struct { macaron *macaron.Macaron context context.Context streamManager *live.StreamManager + + httpSrv *http.Server } func NewHttpServer() *HttpServer { @@ -45,11 +48,20 @@ func (hs *HttpServer) Start(ctx context.Context) error { listenAddr := fmt.Sprintf("%s:%s", setting.HttpAddr, setting.HttpPort) hs.log.Info("Initializing HTTP Server", "address", listenAddr, "protocol", setting.Protocol, "subUrl", setting.AppSubUrl) + hs.httpSrv = &http.Server{Addr: listenAddr, Handler: hs.macaron} switch setting.Protocol { case setting.HTTP: - err = http.ListenAndServe(listenAddr, hs.macaron) + err = hs.httpSrv.ListenAndServe() + if err == http.ErrServerClosed { + hs.log.Debug("server was shutdown gracefully") + return nil + } case setting.HTTPS: - err = hs.listenAndServeTLS(listenAddr, setting.CertFile, setting.KeyFile) + err = hs.httpSrv.ListenAndServeTLS(setting.CertFile, setting.KeyFile) + if err == http.ErrServerClosed { + hs.log.Debug("server was shutdown gracefully") + return nil + } default: hs.log.Error("Invalid protocol", "protocol", setting.Protocol) err = errors.New("Invalid Protocol") @@ -58,6 +70,12 @@ func (hs *HttpServer) Start(ctx context.Context) error { return err } +func (hs *HttpServer) Shutdown(ctx context.Context) error { + err := hs.httpSrv.Shutdown(ctx) + hs.log.Info("stopped http server") + return err +} + func (hs *HttpServer) listenAndServeTLS(listenAddr, certfile, keyfile string) error { if certfile == "" { return fmt.Errorf("cert_file cannot be empty when using HTTPS") @@ -75,7 +93,32 @@ func (hs *HttpServer) listenAndServeTLS(listenAddr, certfile, keyfile string) er return fmt.Errorf(`Cannot find SSL key_file at %v`, setting.KeyFile) } - return http.ListenAndServeTLS(listenAddr, setting.CertFile, setting.KeyFile, hs.macaron) + tlsCfg := &tls.Config{ + MinVersion: tls.VersionTLS12, + PreferServerCipherSuites: true, + CipherSuites: []uint16{ + tls.TLS_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_RSA_WITH_AES_256_CBC_SHA, + tls.TLS_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, + tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + }, + } + srv := &http.Server{ + Addr: listenAddr, + Handler: hs.macaron, + TLSConfig: tlsCfg, + TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler), 0), + } + + return srv.ListenAndServeTLS(setting.CertFile, setting.KeyFile) } func (hs *HttpServer) newMacaron() *macaron.Macaron { @@ -107,6 +150,7 @@ func (hs *HttpServer) newMacaron() *macaron.Macaron { m.Use(middleware.GetContextHandler()) m.Use(middleware.Sessioner(&setting.SessionOptions)) m.Use(middleware.RequestMetrics()) + m.Use(middleware.OrgRedirect()) // needs to be after context handler if setting.EnforceDomain { diff --git a/pkg/api/login.go b/pkg/api/login.go index 8fbe414c08c..d2bbf4e81b9 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -35,6 +35,11 @@ func LoginView(c *middleware.Context) { viewData.Settings["loginHint"] = setting.LoginHint viewData.Settings["disableLoginForm"] = setting.DisableLoginForm + if loginError, ok := c.Session.Get("loginError").(string); ok { + c.Session.Delete("loginError") + viewData.Settings["loginError"] = loginError + } + if !tryLoginUsingRememberCookie(c) { c.HTML(200, VIEW_INDEX, viewData) return @@ -94,6 +99,10 @@ func LoginApiPing(c *middleware.Context) { } func LoginPost(c *middleware.Context, cmd dtos.LoginCommand) Response { + if setting.DisableLoginForm { + return ApiError(401, "Login is disabled", nil) + } + authQuery := login.LoginUserQuery{ Username: cmd.User, Password: cmd.Password, diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 574d08af09f..5fbd9fea709 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -10,6 +10,7 @@ import ( "io/ioutil" "log" "net/http" + "net/url" "golang.org/x/net/context" "golang.org/x/oauth2" @@ -22,6 +23,13 @@ import ( "github.com/grafana/grafana/pkg/social" ) +var ( + ErrProviderDeniedRequest = errors.New("Login provider denied login request") + ErrEmailNotAllowed = errors.New("Required email domain not fulfilled") + ErrSignUpNotAllowed = errors.New("Signup is not allowed for this adapter") + ErrUsersQuotaReached = errors.New("Users quota reached") +) + func GenStateString() string { rnd := make([]byte, 32) rand.Read(rnd) @@ -44,8 +52,7 @@ func OAuthLogin(ctx *middleware.Context) { error := ctx.Query("error") if error != "" { errorDesc := ctx.Query("error_description") - ctx.Logger.Info("OAuthLogin Failed", "error", error, "errorDesc", errorDesc) - ctx.Redirect(setting.AppSubUrl + "/login?failCode=1003") + redirectWithError(ctx, ErrProviderDeniedRequest, "error", error, "errorDesc", errorDesc) return } @@ -117,10 +124,8 @@ func OAuthLogin(ctx *middleware.Context) { // get user info userInfo, err := connect.UserInfo(client) if err != nil { - if err == social.ErrMissingTeamMembership { - ctx.Redirect(setting.AppSubUrl + "/login?failCode=1000") - } else if err == social.ErrMissingOrganizationMembership { - ctx.Redirect(setting.AppSubUrl + "/login?failCode=1001") + if sErr, ok := err.(*social.Error); ok { + redirectWithError(ctx, sErr) } else { ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err) } @@ -131,8 +136,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?failCode=1002") + redirectWithError(ctx, ErrEmailNotAllowed) return } @@ -142,7 +146,7 @@ func OAuthLogin(ctx *middleware.Context) { // create account if missing if err == m.ErrUserNotFound { if !connect.IsSignupAllowed() { - ctx.Redirect(setting.AppSubUrl + "/login") + redirectWithError(ctx, ErrSignUpNotAllowed) return } limitReached, err := middleware.QuotaReached(ctx, "user") @@ -151,7 +155,7 @@ func OAuthLogin(ctx *middleware.Context) { return } if limitReached { - ctx.Redirect(setting.AppSubUrl + "/login") + redirectWithError(ctx, ErrUsersQuotaReached) return } cmd := m.CreateUserCommand{ @@ -177,5 +181,18 @@ func OAuthLogin(ctx *middleware.Context) { metrics.M_Api_Login_OAuth.Inc(1) + if redirectTo, _ := url.QueryUnescape(ctx.GetCookie("redirect_to")); len(redirectTo) > 0 { + ctx.SetCookie("redirect_to", "", -1, setting.AppSubUrl+"/") + ctx.Redirect(redirectTo) + return + } + ctx.Redirect(setting.AppSubUrl + "/") } + +func redirectWithError(ctx *middleware.Context, err error, v ...interface{}) { + ctx.Logger.Info(err.Error(), v...) + // TODO: we can use the flash storage here once it's implemented + ctx.Session.Set("loginError", err.Error()) + ctx.Redirect(setting.AppSubUrl + "/login") +} diff --git a/pkg/api/metrics.go b/pkg/api/metrics.go index dad3b159ad6..9194637af15 100644 --- a/pkg/api/metrics.go +++ b/pkg/api/metrics.go @@ -33,7 +33,7 @@ func QueryMetrics(c *middleware.Context, reqDto dtos.MetricRequest) Response { }) } - resp, err := tsdb.HandleRequest(context.TODO(), request) + resp, err := tsdb.HandleRequest(context.Background(), request) if err != nil { return ApiError(500, "Metric request error", err) } diff --git a/pkg/api/org_invite.go b/pkg/api/org_invite.go index 8d916677ed2..9e85808950f 100644 --- a/pkg/api/org_invite.go +++ b/pkg/api/org_invite.go @@ -38,6 +38,10 @@ func AddOrgInvite(c *middleware.Context, inviteDto dtos.AddInviteForm) Response if err != m.ErrUserNotFound { return ApiError(500, "Failed to query db for existing user check", err) } + + if setting.DisableLoginForm { + return ApiError(401, "User could not be found", nil) + } } else { return inviteExistingUserToOrg(c, userQuery.Result, &inviteDto) } diff --git a/pkg/api/user.go b/pkg/api/user.go index 7bce599d692..39e7fce1462 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -210,14 +210,48 @@ func ChangeUserPassword(c *middleware.Context, cmd m.ChangeUserPasswordCommand) // GET /api/users func SearchUsers(c *middleware.Context) Response { - query := m.SearchUsersQuery{Query: "", Page: 0, Limit: 1000} - if err := bus.Dispatch(&query); err != nil { + query, err := searchUser(c) + if err != nil { + return ApiError(500, "Failed to fetch users", err) + } + + return Json(200, query.Result.Users) +} + +// GET /api/search +func SearchUsersWithPaging(c *middleware.Context) Response { + query, err := searchUser(c) + if err != nil { return ApiError(500, "Failed to fetch users", err) } return Json(200, query.Result) } +func searchUser(c *middleware.Context) (*m.SearchUsersQuery, error) { + perPage := c.QueryInt("perpage") + if perPage <= 0 { + perPage = 1000 + } + page := c.QueryInt("page") + + if page < 1 { + page = 1 + } + + searchQuery := c.Query("query") + + query := &m.SearchUsersQuery{Query: searchQuery, Page: page, Limit: perPage} + if err := bus.Dispatch(query); err != nil { + return nil, err + } + + query.Result.Page = page + query.Result.PerPage = perPage + + return query, nil +} + func SetHelpFlag(c *middleware.Context) Response { flag := c.ParamsInt64(":id") diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go new file mode 100644 index 00000000000..6aa9dd9adbf --- /dev/null +++ b/pkg/api/user_test.go @@ -0,0 +1,109 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/models" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + . "github.com/smartystreets/goconvey/convey" +) + +func TestUserApiEndpoint(t *testing.T) { + Convey("Given a user is logged in", t, func() { + mockResult := models.SearchUserQueryResult{ + Users: []*models.UserSearchHitDTO{ + {Name: "user1"}, + {Name: "user2"}, + }, + TotalCount: 2, + } + + loggedInUserScenario("When calling GET on", "/api/users", func(sc *scenarioContext) { + var sentLimit int + var sendPage int + bus.AddHandler("test", func(query *models.SearchUsersQuery) error { + query.Result = mockResult + + sentLimit = query.Limit + sendPage = query.Page + + return nil + }) + + sc.handlerFunc = SearchUsers + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + So(sentLimit, ShouldEqual, 1000) + So(sendPage, ShouldEqual, 1) + + respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) + So(err, ShouldBeNil) + So(len(respJSON.MustArray()), ShouldEqual, 2) + }) + + loggedInUserScenario("When calling GET with page and limit querystring parameters on", "/api/users", func(sc *scenarioContext) { + var sentLimit int + var sendPage int + bus.AddHandler("test", func(query *models.SearchUsersQuery) error { + query.Result = mockResult + + sentLimit = query.Limit + sendPage = query.Page + + return nil + }) + + sc.handlerFunc = SearchUsers + sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec() + + So(sentLimit, ShouldEqual, 10) + So(sendPage, ShouldEqual, 2) + }) + + loggedInUserScenario("When calling GET on", "/api/users/search", func(sc *scenarioContext) { + var sentLimit int + var sendPage int + bus.AddHandler("test", func(query *models.SearchUsersQuery) error { + query.Result = mockResult + + sentLimit = query.Limit + sendPage = query.Page + + return nil + }) + + sc.handlerFunc = SearchUsersWithPaging + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + So(sentLimit, ShouldEqual, 1000) + So(sendPage, ShouldEqual, 1) + + respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) + So(err, ShouldBeNil) + + So(respJSON.Get("totalCount").MustInt(), ShouldEqual, 2) + So(len(respJSON.Get("users").MustArray()), ShouldEqual, 2) + }) + + loggedInUserScenario("When calling GET with page and perpage querystring parameters on", "/api/users/search", func(sc *scenarioContext) { + var sentLimit int + var sendPage int + bus.AddHandler("test", func(query *models.SearchUsersQuery) error { + query.Result = mockResult + + sentLimit = query.Limit + sendPage = query.Page + + return nil + }) + + sc.handlerFunc = SearchUsersWithPaging + sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec() + + So(sentLimit, ShouldEqual, 10) + So(sendPage, ShouldEqual, 2) + }) + }) +} diff --git a/pkg/cmd/grafana-cli/main.go b/pkg/cmd/grafana-cli/main.go index 658edd0f895..e586e606fb5 100644 --- a/pkg/cmd/grafana-cli/main.go +++ b/pkg/cmd/grafana-cli/main.go @@ -35,7 +35,7 @@ func main() { cli.StringFlag{ Name: "repo", Usage: "url to the plugin repository", - Value: "https://grafana.net/api/plugins", + Value: "https://grafana.com/api/plugins", EnvVar: "GF_PLUGIN_REPO", }, cli.BoolFlag{ diff --git a/pkg/cmd/grafana-cli/utils/grafana_path.go b/pkg/cmd/grafana-cli/utils/grafana_path.go index fa6dc46d783..afb622bbb93 100644 --- a/pkg/cmd/grafana-cli/utils/grafana_path.go +++ b/pkg/cmd/grafana-cli/utils/grafana_path.go @@ -40,6 +40,8 @@ func returnOsDefault(currentOs string) string { return "../data/plugins" case "darwin": return "/usr/local/var/lib/grafana/plugins" + case "freebsd": + return "/var/db/grafana/plugins" default: //"linux" return "/var/lib/grafana/plugins" } diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go index 94eb8e845ea..30da2342c2b 100644 --- a/pkg/cmd/grafana-server/server.go +++ b/pkg/cmd/grafana-server/server.go @@ -3,7 +3,6 @@ package main import ( "context" "os" - "time" "golang.org/x/sync/errgroup" @@ -39,6 +38,8 @@ type GrafanaServerImpl struct { shutdownFn context.CancelFunc childRoutines *errgroup.Group log log.Logger + + httpServer *api.HttpServer } func (g *GrafanaServerImpl) Start() { @@ -74,9 +75,9 @@ func (g *GrafanaServerImpl) Start() { } func (g *GrafanaServerImpl) startHttpServer() { - httpServer := api.NewHttpServer() + g.httpServer = api.NewHttpServer() - err := httpServer.Start(g.context) + err := g.httpServer.Start(g.context) if err != nil { g.log.Error("Fail to start server", "error", err) @@ -88,24 +89,15 @@ func (g *GrafanaServerImpl) startHttpServer() { func (g *GrafanaServerImpl) Shutdown(code int, reason string) { g.log.Info("Shutdown started", "code", code, "reason", reason) + err := g.httpServer.Shutdown(g.context) + if err != nil { + g.log.Error("Failed to shutdown server", "error", err) + } + g.shutdownFn() - err := g.childRoutines.Wait() + 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/components/imguploader/imguploader.go b/pkg/components/imguploader/imguploader.go index 94cde098297..ae5c89a0049 100644 --- a/pkg/components/imguploader/imguploader.go +++ b/pkg/components/imguploader/imguploader.go @@ -27,25 +27,15 @@ func NewImageUploader() (ImageUploader, error) { return nil, err } - bucket := s3sec.Key("bucket_url").MustString("") + bucketUrl := s3sec.Key("bucket_url").MustString("") accessKey := s3sec.Key("access_key").MustString("") secretKey := s3sec.Key("secret_key").MustString("") - - region := "" - rBucket := regexp.MustCompile(`https?:\/\/(.*)\.s3(-([^.]+))?\.amazonaws\.com\/?`) - matches := rBucket.FindStringSubmatch(bucket) - if len(matches) == 0 { - return nil, fmt.Errorf("Could not find bucket setting for image.uploader.s3") - } else { - bucket = matches[1] - if matches[3] != "" { - region = matches[3] - } else { - region = "us-east-1" - } + info, err := getRegionAndBucketFromUrl(bucketUrl) + if err != nil { + return nil, err } - return NewS3Uploader(region, bucket, "public-read", accessKey, secretKey), nil + return NewS3Uploader(info.region, info.bucket, "public-read", accessKey, secretKey), nil case "webdav": webdavSec, err := setting.Cfg.GetSection("external_image_storage.webdav") if err != nil { @@ -65,3 +55,37 @@ func NewImageUploader() (ImageUploader, error) { return NopImageUploader{}, nil } + +type s3Info struct { + region string + bucket string +} + +func getRegionAndBucketFromUrl(url string) (*s3Info, error) { + info := &s3Info{} + urlRegex := regexp.MustCompile(`https?:\/\/(.*)\.s3(-([^.]+))?\.amazonaws\.com\/?`) + matches := urlRegex.FindStringSubmatch(url) + if len(matches) > 0 { + info.bucket = matches[1] + if matches[3] != "" { + info.region = matches[3] + } else { + info.region = "us-east-1" + } + return info, nil + } + + urlRegex2 := regexp.MustCompile(`https?:\/\/s3(-([^.]+))?\.amazonaws\.com\/(.*)?`) + matches2 := urlRegex2.FindStringSubmatch(url) + if len(matches2) > 0 { + info.bucket = matches2[3] + if matches2[2] != "" { + info.region = matches2[2] + } else { + info.region = "us-east-1" + } + return info, nil + } + + return nil, fmt.Errorf("Could not find bucket setting for image.uploader.s3") +} diff --git a/pkg/components/imguploader/imguploader_test.go b/pkg/components/imguploader/imguploader_test.go index b871aa55a64..4faaa6415f0 100644 --- a/pkg/components/imguploader/imguploader_test.go +++ b/pkg/components/imguploader/imguploader_test.go @@ -10,29 +10,66 @@ import ( func TestImageUploaderFactory(t *testing.T) { Convey("Can create image uploader for ", t, func() { - Convey("S3ImageUploader", func() { - var err error + Convey("S3ImageUploader config", func() { setting.NewConfigContext(&setting.CommandLineArgs{ HomePath: "../../../", }) setting.ImageUploadProvider = "s3" - s3sec, err := setting.Cfg.GetSection("external_image_storage.s3") - s3sec.NewKey("bucket_url", "https://foo.bar.baz.s3-us-east-2.amazonaws.com") - s3sec.NewKey("access_key", "access_key") - s3sec.NewKey("secret_key", "secret_key") + Convey("with bucket url https://foo.bar.baz.s3-us-east-2.amazonaws.com", func() { + s3sec, err := setting.Cfg.GetSection("external_image_storage.s3") + s3sec.NewKey("bucket_url", "https://foo.bar.baz.s3-us-east-2.amazonaws.com") + s3sec.NewKey("access_key", "access_key") + s3sec.NewKey("secret_key", "secret_key") - uploader, err := NewImageUploader() + uploader, err := NewImageUploader() - So(err, ShouldBeNil) - original, ok := uploader.(*S3Uploader) + So(err, ShouldBeNil) + original, ok := uploader.(*S3Uploader) - So(ok, ShouldBeTrue) - So(original.region, ShouldEqual, "us-east-2") - So(original.bucket, ShouldEqual, "foo.bar.baz") - So(original.accessKey, ShouldEqual, "access_key") - So(original.secretKey, ShouldEqual, "secret_key") + So(ok, ShouldBeTrue) + So(original.region, ShouldEqual, "us-east-2") + So(original.bucket, ShouldEqual, "foo.bar.baz") + So(original.accessKey, ShouldEqual, "access_key") + So(original.secretKey, ShouldEqual, "secret_key") + }) + + Convey("with bucket url https://s3.amazonaws.com/mybucket", func() { + s3sec, err := setting.Cfg.GetSection("external_image_storage.s3") + s3sec.NewKey("bucket_url", "https://s3.amazonaws.com/my.bucket.com") + s3sec.NewKey("access_key", "access_key") + s3sec.NewKey("secret_key", "secret_key") + + uploader, err := NewImageUploader() + + So(err, ShouldBeNil) + original, ok := uploader.(*S3Uploader) + + So(ok, ShouldBeTrue) + So(original.region, ShouldEqual, "us-east-1") + So(original.bucket, ShouldEqual, "my.bucket.com") + So(original.accessKey, ShouldEqual, "access_key") + So(original.secretKey, ShouldEqual, "secret_key") + }) + + Convey("with bucket url https://s3-us-west-2.amazonaws.com/mybucket", func() { + s3sec, err := setting.Cfg.GetSection("external_image_storage.s3") + s3sec.NewKey("bucket_url", "https://s3-us-west-2.amazonaws.com/my.bucket.com") + s3sec.NewKey("access_key", "access_key") + s3sec.NewKey("secret_key", "secret_key") + + uploader, err := NewImageUploader() + + So(err, ShouldBeNil) + original, ok := uploader.(*S3Uploader) + + So(ok, ShouldBeTrue) + So(original.region, ShouldEqual, "us-west-2") + So(original.bucket, ShouldEqual, "my.bucket.com") + So(original.accessKey, ShouldEqual, "access_key") + So(original.secretKey, ShouldEqual, "secret_key") + }) }) Convey("Webdav uploader", func() { diff --git a/pkg/components/imguploader/s3uploader.go b/pkg/components/imguploader/s3uploader.go index 5f476b9e366..4f8632f965c 100644 --- a/pkg/components/imguploader/s3uploader.go +++ b/pkg/components/imguploader/s3uploader.go @@ -35,7 +35,10 @@ func NewS3Uploader(region, bucket, acl, accessKey, secretKey string) *S3Uploader } func (u *S3Uploader) Upload(imageDiskPath string) (string, error) { - sess := session.New() + sess, err := session.NewSession() + if err != nil { + return "", err + } creds := credentials.NewChainCredentials( []credentials.Provider{ &credentials.StaticProvider{Value: credentials.Value{ @@ -58,7 +61,11 @@ func (u *S3Uploader) Upload(imageDiskPath string) (string, error) { return "", err } - svc := s3.New(session.New(cfg), cfg) + sess, err = session.NewSession(cfg) + if err != nil { + return "", err + } + svc := s3.New(sess, cfg) params := &s3.PutObjectInput{ Bucket: aws.String(u.bucket), Key: aws.String(key), diff --git a/pkg/components/imguploader/webdavuploader.go b/pkg/components/imguploader/webdavuploader.go index 541a44d4b4a..93b3f9d2efa 100644 --- a/pkg/components/imguploader/webdavuploader.go +++ b/pkg/components/imguploader/webdavuploader.go @@ -4,9 +4,11 @@ import ( "bytes" "fmt" "io/ioutil" + "net" "net/http" "net/url" "path" + "time" "github.com/grafana/grafana/pkg/util" ) @@ -17,6 +19,18 @@ type WebdavUploader struct { password string } +var netTransport = &http.Transport{ + Dial: (&net.Dialer{ + Timeout: 60 * time.Second, + }).Dial, + TLSHandshakeTimeout: 5 * time.Second, +} + +var netClient = &http.Client{ + Timeout: time.Second * 60, + Transport: netTransport, +} + func (u *WebdavUploader) Upload(pa string) (string, error) { url, _ := url.Parse(u.url) url.Path = path.Join(url.Path, util.GetRandomString(20)+".png") @@ -28,7 +42,7 @@ func (u *WebdavUploader) Upload(pa string) (string, error) { req.SetBasicAuth(u.username, u.password) } - res, err := http.DefaultClient.Do(req) + res, err := netClient.Do(req) if err != nil { return "", err diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 1020f28f874..c23a53009a9 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -49,7 +49,9 @@ var ( M_Alerting_Notification_Sent_Victorops Counter M_Alerting_Notification_Sent_OpsGenie Counter M_Alerting_Notification_Sent_Telegram Counter + M_Alerting_Notification_Sent_Threema Counter M_Alerting_Notification_Sent_Sensu Counter + M_Alerting_Notification_Sent_Pushover Counter M_Aws_CloudWatch_GetMetricStatistics Counter M_Aws_CloudWatch_ListMetrics Counter @@ -118,8 +120,10 @@ func initMetricVars(settings *MetricSettings) { M_Alerting_Notification_Sent_Victorops = RegCounter("alerting.notifications_sent", "type", "victorops") M_Alerting_Notification_Sent_OpsGenie = RegCounter("alerting.notifications_sent", "type", "opsgenie") M_Alerting_Notification_Sent_Telegram = RegCounter("alerting.notifications_sent", "type", "telegram") + M_Alerting_Notification_Sent_Threema = RegCounter("alerting.notifications_sent", "type", "threema") M_Alerting_Notification_Sent_Sensu = RegCounter("alerting.notifications_sent", "type", "sensu") M_Alerting_Notification_Sent_LINE = RegCounter("alerting.notifications_sent", "type", "LINE") + M_Alerting_Notification_Sent_Pushover = RegCounter("alerting.notifications_sent", "type", "pushover") M_Aws_CloudWatch_GetMetricStatistics = RegCounter("aws.cloudwatch.get_metric_statistics") M_Aws_CloudWatch_ListMetrics = RegCounter("aws.cloudwatch.list_metrics") diff --git a/pkg/middleware/auth_proxy_test.go b/pkg/middleware/auth_proxy_test.go index cc9253b6a77..bd587bc0c7f 100644 --- a/pkg/middleware/auth_proxy_test.go +++ b/pkg/middleware/auth_proxy_test.go @@ -90,6 +90,10 @@ func (s *mockSession) Get(k interface{}) interface{} { return s.value } +func (s *mockSession) Delete(k interface{}) interface{} { + return nil +} + func (s *mockSession) ID() string { return "" } diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 5cffa0a2884..a1836a6744e 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -326,6 +326,7 @@ func middlewareScenario(desc string, fn scenarioFunc) { // mock out gc goroutine startSessionGC = func() {} sc.m.Use(Sessioner(&session.Options{})) + sc.m.Use(OrgRedirect()) sc.defaultHandler = func(c *Context) { sc.context = c diff --git a/pkg/middleware/org_redirect.go b/pkg/middleware/org_redirect.go new file mode 100644 index 00000000000..5945663d4d1 --- /dev/null +++ b/pkg/middleware/org_redirect.go @@ -0,0 +1,44 @@ +package middleware + +import ( + "net/http" + "strconv" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + + "gopkg.in/macaron.v1" +) + +func OrgRedirect() macaron.Handler { + return func(res http.ResponseWriter, req *http.Request, c *macaron.Context) { + orgIdValue := req.URL.Query().Get("orgId") + orgId, err := strconv.ParseInt(orgIdValue, 10, 32) + + if err != nil || orgId == 0 { + return + } + + ctx, ok := c.Data["ctx"].(*Context) + if !ok || !ctx.IsSignedIn { + return + } + + if orgId == ctx.OrgId { + return + } + + cmd := models.SetUsingOrgCommand{UserId: ctx.UserId, OrgId: orgId} + if err := bus.Dispatch(&cmd); err != nil { + if ctx.IsApiRequest() { + ctx.JsonApiErr(404, "Not found", nil) + } else { + ctx.Error(404, "Not found") + } + + return + } + + c.Redirect(c.Req.URL.String(), 302) + } +} diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go new file mode 100644 index 00000000000..1f3d01f30f2 --- /dev/null +++ b/pkg/middleware/org_redirect_test.go @@ -0,0 +1,60 @@ +package middleware + +import ( + "testing" + + "fmt" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestOrgRedirectMiddleware(t *testing.T) { + + Convey("Can redirect to correct org", t, func() { + middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) { + sc.fakeReq("GET", "/").handler(func(c *Context) { + c.Session.Set(SESS_KEY_USERID, int64(12)) + }).exec() + + bus.AddHandler("test", func(query *models.SetUsingOrgCommand) error { + return nil + }) + + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 1, UserId: 12} + return nil + }) + + sc.m.Get("/", sc.defaultHandler) + sc.fakeReq("GET", "/?orgId=3").exec() + + Convey("change org and redirect", func() { + So(sc.resp.Code, ShouldEqual, 302) + }) + }) + + middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) { + sc.fakeReq("GET", "/").handler(func(c *Context) { + c.Session.Set(SESS_KEY_USERID, int64(12)) + }).exec() + + bus.AddHandler("test", func(query *models.SetUsingOrgCommand) error { + return fmt.Errorf("") + }) + + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 1, UserId: 12} + return nil + }) + + sc.m.Get("/", sc.defaultHandler) + sc.fakeReq("GET", "/?orgId=3").exec() + + Convey("not allowed to change org", func() { + So(sc.resp.Code, ShouldEqual, 404) + }) + }) + }) +} diff --git a/pkg/middleware/session.go b/pkg/middleware/session.go index 0700612ecc2..a6af63d18de 100644 --- a/pkg/middleware/session.go +++ b/pkg/middleware/session.go @@ -1,6 +1,7 @@ package middleware import ( + "math/rand" "time" "github.com/go-macaron/session" @@ -8,6 +9,7 @@ import ( _ "github.com/go-macaron/session/mysql" _ "github.com/go-macaron/session/postgres" _ "github.com/go-macaron/session/redis" + "github.com/grafana/grafana/pkg/log" "gopkg.in/macaron.v1" ) @@ -22,10 +24,12 @@ var sessionManager *session.Manager var sessionOptions *session.Options var startSessionGC func() var getSessionCount func() int +var sessionLogger = log.New("session") func init() { startSessionGC = func() { sessionManager.GC() + sessionLogger.Debug("Session GC") time.AfterFunc(time.Duration(sessionOptions.Gclifetime)*time.Second, startSessionGC) } getSessionCount = func() int { @@ -67,7 +71,9 @@ func Sessioner(options *session.Options) macaron.Handler { panic(err) } - go startSessionGC() + // start GC threads after some random seconds + rndSeconds := 10 + rand.Int63n(180) + time.AfterFunc(time.Duration(rndSeconds)*time.Second, startSessionGC) return func(ctx *Context) { ctx.Next() @@ -87,6 +93,8 @@ type SessionStore interface { Set(interface{}, interface{}) error // Get gets value by given key in session. Get(interface{}) interface{} + // Delete deletes a key from session. + Delete(interface{}) interface{} // ID returns current session ID. ID() string // Release releases session resource and save data to provider. @@ -122,6 +130,13 @@ func (s *SessionWrapper) Get(k interface{}) interface{} { return nil } +func (s *SessionWrapper) Delete(k interface{}) interface{} { + if s.session != nil { + return s.session.Delete(k) + } + return nil +} + func (s *SessionWrapper) ID() string { if s.session != nil { return s.session.ID() diff --git a/pkg/models/alert.go b/pkg/models/alert.go index 61976677764..fa3f4b466a8 100644 --- a/pkg/models/alert.go +++ b/pkg/models/alert.go @@ -35,6 +35,7 @@ const ( var ( ErrCannotChangeStateOnPausedAlert error = fmt.Errorf("Cannot change state on pause alert") + ErrRequiresNewState error = fmt.Errorf("update alert state requires a new state.") ) func (s AlertStateType) IsValid() bool { diff --git a/pkg/models/datasource.go b/pkg/models/datasource.go index 58bf8b4ca18..169189670d6 100644 --- a/pkg/models/datasource.go +++ b/pkg/models/datasource.go @@ -122,11 +122,16 @@ type UpdateDataSourceCommand struct { Id int64 `json:"-"` } -type DeleteDataSourceCommand struct { +type DeleteDataSourceByIdCommand struct { Id int64 OrgId int64 } +type DeleteDataSourceByNameCommand struct { + Name string + OrgId int64 +} + // --------------------- // QUERIES diff --git a/pkg/models/server.go b/pkg/models/server.go index 876fc91dd01..4d683835256 100644 --- a/pkg/models/server.go +++ b/pkg/models/server.go @@ -1,10 +1,6 @@ package models -import "context" - type GrafanaServer interface { - context.Context - Start() Shutdown(code int, reason string) } diff --git a/pkg/models/user.go b/pkg/models/user.go index e14f4486ba3..e0a36be8c0a 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -130,7 +130,14 @@ type SearchUsersQuery struct { Page int Limit int - Result []*UserSearchHitDTO + Result SearchUserQueryResult +} + +type SearchUserQueryResult struct { + TotalCount int64 `json:"totalCount"` + Users []*UserSearchHitDTO `json:"users"` + Page int `json:"page"` + PerPage int `json:"perPage"` } type GetUserOrgListQuery struct { diff --git a/pkg/plugins/update_checker.go b/pkg/plugins/update_checker.go index 76c566803ac..a2594ad915a 100644 --- a/pkg/plugins/update_checker.go +++ b/pkg/plugins/update_checker.go @@ -60,7 +60,7 @@ func checkForUpdates() { log.Trace("Checking for updates") pluginSlugs := getAllExternalPluginSlugs() - resp, err := httpClient.Get("https://grafana.net/api/plugins/versioncheck?slugIn=" + pluginSlugs + "&grafanaVersion=" + setting.BuildVersion) + resp, err := httpClient.Get("https://grafana.com/api/plugins/versioncheck?slugIn=" + pluginSlugs + "&grafanaVersion=" + setting.BuildVersion) if err != nil { log.Trace("Failed to get plugins repo from grafana.net, %v", err.Error()) @@ -71,14 +71,14 @@ func checkForUpdates() { body, err := ioutil.ReadAll(resp.Body) if err != nil { - log.Trace("Update check failed, reading response from grafana.net, %v", err.Error()) + log.Trace("Update check failed, reading response from grafana.com, %v", err.Error()) return } gNetPlugins := []GrafanaNetPlugin{} err = json.Unmarshal(body, &gNetPlugins) if err != nil { - log.Trace("Failed to unmarshal plugin repo, reading response from grafana.net, %v", err.Error()) + log.Trace("Failed to unmarshal plugin repo, reading response from grafana.com, %v", err.Error()) return } diff --git a/pkg/services/alerting/conditions/query.go b/pkg/services/alerting/conditions/query.go index eafb233a60b..e0a6035355d 100644 --- a/pkg/services/alerting/conditions/query.go +++ b/pkg/services/alerting/conditions/query.go @@ -67,6 +67,7 @@ func (c *QueryCondition) Eval(context *alerting.EvalContext) (*alerting.Conditio matches = append(matches, &alerting.EvalMatch{ Metric: series.Name, Value: reducedValue, + Tags: series.Tags, }) } } @@ -78,7 +79,7 @@ func (c *QueryCondition) Eval(context *alerting.EvalContext) (*alerting.Conditio if context.IsTestRun { context.Logs = append(context.Logs, &alerting.ResultLogEntry{ - Message: fmt.Sprintf("Condition[%d]: Eval: %v, Query Returned No Series (reduced to null/no value)", evalMatch), + Message: fmt.Sprintf("Condition: Eval: %v, Query Returned No Series (reduced to null/no value)", evalMatch), }) } diff --git a/pkg/services/alerting/engine.go b/pkg/services/alerting/engine.go index b99b7506614..51d48287c14 100644 --- a/pkg/services/alerting/engine.go +++ b/pkg/services/alerting/engine.go @@ -98,7 +98,7 @@ func (e *Engine) processJob(grafanaCtx context.Context, job *Job) error { } }() - alertCtx, cancelFn := context.WithTimeout(context.TODO(), alertTimeout) + alertCtx, cancelFn := context.WithTimeout(context.Background(), alertTimeout) job.Running = true evalContext := NewEvalContext(alertCtx, job.Rule) diff --git a/pkg/services/alerting/eval_context.go b/pkg/services/alerting/eval_context.go index 2b252da8c4b..e92edd2af12 100644 --- a/pkg/services/alerting/eval_context.go +++ b/pkg/services/alerting/eval_context.go @@ -113,7 +113,7 @@ func (c *EvalContext) GetRuleUrl() (string, error) { 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) + ruleUrl := fmt.Sprintf("%sdashboard/db/%s?fullscreen&edit&tab=alert&panelId=%d&orgId=%d", setting.AppUrl, slug, c.Rule.PanelId, c.Rule.OrgId) return ruleUrl, nil } } diff --git a/pkg/services/alerting/interfaces.go b/pkg/services/alerting/interfaces.go index 566fbdb2898..0955155575c 100644 --- a/pkg/services/alerting/interfaces.go +++ b/pkg/services/alerting/interfaces.go @@ -21,6 +21,18 @@ type Notifier interface { GetIsDefault() bool } +type NotifierSlice []Notifier + +func (notifiers NotifierSlice) ShouldUploadImage() bool { + for _, notifier := range notifiers { + if notifier.NeedsImage() { + return true + } + } + + return false +} + type ConditionResult struct { Firing bool NoDataFound bool diff --git a/pkg/services/alerting/notifier.go b/pkg/services/alerting/notifier.go index 7e213058cd0..e831b566b22 100644 --- a/pkg/services/alerting/notifier.go +++ b/pkg/services/alerting/notifier.go @@ -21,37 +21,25 @@ type NotifierPlugin struct { Factory NotifierFactory `json:"-"` } -type RootNotifier struct { +type NotificationService interface { + Send(context *EvalContext) error +} + +func NewNotificationService() NotificationService { + return newNotificationService() +} + +type notificationService struct { log log.Logger } -func NewRootNotifier() *RootNotifier { - return &RootNotifier{ +func newNotificationService() *notificationService { + return ¬ificationService{ log: log.New("alerting.notifier"), } } -func (n *RootNotifier) GetType() string { - return "root" -} - -func (n *RootNotifier) NeedsImage() bool { - return false -} - -func (n *RootNotifier) PassesFilter(rule *Rule) bool { - return false -} - -func (n *RootNotifier) GetNotifierId() int64 { - return 0 -} - -func (n *RootNotifier) GetIsDefault() bool { - return false -} - -func (n *RootNotifier) Notify(context *EvalContext) error { +func (n *notificationService) Send(context *EvalContext) error { notifiers, err := n.getNotifiers(context.Rule.OrgId, context.Rule.Notifications, context) if err != nil { return err @@ -63,14 +51,16 @@ func (n *RootNotifier) Notify(context *EvalContext) error { return nil } - if err = n.uploadImage(context); err != nil { - n.log.Error("Failed to upload alert panel image.", "error", err) + if notifiers.ShouldUploadImage() { + if err = n.uploadImage(context); err != nil { + n.log.Error("Failed to upload alert panel image.", "error", err) + } } return n.sendNotifications(context, notifiers) } -func (n *RootNotifier) sendNotifications(context *EvalContext, notifiers []Notifier) error { +func (n *notificationService) sendNotifications(context *EvalContext, notifiers []Notifier) error { g, _ := errgroup.WithContext(context.Ctx) for _, notifier := range notifiers { @@ -82,7 +72,7 @@ func (n *RootNotifier) sendNotifications(context *EvalContext, notifiers []Notif return g.Wait() } -func (n *RootNotifier) uploadImage(context *EvalContext) (err error) { +func (n *notificationService) uploadImage(context *EvalContext) (err error) { uploader, err := imguploader.NewImageUploader() if err != nil { return err @@ -116,7 +106,7 @@ func (n *RootNotifier) uploadImage(context *EvalContext) (err error) { return nil } -func (n *RootNotifier) getNotifiers(orgId int64, notificationIds []int64, context *EvalContext) ([]Notifier, error) { +func (n *notificationService) getNotifiers(orgId int64, notificationIds []int64, context *EvalContext) (NotifierSlice, error) { query := &m.GetAlertNotificationsToSendQuery{OrgId: orgId, Ids: notificationIds} if err := bus.Dispatch(query); err != nil { @@ -137,7 +127,7 @@ func (n *RootNotifier) getNotifiers(orgId int64, notificationIds []int64, contex return result, nil } -func (n *RootNotifier) createNotifierFor(model *m.AlertNotification) (Notifier, error) { +func (n *notificationService) createNotifierFor(model *m.AlertNotification) (Notifier, error) { notifierPlugin, found := notifierFactories[model.Type] if !found { return nil, errors.New("Unsupported notification type") diff --git a/pkg/services/alerting/notifiers/base.go b/pkg/services/alerting/notifiers/base.go index f1e748207d8..dc22e1acaa0 100644 --- a/pkg/services/alerting/notifiers/base.go +++ b/pkg/services/alerting/notifiers/base.go @@ -6,18 +6,22 @@ import ( ) type NotifierBase struct { - Name string - Type string - Id int64 - IsDeault bool + Name string + Type string + Id int64 + IsDeault bool + UploadImage bool } func NewNotifierBase(id int64, isDefault bool, name, notifierType string, model *simplejson.Json) NotifierBase { + uploadImage := model.Get("uploadImage").MustBool(true) + return NotifierBase{ - Id: id, - Name: name, - IsDeault: isDefault, - Type: notifierType, + Id: id, + Name: name, + IsDeault: isDefault, + Type: notifierType, + UploadImage: uploadImage, } } @@ -30,7 +34,7 @@ func (n *NotifierBase) GetType() string { } func (n *NotifierBase) NeedsImage() bool { - return true + return n.UploadImage } func (n *NotifierBase) GetNotifierId() int64 { diff --git a/pkg/services/alerting/notifiers/email.go b/pkg/services/alerting/notifiers/email.go index 4058d3860b5..dcf71f0f99e 100644 --- a/pkg/services/alerting/notifiers/email.go +++ b/pkg/services/alerting/notifiers/email.go @@ -16,7 +16,7 @@ func init() { alerting.RegisterNotifier(&alerting.NotifierPlugin{ Type: "email", Name: "Email", - Description: "Sends notifications using Grafana server configured STMP settings", + Description: "Sends notifications using Grafana server configured SMTP settings", Factory: NewEmailNotifier, OptionsTemplate: `

Email addresses

diff --git a/pkg/services/alerting/notifiers/hipchat.go b/pkg/services/alerting/notifiers/hipchat.go new file mode 100644 index 00000000000..0eb21865b9f --- /dev/null +++ b/pkg/services/alerting/notifiers/hipchat.go @@ -0,0 +1,155 @@ +package notifiers + +import ( + "encoding/json" + "strconv" + "strings" + + "fmt" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" +) + +func init() { + alerting.RegisterNotifier(&alerting.NotifierPlugin{ + Type: "hipchat", + Name: "HipChat", + Description: "Sends notifications uto a HipChat Room", + Factory: NewHipChatNotifier, + OptionsTemplate: ` +

HipChat settings

+
+ Hip Chat Url + +
+
+ API Key + +
+
+ Room ID + + +
+ `, + }) + +} + +const ( + maxFieldCount int = 4 +) + +func NewHipChatNotifier(model *models.AlertNotification) (alerting.Notifier, error) { + url := model.Settings.Get("url").MustString() + if strings.HasSuffix(url, "/") { + url = url[:len(url)-1] + } + if url == "" { + return nil, alerting.ValidationError{Reason: "Could not find url property in settings"} + } + + apikey := model.Settings.Get("apikey").MustString() + roomId := model.Settings.Get("roomid").MustString() + + return &HipChatNotifier{ + NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings), + Url: url, + ApiKey: apikey, + RoomId: roomId, + log: log.New("alerting.notifier.hipchat"), + }, nil +} + +type HipChatNotifier struct { + NotifierBase + Url string + ApiKey string + RoomId string + log log.Logger +} + +func (this *HipChatNotifier) Notify(evalContext *alerting.EvalContext) error { + this.log.Info("Executing hipchat notification", "ruleId", evalContext.Rule.Id, "notification", this.Name) + + ruleUrl, err := evalContext.GetRuleUrl() + if err != nil { + this.log.Error("Failed get rule link", "error", err) + return err + } + + message := evalContext.GetNotificationTitle() + " in state " + evalContext.GetStateModel().Text + "
Check Dasboard" + fields := make([]map[string]interface{}, 0) + message += "
" + for index, evt := range evalContext.EvalMatches { + message += evt.Metric + " :: " + strconv.FormatFloat(evt.Value.Float64, 'f', -1, 64) + "
" + fields = append(fields, map[string]interface{}{ + "title": evt.Metric, + "value": evt.Value, + "short": true, + }) + if index > maxFieldCount { + break + } + } + + if evalContext.Error != nil { + fields = append(fields, map[string]interface{}{ + "title": "Error message", + "value": evalContext.Error.Error(), + "short": false, + }) + } + + if evalContext.Rule.State != models.AlertStateOK { //dont add message when going back to alert state ok. + message += " " + evalContext.Rule.Message + } + //HipChat has a set list of colors + var color string + switch evalContext.Rule.State { + case models.AlertStateOK: + color = "green" + case models.AlertStateNoData: + color = "grey" + case models.AlertStateAlerting: + color = "red" + } + + // Add a card with link to the dashboard + card := map[string]interface{}{ + "style": "link", + "url": ruleUrl, + "id": "1", + "title": evalContext.GetNotificationTitle(), + "description": evalContext.GetNotificationTitle() + " in state " + evalContext.GetStateModel().Text, + "icon": map[string]interface{}{ + "url": "https://grafana.com/assets/img/fav32.png", + }, + "date": evalContext.EndTime.Unix(), + } + + body := map[string]interface{}{ + "message": message, + "notify": "true", + "message_format": "html", + "color": color, + "card": card, + } + + hipUrl := fmt.Sprintf("%s/v2/room/%s/notification?auth_token=%s", this.Url, this.RoomId, this.ApiKey) + data, _ := json.Marshal(&body) + cmd := &models.SendWebhookSync{Url: hipUrl, Body: string(data)} + + if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { + this.log.Error("Failed to send hipchat notification", "error", err, "webhook", this.Name) + return err + } + + return nil +} diff --git a/pkg/services/alerting/notifiers/hipchat_test.go b/pkg/services/alerting/notifiers/hipchat_test.go new file mode 100644 index 00000000000..1597be12eb8 --- /dev/null +++ b/pkg/services/alerting/notifiers/hipchat_test.go @@ -0,0 +1,81 @@ +package notifiers + +import ( + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestHipChatNotifier(t *testing.T) { + Convey("HipChat notifier tests", t, func() { + + Convey("Parsing alert notification from settings", func() { + Convey("empty settings should return error", func() { + json := `{ }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "ops", + Type: "hipchat", + Settings: settingsJSON, + } + + _, err := NewHipChatNotifier(model) + So(err, ShouldNotBeNil) + }) + + Convey("from settings", func() { + json := ` + { + "url": "http://google.com" + }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "ops", + Type: "hipchat", + Settings: settingsJSON, + } + + not, err := NewHipChatNotifier(model) + hipchatNotifier := not.(*HipChatNotifier) + + So(err, ShouldBeNil) + So(hipchatNotifier.Name, ShouldEqual, "ops") + So(hipchatNotifier.Type, ShouldEqual, "hipchat") + So(hipchatNotifier.Url, ShouldEqual, "http://google.com") + So(hipchatNotifier.ApiKey, ShouldEqual, "") + So(hipchatNotifier.RoomId, ShouldEqual, "") + }) + + Convey("from settings with Recipient and Mention", func() { + json := ` + { + "url": "http://www.hipchat.com", + "apikey": "1234", + "roomid": "1234" + }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "ops", + Type: "hipchat", + Settings: settingsJSON, + } + + not, err := NewHipChatNotifier(model) + hipchatNotifier := not.(*HipChatNotifier) + + So(err, ShouldBeNil) + So(hipchatNotifier.Name, ShouldEqual, "ops") + So(hipchatNotifier.Type, ShouldEqual, "hipchat") + So(hipchatNotifier.Url, ShouldEqual, "http://www.hipchat.com") + So(hipchatNotifier.ApiKey, ShouldEqual, "1234") + So(hipchatNotifier.RoomId, ShouldEqual, "1234") + }) + + }) + }) +} diff --git a/pkg/services/alerting/notifiers/opsgenie.go b/pkg/services/alerting/notifiers/opsgenie.go index 742aeb922b6..6792702b539 100644 --- a/pkg/services/alerting/notifiers/opsgenie.go +++ b/pkg/services/alerting/notifiers/opsgenie.go @@ -30,7 +30,7 @@ func init() { label="Auto close incidents" label-class="width-14" checked="ctrl.model.settings.autoClose" - tooltip="Automatically close alerts in OpseGenie once the alert goes back to ok."> + tooltip="Automatically close alerts in OpsGenie once the alert goes back to ok.">
`, diff --git a/pkg/services/alerting/notifiers/pushover.go b/pkg/services/alerting/notifiers/pushover.go new file mode 100644 index 00000000000..c68ad421c62 --- /dev/null +++ b/pkg/services/alerting/notifiers/pushover.go @@ -0,0 +1,177 @@ +package notifiers + +import ( + "fmt" + "net/url" + "strconv" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/metrics" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" +) + +const PUSHOVER_ENDPOINT = "https://api.pushover.net/1/messages.json" + +func init() { + alerting.RegisterNotifier(&alerting.NotifierPlugin{ + Type: "pushover", + Name: "Pushover", + Description: "Sends HTTP POST request to the Pushover API", + Factory: NewPushoverNotifier, + OptionsTemplate: ` +

Pushover settings

+
+ API Token + +
+
+ User key(s) + +
+
+ Device(s) (optional) + +
+
+ Priority + +
+
+ Retry + + Expire + +
+
+ Sound + +
+ `, + }) +} + +func NewPushoverNotifier(model *m.AlertNotification) (alerting.Notifier, error) { + userKey := model.Settings.Get("userKey").MustString() + apiToken := model.Settings.Get("apiToken").MustString() + device := model.Settings.Get("device").MustString() + priority, _ := strconv.Atoi(model.Settings.Get("priority").MustString()) + retry, _ := strconv.Atoi(model.Settings.Get("retry").MustString()) + expire, _ := strconv.Atoi(model.Settings.Get("expire").MustString()) + sound := model.Settings.Get("sound").MustString() + + if userKey == "" { + return nil, alerting.ValidationError{Reason: "User key not given"} + } + if apiToken == "" { + return nil, alerting.ValidationError{Reason: "API token not given"} + } + return &PushoverNotifier{ + NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings), + UserKey: userKey, + ApiToken: apiToken, + Priority: priority, + Retry: retry, + Expire: expire, + Device: device, + Sound: sound, + log: log.New("alerting.notifier.pushover"), + }, nil +} + +type PushoverNotifier struct { + NotifierBase + UserKey string + ApiToken string + Priority int + Retry int + Expire int + Device string + Sound string + log log.Logger +} + +func (this *PushoverNotifier) Notify(evalContext *alerting.EvalContext) error { + metrics.M_Alerting_Notification_Sent_Pushover.Inc(1) + ruleUrl, err := evalContext.GetRuleUrl() + if err != nil { + this.log.Error("Failed get rule link", "error", err) + return err + } + message := evalContext.Rule.Message + for idx, evt := range evalContext.EvalMatches { + message += fmt.Sprintf("\n%s: %v", evt.Metric, evt.Value) + if idx > 4 { + break + } + } + if evalContext.Error != nil { + message += fmt.Sprintf("\nError message %s", evalContext.Error.Error()) + } + q := url.Values{} + q.Add("user", this.UserKey) + q.Add("token", this.ApiToken) + q.Add("priority", strconv.Itoa(this.Priority)) + if this.Priority == 2 { + q.Add("retry", strconv.Itoa(this.Retry)) + q.Add("expire", strconv.Itoa(this.Expire)) + } + if this.Device != "" { + q.Add("device", this.Device) + } + if this.Sound != "default" { + q.Add("sound", this.Sound) + } + q.Add("title", evalContext.GetNotificationTitle()) + q.Add("url", ruleUrl) + q.Add("url_title", "Show dashboard with alert") + q.Add("message", message) + q.Add("html", "1") + + cmd := &m.SendWebhookSync{ + Url: PUSHOVER_ENDPOINT, + HttpMethod: "POST", + HttpHeader: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + Body: q.Encode(), + } + + if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { + this.log.Error("Failed to send pushover notification", "error", err, "webhook", this.Name) + return err + } + + return nil +} diff --git a/pkg/services/alerting/notifiers/pushover_test.go b/pkg/services/alerting/notifiers/pushover_test.go new file mode 100644 index 00000000000..a3bac73e065 --- /dev/null +++ b/pkg/services/alerting/notifiers/pushover_test.go @@ -0,0 +1,58 @@ +package notifiers + +import ( + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestPushoverNotifier(t *testing.T) { + Convey("Pushover notifier tests", t, func() { + + Convey("Parsing alert notification from settings", func() { + Convey("empty settings should return error", func() { + json := `{ }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "Pushover", + Type: "pushover", + Settings: settingsJSON, + } + + _, err := NewPushoverNotifier(model) + So(err, ShouldNotBeNil) + }) + + Convey("from settings", func() { + json := ` + { + "apiToken": "4SrUFQL4A5V5TQ1z5Pg9nxHXPXSTve", + "userKey": "tzNZYf36y0ohWwXo4XoUrB61rz1A4o", + "priority": "1", + "sound": "pushover" + }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "Pushover", + Type: "pushover", + Settings: settingsJSON, + } + + not, err := NewPushoverNotifier(model) + pushoverNotifier := not.(*PushoverNotifier) + + So(err, ShouldBeNil) + So(pushoverNotifier.Name, ShouldEqual, "Pushover") + So(pushoverNotifier.Type, ShouldEqual, "pushover") + So(pushoverNotifier.ApiToken, ShouldEqual, "4SrUFQL4A5V5TQ1z5Pg9nxHXPXSTve") + So(pushoverNotifier.UserKey, ShouldEqual, "tzNZYf36y0ohWwXo4XoUrB61rz1A4o") + So(pushoverNotifier.Priority, ShouldEqual, 1) + So(pushoverNotifier.Sound, ShouldEqual, "pushover") + }) + }) + }) +} diff --git a/pkg/services/alerting/notifiers/slack.go b/pkg/services/alerting/notifiers/slack.go index 7b2bfd09c9a..a0d08ae2ba7 100644 --- a/pkg/services/alerting/notifiers/slack.go +++ b/pkg/services/alerting/notifiers/slack.go @@ -116,6 +116,7 @@ func (this *SlackNotifier) Notify(evalContext *alerting.EvalContext) error { body := map[string]interface{}{ "attachments": []map[string]interface{}{ { + "fallback": evalContext.GetNotificationTitle(), "color": evalContext.GetStateModel().Color, "title": evalContext.GetNotificationTitle(), "title_link": ruleUrl, @@ -123,7 +124,7 @@ func (this *SlackNotifier) Notify(evalContext *alerting.EvalContext) error { "fields": fields, "image_url": evalContext.ImagePublicUrl, "footer": "Grafana v" + setting.BuildVersion, - "footer_icon": "http://grafana.org/assets/img/fav32.png", + "footer_icon": "https://grafana.com/assets/img/fav32.png", "ts": time.Now().Unix(), }, }, diff --git a/pkg/services/alerting/notifiers/telegram.go b/pkg/services/alerting/notifiers/telegram.go index 0c3e3bd3c61..f70d299bc6c 100644 --- a/pkg/services/alerting/notifiers/telegram.go +++ b/pkg/services/alerting/notifiers/telegram.go @@ -93,6 +93,9 @@ func (this *TelegramNotifier) Notify(evalContext *alerting.EvalContext) error { if err == nil { message = message + fmt.Sprintf("URL: %s\n", ruleUrl) } + if evalContext.ImagePublicUrl != "" { + message = message + fmt.Sprintf("Image: %s\n", evalContext.ImagePublicUrl) + } bodyJSON.Set("text", message) url := fmt.Sprintf(telegeramApiUrl, this.BotToken, "sendMessage") diff --git a/pkg/services/alerting/notifiers/threema.go b/pkg/services/alerting/notifiers/threema.go new file mode 100644 index 00000000000..80c84c26a24 --- /dev/null +++ b/pkg/services/alerting/notifiers/threema.go @@ -0,0 +1,171 @@ +package notifiers + +import ( + "fmt" + "net/url" + "strings" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/metrics" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" +) + +var ( + threemaGwBaseURL = "https://msgapi.threema.ch/%s" +) + +func init() { + alerting.RegisterNotifier(&alerting.NotifierPlugin{ + Type: "threema", + Name: "Threema Gateway", + Description: "Sends notifications to Threema using the Threema Gateway", + Factory: NewThreemaNotifier, + OptionsTemplate: ` +

Threema Gateway settings

+

+ Notifications can be configured for any Threema Gateway ID of type + "Basic". End-to-End IDs are not currently supported. +

+

+ The Threema Gateway ID can be set up at + https://gateway.threema.ch/. +

+
+ Gateway ID + + + + Your 8 character Threema Gateway ID (starting with a *) + +
+
+ Recipient ID + + + + The 8 character Threema ID that should receive the alerts + +
+
+ API Secret + + + + Your Threema Gateway API secret + +
+ `, + }) + +} + +type ThreemaNotifier struct { + NotifierBase + GatewayID string + RecipientID string + APISecret string + log log.Logger +} + +func NewThreemaNotifier(model *m.AlertNotification) (alerting.Notifier, error) { + if model.Settings == nil { + return nil, alerting.ValidationError{Reason: "No Settings Supplied"} + } + + gatewayID := model.Settings.Get("gateway_id").MustString() + recipientID := model.Settings.Get("recipient_id").MustString() + apiSecret := model.Settings.Get("api_secret").MustString() + + // Validation + if gatewayID == "" { + return nil, alerting.ValidationError{Reason: "Could not find Threema Gateway ID in settings"} + } + if !strings.HasPrefix(gatewayID, "*") { + return nil, alerting.ValidationError{Reason: "Invalid Threema Gateway ID: Must start with a *"} + } + if len(gatewayID) != 8 { + return nil, alerting.ValidationError{Reason: "Invalid Threema Gateway ID: Must be 8 characters long"} + } + if recipientID == "" { + return nil, alerting.ValidationError{Reason: "Could not find Threema Recipient ID in settings"} + } + if len(recipientID) != 8 { + return nil, alerting.ValidationError{Reason: "Invalid Threema Recipient ID: Must be 8 characters long"} + } + if apiSecret == "" { + return nil, alerting.ValidationError{Reason: "Could not find Threema API secret in settings"} + } + + return &ThreemaNotifier{ + NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings), + GatewayID: gatewayID, + RecipientID: recipientID, + APISecret: apiSecret, + log: log.New("alerting.notifier.threema"), + }, nil +} + +func (notifier *ThreemaNotifier) Notify(evalContext *alerting.EvalContext) error { + notifier.log.Info("Sending alert notification from", "threema_id", notifier.GatewayID) + notifier.log.Info("Sending alert notification to", "threema_id", notifier.RecipientID) + metrics.M_Alerting_Notification_Sent_Threema.Inc(1) + + // Set up basic API request data + data := url.Values{} + data.Set("from", notifier.GatewayID) + data.Set("to", notifier.RecipientID) + data.Set("secret", notifier.APISecret) + + // Determine emoji + stateEmoji := "" + switch evalContext.Rule.State { + case m.AlertStateOK: + stateEmoji = "\u2705 " // White Heavy Check Mark + case m.AlertStateNoData: + stateEmoji = "\u2753 " // Black Question Mark Ornament + case m.AlertStateAlerting: + stateEmoji = "\u26A0 " // Warning sign + } + + // Build message + message := fmt.Sprintf("%s%s\n\n*State:* %s\n*Message:* %s\n", + stateEmoji, evalContext.GetNotificationTitle(), + evalContext.Rule.Name, evalContext.Rule.Message) + ruleURL, err := evalContext.GetRuleUrl() + if err == nil { + message = message + fmt.Sprintf("*URL:* %s\n", ruleURL) + } + if evalContext.ImagePublicUrl != "" { + message = message + fmt.Sprintf("*Image:* %s\n", evalContext.ImagePublicUrl) + } + data.Set("text", message) + + // Prepare and send request + url := fmt.Sprintf(threemaGwBaseURL, "send_simple") + body := data.Encode() + headers := map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + } + cmd := &m.SendWebhookSync{ + Url: url, + Body: body, + HttpMethod: "POST", + HttpHeader: headers, + } + if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { + notifier.log.Error("Failed to send webhook", "error", err, "webhook", notifier.Name) + return err + } + + return nil +} diff --git a/pkg/services/alerting/notifiers/threema_test.go b/pkg/services/alerting/notifiers/threema_test.go new file mode 100644 index 00000000000..3f23730a249 --- /dev/null +++ b/pkg/services/alerting/notifiers/threema_test.go @@ -0,0 +1,119 @@ +package notifiers + +import ( + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" + . "github.com/smartystreets/goconvey/convey" +) + +func TestThreemaNotifier(t *testing.T) { + Convey("Threema notifier tests", t, func() { + + Convey("Parsing alert notification from settings", func() { + Convey("empty settings should return error", func() { + json := `{ }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "threema_testing", + Type: "threema", + Settings: settingsJSON, + } + + _, err := NewThreemaNotifier(model) + So(err, ShouldNotBeNil) + }) + + Convey("valid settings should be parsed successfully", func() { + json := ` + { + "gateway_id": "*3MAGWID", + "recipient_id": "ECHOECHO", + "api_secret": "1234" + }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "threema_testing", + Type: "threema", + Settings: settingsJSON, + } + + not, err := NewThreemaNotifier(model) + So(err, ShouldBeNil) + threemaNotifier := not.(*ThreemaNotifier) + + So(err, ShouldBeNil) + So(threemaNotifier.Name, ShouldEqual, "threema_testing") + So(threemaNotifier.Type, ShouldEqual, "threema") + So(threemaNotifier.GatewayID, ShouldEqual, "*3MAGWID") + So(threemaNotifier.RecipientID, ShouldEqual, "ECHOECHO") + So(threemaNotifier.APISecret, ShouldEqual, "1234") + }) + + Convey("invalid Threema Gateway IDs should be rejected (prefix)", func() { + json := ` + { + "gateway_id": "ECHOECHO", + "recipient_id": "ECHOECHO", + "api_secret": "1234" + }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "threema_testing", + Type: "threema", + Settings: settingsJSON, + } + + not, err := NewThreemaNotifier(model) + So(not, ShouldBeNil) + So(err.(alerting.ValidationError).Reason, ShouldEqual, "Invalid Threema Gateway ID: Must start with a *") + }) + + Convey("invalid Threema Gateway IDs should be rejected (length)", func() { + json := ` + { + "gateway_id": "*ECHOECHO", + "recipient_id": "ECHOECHO", + "api_secret": "1234" + }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "threema_testing", + Type: "threema", + Settings: settingsJSON, + } + + not, err := NewThreemaNotifier(model) + So(not, ShouldBeNil) + So(err.(alerting.ValidationError).Reason, ShouldEqual, "Invalid Threema Gateway ID: Must be 8 characters long") + }) + + Convey("invalid Threema Recipient IDs should be rejected (length)", func() { + json := ` + { + "gateway_id": "*3MAGWID", + "recipient_id": "ECHOECH", + "api_secret": "1234" + }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "threema_testing", + Type: "threema", + Settings: settingsJSON, + } + + not, err := NewThreemaNotifier(model) + So(not, ShouldBeNil) + So(err.(alerting.ValidationError).Reason, ShouldEqual, "Invalid Threema Recipient ID: Must be 8 characters long") + }) + + }) + }) +} diff --git a/pkg/services/alerting/result_handler.go b/pkg/services/alerting/result_handler.go index 6b31dd4f951..1298a5dda36 100644 --- a/pkg/services/alerting/result_handler.go +++ b/pkg/services/alerting/result_handler.go @@ -16,14 +16,14 @@ type ResultHandler interface { } type DefaultResultHandler struct { - notifier Notifier + notifier NotificationService log log.Logger } func NewResultHandler() *DefaultResultHandler { return &DefaultResultHandler{ log: log.New("alerting.resultHandler"), - notifier: NewRootNotifier(), + notifier: NewNotificationService(), } } @@ -61,6 +61,12 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error { handler.log.Error("Cannot change state on alert thats pause", "error", err) return err } + + if err == m.ErrRequiresNewState { + handler.log.Info("Alert already updated") + return nil + } + handler.log.Error("Failed to save state", "error", err) } @@ -85,7 +91,7 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error { } if evalContext.ShouldSendNotification() { - handler.notifier.Notify(evalContext) + handler.notifier.Send(evalContext) } } diff --git a/pkg/services/alerting/test_notification.go b/pkg/services/alerting/test_notification.go index a40d0ac3c5c..91b3814ac7b 100644 --- a/pkg/services/alerting/test_notification.go +++ b/pkg/services/alerting/test_notification.go @@ -23,7 +23,7 @@ func init() { } func handleNotificationTestCommand(cmd *NotificationTestCommand) error { - notifier := NewRootNotifier() + notifier := newNotificationService() model := &m.AlertNotification{ Name: cmd.Name, @@ -38,10 +38,10 @@ func handleNotificationTestCommand(cmd *NotificationTestCommand) error { return err } - return notifier.sendNotifications(createTestEvalContext(), []Notifier{notifiers}) + return notifier.sendNotifications(createTestEvalContext(cmd), []Notifier{notifiers}) } -func createTestEvalContext() *EvalContext { +func createTestEvalContext(cmd *NotificationTestCommand) *EvalContext { testRule := &Rule{ DashboardId: 1, PanelId: 1, @@ -50,8 +50,10 @@ func createTestEvalContext() *EvalContext { State: m.AlertStateAlerting, } - ctx := NewEvalContext(context.TODO(), testRule) - ctx.ImagePublicUrl = "http://grafana.org/assets/img/blog/mixed_styles.png" + ctx := NewEvalContext(context.Background(), testRule) + if cmd.Settings.Get("uploadImage").MustBool(true) { + ctx.ImagePublicUrl = "http://grafana.org/assets/img/blog/mixed_styles.png" + } ctx.IsTestRun = true ctx.Firing = true ctx.Error = nil diff --git a/pkg/services/alerting/test_rule.go b/pkg/services/alerting/test_rule.go index 82b1a6276a1..e3aa95e0ede 100644 --- a/pkg/services/alerting/test_rule.go +++ b/pkg/services/alerting/test_rule.go @@ -49,7 +49,7 @@ func handleAlertTestCommand(cmd *AlertTestCommand) error { func testAlertRule(rule *Rule) *EvalContext { handler := NewEvalHandler() - context := NewEvalContext(context.TODO(), rule) + context := NewEvalContext(context.Background(), rule) context.IsTestRun = true handler.Eval(context) diff --git a/pkg/services/annotations/annotations.go b/pkg/services/annotations/annotations.go index 3fc3bafe5c5..d9d15bca34b 100644 --- a/pkg/services/annotations/annotations.go +++ b/pkg/services/annotations/annotations.go @@ -11,14 +11,14 @@ type Repository interface { type ItemQuery struct { OrgId int64 `json:"orgId"` From int64 `json:"from"` - To int64 `json:"from"` + To int64 `json:"to"` Type ItemType `json:"type"` AlertId int64 `json:"alertId"` DashboardId int64 `json:"dashboardId"` PanelId int64 `json:"panelId"` NewState []string `json:"newState"` - Limit int64 `json:"alertId"` + Limit int64 `json:"limit"` } type DeleteParams struct { @@ -48,7 +48,7 @@ type Item struct { OrgId int64 `json:"orgId"` DashboardId int64 `json:"dashboardId"` PanelId int64 `json:"panelId"` - CategoryId int64 `json:"panelId"` + CategoryId int64 `json:"categoryId"` Type ItemType `json:"type"` Title string `json:"title"` Text string `json:"text"` diff --git a/pkg/services/notifications/mailer.go b/pkg/services/notifications/mailer.go index 1ca4df8877a..ae1348cbf99 100644 --- a/pkg/services/notifications/mailer.go +++ b/pkg/services/notifications/mailer.go @@ -150,7 +150,7 @@ func buildEmailMessage(cmd *m.SendEmailCommand) (*Message, error) { return &Message{ To: cmd.To, - From: setting.Smtp.FromAddress, + From: fmt.Sprintf("%s <%s>", setting.Smtp.FromName, setting.Smtp.FromAddress), Subject: subject, Body: buffer.String(), EmbededFiles: cmd.EmbededFiles, diff --git a/pkg/services/notifications/notifications.go b/pkg/services/notifications/notifications.go index 095ce15ef32..c765774d062 100644 --- a/pkg/services/notifications/notifications.go +++ b/pkg/services/notifications/notifications.go @@ -48,7 +48,7 @@ func Init() error { } if !util.IsEmail(setting.Smtp.FromAddress) { - return errors.New("Invalid email address for smpt from_adress config") + return errors.New("Invalid email address for SMTP from_address config") } if setting.EmailCodeValidMinutes == 0 { diff --git a/pkg/services/notifications/notifications_test.go b/pkg/services/notifications/notifications_test.go index 79db664e893..3a5ff5fedb7 100644 --- a/pkg/services/notifications/notifications_test.go +++ b/pkg/services/notifications/notifications_test.go @@ -23,6 +23,7 @@ func TestNotifications(t *testing.T) { setting.Smtp.Enabled = true setting.Smtp.TemplatesPattern = "emails/*.html" setting.Smtp.FromAddress = "from@address.com" + setting.Smtp.FromName = "Grafana Admin" err := Init() So(err, ShouldBeNil) diff --git a/pkg/services/notifications/send_email_integration_test.go b/pkg/services/notifications/send_email_integration_test.go index 1a4406dc13a..a9a5215d3ca 100644 --- a/pkg/services/notifications/send_email_integration_test.go +++ b/pkg/services/notifications/send_email_integration_test.go @@ -18,12 +18,15 @@ func TestEmailIntegrationTest(t *testing.T) { setting.Smtp.Enabled = true setting.Smtp.TemplatesPattern = "emails/*.html" setting.Smtp.FromAddress = "from@address.com" + setting.Smtp.FromName = "Grafana Admin" setting.BuildVersion = "4.0.0" err := Init() So(err, ShouldBeNil) addToMailQueue = func(msg *Message) { + So(msg.From, ShouldEqual, "Grafana Admin ") + So(msg.To[0], ShouldEqual, "asdf@asdf.com") ioutil.WriteFile("../../../tmp/test_email.html", []byte(msg.Body), 0777) } @@ -40,6 +43,7 @@ func TestEmailIntegrationTest(t *testing.T) { "RuleUrl": "http://localhost:3000/dashboard/db/graphite-dashboard", "ImageLink": "http://localhost:3000/render/dashboard-solo/db/graphite-dashboard?panelId=1&from=1471008499616&to=1471012099617&width=1000&height=500", "AlertPageUrl": "http://localhost:3000/alerting", + "EmbededImage": "test.png", "EvalMatches": []map[string]string{ { "Metric": "desktop", @@ -51,7 +55,7 @@ func TestEmailIntegrationTest(t *testing.T) { }, }, }, - To: []string{"asdf@asdf.com "}, + To: []string{"asdf@asdf.com"}, Template: "alert_notification.html", } diff --git a/pkg/services/notifications/webhook.go b/pkg/services/notifications/webhook.go index cf39af10478..c74804ab828 100644 --- a/pkg/services/notifications/webhook.go +++ b/pkg/services/notifications/webhook.go @@ -5,7 +5,9 @@ import ( "context" "fmt" "io/ioutil" + "net" "net/http" + "time" "golang.org/x/net/context/ctxhttp" @@ -22,6 +24,18 @@ type Webhook struct { HttpHeader map[string]string } +var netTransport = &http.Transport{ + Proxy: http.ProxyFromEnvironment, + Dial: (&net.Dialer{ + Timeout: 30 * time.Second, + }).Dial, + TLSHandshakeTimeout: 5 * time.Second, +} +var netClient = &http.Client{ + Timeout: time.Second * 30, + Transport: netTransport, +} + var ( webhookQueue chan *Webhook webhookLog log.Logger @@ -37,7 +51,7 @@ func processWebhookQueue() { for { select { case webhook := <-webhookQueue: - err := sendWebRequestSync(context.TODO(), webhook) + err := sendWebRequestSync(context.Background(), webhook) if err != nil { webhookLog.Error("Failed to send webrequest ", "error", err) @@ -68,7 +82,7 @@ func sendWebRequestSync(ctx context.Context, webhook *Webhook) error { request.Header.Set(k, v) } - resp, err := ctxhttp.Do(ctx, http.DefaultClient, request) + resp, err := ctxhttp.Do(ctx, netClient, request) if err != nil { return err } diff --git a/pkg/services/sqlstore/alert.go b/pkg/services/sqlstore/alert.go index 66f3b5b7250..4bf4cb5d0e9 100644 --- a/pkg/services/sqlstore/alert.go +++ b/pkg/services/sqlstore/alert.go @@ -240,6 +240,10 @@ func SetAlertState(cmd *m.SetAlertStateCommand) error { return m.ErrCannotChangeStateOnPausedAlert } + if alert.State == cmd.State { + return m.ErrRequiresNewState + } + alert.State = cmd.State alert.StateChanges += 1 alert.NewStateDate = time.Now() diff --git a/pkg/services/sqlstore/datasource.go b/pkg/services/sqlstore/datasource.go index 57abaf35083..de838163681 100644 --- a/pkg/services/sqlstore/datasource.go +++ b/pkg/services/sqlstore/datasource.go @@ -13,7 +13,8 @@ import ( func init() { bus.AddHandler("sql", GetDataSources) bus.AddHandler("sql", AddDataSource) - bus.AddHandler("sql", DeleteDataSource) + bus.AddHandler("sql", DeleteDataSourceById) + bus.AddHandler("sql", DeleteDataSourceByName) bus.AddHandler("sql", UpdateDataSource) bus.AddHandler("sql", GetDataSourceById) bus.AddHandler("sql", GetDataSourceByName) @@ -50,7 +51,7 @@ func GetDataSources(query *m.GetDataSourcesQuery) error { return sess.Find(&query.Result) } -func DeleteDataSource(cmd *m.DeleteDataSourceCommand) error { +func DeleteDataSourceById(cmd *m.DeleteDataSourceByIdCommand) error { return inTransaction(func(sess *xorm.Session) error { var rawSql = "DELETE FROM data_source WHERE id=? and org_id=?" _, err := sess.Exec(rawSql, cmd.Id, cmd.OrgId) @@ -58,6 +59,14 @@ func DeleteDataSource(cmd *m.DeleteDataSourceCommand) error { }) } +func DeleteDataSourceByName(cmd *m.DeleteDataSourceByNameCommand) error { + return inTransaction(func(sess *xorm.Session) error { + var rawSql = "DELETE FROM data_source WHERE name=? and org_id=?" + _, err := sess.Exec(rawSql, cmd.Name, cmd.OrgId) + return err + }) +} + func AddDataSource(cmd *m.AddDataSourceCommand) error { return inTransaction(func(sess *xorm.Session) error { diff --git a/pkg/services/sqlstore/datasource_test.go b/pkg/services/sqlstore/datasource_test.go index 35752eeaafc..2749a3cc426 100644 --- a/pkg/services/sqlstore/datasource_test.go +++ b/pkg/services/sqlstore/datasource_test.go @@ -79,8 +79,16 @@ func TestDataAccess(t *testing.T) { ds := query.Result[0] - Convey("Can delete datasource", func() { - err := DeleteDataSource(&m.DeleteDataSourceCommand{Id: ds.Id, OrgId: ds.OrgId}) + Convey("Can delete datasource by id", func() { + err := DeleteDataSourceById(&m.DeleteDataSourceByIdCommand{Id: ds.Id, OrgId: ds.OrgId}) + So(err, ShouldBeNil) + + GetDataSources(&query) + So(len(query.Result), ShouldEqual, 0) + }) + + Convey("Can delete datasource by name", func() { + err := DeleteDataSourceByName(&m.DeleteDataSourceByNameCommand{Name: ds.Name, OrgId: ds.OrgId}) So(err, ShouldBeNil) GetDataSources(&query) @@ -88,7 +96,7 @@ func TestDataAccess(t *testing.T) { }) Convey("Can not delete datasource with wrong orgId", func() { - err := DeleteDataSource(&m.DeleteDataSourceCommand{Id: ds.Id, OrgId: 123123}) + err := DeleteDataSourceById(&m.DeleteDataSourceByIdCommand{Id: ds.Id, OrgId: 123123}) So(err, ShouldBeNil) GetDataSources(&query) diff --git a/pkg/services/sqlstore/logger.go b/pkg/services/sqlstore/logger.go new file mode 100644 index 00000000000..ae1145c21b0 --- /dev/null +++ b/pkg/services/sqlstore/logger.go @@ -0,0 +1,123 @@ +package sqlstore + +import ( + "fmt" + + glog "github.com/grafana/grafana/pkg/log" + + "github.com/go-xorm/core" +) + +type XormLogger struct { + grafanaLog glog.Logger + level glog.Lvl + showSQL bool +} + +func NewXormLogger(level glog.Lvl, grafanaLog glog.Logger) *XormLogger { + return &XormLogger{ + grafanaLog: grafanaLog, + level: level, + showSQL: true, + } +} + +// Error implement core.ILogger +func (s *XormLogger) Err(v ...interface{}) error { + if s.level <= glog.LvlError { + s.grafanaLog.Error(fmt.Sprint(v...)) + } + return nil +} + +// Errorf implement core.ILogger +func (s *XormLogger) Errf(format string, v ...interface{}) error { + if s.level <= glog.LvlError { + s.grafanaLog.Error(fmt.Sprintf(format, v...)) + } + return nil +} + +// Debug implement core.ILogger +func (s *XormLogger) Debug(v ...interface{}) error { + if s.level <= glog.LvlDebug { + s.grafanaLog.Debug(fmt.Sprint(v...)) + } + return nil +} + +// Debugf implement core.ILogger +func (s *XormLogger) Debugf(format string, v ...interface{}) error { + if s.level <= glog.LvlDebug { + s.grafanaLog.Debug(fmt.Sprintf(format, v...)) + } + return nil +} + +// Info implement core.ILogger +func (s *XormLogger) Info(v ...interface{}) error { + if s.level <= glog.LvlInfo { + s.grafanaLog.Info(fmt.Sprint(v...)) + } + return nil +} + +// Infof implement core.ILogger +func (s *XormLogger) Infof(format string, v ...interface{}) error { + if s.level <= glog.LvlInfo { + s.grafanaLog.Info(fmt.Sprintf(format, v...)) + } + return nil +} + +// Warn implement core.ILogger +func (s *XormLogger) Warning(v ...interface{}) error { + if s.level <= glog.LvlWarn { + s.grafanaLog.Warn(fmt.Sprint(v...)) + } + return nil +} + +// Warnf implement core.ILogger +func (s *XormLogger) Warningf(format string, v ...interface{}) error { + if s.level <= glog.LvlWarn { + s.grafanaLog.Warn(fmt.Sprintf(format, v...)) + } + return nil +} + +// Level implement core.ILogger +func (s *XormLogger) Level() core.LogLevel { + switch s.level { + case glog.LvlError: + return core.LOG_ERR + case glog.LvlWarn: + return core.LOG_WARNING + case glog.LvlInfo: + return core.LOG_INFO + case glog.LvlDebug: + return core.LOG_DEBUG + default: + return core.LOG_ERR + } +} + +// SetLevel implement core.ILogger +func (s *XormLogger) SetLevel(l core.LogLevel) error { + return nil +} + +// ShowSQL implement core.ILogger +func (s *XormLogger) ShowSQL(show ...bool) { + s.grafanaLog.Error("ShowSQL", "show", "show") + if len(show) == 0 { + s.showSQL = true + return + } + s.showSQL = show[0] +} + +// IsShowSQL implement core.ILogger +func (s *XormLogger) IsShowSQL() bool { + return s.showSQL +} diff --git a/pkg/services/sqlstore/migrations/alert_mig.go b/pkg/services/sqlstore/migrations/alert_mig.go index b6956be41c7..7b6558656f1 100644 --- a/pkg/services/sqlstore/migrations/alert_mig.go +++ b/pkg/services/sqlstore/migrations/alert_mig.go @@ -50,7 +50,7 @@ func addAlertMigrations(mg *Migrator) { Columns: []*Column{ {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, {Name: "org_id", Type: DB_BigInt, Nullable: false}, - {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "name", Type: DB_NVarchar, Length: 190, Nullable: false}, {Name: "type", Type: DB_NVarchar, Length: 255, Nullable: false}, {Name: "settings", Type: DB_Text, Nullable: false}, {Name: "created", Type: DB_DateTime, Nullable: false}, @@ -67,4 +67,19 @@ func addAlertMigrations(mg *Migrator) { })) mg.AddMigration("add index alert_notification org_id & name", NewAddIndexMigration(alert_notification, alert_notification.Indices[0])) + mg.AddMigration("Update alert table charset", NewTableCharsetMigration("alert", []*Column{ + {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "message", Type: DB_Text, Nullable: false}, + {Name: "state", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "settings", Type: DB_Text, Nullable: false}, + {Name: "severity", Type: DB_Text, Nullable: false}, + {Name: "execution_error", Type: DB_Text, Nullable: false}, + {Name: "eval_data", Type: DB_Text, Nullable: true}, + })) + + mg.AddMigration("Update alert_notification table charset", NewTableCharsetMigration("alert_notification", []*Column{ + {Name: "name", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "type", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "settings", Type: DB_Text, Nullable: false}, + })) } diff --git a/pkg/services/sqlstore/migrations/annotation_mig.go b/pkg/services/sqlstore/migrations/annotation_mig.go index a307100ad3c..4a7206f9d64 100644 --- a/pkg/services/sqlstore/migrations/annotation_mig.go +++ b/pkg/services/sqlstore/migrations/annotation_mig.go @@ -44,4 +44,14 @@ func addAnnotationMig(mg *Migrator) { mg.AddMigration("add index annotation 2 v3", NewAddIndexMigration(table, table.Indices[2])) mg.AddMigration("add index annotation 3 v3", NewAddIndexMigration(table, table.Indices[3])) mg.AddMigration("add index annotation 4 v3", NewAddIndexMigration(table, table.Indices[4])) + + mg.AddMigration("Update annotation table charset", NewTableCharsetMigration("annotation", []*Column{ + {Name: "type", Type: DB_NVarchar, Length: 25, Nullable: false}, + {Name: "title", Type: DB_Text, Nullable: false}, + {Name: "text", Type: DB_Text, Nullable: false}, + {Name: "metric", Type: DB_NVarchar, Length: 255, Nullable: true}, + {Name: "prev_state", Type: DB_NVarchar, Length: 25, Nullable: false}, + {Name: "new_state", Type: DB_NVarchar, Length: 25, Nullable: false}, + {Name: "data", Type: DB_Text, Nullable: false}, + })) } diff --git a/pkg/services/sqlstore/migrations/apikey_mig.go b/pkg/services/sqlstore/migrations/apikey_mig.go index a02e759f551..928f84c4fb0 100644 --- a/pkg/services/sqlstore/migrations/apikey_mig.go +++ b/pkg/services/sqlstore/migrations/apikey_mig.go @@ -8,7 +8,7 @@ func addApiKeyMigrations(mg *Migrator) { Columns: []*Column{ {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, {Name: "account_id", Type: DB_BigInt, Nullable: false}, - {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "name", Type: DB_NVarchar, Length: 190, Nullable: false}, {Name: "key", Type: DB_Varchar, Length: 64, Nullable: false}, {Name: "role", Type: DB_NVarchar, Length: 255, Nullable: false}, {Name: "created", Type: DB_DateTime, Nullable: false}, @@ -41,8 +41,8 @@ func addApiKeyMigrations(mg *Migrator) { Columns: []*Column{ {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, {Name: "org_id", Type: DB_BigInt, Nullable: false}, - {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: false}, - {Name: "key", Type: DB_Varchar, Length: 255, Nullable: false}, + {Name: "name", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "key", Type: DB_Varchar, Length: 190, Nullable: false}, {Name: "role", Type: DB_NVarchar, Length: 255, Nullable: false}, {Name: "created", Type: DB_DateTime, Nullable: false}, {Name: "updated", Type: DB_DateTime, Nullable: false}, @@ -72,4 +72,10 @@ func addApiKeyMigrations(mg *Migrator) { })) mg.AddMigration("Drop old table api_key_v1", NewDropTableMigration("api_key_v1")) + + mg.AddMigration("Update api_key table charset", NewTableCharsetMigration("api_key", []*Column{ + {Name: "name", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "key", Type: DB_Varchar, Length: 190, Nullable: false}, + {Name: "role", Type: DB_NVarchar, Length: 255, Nullable: false}, + })) } diff --git a/pkg/services/sqlstore/migrations/dashboard_mig.go b/pkg/services/sqlstore/migrations/dashboard_mig.go index 283501d366f..0ef2f3be54f 100644 --- a/pkg/services/sqlstore/migrations/dashboard_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_mig.go @@ -8,7 +8,7 @@ func addDashboardMigration(mg *Migrator) { Columns: []*Column{ {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, {Name: "version", Type: DB_Int, Nullable: false}, - {Name: "slug", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "slug", Type: DB_NVarchar, Length: 190, Nullable: false}, {Name: "title", Type: DB_NVarchar, Length: 255, Nullable: false}, {Name: "data", Type: DB_Text, Nullable: false}, {Name: "account_id", Type: DB_BigInt, Nullable: false}, @@ -56,7 +56,7 @@ func addDashboardMigration(mg *Migrator) { Columns: []*Column{ {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, {Name: "version", Type: DB_Int, Nullable: false}, - {Name: "slug", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "slug", Type: DB_NVarchar, Length: 190, Nullable: false}, {Name: "title", Type: DB_NVarchar, Length: 255, Nullable: false}, {Name: "data", Type: DB_Text, Nullable: false}, {Name: "org_id", Type: DB_BigInt, Nullable: false}, @@ -125,4 +125,15 @@ func addDashboardMigration(mg *Migrator) { mg.AddMigration("Add index for dashboard_id in dashboard_tag", NewAddIndexMigration(dashboardTagV1, &Index{ Cols: []string{"dashboard_id"}, Type: IndexType, })) + + mg.AddMigration("Update dashboard table charset", NewTableCharsetMigration("dashboard", []*Column{ + {Name: "slug", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "title", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "plugin_id", Type: DB_NVarchar, Nullable: true, Length: 255}, + {Name: "data", Type: DB_MediumText, Nullable: false}, + })) + + mg.AddMigration("Update dashboard_tag table charset", NewTableCharsetMigration("dashboard_tag", []*Column{ + {Name: "term", Type: DB_NVarchar, Length: 50, Nullable: false}, + })) } diff --git a/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go b/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go index b08cc451e55..1a72bcba929 100644 --- a/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go @@ -8,7 +8,7 @@ func addDashboardSnapshotMigrations(mg *Migrator) { Columns: []*Column{ {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: false}, - {Name: "key", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "key", Type: DB_NVarchar, Length: 190, Nullable: false}, {Name: "dashboard", Type: DB_Text, Nullable: false}, {Name: "expires", Type: DB_DateTime, Nullable: false}, {Name: "created", Type: DB_DateTime, Nullable: false}, @@ -28,8 +28,8 @@ func addDashboardSnapshotMigrations(mg *Migrator) { Columns: []*Column{ {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: false}, - {Name: "key", Type: DB_NVarchar, Length: 255, Nullable: false}, - {Name: "delete_key", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "key", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "delete_key", Type: DB_NVarchar, Length: 190, Nullable: false}, {Name: "org_id", Type: DB_BigInt, Nullable: false}, {Name: "user_id", Type: DB_BigInt, Nullable: false}, {Name: "external", Type: DB_Bool, Nullable: false}, @@ -54,4 +54,12 @@ func addDashboardSnapshotMigrations(mg *Migrator) { Sqlite("SELECT 0 WHERE 0;"). Postgres("SELECT 0;"). Mysql("ALTER TABLE dashboard_snapshot MODIFY dashboard MEDIUMTEXT;")) + + mg.AddMigration("Update dashboard_snapshot table charset", NewTableCharsetMigration("dashboard_snapshot", []*Column{ + {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "key", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "delete_key", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "external_url", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "dashboard", Type: DB_MediumText, Nullable: false}, + })) } diff --git a/pkg/services/sqlstore/migrations/datasource_mig.go b/pkg/services/sqlstore/migrations/datasource_mig.go index 396f80df99f..c694b5b7cfb 100644 --- a/pkg/services/sqlstore/migrations/datasource_mig.go +++ b/pkg/services/sqlstore/migrations/datasource_mig.go @@ -10,7 +10,7 @@ func addDataSourceMigration(mg *Migrator) { {Name: "account_id", Type: DB_BigInt, Nullable: false}, {Name: "version", Type: DB_Int, Nullable: false}, {Name: "type", Type: DB_NVarchar, Length: 255, Nullable: false}, - {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "name", Type: DB_NVarchar, Length: 190, Nullable: false}, {Name: "access", Type: DB_NVarchar, Length: 255, Nullable: false}, {Name: "url", Type: DB_NVarchar, Length: 255, Nullable: false}, {Name: "password", Type: DB_NVarchar, Length: 255, Nullable: true}, @@ -49,7 +49,7 @@ func addDataSourceMigration(mg *Migrator) { {Name: "org_id", Type: DB_BigInt, Nullable: false}, {Name: "version", Type: DB_Int, Nullable: false}, {Name: "type", Type: DB_NVarchar, Length: 255, Nullable: false}, - {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "name", Type: DB_NVarchar, Length: 190, Nullable: false}, {Name: "access", Type: DB_NVarchar, Length: 255, Nullable: false}, {Name: "url", Type: DB_NVarchar, Length: 255, Nullable: false}, {Name: "password", Type: DB_NVarchar, Length: 255, Nullable: true}, @@ -106,4 +106,18 @@ func addDataSourceMigration(mg *Migrator) { mg.AddMigration("Add secure json data column", NewAddColumnMigration(tableV2, &Column{ Name: "secure_json_data", Type: DB_Text, Nullable: true, })) + + mg.AddMigration("Update data_source table charset", NewTableCharsetMigration(tableV2.Name, []*Column{ + {Name: "type", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "name", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "access", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "url", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "password", Type: DB_NVarchar, Length: 255, Nullable: true}, + {Name: "user", Type: DB_NVarchar, Length: 255, Nullable: true}, + {Name: "database", Type: DB_NVarchar, Length: 255, Nullable: true}, + {Name: "basic_auth_user", Type: DB_NVarchar, Length: 255, Nullable: true}, + {Name: "basic_auth_password", Type: DB_NVarchar, Length: 255, Nullable: true}, + {Name: "json_data", Type: DB_Text, Nullable: true}, + {Name: "secure_json_data", Type: DB_Text, Nullable: true}, + })) } diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index f0f69ee0e23..a88c1448bb7 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -5,7 +5,7 @@ import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" // --- Migration Guide line --- // 1. Never change a migration that is committed and pushed to master // 2. Always add new migrations (to change or undo previous migrations) -// 3. Some migraitons are not yet written (rename column, table, drop table, index etc) +// 3. Some migrations are not yet written (rename column, table, drop table, index etc) func AddMigrations(mg *Migrator) { addMigrationLogMigrations(mg) diff --git a/pkg/services/sqlstore/migrations/org_mig.go b/pkg/services/sqlstore/migrations/org_mig.go index 8e25da76b58..12e0a04256a 100644 --- a/pkg/services/sqlstore/migrations/org_mig.go +++ b/pkg/services/sqlstore/migrations/org_mig.go @@ -8,7 +8,7 @@ func addOrgMigrations(mg *Migrator) { Columns: []*Column{ {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, {Name: "version", Type: DB_Int, Nullable: false}, - {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "name", Type: DB_NVarchar, Length: 190, Nullable: false}, {Name: "address1", Type: DB_NVarchar, Length: 255, Nullable: true}, {Name: "address2", Type: DB_NVarchar, Length: 255, Nullable: true}, {Name: "city", Type: DB_NVarchar, Length: 255, Nullable: true}, @@ -68,4 +68,19 @@ func addOrgMigrations(mg *Migrator) { mg.AddMigration("Drop old table account", NewDropTableMigration("account")) mg.AddMigration("Drop old table account_user", NewDropTableMigration("account_user")) + + mg.AddMigration("Update org table charset", NewTableCharsetMigration("org", []*Column{ + {Name: "name", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "address1", Type: DB_NVarchar, Length: 255, Nullable: true}, + {Name: "address2", Type: DB_NVarchar, Length: 255, Nullable: true}, + {Name: "city", Type: DB_NVarchar, Length: 255, Nullable: true}, + {Name: "state", Type: DB_NVarchar, Length: 255, Nullable: true}, + {Name: "zip_code", Type: DB_NVarchar, Length: 50, Nullable: true}, + {Name: "country", Type: DB_NVarchar, Length: 255, Nullable: true}, + {Name: "billing_email", Type: DB_NVarchar, Length: 255, Nullable: true}, + })) + + mg.AddMigration("Update org_user table charset", NewTableCharsetMigration("org_user", []*Column{ + {Name: "role", Type: DB_NVarchar, Length: 20}, + })) } diff --git a/pkg/services/sqlstore/migrations/playlist_mig.go b/pkg/services/sqlstore/migrations/playlist_mig.go index 636fc091006..44ae4b6cb0e 100644 --- a/pkg/services/sqlstore/migrations/playlist_mig.go +++ b/pkg/services/sqlstore/migrations/playlist_mig.go @@ -32,4 +32,15 @@ func addPlaylistMigrations(mg *Migrator) { } mg.AddMigration("create playlist item table v2", NewAddTableMigration(playlistItemV2)) + + mg.AddMigration("Update playlist table charset", NewTableCharsetMigration("playlist", []*Column{ + {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "interval", Type: DB_NVarchar, Length: 255, Nullable: false}, + })) + + mg.AddMigration("Update playlist_item table charset", NewTableCharsetMigration("playlist_item", []*Column{ + {Name: "type", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "value", Type: DB_Text, Nullable: false}, + {Name: "title", Type: DB_Text, Nullable: false}, + })) } diff --git a/pkg/services/sqlstore/migrations/plugin_setting.go b/pkg/services/sqlstore/migrations/plugin_setting.go index 0700ab67d2f..66a984b56d4 100644 --- a/pkg/services/sqlstore/migrations/plugin_setting.go +++ b/pkg/services/sqlstore/migrations/plugin_setting.go @@ -9,7 +9,7 @@ func addAppSettingsMigration(mg *Migrator) { Columns: []*Column{ {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, {Name: "org_id", Type: DB_BigInt, Nullable: true}, - {Name: "plugin_id", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "plugin_id", Type: DB_NVarchar, Length: 190, Nullable: false}, {Name: "enabled", Type: DB_Bool, Nullable: false}, {Name: "pinned", Type: DB_Bool, Nullable: false}, {Name: "json_data", Type: DB_Text, Nullable: true}, @@ -32,4 +32,10 @@ func addAppSettingsMigration(mg *Migrator) { Name: "plugin_version", Type: DB_NVarchar, Nullable: true, Length: 50, })) + mg.AddMigration("Update plugin_setting table charset", NewTableCharsetMigration("plugin_setting", []*Column{ + {Name: "plugin_id", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "json_data", Type: DB_Text, Nullable: true}, + {Name: "secure_json_data", Type: DB_Text, Nullable: true}, + {Name: "plugin_version", Type: DB_NVarchar, Nullable: true, Length: 50}, + })) } diff --git a/pkg/services/sqlstore/migrations/preferences_mig.go b/pkg/services/sqlstore/migrations/preferences_mig.go index 67a8169a7a8..b3822fe5239 100644 --- a/pkg/services/sqlstore/migrations/preferences_mig.go +++ b/pkg/services/sqlstore/migrations/preferences_mig.go @@ -29,4 +29,9 @@ func addPreferencesMigrations(mg *Migrator) { // create table mg.AddMigration("create preferences table v3", NewAddTableMigration(preferencesV2)) + + mg.AddMigration("Update preferences table charset", NewTableCharsetMigration("preferences", []*Column{ + {Name: "timezone", Type: DB_NVarchar, Length: 50, Nullable: false}, + {Name: "theme", Type: DB_NVarchar, Length: 20, Nullable: false}, + })) } diff --git a/pkg/services/sqlstore/migrations/quota_mig.go b/pkg/services/sqlstore/migrations/quota_mig.go index d877bb4c3c1..34f0da662a9 100644 --- a/pkg/services/sqlstore/migrations/quota_mig.go +++ b/pkg/services/sqlstore/migrations/quota_mig.go @@ -12,7 +12,7 @@ func addQuotaMigration(mg *Migrator) { {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, {Name: "org_id", Type: DB_BigInt, Nullable: true}, {Name: "user_id", Type: DB_BigInt, Nullable: true}, - {Name: "target", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "target", Type: DB_NVarchar, Length: 190, Nullable: false}, {Name: "limit", Type: DB_BigInt, Nullable: false}, {Name: "created", Type: DB_DateTime, Nullable: false}, {Name: "updated", Type: DB_DateTime, Nullable: false}, @@ -25,4 +25,8 @@ func addQuotaMigration(mg *Migrator) { //------- indexes ------------------ addTableIndicesMigrations(mg, "v1", quotaV1) + + mg.AddMigration("Update quota table charset", NewTableCharsetMigration("quota", []*Column{ + {Name: "target", Type: DB_NVarchar, Length: 190, Nullable: false}, + })) } diff --git a/pkg/services/sqlstore/migrations/temp_user.go b/pkg/services/sqlstore/migrations/temp_user.go index 3cae5d82472..5592ab7e4ad 100644 --- a/pkg/services/sqlstore/migrations/temp_user.go +++ b/pkg/services/sqlstore/migrations/temp_user.go @@ -35,4 +35,13 @@ func addTempUserMigrations(mg *Migrator) { // create table mg.AddMigration("create temp user table v1-7", NewAddTableMigration(tempUserV1)) addTableIndicesMigrations(mg, "v1-7", tempUserV1) + + mg.AddMigration("Update temp_user table charset", NewTableCharsetMigration("temp_user", []*Column{ + {Name: "email", Type: DB_NVarchar, Length: 255}, + {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: true}, + {Name: "role", Type: DB_NVarchar, Length: 20, Nullable: true}, + {Name: "code", Type: DB_NVarchar, Length: 255}, + {Name: "status", Type: DB_Varchar, Length: 20}, + {Name: "remote_addr", Type: DB_Varchar, Length: 255, Nullable: true}, + })) } diff --git a/pkg/services/sqlstore/migrations/user_mig.go b/pkg/services/sqlstore/migrations/user_mig.go index 67446f64d1a..a12c4987792 100644 --- a/pkg/services/sqlstore/migrations/user_mig.go +++ b/pkg/services/sqlstore/migrations/user_mig.go @@ -8,8 +8,8 @@ func addUserMigrations(mg *Migrator) { Columns: []*Column{ {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, {Name: "version", Type: DB_Int, Nullable: false}, - {Name: "login", Type: DB_NVarchar, Length: 255, Nullable: false}, - {Name: "email", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "login", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "email", Type: DB_NVarchar, Length: 190, Nullable: false}, {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: true}, {Name: "password", Type: DB_NVarchar, Length: 255, Nullable: true}, {Name: "salt", Type: DB_NVarchar, Length: 50, Nullable: true}, @@ -47,8 +47,8 @@ func addUserMigrations(mg *Migrator) { Columns: []*Column{ {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, {Name: "version", Type: DB_Int, Nullable: false}, - {Name: "login", Type: DB_NVarchar, Length: 255, Nullable: false}, - {Name: "email", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "login", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "email", Type: DB_NVarchar, Length: 190, Nullable: false}, {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: true}, {Name: "password", Type: DB_NVarchar, Length: 255, Nullable: true}, {Name: "salt", Type: DB_NVarchar, Length: 50, Nullable: true}, @@ -92,4 +92,15 @@ func addUserMigrations(mg *Migrator) { mg.AddMigration("Add column help_flags1 to user table", NewAddColumnMigration(userV2, &Column{ Name: "help_flags1", Type: DB_BigInt, Nullable: false, Default: "0", })) + + mg.AddMigration("Update user table charset", NewTableCharsetMigration("user", []*Column{ + {Name: "login", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "email", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: true}, + {Name: "password", Type: DB_NVarchar, Length: 255, Nullable: true}, + {Name: "salt", Type: DB_NVarchar, Length: 50, Nullable: true}, + {Name: "rands", Type: DB_NVarchar, Length: 50, Nullable: true}, + {Name: "company", Type: DB_NVarchar, Length: 255, Nullable: true}, + {Name: "theme", Type: DB_NVarchar, Length: 255, Nullable: true}, + })) } diff --git a/pkg/services/sqlstore/migrator/dialect.go b/pkg/services/sqlstore/migrator/dialect.go index 4473b560428..651405921d9 100644 --- a/pkg/services/sqlstore/migrator/dialect.go +++ b/pkg/services/sqlstore/migrator/dialect.go @@ -29,6 +29,7 @@ type Dialect interface { TableCheckSql(tableName string) (string, []interface{}) RenameTable(oldName string, newName string) string + UpdateTableSql(tableName string, columns []*Column) string } func NewDialect(name string) Dialect { @@ -102,7 +103,7 @@ func (b *BaseDialect) CreateTableSql(table *Table) string { sql = sql[:len(sql)-2] + ")" if b.dialect.SupportEngine() { - sql += " ENGINE=InnoDB DEFAULT CHARSET UTF8 " + sql += " ENGINE=InnoDB DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci" } sql += ";" @@ -160,3 +161,7 @@ func (db *BaseDialect) DropIndexSql(tableName string, index *Index) string { name = index.XName(tableName) return fmt.Sprintf("DROP INDEX %v ON %s", quote(name), quote(tableName)) } + +func (db *BaseDialect) UpdateTableSql(tableName string, columns []*Column) string { + return "-- NOT REQUIRED" +} diff --git a/pkg/services/sqlstore/migrator/migrations.go b/pkg/services/sqlstore/migrator/migrations.go index 7d97abb6bb9..2fec8825fa4 100644 --- a/pkg/services/sqlstore/migrator/migrations.go +++ b/pkg/services/sqlstore/migrator/migrations.go @@ -200,3 +200,17 @@ func (m *CopyTableDataMigration) IfTableExists(tableName string) *CopyTableDataM func (m *CopyTableDataMigration) Sql(d Dialect) string { return d.CopyTableData(m.sourceTable, m.targetTable, m.sourceCols, m.targetCols) } + +type TableCharsetMigration struct { + MigrationBase + tableName string + columns []*Column +} + +func NewTableCharsetMigration(tableName string, columns []*Column) *TableCharsetMigration { + return &TableCharsetMigration{tableName: tableName, columns: columns} +} + +func (m *TableCharsetMigration) Sql(d Dialect) string { + return d.UpdateTableSql(m.tableName, m.columns) +} diff --git a/pkg/services/sqlstore/migrator/mysql_dialect.go b/pkg/services/sqlstore/migrator/mysql_dialect.go index 48c2cd3ea55..1968558dbb8 100644 --- a/pkg/services/sqlstore/migrator/mysql_dialect.go +++ b/pkg/services/sqlstore/migrator/mysql_dialect.go @@ -1,6 +1,9 @@ package migrator -import "strconv" +import ( + "strconv" + "strings" +) type Mysql struct { BaseDialect @@ -76,6 +79,12 @@ func (db *Mysql) SqlType(c *Column) string { } else if hasLen1 { res += "(" + strconv.Itoa(c.Length) + ")" } + + switch c.Type { + case DB_Char, DB_Varchar, DB_NVarchar, DB_TinyText, DB_Text, DB_MediumText, DB_LongText: + res += " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + } + return res } @@ -84,3 +93,15 @@ func (db *Mysql) TableCheckSql(tableName string) (string, []interface{}) { sql := "SELECT `TABLE_NAME` from `INFORMATION_SCHEMA`.`TABLES` WHERE `TABLE_SCHEMA`=? and `TABLE_NAME`=?" return sql, args } + +func (db *Mysql) UpdateTableSql(tableName string, columns []*Column) string { + var statements = []string{} + + statements = append(statements, "DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") + + for _, col := range columns { + statements = append(statements, "MODIFY "+col.StringNoPk(db)) + } + + return "ALTER TABLE " + db.Quote(tableName) + " " + strings.Join(statements, ", ") + ";" +} diff --git a/pkg/services/sqlstore/migrator/postgres_dialect.go b/pkg/services/sqlstore/migrator/postgres_dialect.go index 5500b9f1684..92109efdfab 100644 --- a/pkg/services/sqlstore/migrator/postgres_dialect.go +++ b/pkg/services/sqlstore/migrator/postgres_dialect.go @@ -3,6 +3,7 @@ package migrator import ( "fmt" "strconv" + "strings" ) type Postgres struct { @@ -112,3 +113,13 @@ func (db *Postgres) DropIndexSql(tableName string, index *Index) string { idxName := index.XName(tableName) return fmt.Sprintf("DROP INDEX %v", quote(idxName)) } + +func (db *Postgres) UpdateTableSql(tableName string, columns []*Column) string { + var statements = []string{} + + for _, col := range columns { + statements = append(statements, "ALTER "+db.QuoteStr()+col.Name+db.QuoteStr()+" TYPE "+db.SqlType(col)) + } + + return "ALTER TABLE " + db.Quote(tableName) + " " + strings.Join(statements, ", ") + ";" +} diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index f52175c2e5c..e7c718fc9a8 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/pkg/services/sqlstore/org_test.go @@ -63,8 +63,8 @@ func TestAccountDataAccess(t *testing.T) { err := SearchUsers(&query) So(err, ShouldBeNil) - So(query.Result[0].Email, ShouldEqual, "ac1@test.com") - So(query.Result[1].Email, ShouldEqual, "ac2@test.com") + So(query.Result.Users[0].Email, ShouldEqual, "ac1@test.com") + So(query.Result.Users[1].Email, ShouldEqual, "ac2@test.com") }) Convey("Given an added org user", func() { diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 5c44fe85f50..a2106ddbffd 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -29,6 +29,8 @@ type DatabaseConfig struct { ClientKeyPath string ClientCertPath string ServerCertName string + MaxOpenConn int + MaxIdleConn int } var ( @@ -112,7 +114,7 @@ func getEngine() (*xorm.Engine, error) { protocol = "unix" } - cnnstr = fmt.Sprintf("%s:%s@%s(%s)/%s?charset=utf8", + cnnstr = fmt.Sprintf("%s:%s@%s(%s)/%s?charset=utf8mb4", DbCfg.User, DbCfg.Pwd, protocol, DbCfg.Host, DbCfg.Name) if DbCfg.SslMode == "true" || DbCfg.SslMode == "skip-verify" { @@ -150,7 +152,17 @@ func getEngine() (*xorm.Engine, error) { } sqlog.Info("Initializing DB", "dbtype", DbCfg.Type) - return xorm.NewEngine(DbCfg.Type, cnnstr) + engine, err := xorm.NewEngine(DbCfg.Type, cnnstr) + if err != nil { + return nil, err + } else { + engine.SetMaxOpenConns(DbCfg.MaxOpenConn) + engine.SetMaxIdleConns(DbCfg.MaxIdleConn) + // engine.SetLogger(NewXormLogger(log.LvlInfo, log.New("sqlstore.xorm"))) + // engine.ShowSQL = true + // engine.ShowInfo = true + } + return engine, nil } func LoadConfig() { @@ -177,6 +189,8 @@ func LoadConfig() { DbCfg.Host = sec.Key("host").String() DbCfg.Name = sec.Key("name").String() DbCfg.User = sec.Key("user").String() + DbCfg.MaxOpenConn = sec.Key("max_open_conn").MustInt(0) + DbCfg.MaxIdleConn = sec.Key("max_idle_conn").MustInt(0) if len(DbCfg.Pwd) == 0 { DbCfg.Pwd = sec.Key("password").String() } diff --git a/pkg/services/sqlstore/sqlutil/sqlutil.go b/pkg/services/sqlstore/sqlutil/sqlutil.go index 075b56c1c18..68ced55722c 100644 --- a/pkg/services/sqlstore/sqlutil/sqlutil.go +++ b/pkg/services/sqlstore/sqlutil/sqlutil.go @@ -12,7 +12,7 @@ type TestDB struct { } var TestDB_Sqlite3 = TestDB{DriverName: "sqlite3", ConnStr: ":memory:?_loc=Local"} -var TestDB_Mysql = TestDB{DriverName: "mysql", ConnStr: "grafana:password@tcp(localhost:3306)/grafana_tests?charset=utf8"} +var TestDB_Mysql = TestDB{DriverName: "mysql", ConnStr: "grafana:password@tcp(localhost:3306)/grafana_tests?charset=utf8mb4"} var TestDB_Postgres = TestDB{DriverName: "postgres", ConnStr: "user=grafanatest password=grafanatest host=localhost port=5432 dbname=grafanatest sslmode=disable"} func CleanDB(x *xorm.Engine) { diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index b26a9153f55..9a44d6f194e 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -6,6 +6,8 @@ import ( "github.com/go-xorm/xorm" + "fmt" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/events" m "github.com/grafana/grafana/pkg/models" @@ -260,6 +262,20 @@ func ChangeUserPassword(cmd *m.ChangeUserPasswordCommand) error { } func SetUsingOrg(cmd *m.SetUsingOrgCommand) error { + getOrgsForUserCmd := &m.GetUserOrgListQuery{UserId: cmd.UserId} + GetUserOrgList(getOrgsForUserCmd) + + valid := false + for _, other := range getOrgsForUserCmd.Result { + if other.OrgId == cmd.OrgId { + valid = true + } + } + + if !valid { + return fmt.Errorf("user does not belong ot org") + } + return inTransaction(func(sess *xorm.Session) error { user := m.User{} sess.Id(cmd.UserId).Get(&user) @@ -344,12 +360,30 @@ func GetSignedInUser(query *m.GetSignedInUserQuery) error { } func SearchUsers(query *m.SearchUsersQuery) error { - query.Result = make([]*m.UserSearchHitDTO, 0) + query.Result = m.SearchUserQueryResult{ + Users: make([]*m.UserSearchHitDTO, 0), + } + queryWithWildcards := "%" + query.Query + "%" + sess := x.Table("user") - sess.Where("email LIKE ?", query.Query+"%") - sess.Limit(query.Limit, query.Limit*query.Page) + if query.Query != "" { + sess.Where("email LIKE ? OR name LIKE ? OR login like ?", queryWithWildcards, queryWithWildcards, queryWithWildcards) + } + offset := query.Limit * (query.Page - 1) + sess.Limit(query.Limit, offset) sess.Cols("id", "email", "name", "login", "is_admin") - err := sess.Find(&query.Result) + if err := sess.Find(&query.Result.Users); err != nil { + return err + } + + user := m.User{} + + countSess := x.Table("user") + if query.Query != "" { + countSess.Where("email LIKE ? OR name LIKE ? OR login like ?", queryWithWildcards, queryWithWildcards, queryWithWildcards) + } + count, err := countSess.Count(&user) + query.Result.TotalCount = count return err } diff --git a/pkg/services/sqlstore/user_test.go b/pkg/services/sqlstore/user_test.go new file mode 100644 index 00000000000..decb4682552 --- /dev/null +++ b/pkg/services/sqlstore/user_test.go @@ -0,0 +1,93 @@ +package sqlstore + +import ( + "fmt" + "testing" + + . "github.com/smartystreets/goconvey/convey" + + "github.com/grafana/grafana/pkg/models" +) + +func TestUserDataAccess(t *testing.T) { + + Convey("Testing DB", t, func() { + InitTestDB(t) + + var err error + for i := 0; i < 5; i++ { + err = CreateUser(&models.CreateUserCommand{ + Email: fmt.Sprint("user", i, "@test.com"), + Name: fmt.Sprint("user", i), + Login: fmt.Sprint("loginuser", i), + }) + So(err, ShouldBeNil) + } + + Convey("Can return the first page of users and a total count", func() { + query := models.SearchUsersQuery{Query: "", Page: 1, Limit: 3} + err = SearchUsers(&query) + + So(err, ShouldBeNil) + So(len(query.Result.Users), ShouldEqual, 3) + So(query.Result.TotalCount, ShouldEqual, 5) + }) + + Convey("Can return the second page of users and a total count", func() { + query := models.SearchUsersQuery{Query: "", Page: 2, Limit: 3} + err = SearchUsers(&query) + + So(err, ShouldBeNil) + So(len(query.Result.Users), ShouldEqual, 2) + So(query.Result.TotalCount, ShouldEqual, 5) + }) + + Convey("Can return list of users matching query on user name", func() { + query := models.SearchUsersQuery{Query: "use", Page: 1, Limit: 3} + err = SearchUsers(&query) + + So(err, ShouldBeNil) + So(len(query.Result.Users), ShouldEqual, 3) + So(query.Result.TotalCount, ShouldEqual, 5) + + query = models.SearchUsersQuery{Query: "ser1", Page: 1, Limit: 3} + err = SearchUsers(&query) + + So(err, ShouldBeNil) + So(len(query.Result.Users), ShouldEqual, 1) + So(query.Result.TotalCount, ShouldEqual, 1) + + query = models.SearchUsersQuery{Query: "USER1", Page: 1, Limit: 3} + err = SearchUsers(&query) + + So(err, ShouldBeNil) + So(len(query.Result.Users), ShouldEqual, 1) + So(query.Result.TotalCount, ShouldEqual, 1) + + query = models.SearchUsersQuery{Query: "idontexist", Page: 1, Limit: 3} + err = SearchUsers(&query) + + So(err, ShouldBeNil) + So(len(query.Result.Users), ShouldEqual, 0) + So(query.Result.TotalCount, ShouldEqual, 0) + }) + + Convey("Can return list of users matching query on email", func() { + query := models.SearchUsersQuery{Query: "ser1@test.com", Page: 1, Limit: 3} + err = SearchUsers(&query) + + So(err, ShouldBeNil) + So(len(query.Result.Users), ShouldEqual, 1) + So(query.Result.TotalCount, ShouldEqual, 1) + }) + + Convey("Can return list of users matching query on login name", func() { + query := models.SearchUsersQuery{Query: "loginuser1", Page: 1, Limit: 3} + err = SearchUsers(&query) + + So(err, ShouldBeNil) + So(len(query.Result.Users), ShouldEqual, 1) + So(query.Result.TotalCount, ShouldEqual, 1) + }) + }) +} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 9c2ba6ee13c..cacb4547162 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -96,6 +96,7 @@ var ( LoginHint string DefaultTheme string DisableLoginForm bool + DisableSignoutMenu bool // Http auth AdminUser string @@ -455,22 +456,6 @@ func validateStaticRootPath() error { return fmt.Errorf("Failed to detect generated css or javascript files in static root (%s), have you executed default grunt task?", StaticRootPath) } -// func readInstanceName() string { -// hostname, _ := os.Hostname() -// if hostname == "" { -// hostname = "hostname_unknown" -// } -// -// instanceName := Cfg.Section("").Key("instance_name").MustString("") -// if instanceName = "" { -// // set value as it might be used in other places -// Cfg.Section("").Key("instance_name").SetValue(hostname) -// instanceName = hostname -// } -// -// return -// } - func NewConfigContext(args *CommandLineArgs) error { setHomePath(args) loadConfiguration(args) @@ -544,6 +529,7 @@ func NewConfigContext(args *CommandLineArgs) error { // auth auth := Cfg.Section("auth") DisableLoginForm = auth.Key("disable_login_form").MustBool(false) + DisableSignoutMenu = auth.Key("disable_signout_menu").MustBool(false) // anonymous access AnonymousEnabled = Cfg.Section("auth.anonymous").Key("enabled").MustBool(false) @@ -590,7 +576,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.com") imageUploadingSection := Cfg.Section("external_image_storage") ImageUploadProvider = imageUploadingSection.Key("provider").MustString("internal") diff --git a/pkg/setting/setting_smtp.go b/pkg/setting/setting_smtp.go index e84b61634cc..db065070620 100644 --- a/pkg/setting/setting_smtp.go +++ b/pkg/setting/setting_smtp.go @@ -8,6 +8,7 @@ type SmtpSettings struct { CertFile string KeyFile string FromAddress string + FromName string SkipVerify bool SendWelcomeEmailOnSignUp bool @@ -23,6 +24,7 @@ func readSmtpSettings() { Smtp.CertFile = sec.Key("cert_file").String() Smtp.KeyFile = sec.Key("key_file").String() Smtp.FromAddress = sec.Key("from_address").String() + Smtp.FromName = sec.Key("from_name").String() Smtp.SkipVerify = sec.Key("skip_verify").MustBool(false) emails := Cfg.Section("emails") diff --git a/pkg/social/github_oauth.go b/pkg/social/github_oauth.go index 64ff1d86851..271a472be84 100644 --- a/pkg/social/github_oauth.go +++ b/pkg/social/github_oauth.go @@ -2,7 +2,6 @@ package social import ( "encoding/json" - "errors" "fmt" "net/http" @@ -21,11 +20,8 @@ type SocialGithub struct { } var ( - ErrMissingTeamMembership = errors.New("User not a member of one of the required teams") -) - -var ( - ErrMissingOrganizationMembership = errors.New("User not a member of one of the required organizations") + ErrMissingTeamMembership = &Error{"User not a member of one of the required teams"} + ErrMissingOrganizationMembership = &Error{"User not a member of one of the required organizations"} ) func (s *SocialGithub) Type() int { diff --git a/pkg/social/social.go b/pkg/social/social.go index 860d068957e..29c4b7ecbb4 100644 --- a/pkg/social/social.go +++ b/pkg/social/social.go @@ -29,6 +29,14 @@ type SocialConnector interface { Client(ctx context.Context, t *oauth2.Token) *http.Client } +type Error struct { + s string +} + +func (e *Error) Error() string { + return e.s +} + var ( SocialBaseUrl = "/login/" SocialMap = make(map[string]SocialConnector) diff --git a/pkg/tsdb/models.go b/pkg/tsdb/models.go index 68f26a68d50..ade9b1c75f2 100644 --- a/pkg/tsdb/models.go +++ b/pkg/tsdb/models.go @@ -51,8 +51,9 @@ type QueryResult struct { } type TimeSeries struct { - Name string `json:"name"` - Points TimeSeriesPoints `json:"points"` + Name string `json:"name"` + Points TimeSeriesPoints `json:"points"` + Tags map[string]string `json:"tags"` } type TimePoint [2]null.Float diff --git a/pkg/tsdb/mqe/httpClient.go b/pkg/tsdb/mqe/httpClient.go index c4a7807f9cb..d8bf0888a35 100644 --- a/pkg/tsdb/mqe/httpClient.go +++ b/pkg/tsdb/mqe/httpClient.go @@ -86,7 +86,7 @@ func (e *apiClient) spawnWorker(ctx context.Context, id int, jobs chan QueryToSe return } - series, err := e.responseParser.Parse(resp, query.QueryRef) + series, err := e.responseParser.Parse(resp, query) if err != nil { errors <- err return diff --git a/pkg/tsdb/mqe/mqe.go b/pkg/tsdb/mqe/mqe.go index 5d5f0950de5..ae828e95c20 100644 --- a/pkg/tsdb/mqe/mqe.go +++ b/pkg/tsdb/mqe/mqe.go @@ -40,6 +40,7 @@ func init() { type QueryToSend struct { RawQuery string + Metric Metric QueryRef *Query } diff --git a/pkg/tsdb/mqe/response_parser.go b/pkg/tsdb/mqe/response_parser.go index d40106fc197..f3fdb00f0aa 100644 --- a/pkg/tsdb/mqe/response_parser.go +++ b/pkg/tsdb/mqe/response_parser.go @@ -4,9 +4,13 @@ import ( "encoding/json" "io/ioutil" "net/http" + "strconv" + "strings" "fmt" + "regexp" + "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/tsdb" @@ -18,6 +22,16 @@ func NewResponseParser() *ResponseParser { } } +var ( + indexAliasPattern *regexp.Regexp + wildcardAliasPattern *regexp.Regexp +) + +func init() { + indexAliasPattern = regexp.MustCompile(`\$(\d)`) + wildcardAliasPattern = regexp.MustCompile(`[*!]`) +} + type MQEResponse struct { Success bool `json:"success"` Name string `json:"name"` @@ -47,7 +61,7 @@ type ResponseParser struct { log log.Logger } -func (parser *ResponseParser) Parse(res *http.Response, queryRef *Query) ([]*tsdb.TimeSeries, error) { +func (parser *ResponseParser) Parse(res *http.Response, queryRef QueryToSend) ([]*tsdb.TimeSeries, error) { body, err := ioutil.ReadAll(res.Body) defer res.Body.Close() if err != nil { @@ -73,22 +87,14 @@ func (parser *ResponseParser) Parse(res *http.Response, queryRef *Query) ([]*tsd var series []*tsdb.TimeSeries for _, body := range data.Body { for _, mqeSerie := range body.Series { - namePrefix := "" - - //append predefined tags to seriename - for key, value := range mqeSerie.Tagset { - if key == "cluster" && queryRef.AddClusterToAlias { - namePrefix += value + " " - } + serie := &tsdb.TimeSeries{ + Tags: map[string]string{}, + Name: parser.formatLegend(body, mqeSerie, queryRef), } for key, value := range mqeSerie.Tagset { - if key == "host" && queryRef.AddHostToAlias { - namePrefix += value + " " - } + serie.Tags[key] = value } - serie := &tsdb.TimeSeries{Name: namePrefix + body.Name} - for i, value := range mqeSerie.Values { timestamp := body.TimeRange.Start + int64(i)*body.TimeRange.Resolution serie.Points = append(serie.Points, tsdb.NewTimePoint(value, float64(timestamp))) @@ -100,3 +106,72 @@ func (parser *ResponseParser) Parse(res *http.Response, queryRef *Query) ([]*tsd return series, nil } + +func (parser *ResponseParser) formatLegend(body MQEResponseSerie, mqeSerie MQESerie, queryToSend QueryToSend) string { + namePrefix := "" + + //append predefined tags to seriename + for key, value := range mqeSerie.Tagset { + if key == "cluster" && queryToSend.QueryRef.AddClusterToAlias { + namePrefix += value + " " + } + } + for key, value := range mqeSerie.Tagset { + if key == "host" && queryToSend.QueryRef.AddHostToAlias { + namePrefix += value + " " + } + } + + return namePrefix + parser.formatName(body, queryToSend) +} + +func (parser *ResponseParser) formatName(body MQEResponseSerie, queryToSend QueryToSend) string { + if indexAliasPattern.MatchString(queryToSend.Metric.Alias) { + return parser.indexAlias(body, queryToSend) + } + + if wildcardAliasPattern.MatchString(queryToSend.Metric.Metric) && wildcardAliasPattern.MatchString(queryToSend.Metric.Alias) { + return parser.wildcardAlias(body, queryToSend) + } + + return body.Name +} + +func (parser *ResponseParser) wildcardAlias(body MQEResponseSerie, queryToSend QueryToSend) string { + regString := strings.Replace(queryToSend.Metric.Metric, `*`, `(.*)`, 1) + reg, err := regexp.Compile(regString) + if err != nil { + return queryToSend.Metric.Alias + } + + matches := reg.FindAllStringSubmatch(queryToSend.RawQuery, -1) + + if len(matches) == 0 || len(matches[0]) < 2 { + return queryToSend.Metric.Alias + } + + return matches[0][1] +} + +func (parser *ResponseParser) indexAlias(body MQEResponseSerie, queryToSend QueryToSend) string { + queryNameParts := strings.Split(queryToSend.Metric.Metric, `.`) + + name := indexAliasPattern.ReplaceAllStringFunc(queryToSend.Metric.Alias, func(in string) string { + positionName := strings.TrimSpace(strings.Replace(in, "$", "", 1)) + + pos, err := strconv.Atoi(positionName) + if err != nil { + return "" + } + + for i, part := range queryNameParts { + if i == pos-1 { + return strings.TrimSpace(part) + } + } + + return "" + }) + + return strings.Replace(name, " ", ".", -1) +} diff --git a/pkg/tsdb/mqe/response_parser_test.go b/pkg/tsdb/mqe/response_parser_test.go index 63761841d97..34259aaea48 100644 --- a/pkg/tsdb/mqe/response_parser_test.go +++ b/pkg/tsdb/mqe/response_parser_test.go @@ -8,11 +8,12 @@ import ( "io/ioutil" + "github.com/grafana/grafana/pkg/components/null" . "github.com/smartystreets/goconvey/convey" ) var ( - dummieJson string + testJson string ) func TestMQEResponseParser(t *testing.T) { @@ -20,14 +21,17 @@ func TestMQEResponseParser(t *testing.T) { parser := NewResponseParser() Convey("Can parse response", func() { - queryRef := &Query{ - AddClusterToAlias: true, - AddHostToAlias: true, + queryRef := QueryToSend{ + QueryRef: &Query{ + AddClusterToAlias: true, + AddHostToAlias: true, + }, + Metric: Metric{Alias: ""}, } response := &http.Response{ StatusCode: 200, - Body: ioutil.NopCloser(strings.NewReader(dummieJson)), + Body: ioutil.NopCloser(strings.NewReader(testJson)), } res, err := parser.Parse(response, queryRef) So(err, ShouldBeNil) @@ -39,12 +43,64 @@ func TestMQEResponseParser(t *testing.T) { So(res[0].Points[i][0].Float64, ShouldEqual, i+1) So(res[0].Points[i][1].Float64, ShouldEqual, startTime+(i*30000)) } + + }) + + Convey("Can format legend", func() { + mqeSerie := MQESerie{ + Tagset: map[string]string{ + "cluster": "demoapp", + "host": "staples-lab-1", + }, + Values: []null.Float{null.NewFloat(3, true)}, + } + + Convey("with empty alias", func() { + serie := MQEResponseSerie{Name: "os.disk.sda3.weighted_io_time"} + queryRef := QueryToSend{ + QueryRef: &Query{ + AddClusterToAlias: true, + AddHostToAlias: true, + }, + Metric: Metric{Alias: ""}, + } + legend := parser.formatLegend(serie, mqeSerie, queryRef) + So(legend, ShouldEqual, "demoapp staples-lab-1 os.disk.sda3.weighted_io_time") + }) + + Convey("with index alias (ex $2 $3)", func() { + serie := MQEResponseSerie{Name: "os.disk.sda3.weighted_io_time"} + queryRef := QueryToSend{ + QueryRef: &Query{ + AddClusterToAlias: true, + AddHostToAlias: true, + }, + Metric: Metric{Alias: "$2 $3", Metric: "os.disk.sda3.weighted_io_time"}, + } + legend := parser.formatLegend(serie, mqeSerie, queryRef) + So(legend, ShouldEqual, "demoapp staples-lab-1 disk.sda3") + }) + + Convey("with wildcard alias", func() { + serie := MQEResponseSerie{Name: "os.disk.sda3.weighted_io_time", Query: "os.disk.*"} + + queryRef := QueryToSend{ + QueryRef: &Query{ + AddClusterToAlias: true, + AddHostToAlias: true, + }, + RawQuery: "os.disk.sda3.weighted_io_time", + Metric: Metric{Alias: "*", Metric: "os.disk.*.weighted_io_time"}, + } + legend := parser.formatLegend(serie, mqeSerie, queryRef) + So(legend, ShouldEqual, "demoapp staples-lab-1 sda3") + }) }) }) } func init() { - dummieJson = `{ + testJson = `{ "success": true, "name": "select", "body": [ diff --git a/pkg/tsdb/mqe/types.go b/pkg/tsdb/mqe/types.go index 4fa2c1d4e7f..0bd436ee9bd 100644 --- a/pkg/tsdb/mqe/types.go +++ b/pkg/tsdb/mqe/types.go @@ -53,6 +53,7 @@ func (q *Query) Build(availableSeries []string) ([]QueryToSend, error) { queriesToSend = append(queriesToSend, QueryToSend{ RawQuery: rawQuery, QueryRef: q, + Metric: metric, }) } else { m := strings.Replace(metric.Metric, "*", ".*", -1) @@ -70,6 +71,7 @@ func (q *Query) Build(availableSeries []string) ([]QueryToSend, error) { queriesToSend = append(queriesToSend, QueryToSend{ RawQuery: rawQuery, QueryRef: q, + Metric: metric, }) } } diff --git a/pkg/tsdb/opentsdb/opentsdb.go b/pkg/tsdb/opentsdb/opentsdb.go index 684f48bfc59..c0ba6603b20 100644 --- a/pkg/tsdb/opentsdb/opentsdb.go +++ b/pkg/tsdb/opentsdb/opentsdb.go @@ -190,6 +190,10 @@ func (e *OpenTsdbExecutor) buildMetric(query *tsdb.Query) map[string]interface{} rateOptions["resetValue"] = resetValue.MustFloat64() } + if !counterMaxCheck && (!resetValueCheck || resetValue.MustFloat64() == 0) { + rateOptions["dropcounter"] = true + } + metric["rateOptions"] = rateOptions } diff --git a/pkg/tsdb/prometheus/prometheus.go b/pkg/tsdb/prometheus/prometheus.go index f3704c2a430..fad3ca533ac 100644 --- a/pkg/tsdb/prometheus/prometheus.go +++ b/pkg/tsdb/prometheus/prometheus.go @@ -22,6 +22,18 @@ type PrometheusExecutor struct { Transport *http.Transport } +type basicAuthTransport struct { + *http.Transport + + username string + password string +} + +func (bat basicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.SetBasicAuth(bat.username, bat.password) + return bat.Transport.RoundTrip(req) +} + func NewPrometheusExecutor(dsInfo *models.DataSource) (tsdb.Executor, error) { transport, err := dsInfo.GetHttpTransport() if err != nil { @@ -51,6 +63,14 @@ func (e *PrometheusExecutor) getClient() (prometheus.QueryAPI, error) { Transport: e.Transport, } + if e.BasicAuth { + cfg.Transport = basicAuthTransport{ + Transport: e.Transport, + username: e.BasicAuthUser, + password: e.BasicAuthPassword, + } + } + client, err := prometheus.New(cfg) if err != nil { return nil, err @@ -157,6 +177,11 @@ func parseResponse(value pmodel.Value, query *PrometheusQuery) (map[string]*tsdb for _, v := range data { series := tsdb.TimeSeries{ Name: formatLegend(v.Metric, query), + Tags: map[string]string{}, + } + + for k, v := range v.Metric { + series.Tags[string(k)] = string(v) } for _, k := range v.Values { diff --git a/public/app/core/components/help/help.ts b/public/app/core/components/help/help.ts index 3bc9b57077a..f7914a73a28 100644 --- a/public/app/core/components/help/help.ts +++ b/public/app/core/components/help/help.ts @@ -26,6 +26,8 @@ export class HelpCtrl { {keys: ['d', 's'], description: 'Dashboard settings'}, {keys: ['d', 'v'], description: 'Toggle in-active / view mode'}, {keys: ['d', 'k'], description: 'Toggle kiosk mode (hides top nav)'}, + {keys: ['d', 'E'], description: 'Expand all rows'}, + {keys: ['d', 'C'], description: 'Collapse all ros'}, {keys: ['mod+o'], description: 'Toggle shared graph crosshair'}, ], 'Focused Panel': [ diff --git a/public/app/core/components/search/search.html b/public/app/core/components/search/search.html index cc021bb69ed..c9710e1403f 100644 --- a/public/app/core/components/search/search.html +++ b/public/app/core/components/search/search.html @@ -68,8 +68,8 @@ Import - - Find dashboards on + + Find dashboards on Grafana.com
diff --git a/public/app/core/components/sidemenu/sidemenu.ts b/public/app/core/components/sidemenu/sidemenu.ts index 2e8e03eca7a..a6bc170fddd 100644 --- a/public/app/core/components/sidemenu/sidemenu.ts +++ b/public/app/core/components/sidemenu/sidemenu.ts @@ -23,7 +23,7 @@ export class SideMenuCtrl { this.isSignedIn = contextSrv.isSignedIn; this.user = contextSrv.user; this.appSubUrl = config.appSubUrl; - this.showSignout = this.contextSrv.isSignedIn && !config['authProxyEnabled']; + this.showSignout = this.contextSrv.isSignedIn && !config['disableSignoutMenu']; this.maxShownOrgs = 10; this.mainLinks = config.bootData.mainNavLinks; diff --git a/public/app/core/controllers/login_ctrl.js b/public/app/core/controllers/login_ctrl.js index fa3af3d10f0..54323711604 100644 --- a/public/app/core/controllers/login_ctrl.js +++ b/public/app/core/controllers/login_ctrl.js @@ -7,13 +7,6 @@ define([ 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", - "1003": "Login provider denied login request", - }; - coreModule.default.controller('LoginCtrl', function($scope, backendSrv, contextSrv, $location) { $scope.formModel = { user: '', @@ -36,11 +29,8 @@ function (angular, _, coreModule, config) { $scope.init = function() { $scope.$watch("loginMode", $scope.loginModeChanged); - var params = $location.search(); - if (params.failCode) { - $scope.appEvent('alert-warning', ['Login Failed', failCodes[params.failCode]]); - delete params.failedMsg; - $location.search(params); + if (config.loginError) { + $scope.appEvent('alert-warning', ['Login Failed', config.loginError]); } }; diff --git a/public/app/core/routes/routes.ts b/public/app/core/routes/routes.ts index 28e1dcf1cd1..3ccf89bae01 100644 --- a/public/app/core/routes/routes.ts +++ b/public/app/core/routes/routes.ts @@ -113,6 +113,7 @@ function setupAngularRoutes($routeProvider, $locationProvider) { .when('/admin/users', { templateUrl: 'public/app/features/admin/partials/users.html', controller : 'AdminListUsersCtrl', + controllerAs: 'ctrl', resolve: loadAdminBundle, }) .when('/admin/users/create', { diff --git a/public/app/core/services/analytics.js b/public/app/core/services/analytics.js index 982cdd23d1d..a20a11bb2b6 100644 --- a/public/app/core/services/analytics.js +++ b/public/app/core/services/analytics.js @@ -1,27 +1,39 @@ define([ 'angular', - '../core_module', + 'jquery', + 'app/core/core_module', + 'app/core/config', ], -function(angular, coreModule) { +function(angular, $, coreModule, config) { 'use strict'; coreModule.default.service('googleAnalyticsSrv', function($rootScope, $location) { - var first = true; + + function gaInit() { + $.getScript('https://www.google-analytics.com/analytics.js'); // jQuery shortcut + var ga = window.ga = window.ga || function () { (ga.q = ga.q || []).push(arguments); }; ga.l = +new Date; + ga('create', config.googleAnalyticsId, 'auto'); + return ga; + } this.init = function() { + $rootScope.$on('$viewContentLoaded', function() { - // skip first - if (first) { - first = false; - return; - } - window.ga('send', 'pageview', { page: $location.url() }); + var track = { page: $location.url() }; + + var ga = window.ga || gaInit(); + + ga('set', track); + ga('send', 'pageview'); }); + }; }).run(function(googleAnalyticsSrv) { - if (window.ga) { + + if (config.googleAnalyticsId) { googleAnalyticsSrv.init(); } + }); }); diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index 9a5ec5d219b..ba4f4dc2fb9 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -94,6 +94,21 @@ export class BackendSrv { }); }; + addCanceler(requestId, canceler) { + if (requestId in this.inFlightRequests) { + this.inFlightRequests[requestId].push(canceler); + } else { + this.inFlightRequests[requestId] = [canceler]; + } + } + + resolveCancelerIfExists(requestId) { + var cancelers = this.inFlightRequests[requestId]; + if (!_.isUndefined(cancelers) && cancelers.length) { + cancelers[0].resolve(); + } + } + datasourceRequest(options) { options.retry = options.retry || 0; @@ -101,16 +116,13 @@ export class BackendSrv { // particular query. If the requestID exists, the promise it is keyed to // is canceled, canceling the previous datasource request if it is still // in-flight. - var canceler; - if (options.requestId) { - canceler = this.inFlightRequests[options.requestId]; - if (canceler) { - canceler.resolve(); - } + var requestId = options.requestId; + if (requestId) { + this.resolveCancelerIfExists(requestId); // create new canceler - canceler = this.$q.defer(); + var canceler = this.$q.defer(); options.timeout = canceler.promise; - this.inFlightRequests[options.requestId] = canceler; + this.addCanceler(requestId, canceler); } var requestIsLocal = options.url.indexOf('/') === 0; @@ -158,7 +170,7 @@ export class BackendSrv { }).finally(() => { // clean up if (options.requestId) { - delete this.inFlightRequests[options.requestId]; + this.inFlightRequests[options.requestId].shift(); } }); }; diff --git a/public/app/core/services/datasource_srv.js b/public/app/core/services/datasource_srv.js index b8b94cd286d..e722aae355b 100644 --- a/public/app/core/services/datasource_srv.js +++ b/public/app/core/services/datasource_srv.js @@ -21,6 +21,10 @@ function (angular, _, coreModule, config) { name = templateSrv.replace(name); + if (name === 'default') { + return this.get(config.defaultDatasource); + } + if (this.datasources[name]) { return $q.when(this.datasources[name]); } @@ -97,10 +101,19 @@ function (angular, _, coreModule, config) { } metricSources.sort(function(a, b) { - if (a.meta.builtIn || a.name > b.name) { + if (a.meta.builtIn) { return 1; } - if (a.name < b.name) { + + if (b.meta.builtIn) { + return -1; + } + + if (a.name.toLowerCase() > b.name.toLowerCase()) { + return 1; + } + + if (a.name.toLowerCase() < b.name.toLowerCase()) { return -1; } return 0; diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 7cff30ee903..7abc3993e9d 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -187,6 +187,20 @@ export class KeybindingSrv { } }); + // collapse all rows + this.bind('d C', () => { + for (let row of dashboard.rows) { + row.collapse = true; + } + }); + + // expand all rows + this.bind('d E', () => { + for (let row of dashboard.rows) { + row.collapse = false; + } + }); + this.bind('d r', () => { scope.broadcastRefresh(); }); diff --git a/public/app/core/time_series2.ts b/public/app/core/time_series2.ts index 4563a09cbca..e5d6c342fd0 100644 --- a/public/app/core/time_series2.ts +++ b/public/app/core/time_series2.ts @@ -100,6 +100,7 @@ export default class TimeSeries { this.stats.total = 0; this.stats.max = -Number.MAX_VALUE; this.stats.min = Number.MAX_VALUE; + this.stats.logmin = Number.MAX_VALUE; this.stats.avg = null; this.stats.current = null; this.stats.first = null; @@ -172,6 +173,11 @@ export default class TimeSeries { } } previousValue = currentValue; + + if (currentValue < this.stats.logmin && currentValue > 0) { + this.stats.logmin = currentValue; + } + } if (currentValue !== 0) { diff --git a/public/app/core/utils/kbn.js b/public/app/core/utils/kbn.js index 182ab0e309a..d619d9c4977 100644 --- a/public/app/core/utils/kbn.js +++ b/public/app/core/utils/kbn.js @@ -394,6 +394,10 @@ function($, _) { return "0x" + hexString; }; + kbn.valueFormats.sci = function(value, decimals) { + return value.toExponential(decimals); + }; + // Currencies kbn.valueFormats.currencyUSD = kbn.formatBuilders.currency('$'); kbn.valueFormats.currencyGBP = kbn.formatBuilders.currency('£'); @@ -459,11 +463,19 @@ function($, _) { kbn.valueFormats.humidity = kbn.formatBuilders.fixedUnit('%H'); // Pressure - kbn.valueFormats.pressurembar = kbn.formatBuilders.fixedUnit('mbar'); + kbn.valueFormats.pressurebar = kbn.formatBuilders.decimalSIPrefix('bar'); + kbn.valueFormats.pressurembar = kbn.formatBuilders.decimalSIPrefix('bar', -1); + kbn.valueFormats.pressurekbar = kbn.formatBuilders.decimalSIPrefix('bar', 1); kbn.valueFormats.pressurehpa = kbn.formatBuilders.fixedUnit('hPa'); kbn.valueFormats.pressurehg = kbn.formatBuilders.fixedUnit('"Hg'); kbn.valueFormats.pressurepsi = kbn.formatBuilders.scaledUnits(1000, [' psi', ' ksi', ' Mpsi']); + // Force + kbn.valueFormats.forceNm = kbn.formatBuilders.decimalSIPrefix('Nm'); + kbn.valueFormats.forcekNm = kbn.formatBuilders.decimalSIPrefix('Nm', 1); + kbn.valueFormats.forceN = kbn.formatBuilders.decimalSIPrefix('N'); + kbn.valueFormats.forcekN = kbn.formatBuilders.decimalSIPrefix('N', 1); + // Length kbn.valueFormats.lengthm = kbn.formatBuilders.decimalSIPrefix('m'); kbn.valueFormats.lengthmm = kbn.formatBuilders.decimalSIPrefix('m', -1); @@ -480,6 +492,14 @@ function($, _) { kbn.valueFormats.litre = kbn.formatBuilders.decimalSIPrefix('L'); kbn.valueFormats.mlitre = kbn.formatBuilders.decimalSIPrefix('L', -1); kbn.valueFormats.m3 = kbn.formatBuilders.decimalSIPrefix('m3'); + kbn.valueFormats.dm3 = kbn.formatBuilders.decimalSIPrefix('dm3'); + kbn.valueFormats.gallons = kbn.formatBuilders.fixedUnit('gal'); + + // Flow + kbn.valueFormats.flowgpm = kbn.formatBuilders.fixedUnit('gpm'); + kbn.valueFormats.flowcms = kbn.formatBuilders.fixedUnit('cms'); + kbn.valueFormats.flowcfs = kbn.formatBuilders.fixedUnit('cfs'); + kbn.valueFormats.flowcfm = kbn.formatBuilders.fixedUnit('cfm'); // Time kbn.valueFormats.hertz = kbn.formatBuilders.decimalSIPrefix('Hz'); @@ -687,15 +707,16 @@ function($, _) { { text: 'none', submenu: [ - {text: 'none' , value: 'none' }, - {text: 'short', value: 'short' }, - {text: 'percent (0-100)', value: 'percent' }, - {text: 'percent (0.0-1.0)', value: 'percentunit'}, - {text: 'Humidity (%H)', value: 'humidity' }, - {text: 'ppm', value: 'ppm' }, - {text: 'decibel', value: 'dB' }, - {text: 'hexadecimal (0x)', value: 'hex0x' }, - {text: 'hexadecimal', value: 'hex' }, + {text: 'none' , value: 'none' }, + {text: 'short', value: 'short' }, + {text: 'percent (0-100)', value: 'percent' }, + {text: 'percent (0.0-1.0)', value: 'percentunit'}, + {text: 'Humidity (%H)', value: 'humidity' }, + {text: 'ppm', value: 'ppm' }, + {text: 'decibel', value: 'dB' }, + {text: 'hexadecimal (0x)', value: 'hex0x' }, + {text: 'hexadecimal', value: 'hex' }, + {text: 'scientific notation', value: 'sci' }, ] }, { @@ -790,9 +811,11 @@ function($, _) { { text: 'volume', submenu: [ - {text: 'millilitre', value: 'mlitre'}, - {text: 'litre', value: 'litre' }, - {text: 'cubic metre', value: 'm3' }, + {text: 'millilitre', value: 'mlitre' }, + {text: 'litre', value: 'litre' }, + {text: 'cubic metre', value: 'm3' }, + {text: 'cubic decimetre', value: 'dm3' }, + {text: 'gallons', value: 'gallons'}, ] }, { @@ -827,10 +850,30 @@ function($, _) { text: 'pressure', submenu: [ {text: 'Millibars', value: 'pressurembar'}, + {text: 'Bars', value: 'pressurebar' }, + {text: 'Kilobars', value: 'pressurekbar'}, {text: 'Hectopascals', value: 'pressurehpa' }, {text: 'Inches of mercury', value: 'pressurehg' }, {text: 'PSI', value: 'pressurepsi' }, ] + }, + { + text: 'force', + submenu: [ + {text: 'Newton-meters (Nm)', value: 'forceNm' }, + {text: 'Kilonewton-meters (kNm)', value: 'forcekNm' }, + {text: 'Newtons (N)', value: 'forceN' }, + {text: 'Kilonewtons (kN)', value: 'forcekN' }, + ] + }, + { + text: 'flow', + submenu: [ + {text: 'Gallons/min (gpm)', value: 'flowgpm' }, + {text: 'Cubic meters/sec (cms)', value: 'flowcms' }, + {text: 'Cubic feet/sec (cfs)', value: 'flowcfs' }, + {text: 'Cubic feet/min (cfm)', value: 'flowcfm' }, + ] } ]; }; diff --git a/public/app/core/utils/rangeutil.ts b/public/app/core/utils/rangeutil.ts index ea9ad111e8d..81fcbeb913f 100644 --- a/public/app/core/utils/rangeutil.ts +++ b/public/app/core/utils/rangeutil.ts @@ -41,7 +41,7 @@ var rangeOptions = [ { from: 'now-2d', to: 'now', display: 'Last 2 days', section: 0 }, { from: 'now-7d', to: 'now', display: 'Last 7 days', section: 0 }, { from: 'now-30d', to: 'now', display: 'Last 30 days', section: 0 }, - { from: 'now-60d', to: 'now', display: 'Last 60 days', section: 0 }, + { from: 'now-90d', to: 'now', display: 'Last 90 days', section: 0 }, { from: 'now-6M', to: 'now', display: 'Last 6 months', section: 0 }, { from: 'now-1y', to: 'now', display: 'Last 1 year', section: 0 }, { from: 'now-2y', to: 'now', display: 'Last 2 years', section: 0 }, @@ -146,4 +146,3 @@ export function describeTimeRange(range) { return range.from.toString() + ' to ' + range.to.toString(); } - diff --git a/public/app/features/admin/admin.ts b/public/app/features/admin/admin.ts index b93fd07a059..6809e4c54ab 100644 --- a/public/app/features/admin/admin.ts +++ b/public/app/features/admin/admin.ts @@ -1,4 +1,4 @@ -import './adminListUsersCtrl'; +import AdminListUsersCtrl from './admin_list_users_ctrl'; import './adminListOrgsCtrl'; import './adminEditOrgCtrl'; import './adminEditUserCtrl'; @@ -37,3 +37,4 @@ export class AdminStatsCtrl { coreModule.controller('AdminSettingsCtrl', AdminSettingsCtrl); coreModule.controller('AdminHomeCtrl', AdminHomeCtrl); coreModule.controller('AdminStatsCtrl', AdminStatsCtrl); +coreModule.controller('AdminListUsersCtrl', AdminListUsersCtrl); diff --git a/public/app/features/admin/adminListUsersCtrl.js b/public/app/features/admin/adminListUsersCtrl.js deleted file mode 100644 index 721adcfb98c..00000000000 --- a/public/app/features/admin/adminListUsersCtrl.js +++ /dev/null @@ -1,38 +0,0 @@ -define([ - 'angular', -], -function (angular) { - 'use strict'; - - var module = angular.module('grafana.controllers'); - - module.controller('AdminListUsersCtrl', function($scope, backendSrv) { - - $scope.init = function() { - $scope.getUsers(); - }; - - $scope.getUsers = function() { - backendSrv.get('/api/users').then(function(users) { - $scope.users = users; - }); - }; - - $scope.deleteUser = function(user) { - $scope.appEvent('confirm-modal', { - title: 'Delete', - text: 'Do you want to delete ' + user.login + '?', - icon: 'fa-trash', - yesText: 'Delete', - onConfirm: function() { - backendSrv.delete('/api/admin/users/' + user.id).then(function() { - $scope.getUsers(); - }); - } - }); - }; - - $scope.init(); - - }); -}); diff --git a/public/app/features/admin/admin_list_users_ctrl.ts b/public/app/features/admin/admin_list_users_ctrl.ts new file mode 100644 index 00000000000..e2ed12cba60 --- /dev/null +++ b/public/app/features/admin/admin_list_users_ctrl.ts @@ -0,0 +1,51 @@ +/// + +export default class AdminListUsersCtrl { + users: any; + pages = []; + perPage = 50; + page = 1; + totalPages: number; + showPaging = false; + query: any; + + /** @ngInject */ + constructor(private $scope, private backendSrv) { + this.query = ''; + this.getUsers(); + } + + getUsers() { + this.backendSrv.get(`/api/users/search?perpage=${this.perPage}&page=${this.page}&query=${this.query}`).then((result) => { + this.users = result.users; + this.page = result.page; + this.perPage = result.perPage; + this.totalPages = Math.ceil(result.totalCount / result.perPage); + this.showPaging = this.totalPages > 1; + this.pages = []; + + for (var i = 1; i < this.totalPages+1; i++) { + this.pages.push({ page: i, current: i === this.page}); + } + }); + } + + navigateToPage(page) { + this.page = page.page; + this.getUsers(); + } + + deleteUser(user) { + this.$scope.appEvent('confirm-modal', { + title: 'Delete', + text: 'Do you want to delete ' + user.login + '?', + icon: 'fa-trash', + yesText: 'Delete', + onConfirm: () => { + this.backendSrv.delete('/api/admin/users/' + user.id).then(() => { + this.getUsers(); + }); + } + }); + } +} diff --git a/public/app/features/admin/partials/users.html b/public/app/features/admin/partials/users.html index a1c86391088..18ffdb4eaeb 100644 --- a/public/app/features/admin/partials/users.html +++ b/public/app/features/admin/partials/users.html @@ -1,49 +1,68 @@ - - - Users - + + + Users +
- +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + -
IdNameLoginEmailGrafana Admin
{{user.id}}{{user.name}}{{user.login}}{{user.email}}{{user.isAdmin}} + + + Edit + +    + + + +
- - - - - - - - - - - - - - - - - - - - -
IdNameLoginEmailGrafana Admin
{{user.id}}{{user.name}}{{user.login}}{{user.email}}{{user.isAdmin}} - - - Edit - -    - - - -
+ +
+ +
+
    +
  1. + +
  2. +
+
diff --git a/public/app/features/alerting/alert_def.ts b/public/app/features/alerting/alert_def.ts index fab612b5558..c65a35878e7 100644 --- a/public/app/features/alerting/alert_def.ts +++ b/public/app/features/alerting/alert_def.ts @@ -13,7 +13,7 @@ var alertQueryDef = new QueryPartDef({ {name: "from", type: "string", options: ['1s', '10s', '1m', '5m', '10m', '15m', '1h', '24h', '48h']}, {name: "to", type: "string", options: ['now']}, ], - defaultParams: ['#A', '5m', 'now', 'avg'] + defaultParams: ['#A', '15m', 'now', 'avg'] }); var conditionTypes = [ diff --git a/public/app/features/alerting/alert_list_ctrl.ts b/public/app/features/alerting/alert_list_ctrl.ts index 78713ee0c4c..c74f61c8a1c 100644 --- a/public/app/features/alerting/alert_list_ctrl.ts +++ b/public/app/features/alerting/alert_list_ctrl.ts @@ -15,6 +15,7 @@ export class AlertListCtrl { {text: 'OK', value: 'ok'}, {text: 'Alerting', value: 'alerting'}, {text: 'No Data', value: 'no_data'}, + {text: 'Paused', value: 'paused'}, ]; filters = { diff --git a/public/app/features/alerting/alert_tab_ctrl.ts b/public/app/features/alerting/alert_tab_ctrl.ts index 89b1c63372f..b21e5363ad8 100644 --- a/public/app/features/alerting/alert_tab_ctrl.ts +++ b/public/app/features/alerting/alert_tab_ctrl.ts @@ -104,6 +104,8 @@ export class AlertTabCtrl { case "webhook": return "fa fa-cubes"; case "pagerduty": return "fa fa-bullhorn"; case "opsgenie": return "fa fa-bell"; + case "hipchat": return "fa fa-mail-forward"; + case "pushover": return "fa fa-mobile"; } } diff --git a/public/app/features/alerting/notification_edit_ctrl.ts b/public/app/features/alerting/notification_edit_ctrl.ts index 39c9d8ca468..6ad11cb9023 100644 --- a/public/app/features/alerting/notification_edit_ctrl.ts +++ b/public/app/features/alerting/notification_edit_ctrl.ts @@ -17,6 +17,7 @@ export class AlertNotificationEditCtrl { settings: { httpMethod: 'POST', autoResolve: true, + uploadImage: true, }, isDefault: false }; @@ -32,7 +33,7 @@ export class AlertNotificationEditCtrl { } if (!this.$routeParams.id) { - return this.model; + return _.defaults(this.model, this.defaults); } return this.backendSrv.get(`/api/alert-notifications/${this.$routeParams.id}`).then(result => { diff --git a/public/app/features/alerting/partials/alert_tab.html b/public/app/features/alerting/partials/alert_tab.html index 8b11ec4ebc1..76841db75ba 100644 --- a/public/app/features/alerting/partials/alert_tab.html +++ b/public/app/features/alerting/partials/alert_tab.html @@ -47,7 +47,7 @@ OF
- +
diff --git a/public/app/features/alerting/partials/notification_edit.html b/public/app/features/alerting/partials/notification_edit.html index dfdfb115f4b..6bf45eff660 100644 --- a/public/app/features/alerting/partials/notification_edit.html +++ b/public/app/features/alerting/partials/notification_edit.html @@ -24,15 +24,20 @@
-
- - -
+ + + +
diff --git a/public/app/features/dashboard/dashnav/dashnav.html b/public/app/features/dashboard/dashnav/dashnav.html index 7cb22c756cd..7deb46931c2 100644 --- a/public/app/features/dashboard/dashnav/dashnav.html +++ b/public/app/features/dashboard/dashnav/dashnav.html @@ -50,7 +50,7 @@
  • Export - +
  • diff --git a/public/app/features/dashboard/dashnav/dashnav.ts b/public/app/features/dashboard/dashnav/dashnav.ts index 33bb4411640..faaac986ee7 100644 --- a/public/app/features/dashboard/dashnav/dashnav.ts +++ b/public/app/features/dashboard/dashnav/dashnav.ts @@ -106,6 +106,7 @@ export class DashNavCtrl { confirmText: confirmText, yesText: 'Delete', onConfirm: function() { + $scope.dashboardMeta.canSave = false; $scope.deleteDashboardConfirmed(); } }); diff --git a/public/app/features/dashboard/export/export_modal.html b/public/app/features/dashboard/export/export_modal.html index e890e405020..8533a4beea0 100644 --- a/public/app/features/dashboard/export/export_modal.html +++ b/public/app/features/dashboard/export/export_modal.html @@ -12,7 +12,7 @@
    diff --git a/public/app/features/dashboard/import/dash_import.html b/public/app/features/dashboard/import/dash_import.html index 5e19a02c28c..8a42d64c0c5 100644 --- a/public/app/features/dashboard/import/dash_import.html +++ b/public/app/features/dashboard/import/dash_import.html @@ -18,11 +18,11 @@ -
    Grafana.net Dashboard
    +
    Grafana.com Dashboard
    - +
    +
    diff --git a/public/app/features/org/partials/orgUsers.html b/public/app/features/org/partials/orgUsers.html index 36b00b3bc03..8d348721301 100644 --- a/public/app/features/org/partials/orgUsers.html +++ b/public/app/features/org/partials/orgUsers.html @@ -8,7 +8,7 @@
    -
    +
    diff --git a/public/app/features/org/partials/profile.html b/public/app/features/org/partials/profile.html index c51c3750ee5..7f52fbbd9d1 100644 --- a/public/app/features/org/partials/profile.html +++ b/public/app/features/org/partials/profile.html @@ -9,18 +9,20 @@

    Information

    -
    - Name - -
    -
    - Email - -
    -
    - Username - +
    + Name +
    +
    + Email + + +
    +
    + Username + + +
    diff --git a/public/app/features/org/profile_ctrl.ts b/public/app/features/org/profile_ctrl.ts index c0e8b5ae246..4687218826c 100644 --- a/public/app/features/org/profile_ctrl.ts +++ b/public/app/features/org/profile_ctrl.ts @@ -10,6 +10,7 @@ export class ProfileCtrl { orgs: any = []; userForm: any; showOrgsList = false; + readonlyLoginFields = config.disableLoginForm; /** @ngInject **/ constructor(private backendSrv, private contextSrv, private $location) { diff --git a/public/app/features/panel/metrics_panel_ctrl.ts b/public/app/features/panel/metrics_panel_ctrl.ts index d37a3f5db41..b42d4a8b02a 100644 --- a/public/app/features/panel/metrics_panel_ctrl.ts +++ b/public/app/features/panel/metrics_panel_ctrl.ts @@ -12,6 +12,7 @@ import * as dateMath from 'app/core/utils/datemath'; import {Subject} from 'vendor/npm/rxjs/Subject'; class MetricsPanelCtrl extends PanelCtrl { + scope: any; loading: boolean; datasource: any; datasourceName: any; @@ -40,6 +41,7 @@ class MetricsPanelCtrl extends PanelCtrl { this.datasourceSrv = $injector.get('datasourceSrv'); this.timeSrv = $injector.get('timeSrv'); this.templateSrv = $injector.get('templateSrv'); + this.scope = $scope; if (!this.panel.targets) { this.panel.targets = [{}]; @@ -88,11 +90,10 @@ class MetricsPanelCtrl extends PanelCtrl { delete this.error; this.loading = true; - this.updateTimeRange(); - // load datasource service this.setTimeQueryStart(); this.datasourceSrv.get(this.panel.datasource) + .then(this.updateTimeRange.bind(this)) .then(this.issueQueries.bind(this)) .then(this.handleQueryResult.bind(this)) .catch(err => { @@ -119,7 +120,8 @@ class MetricsPanelCtrl extends PanelCtrl { this.timing.queryEnd = new Date().getTime(); } - updateTimeRange() { + updateTimeRange(datasource?) { + this.datasource = datasource || this.datasource; this.range = this.timeSrv.timeRange(); this.rangeRaw = this.range.raw; @@ -132,6 +134,8 @@ class MetricsPanelCtrl extends PanelCtrl { } this.calculateInterval(); + + return this.datasource; }; calculateInterval() { diff --git a/public/app/features/panel/panel_ctrl.ts b/public/app/features/panel/panel_ctrl.ts index 83c79f4123b..8de78291baa 100644 --- a/public/app/features/panel/panel_ctrl.ts +++ b/public/app/features/panel/panel_ctrl.ts @@ -35,6 +35,8 @@ export class PanelCtrl { containerHeight: any; events: Emitter; timing: any; + skippedLastRefresh: boolean; + isPanelVisible: any; constructor($scope, $injector) { this.$injector = $injector; @@ -75,6 +77,13 @@ export class PanelCtrl { } refresh() { + if (!this.isPanelVisible() && !this.dashboard.meta.soloMode && !this.dashboard.snapshot) { + this.skippedLastRefresh = true; + return; + } + + this.skippedLastRefresh = false; + this.events.emit('refresh', null); } diff --git a/public/app/features/panel/panel_directive.ts b/public/app/features/panel/panel_directive.ts index 24977bd386c..63fdba693cb 100644 --- a/public/app/features/panel/panel_directive.ts +++ b/public/app/features/panel/panel_directive.ts @@ -57,7 +57,7 @@ var panelTemplate = `
    `; -module.directive('grafanaPanel', function($rootScope) { +module.directive('grafanaPanel', function($rootScope, $document) { return { restrict: 'E', template: panelTemplate, @@ -156,10 +156,20 @@ module.directive('grafanaPanel', function($rootScope) { content: function() { return ctrl.getInfoContent({mode: 'tooltip'}); }, - position: 'top center', classes: ctrl.error ? 'drop-error' : 'drop-help', openOn: 'hover', hoverOpenDelay: 100, + tetherOptions: { + attachment: 'bottom left', + targetAttachment: 'top left', + constraints: [ + { + to: 'window', + attachment: 'together', + pin: true + } + ], + } }); } } @@ -175,9 +185,23 @@ module.directive('grafanaPanel', function($rootScope) { elem.on('mouseenter', mouseEnter); elem.on('mouseleave', mouseLeave); + ctrl.isPanelVisible = function () { + var position = panelContainer[0].getBoundingClientRect(); + return (0 < position.top) && (position.top < window.innerHeight); + }; + + const refreshOnScroll = function () { + if (ctrl.skippedLastRefresh) { + ctrl.refresh(); + } + }; + + $document.on('scroll', refreshOnScroll); + scope.$on('$destroy', function() { elem.off(); cornerInfoElem.off(); + $document.off('scroll', refreshOnScroll); if (infoDrop) { infoDrop.destroy(); diff --git a/public/app/features/panel/query_editor_row.ts b/public/app/features/panel/query_editor_row.ts index 76bdd6d8539..8249d84774f 100644 --- a/public/app/features/panel/query_editor_row.ts +++ b/public/app/features/panel/query_editor_row.ts @@ -29,6 +29,10 @@ export class QueryRowCtrl { delete this.target.isNew; this.toggleCollapse(false); } + + if (this.panel.targets.length < 4) { + this.collapsed = false; + } } toggleHideQuery() { diff --git a/public/app/features/plugins/partials/ds_edit.html b/public/app/features/plugins/partials/ds_edit.html index 4756428bead..e94a5300e3d 100644 --- a/public/app/features/plugins/partials/ds_edit.html +++ b/public/app/features/plugins/partials/ds_edit.html @@ -28,9 +28,9 @@
    -
    +
    Name - + The name is used when you select the data source in panels. The Default data source is preselected in new diff --git a/public/app/features/plugins/partials/plugin_list.html b/public/app/features/plugins/partials/plugin_list.html index 7cfb14d238d..88911a0ec2f 100644 --- a/public/app/features/plugins/partials/plugin_list.html +++ b/public/app/features/plugins/partials/plugin_list.html @@ -26,8 +26,8 @@ - - Find more plugins on + + Find more plugins on Grafana.com
    diff --git a/public/app/features/plugins/partials/update_instructions.html b/public/app/features/plugins/partials/update_instructions.html index 9648be53c86..1a3bdeefaaf 100644 --- a/public/app/features/plugins/partials/update_instructions.html +++ b/public/app/features/plugins/partials/update_instructions.html @@ -14,7 +14,7 @@

    Type the following on the command line to update {{plugin.name}}.

    grafana-cli plugins update {{plugin.id}}
    - Check out {{plugin.name}} on Grafana.net for README and changelog. If you do not have access to the command line, ask your Grafana administator. + Check out {{plugin.name}} on Grafana.com for README and changelog. If you do not have access to the command line, ask your Grafana administator.

    Pro tip: To update all plugins at once, type grafana-cli plugins update-all on the command line.

    diff --git a/public/app/features/templating/datasource_variable.ts b/public/app/features/templating/datasource_variable.ts index bfd4d965029..41f2262ab4a 100644 --- a/public/app/features/templating/datasource_variable.ts +++ b/public/app/features/templating/datasource_variable.ts @@ -2,7 +2,7 @@ import _ from 'lodash'; import kbn from 'app/core/utils/kbn'; -import {Variable, assignModelProperties, variableTypes} from './variable'; +import {Variable, containsVariable, assignModelProperties, variableTypes} from './variable'; import {VariableSrv} from './variable_srv'; export class DatasourceVariable implements Variable { @@ -25,7 +25,7 @@ export class DatasourceVariable implements Variable { }; /** @ngInject **/ - constructor(private model, private datasourceSrv, private variableSrv) { + constructor(private model, private datasourceSrv, private variableSrv, private templateSrv) { assignModelProperties(this, model, this.defaults); this.refresh = 1; } @@ -48,7 +48,8 @@ export class DatasourceVariable implements Variable { var regex; if (this.regex) { - regex = kbn.stringToJsRegex(this.regex); + regex = this.templateSrv.replace(this.regex, null, 'regex'); + regex = kbn.stringToJsRegex(regex); } for (var i = 0; i < sources.length; i++) { @@ -74,6 +75,9 @@ export class DatasourceVariable implements Variable { } dependsOn(variable) { + if (this.regex) { + return containsVariable(this.regex, variable.name); + } return false; } diff --git a/public/app/features/templating/editor_ctrl.ts b/public/app/features/templating/editor_ctrl.ts index c5e24741a96..7d0a0a5465a 100644 --- a/public/app/features/templating/editor_ctrl.ts +++ b/public/app/features/templating/editor_ctrl.ts @@ -10,6 +10,7 @@ export class VariableEditorCtrl { constructor(private $scope, private datasourceSrv, private variableSrv, templateSrv) { $scope.variableTypes = variableTypes; $scope.ctrl = {}; + $scope.namePattern = /^((?!__).)*$/; $scope.refreshOptions = [ {value: 0, text: "Never"}, @@ -76,6 +77,11 @@ export class VariableEditorCtrl { return false; } + if ($scope.current.type === 'query' && $scope.current.query.match(new RegExp('\\$' + $scope.current.name))) { + $scope.appEvent('alert-warning', ['Validation', 'Query cannot contain a reference to itself. Variable: $' + $scope.current.name]); + return false; + } + return true; }; diff --git a/public/app/features/templating/interval_variable.ts b/public/app/features/templating/interval_variable.ts index ab1b0e59442..10977596057 100644 --- a/public/app/features/templating/interval_variable.ts +++ b/public/app/features/templating/interval_variable.ts @@ -59,8 +59,9 @@ export class IntervalVariable implements Variable { } updateOptions() { - // extract options in comma separated string - this.options = _.map(this.query.split(/[,]+/), function(text) { + // extract options between quotes and/or comma + this.options = _.map(this.query.match(/(["'])(.*?)\1|\w+/g), function(text) { + text = text.replace(/["']+/g, ''); return {text: text.trim(), value: text.trim()}; }); diff --git a/public/app/features/templating/partials/editor.html b/public/app/features/templating/partials/editor.html index 4fc2553244c..a0683debc5c 100644 --- a/public/app/features/templating/partials/editor.html +++ b/public/app/features/templating/partials/editor.html @@ -1,162 +1,167 @@
    -
    -

    - Templating -

    +
    +

    + Templating +

    - + - -
    + +
    -
    +
    -
    -
    - No template variables defined -
    -
    - - - +
    +
    + No template variables defined +
    +
    - - ${{variable.name}} - - - {{variable.query}} -
    + + + - - - + + - - - -
    + + ${{variable.name}} + + + {{variable.query}} + + Duplicate - - - Edit - - - - - -
    -
    + + + + Edit + + + + + + + + + +
    -
    -
    -   New -
    -
    +
    +
    +   New +
    +
    - -
    Variable
    -
    -
    -
    - Name - -
    -
    - + +
    Variable
    +
    +
    + Template names cannot begin with '__' that's reserved for Grafanas global variables +
    +
    +
    + Name + +
    +
    + Type - {{variableTypes[current.type].description}} + {{variableTypes[current.type].description}} -
    - -
    -
    +
    + +
    +
    -
    -
    - Label - -
    -
    - Hide +
    +
    + Label + +
    +
    + Hide
    - -
    -
    -
    -
    + +
    +
    +
    +
    -
    +
    Interval Options
    -
    - Values - -
    +
    + Values + +
    -
    - - +
    + + -
    - - Step count How many times should the current time range be divided to calculate the value - -
    - -
    -
    -
    - - Min interval The calculated value will not go below this threshold - - -
    -
    -
    +
    + + Step count How many times should the current time range be divided to calculate the value + +
    + +
    +
    +
    + + Min interval The calculated value will not go below this threshold + + +
    +
    +
    -
    +
    Custom Options
    -
    - Values separated by comma - -
    -
    +
    + Values separated by comma + +
    +
    -
    +
    Constant options
    -
    - Value - -
    -
    +
    + Value + +
    +
    -
    +
    Query Options
    Data source
    - +
    @@ -170,8 +175,8 @@
    -
    -
    +
    +
    Query
    @@ -184,26 +189,26 @@
    -
    - - Sort - - How to sort the values of this variable. - - -
    - -
    -
    -
    +
    + + Sort + + How to sort the values of this variable. + + +
    + +
    +
    +
    -
    -
    Data source options
    +
    +
    Data source options
    -
    - -
    - +
    + +
    +
    @@ -222,18 +227,20 @@
    -
    +
    Options
    -
    - Data source -
    - -
    -
    -
    +
    + Data source +
    + +
    +
    +
    -
    -
    Selection Options
    +
    +
    Selection Options
    Value groups/tags (Experimental feature)
    - - -
    - Tags query - -
    -
    -
  • Tag values query
  • - -
    -
    + + +
    + Tags query + +
    +
    +
  • Tag values query
  • + +
    +
    -
    -
    Preview of values (shows max 20)
    -
    -
    - {{option.text}} -
    -
    -
    +
    +
    Preview of values (shows max 20)
    +
    +
    + {{option.text}} +
    +
    +
    -
    - {{infoText}} -
    +
    + {{infoText}} +
    -
    - - -
    +
    + + +
    - -
    + +
    diff --git a/public/app/features/templating/query_variable.ts b/public/app/features/templating/query_variable.ts index 92b150f2f15..a40aade24ef 100644 --- a/public/app/features/templating/query_variable.ts +++ b/public/app/features/templating/query_variable.ts @@ -136,13 +136,17 @@ export class QueryVariable implements Variable { options = []; if (this.regex) { - regex = kbn.stringToJsRegex(this.templateSrv.replace(this.regex)); + regex = kbn.stringToJsRegex(this.templateSrv.replace(this.regex, {}, 'regex')); } - for (i = 0; i < metricNames.length; i++) { var item = metricNames[i]; - var value = item.value || item.text; - var text = item.text || item.value; + var text = item.text === undefined || item.text === null + ? item.value + : item.text; + + var value = item.value === undefined || item.value === null + ? item.text + : item.value; if (_.isNumber(value)) { value = value.toString(); @@ -179,13 +183,13 @@ export class QueryVariable implements Variable { if (sortType === 1) { options = _.sortBy(options, 'text'); } else if (sortType === 2) { - options = _.sortBy(options, function(opt) { + options = _.sortBy(options, (opt) => { var matches = opt.text.match(/.*?(\d+).*/); - if (!matches) { - return 0; - } else { - return parseInt(matches[1], 10); - } + if (!matches || matches.length < 2) { + return -1; + } else { + return parseInt(matches[1], 10); + } }); } diff --git a/public/app/features/templating/specs/query_variable_specs.ts b/public/app/features/templating/specs/query_variable_specs.ts index 591362e0d84..4d3539fb7c6 100644 --- a/public/app/features/templating/specs/query_variable_specs.ts +++ b/public/app/features/templating/specs/query_variable_specs.ts @@ -2,11 +2,11 @@ import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/co import {QueryVariable} from '../query_variable'; -describe('QueryVariable', function() { +describe('QueryVariable', () => { - describe('when creating from model', function() { + describe('when creating from model', () => { - it('should set defaults', function() { + it('should set defaults', () => { var variable = new QueryVariable({}, null, null, null, null); expect(variable.datasource).to.be(null); expect(variable.refresh).to.be(0); @@ -42,5 +42,41 @@ describe('QueryVariable', function() { expect(model.options.length).to.be(0); }); }); + + describe('can convert and sort metric names',() => { + var variable = new QueryVariable({}, null, null, null, null); + variable.sort = 3; // Numerical (asc) + + describe('can sort a mixed array of metric variables', () => { + var input = [ + {text: '0', value: '0'}, + {text: '1', value: '1'}, + {text: null, value: 3}, + {text: undefined, value: 4}, + {text: '5', value: null}, + {text: '6', value: undefined}, + {text: null, value: '7'}, + {text: undefined, value: '8'}, + {text: 9, value: null}, + {text: 10, value: undefined}, + {text: '', value: undefined}, + {text: undefined, value: ''}, + ]; + + var result = variable.metricNamesToVariableValues(input); + it('should return in same order', () => { + var i = 0; + + expect(result.length).to.be(11); + expect(result[i++].text).to.be(''); + expect(result[i++].text).to.be('0'); + expect(result[i++].text).to.be('1'); + expect(result[i++].text).to.be('3'); + expect(result[i++].text).to.be('4'); + expect(result[i++].text).to.be('5'); + expect(result[i++].text).to.be('6'); + }); + }); + }); }); diff --git a/public/app/features/templating/specs/variable_specs.ts b/public/app/features/templating/specs/variable_specs.ts index 9a974eae695..0031c2df79a 100644 --- a/public/app/features/templating/specs/variable_specs.ts +++ b/public/app/features/templating/specs/variable_specs.ts @@ -12,10 +12,15 @@ describe('containsVariable', function() { }); it('should not find it if only part matches with $var syntax', function() { - var contains = containsVariable('this.$ServerDomain.filters', 'Server'); + var contains = containsVariable('this.$serverDomain.filters', 'server'); expect(contains).to.be(false); }); + it('should find it if it ends with variable and passing multiple test strings', function() { + var contains = containsVariable('show field keys from $pgmetric', 'test string2', 'pgmetric'); + expect(contains).to.be(true); + }); + it('should find it with [[var]] syntax', function() { var contains = containsVariable('this.[[test]].filters', 'test'); expect(contains).to.be(true); diff --git a/public/app/features/templating/variable.ts b/public/app/features/templating/variable.ts index 381f1ea7a3c..d451b66ffb1 100644 --- a/public/app/features/templating/variable.ts +++ b/public/app/features/templating/variable.ts @@ -23,7 +23,7 @@ export function containsVariable(...args: any[]) { var str = args[0] || ''; for (var i = 1; i < args.length-1; i++) { - str += args[i] || ''; + str += ' ' + args[i] || ''; } variableName = kbn.regexEscape(variableName); diff --git a/public/app/partials/login.html b/public/app/partials/login.html index b6a37ba46c2..1e457a99b71 100644 --- a/public/app/partials/login.html +++ b/public/app/partials/login.html @@ -57,10 +57,10 @@ - with Github + with GitHub - with Grafana.net + with Grafana.com diff --git a/public/app/plugins/app/testdata/datasource/plugin.json b/public/app/plugins/app/testdata/datasource/plugin.json index 4d66253d78e..80445dfb3bc 100644 --- a/public/app/plugins/app/testdata/datasource/plugin.json +++ b/public/app/plugins/app/testdata/datasource/plugin.json @@ -10,7 +10,7 @@ "info": { "author": { "name": "Grafana Project", - "url": "http://grafana.org" + "url": "https://grafana.com" }, "logos": { "small": "", diff --git a/public/app/plugins/app/testdata/plugin.json b/public/app/plugins/app/testdata/plugin.json index 1d6264c54c1..3efcd687453 100644 --- a/public/app/plugins/app/testdata/plugin.json +++ b/public/app/plugins/app/testdata/plugin.json @@ -7,7 +7,7 @@ "description": "Grafana test data app", "author": { "name": "Grafana Project", - "url": "http://grafana.org" + "url": "https://grafana.com" }, "version": "1.0.17", "updated": "2016-09-26" diff --git a/public/app/plugins/datasource/cloudwatch/datasource.js b/public/app/plugins/datasource/cloudwatch/datasource.js index dc57c41b94d..60c7e167a06 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.js +++ b/public/app/plugins/datasource/cloudwatch/datasource.js @@ -4,9 +4,10 @@ define([ 'moment', 'app/core/utils/datemath', 'app/core/utils/kbn', + 'app/features/templating/variable', './annotation_query', ], -function (angular, _, moment, dateMath, kbn, CloudWatchAnnotationQuery) { +function (angular, _, moment, dateMath, kbn, templatingVariable, CloudWatchAnnotationQuery) { 'use strict'; /** @ngInject */ @@ -390,7 +391,7 @@ function (angular, _, moment, dateMath, kbn, CloudWatchAnnotationQuery) { }); } - this.getExpandedVariables = function(target, dimensionKey, variable) { + this.getExpandedVariables = function(target, dimensionKey, variable, templateSrv) { /* if the all checkbox is marked we should add all values to the targets */ var allSelected = _.find(variable.options, {'selected': true, 'text': 'All'}); return _.chain(variable.options) @@ -403,15 +404,13 @@ function (angular, _, moment, dateMath, kbn, CloudWatchAnnotationQuery) { }) .map(function(v) { var t = angular.copy(target); - t.dimensions[dimensionKey] = v.value; + var scopedVar = {}; + scopedVar[variable.name] = v; + t.dimensions[dimensionKey] = templateSrv.replace(t.dimensions[dimensionKey], scopedVar); return t; }).value(); }; - this.containsVariable = function (str, variableName) { - return str.indexOf('$' + variableName) !== -1; - }; - this.expandTemplateVariable = function(targets, scopedVars, templateSrv) { var self = this; return _.chain(targets) @@ -421,10 +420,13 @@ function (angular, _, moment, dateMath, kbn, CloudWatchAnnotationQuery) { }); if (dimensionKey) { - var variable = _.find(templateSrv.variables, function(variable) { - return self.containsVariable(target.dimensions[dimensionKey], variable.name); + var multiVariable = _.find(templateSrv.variables, function(variable) { + return templatingVariable.containsVariable(target.dimensions[dimensionKey], variable.name) && variable.multi; }); - return self.getExpandedVariables(target, dimensionKey, variable); + var variable = _.find(templateSrv.variables, function(variable) { + return templatingVariable.containsVariable(target.dimensions[dimensionKey], variable.name); + }); + return self.getExpandedVariables(target, dimensionKey, multiVariable || variable, templateSrv); } else { return [target]; } diff --git a/public/app/plugins/datasource/cloudwatch/plugin.json b/public/app/plugins/datasource/cloudwatch/plugin.json index eb2f97335da..bac2ddca8b9 100644 --- a/public/app/plugins/datasource/cloudwatch/plugin.json +++ b/public/app/plugins/datasource/cloudwatch/plugin.json @@ -9,7 +9,7 @@ "info": { "author": { "name": "Grafana Project", - "url": "http://grafana.org" + "url": "https://grafana.com" }, "logos": { "small": "img/amazon-web-services.png", diff --git a/public/app/plugins/datasource/cloudwatch/specs/datasource_specs.ts b/public/app/plugins/datasource/cloudwatch/specs/datasource_specs.ts index 93dfa24036d..afc0f4a5962 100644 --- a/public/app/plugins/datasource/cloudwatch/specs/datasource_specs.ts +++ b/public/app/plugins/datasource/cloudwatch/specs/datasource_specs.ts @@ -134,11 +134,18 @@ describe('CloudWatchDatasource', function() { { name: 'instance_id', options: [ - { value: 'i-23456789', selected: false }, - { value: 'i-34567890', selected: true } + { text: 'i-23456789', value: 'i-23456789', selected: false }, + { text: 'i-34567890', value: 'i-34567890', selected: true } ] } ], + replace: function (target, scopedVars) { + if (target === '$instance_id' && scopedVars['instance_id']['text'] === 'i-34567890') { + return 'i-34567890'; + } else { + return ''; + } + }, getVariableName: function (e) { return 'instance_id'; }, variableExists: function (e) { return true; }, containsVariable: function (str, variableName) { return str.indexOf('$' + variableName) !== -1; } diff --git a/public/app/plugins/datasource/elasticsearch/bucket_agg.js b/public/app/plugins/datasource/elasticsearch/bucket_agg.js index 28f1df08251..d6d641c1655 100644 --- a/public/app/plugins/datasource/elasticsearch/bucket_agg.js +++ b/public/app/plugins/datasource/elasticsearch/bucket_agg.js @@ -50,6 +50,7 @@ function (angular, _, queryDef) { switch($scope.agg.type) { case 'date_histogram': + case 'histogram': case 'terms': { delete $scope.agg.query; $scope.agg.field = 'select field'; @@ -132,6 +133,16 @@ function (angular, _, queryDef) { } break; } + case 'histogram': { + settings.interval = settings.interval || 1000; + settings.min_doc_count = _.defaultTo(settings.min_doc_count, 1); + settingsLinkText = 'Interval: ' + settings.interval; + + if (settings.min_doc_count > 0) { + settingsLinkText += ', Min Doc Count: ' + settings.min_doc_count; + } + break; + } case 'geohash_grid': { // limit precision to 7 settings.precision = Math.max(Math.min(settings.precision, 7), 1); diff --git a/public/app/plugins/datasource/elasticsearch/datasource.js b/public/app/plugins/datasource/elasticsearch/datasource.js index 4377d634982..08f42f2cf50 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.js +++ b/public/app/plugins/datasource/elasticsearch/datasource.js @@ -168,12 +168,22 @@ function (angular, _, moment, kbn, ElasticQueryBuilder, IndexPattern, ElasticRes }; this.testDatasource = function() { - timeSrv.setTime({ from: 'now-1m', to: 'now' }); - return this._get('/_stats').then(function() { - return { status: "success", message: "Data source is working", title: "Success" }; - }, function(err) { + timeSrv.setTime({ from: 'now-1m', to: 'now' }, true); + // validate that the index exist and has date field + return this.getFields({type: 'date'}).then(function(dateFields) { + var timeField = _.find(dateFields, {text: this.timeField}); + if (!timeField) { + return { status: "error", message: "No date field named " + this.timeField + ' found', title: "Error" }; + } + return { status: "success", message: "Index OK. Time field name OK.", title: "Success" }; + }.bind(this), function(err) { + console.log(err); if (err.data && err.data.error) { - return { status: "error", message: angular.toJson(err.data.error), title: "Error" }; + var message = angular.toJson(err.data.error); + if (err.data.error.reason) { + message = err.data.error.reason; + } + return { status: "error", message: message, title: "Error" }; } else { return { status: "error", message: err.status, title: "Error" }; } diff --git a/public/app/plugins/datasource/elasticsearch/img/elasticsearch.svg b/public/app/plugins/datasource/elasticsearch/img/elasticsearch.svg new file mode 100755 index 00000000000..33a4d656061 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/img/elasticsearch.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/plugins/datasource/elasticsearch/img/logo_large.png b/public/app/plugins/datasource/elasticsearch/img/logo_large.png deleted file mode 100644 index 5ded1d8f438..00000000000 Binary files a/public/app/plugins/datasource/elasticsearch/img/logo_large.png and /dev/null differ diff --git a/public/app/plugins/datasource/elasticsearch/metric_agg.js b/public/app/plugins/datasource/elasticsearch/metric_agg.js index 9971c084882..c0c4ffc08d4 100644 --- a/public/app/plugins/datasource/elasticsearch/metric_agg.js +++ b/public/app/plugins/datasource/elasticsearch/metric_agg.js @@ -162,6 +162,9 @@ function (angular, _, queryDef) { }; $scope.getFieldsInternal = function() { + if ($scope.agg.type === 'cardinality') { + return $scope.getFields(); + } return $scope.getFields({$fieldType: 'number'}); }; diff --git a/public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html b/public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html index 562f0b86e56..f1e650f6830 100644 --- a/public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html +++ b/public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html @@ -52,6 +52,17 @@ +
    +
    + + +
    +
    + + +
    +
    +
    diff --git a/public/app/plugins/datasource/elasticsearch/plugin.json b/public/app/plugins/datasource/elasticsearch/plugin.json index d4b07ac8e00..34a9750ee6b 100644 --- a/public/app/plugins/datasource/elasticsearch/plugin.json +++ b/public/app/plugins/datasource/elasticsearch/plugin.json @@ -7,12 +7,12 @@ "description": "Elasticsearch Data Source for Grafana", "author": { "name": "Grafana Project", - "url": "http://grafana.org" + "url": "https://grafana.com" }, "keywords": ["elasticsearch"], "logos": { - "small": "img/logo_large.png", - "large": "img/logo_large.png" + "small": "img/elasticsearch.svg", + "large": "img/elasticsearch.svg" }, "links": [ {"name": "elastic.co", "url": "https://www.elastic.co/products/elasticsearch"} diff --git a/public/app/plugins/datasource/elasticsearch/query_builder.js b/public/app/plugins/datasource/elasticsearch/query_builder.js index 0cd592b0846..fbbd4f7e929 100644 --- a/public/app/plugins/datasource/elasticsearch/query_builder.js +++ b/public/app/plugins/datasource/elasticsearch/query_builder.js @@ -79,6 +79,19 @@ function (queryDef) { return esAgg; }; + ElasticQueryBuilder.prototype.getHistogramAgg = function(aggDef) { + var esAgg = {}; + var settings = aggDef.settings || {}; + esAgg.interval = settings.interval; + esAgg.field = aggDef.field; + esAgg.min_doc_count = settings.min_doc_count || 0; + + if (settings.missing) { + esAgg.missing = settings.missing; + } + return esAgg; + }; + ElasticQueryBuilder.prototype.getFiltersAgg = function(aggDef) { var filterObj = {}; for (var i = 0; i < aggDef.settings.filters.length; i++) { @@ -120,7 +133,28 @@ function (queryDef) { filter = adhocFilters[i]; condition = {}; condition[filter.key] = filter.value; - query.query.bool.filter.push({"term": condition}); + switch(filter.operator){ + case "=": + query.query.bool.filter.push({"term": condition}); + break; + case "!=": + query.query.bool.filter.push({"bool": {"must_not": {"term": condition}}}); + break; + case "<": + condition[filter.key] = {"lt": filter.value}; + query.query.bool.filter.push({"range": condition}); + break; + case ">": + condition[filter.key] = {"gt": filter.value}; + query.query.bool.filter.push({"range": condition}); + break; + case "=~": + query.query.bool.filter.push({"regexp": condition}); + break; + case "!~": + query.query.bool.filter.push({"bool": {"must_not": {"regexp": condition}}}); + break; + } } }; @@ -171,6 +205,10 @@ function (queryDef) { esAgg["date_histogram"] = this.getDateHistogramAgg(aggDef); break; } + case 'histogram': { + esAgg["histogram"] = this.getHistogramAgg(aggDef); + break; + } case 'filters': { esAgg["filters"] = {filters: this.getFiltersAgg(aggDef)}; break; @@ -242,10 +280,12 @@ function (queryDef) { } }); } + var size = 500; - if(queryDef.size){ + if (queryDef.size) { size = queryDef.size; } + query.aggs = { "1": { "terms": { diff --git a/public/app/plugins/datasource/elasticsearch/query_def.js b/public/app/plugins/datasource/elasticsearch/query_def.js index 5afc908edba..a55e2dfd9e5 100644 --- a/public/app/plugins/datasource/elasticsearch/query_def.js +++ b/public/app/plugins/datasource/elasticsearch/query_def.js @@ -24,6 +24,7 @@ function (_) { {text: "Filters", value: 'filters' }, {text: "Geo Hash Grid", value: 'geohash_grid', requiresField: true}, {text: "Date Histogram", value: 'date_histogram', requiresField: true}, + {text: "Histogram", value: 'histogram', requiresField: true}, ], orderByOptions: [ diff --git a/public/app/plugins/datasource/elasticsearch/specs/datasource_specs.ts b/public/app/plugins/datasource/elasticsearch/specs/datasource_specs.ts index c38358c2764..944aeae1bc4 100644 --- a/public/app/plugins/datasource/elasticsearch/specs/datasource_specs.ts +++ b/public/app/plugins/datasource/elasticsearch/specs/datasource_specs.ts @@ -42,7 +42,7 @@ describe('ElasticDatasource', function() { ctx.$rootScope.$apply(); var today = moment.utc().format("YYYY.MM.DD"); - expect(requestOptions.url).to.be("http://es.com/asd-" + today + '/_stats'); + expect(requestOptions.url).to.be("http://es.com/asd-" + today + '/_mapping'); }); }); diff --git a/public/app/plugins/datasource/elasticsearch/specs/elastic_response_specs.ts b/public/app/plugins/datasource/elasticsearch/specs/elastic_response_specs.ts index 4b87cc52c04..589e0ccbfab 100644 --- a/public/app/plugins/datasource/elasticsearch/specs/elastic_response_specs.ts +++ b/public/app/plugins/datasource/elasticsearch/specs/elastic_response_specs.ts @@ -361,6 +361,39 @@ describe('ElasticResponse', function() { }); }); + describe('histogram response', function() { + var result; + + beforeEach(function() { + targets = [{ + refId: 'A', + metrics: [{type: 'count', id: '1'}], + bucketAggs: [{type: 'histogram', field: 'bytes', id: '3'}], + }]; + response = { + responses: [{ + aggregations: { + "3": { + buckets: [ + {doc_count: 1, key: 1000}, + {doc_count: 3, key: 2000}, + {doc_count: 2, key: 1000}, + ] + } + } + }] + }; + + result = new ElasticResponse(targets, response).getTimeSeries(); + }); + + it('should return docs with byte and count', function() { + expect(result.data[0].datapoints.length).to.be(3); + expect(result.data[0].datapoints[0].Count).to.be(1); + expect(result.data[0].datapoints[0].bytes).to.be(1000); + }); + }); + describe('with two filters agg', function() { var result; diff --git a/public/app/plugins/datasource/elasticsearch/specs/query_builder_specs.ts b/public/app/plugins/datasource/elasticsearch/specs/query_builder_specs.ts index e5d58c9db53..18645ad498d 100644 --- a/public/app/plugins/datasource/elasticsearch/specs/query_builder_specs.ts +++ b/public/app/plugins/datasource/elasticsearch/specs/query_builder_specs.ts @@ -249,15 +249,42 @@ describe('ElasticQueryBuilder', function() { expect(firstLevel.aggs["2"].derivative.buckets_path).to.be("3"); }); + it('with histogram', function() { + var query = builder.build({ + metrics: [ + {id: '1', type: 'count' }, + ], + bucketAggs: [ + {type: 'histogram', field: 'bytes', id: '3', settings: {interval: 10, min_doc_count: 2, missing: 5}} + ], + }); + + var firstLevel = query.aggs["3"]; + expect(firstLevel.histogram.field).to.be('bytes'); + expect(firstLevel.histogram.interval).to.be(10); + expect(firstLevel.histogram.min_doc_count).to.be(2); + expect(firstLevel.histogram.missing).to.be(5); + }); + it('with adhoc filters', function() { var query = builder.build({ metrics: [{type: 'Count', id: '0'}], timeField: '@timestamp', bucketAggs: [{type: 'date_histogram', field: '@timestamp', id: '3'}], }, [ - {key: 'key1', operator: '=', value: 'value1'} + {key: 'key1', operator: '=', value: 'value1'}, + {key: 'key2', operator: '!=', value: 'value2'}, + {key: 'key3', operator: '<', value: 'value3'}, + {key: 'key4', operator: '>', value: 'value4'}, + {key: 'key5', operator: '=~', value: 'value5'}, + {key: 'key6', operator: '!~', value: 'value6'}, ]); expect(query.query.bool.filter[2].term["key1"]).to.be("value1"); + expect(query.query.bool.filter[3].bool.must_not.term["key2"]).to.be("value2"); + expect(query.query.bool.filter[4].range["key3"].lt).to.be("value3"); + expect(query.query.bool.filter[5].range["key4"].gt).to.be("value4"); + expect(query.query.bool.filter[6].regexp["key5"]).to.be("value5"); + expect(query.query.bool.filter[7].bool.must_not.regexp["key6"]).to.be("value6"); }); }); diff --git a/public/app/plugins/datasource/graphite/plugin.json b/public/app/plugins/datasource/graphite/plugin.json index 76242fd883c..85009cfd294 100644 --- a/public/app/plugins/datasource/graphite/plugin.json +++ b/public/app/plugins/datasource/graphite/plugin.json @@ -14,7 +14,7 @@ "info": { "author": { "name": "Grafana Project", - "url": "http://grafana.org" + "url": "https://grafana.com" }, "logos": { "small": "img/graphite_logo.png", diff --git a/public/app/plugins/datasource/graphite/query_ctrl.ts b/public/app/plugins/datasource/graphite/query_ctrl.ts index 724a8247f91..f9f34284971 100644 --- a/public/app/plugins/datasource/graphite/query_ctrl.ts +++ b/public/app/plugins/datasource/graphite/query_ctrl.ts @@ -128,6 +128,10 @@ export class GraphiteQueryCtrl extends QueryCtrl { } var path = this.getSegmentPathUpTo(fromIndex + 1); + if (path === "") { + return Promise.resolve(); + } + return this.datasource.metricFindQuery(path).then(segments => { if (segments.length === 0) { if (path !== '') { diff --git a/public/app/plugins/datasource/influxdb/README.md b/public/app/plugins/datasource/influxdb/README.md index d8d98cf5c6b..aca126db36c 100644 --- a/public/app/plugins/datasource/influxdb/README.md +++ b/public/app/plugins/datasource/influxdb/README.md @@ -1,12 +1,12 @@ # InfluxDB Datasource - Native Plugin -Grafana ships with **built in** support for InfluxDB 0.9. +Grafana ships with **built in** support for InfluxDB 0.9. 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. -This is the plugin for InfluxDB 0.9. It is rapidly evolving and we continue to track its API. +This is the plugin for InfluxDB 0.9. It 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. You can find it [here](https://grafana.net/plugins/grafana-influxdb-08-datasource). +InfluxDB 0.8 is no longer maintained by InfluxDB Inc, but we provide support as a convenience to existing users. You can find it [here](https://grafana.com/plugins/grafana-influxdb-08-datasource). Read more about InfluxDB here: diff --git a/public/app/plugins/datasource/influxdb/img/influxdb_logo.svg b/public/app/plugins/datasource/influxdb/img/influxdb_logo.svg index 6fad144f8c7..5316a4c6c7c 100644 --- a/public/app/plugins/datasource/influxdb/img/influxdb_logo.svg +++ b/public/app/plugins/datasource/influxdb/img/influxdb_logo.svg @@ -1,15 +1,26 @@ - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + diff --git a/public/app/plugins/datasource/influxdb/influx_query.ts b/public/app/plugins/datasource/influxdb/influx_query.ts index 065b85cf175..0fc1f1184c1 100644 --- a/public/app/plugins/datasource/influxdb/influx_query.ts +++ b/public/app/plugins/datasource/influxdb/influx_query.ts @@ -21,6 +21,7 @@ export default class InfluxQuery { target.policy = target.policy || 'default'; target.dsType = 'influxdb'; target.resultFormat = target.resultFormat || 'time_series'; + target.orderByTime = target.orderByTime || 'ASC'; target.tags = target.tags || []; target.groupBy = target.groupBy || [ {type: 'time', params: ['$__interval']}, @@ -167,7 +168,7 @@ export default class InfluxQuery { var policy = this.target.policy; var measurement = this.target.measurement || 'measurement'; - if (!measurement.match('^/.*/')) { + if (!measurement.match('^/.*/$')) { measurement = '"' + measurement+ '"'; } else if (interpolate) { measurement = this.templateSrv.replace(measurement, this.scopedVars, 'regex'); @@ -249,6 +250,18 @@ export default class InfluxQuery { query += ' fill(' + target.fill + ')'; } + if (target.orderByTime === 'DESC') { + query += ' ORDER BY time DESC'; + } + + if (target.limit) { + query += ' LIMIT ' + target.limit; + } + + if (target.slimit) { + query += ' SLIMIT ' + target.slimit; + } + return query; } diff --git a/public/app/plugins/datasource/influxdb/partials/query.editor.html b/public/app/plugins/datasource/influxdb/partials/query.editor.html index f718fbc5d29..bff03fbb7d5 100644 --- a/public/app/plugins/datasource/influxdb/partials/query.editor.html +++ b/public/app/plugins/datasource/influxdb/partials/query.editor.html @@ -72,6 +72,28 @@
    +
    +
    + + +
    + +
    +
    + +
    + + +
    + +
    + + +
    +
    +
    +
    +
    diff --git a/public/app/plugins/datasource/influxdb/plugin.json b/public/app/plugins/datasource/influxdb/plugin.json index 635309d1610..56587f91c78 100644 --- a/public/app/plugins/datasource/influxdb/plugin.json +++ b/public/app/plugins/datasource/influxdb/plugin.json @@ -11,7 +11,7 @@ "info": { "author": { "name": "Grafana Project", - "url": "http://grafana.org" + "url": "https://grafana.com" }, "logos": { "small": "img/influxdb_logo.svg", diff --git a/public/app/plugins/datasource/influxdb/query_ctrl.ts b/public/app/plugins/datasource/influxdb/query_ctrl.ts index 26f09d26d29..c676c222d94 100644 --- a/public/app/plugins/datasource/influxdb/query_ctrl.ts +++ b/public/app/plugins/datasource/influxdb/query_ctrl.ts @@ -14,6 +14,7 @@ export class InfluxQueryCtrl extends QueryCtrl { queryBuilder: any; groupBySegment: any; resultFormats: any[]; + orderByTime: any[]; policySegment: any; tagSegments: any[]; selectMenu: any; @@ -32,6 +33,10 @@ export class InfluxQueryCtrl extends QueryCtrl { {text: 'Time series', value: 'time_series'}, {text: 'Table', value: 'table'}, ]; + this.orderByTime = [ + {text: 'ASC', value: 'ASC'}, + {text: 'DESC', value: 'DESC'}, + ]; this.policySegment = uiSegmentSrv.newSegment(this.target.policy); diff --git a/public/app/plugins/datasource/opentsdb/datasource.js b/public/app/plugins/datasource/opentsdb/datasource.js index 76ee763cafc..ecd939d8f74 100644 --- a/public/app/plugins/datasource/opentsdb/datasource.js +++ b/public/app/plugins/datasource/opentsdb/datasource.js @@ -372,7 +372,7 @@ function (angular, _, dateMath) { } var query = { - metric: templateSrv.replace(target.metric, options.scopedVars), + metric: templateSrv.replace(target.metric, options.scopedVars, 'pipe'), aggregator: "avg" }; @@ -411,15 +411,15 @@ function (angular, _, dateMath) { if (target.filters && target.filters.length > 0) { query.filters = angular.copy(target.filters); - if(query.filters){ - for(var filter_key in query.filters){ + if (query.filters){ + for (var filter_key in query.filters) { query.filters[filter_key].filter = templateSrv.replace(query.filters[filter_key].filter, options.scopedVars, 'pipe'); } } } else { query.tags = angular.copy(target.tags); - if(query.tags){ - for(var tag_key in query.tags){ + if (query.tags){ + for (var tag_key in query.tags) { query.tags[tag_key] = templateSrv.replace(query.tags[tag_key], options.scopedVars, 'pipe'); } } diff --git a/public/app/plugins/datasource/opentsdb/plugin.json b/public/app/plugins/datasource/opentsdb/plugin.json index 7de072c6948..b2f4503b5ff 100644 --- a/public/app/plugins/datasource/opentsdb/plugin.json +++ b/public/app/plugins/datasource/opentsdb/plugin.json @@ -11,7 +11,7 @@ "info": { "author": { "name": "Grafana Project", - "url": "http://grafana.org" + "url": "https://grafana.com" }, "logos": { "small": "img/opentsdb_logo.png", diff --git a/public/app/plugins/datasource/prometheus/dashboards/prometheus_stats.json b/public/app/plugins/datasource/prometheus/dashboards/prometheus_stats.json index 3b5b73ea6d4..23f4226e69f 100644 --- a/public/app/plugins/datasource/prometheus/dashboards/prometheus_stats.json +++ b/public/app/plugins/datasource/prometheus/dashboards/prometheus_stats.json @@ -154,7 +154,7 @@ } ], "thresholds": "0,500,4000", - "title": "Interal Storage Queue Length", + "title": "Internal Storage Queue Length", "type": "singlestat", "valueFontSize": "70%", "valueMaps": [ @@ -167,7 +167,7 @@ "valueName": "current" }, { - "content": "\"Prometheus\nPrometheus\n\n

    You're using Prometheus, an open-source systems monitoring and alerting toolkit originally built at SoundCloud. For more information, check out the Grafana and Prometheus projects.

    ", + "content": "\"Prometheus\nPrometheus\n\n

    You're using Prometheus, an open-source systems monitoring and alerting toolkit originally built at SoundCloud. For more information, check out the Grafana and Prometheus projects.

    ", "editable": true, "error": false, "id": 9, @@ -563,7 +563,7 @@ "title": "Grafana Docs", "tooltip": "", "type": "link", - "url": "http://www.grafana.org/docs" + "url": "http://docs.grafana.org/" }, { "icon": "info", diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 972fa2c7c55..f883b40e5ee 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -4,6 +4,7 @@ import angular from 'angular'; import _ from 'lodash'; import moment from 'moment'; +import kbn from 'app/core/utils/kbn'; import * as dateMath from 'app/core/utils/datemath'; import PrometheusMetricFindQuery from './metric_find_query'; @@ -88,12 +89,7 @@ export function PrometheusDatasource(instanceSettings, $q, backendSrv, templateS var intervalFactor = target.intervalFactor || 1; target.step = query.step = this.calculateInterval(interval, intervalFactor); var range = Math.ceil(end - start); - // Prometheus drop query if range/step > 11000 - // calibrate step if it is too big - if (query.step !== 0 && range / query.step > 11000) { - target.step = query.step = Math.ceil(range / 11000); - } - + target.step = query.step = this.adjustStep(query.step, range); queries.push(query); }); @@ -126,6 +122,15 @@ export function PrometheusDatasource(instanceSettings, $q, backendSrv, templateS }); }; + this.adjustStep = function(step, range) { + // Prometheus drop query if range/step > 11000 + // calibrate step if it is too big + if (step !== 0 && range / step > 11000) { + return Math.ceil(range / 11000); + } + return step; + }; + this.performTimeSeriesQuery = function(query, start, end) { if (start > end) { throw { message: 'Invalid time range' }; @@ -175,15 +180,19 @@ export function PrometheusDatasource(instanceSettings, $q, backendSrv, templateS return $q.reject(err); } - var query = { - expr: interpolated, - step: '60s' - }; + var step = '60s'; + if (annotation.step) { + step = templateSrv.replace(annotation.step); + } var start = this.getPrometheusTime(options.range.from, false); var end = this.getPrometheusTime(options.range.to, true); - var self = this; + var query = { + expr: interpolated, + step: this.adjustStep(kbn.interval_to_seconds(step), Math.ceil(end - start)) + 's' + }; + var self = this; return this.performTimeSeriesQuery(query, start, end).then(function(results) { var eventList = []; tagKeys = tagKeys.split(','); diff --git a/public/app/plugins/datasource/prometheus/partials/annotations.editor.html b/public/app/plugins/datasource/prometheus/partials/annotations.editor.html index 818aa42e886..09ee52bda45 100644 --- a/public/app/plugins/datasource/prometheus/partials/annotations.editor.html +++ b/public/app/plugins/datasource/prometheus/partials/annotations.editor.html @@ -1,9 +1,12 @@ - -
    Search expression
    + Search expression
    +
    + step + +
    diff --git a/public/app/plugins/datasource/prometheus/partials/query.editor.html b/public/app/plugins/datasource/prometheus/partials/query.editor.html index 37c27ace58a..1840f74bd74 100644 --- a/public/app/plugins/datasource/prometheus/partials/query.editor.html +++ b/public/app/plugins/datasource/prometheus/partials/query.editor.html @@ -1,12 +1,7 @@ - +
    - - -
    -
    - - +
    @@ -39,6 +34,14 @@ ng-change="ctrl.refreshMetricData()">
    +
    + +
    + + +
    + +
    -
    +
    - +
    -
    +
    - +
    diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index 910ef335599..06af66ddbe7 100755 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -253,6 +253,7 @@ coreModule.directive('grafanaGraph', function($rootScope, timeSrv) { backgroundColor: null, borderWidth: 0, hoverable: true, + clickable: true, color: '#c8c8c8', margin: { left: 0, right: 0 }, }, @@ -459,7 +460,8 @@ coreModule.directive('grafanaGraph', function($rootScope, timeSrv) { show: panel.yaxes[0].show, index: 1, logBase: panel.yaxes[0].logBase || 1, - max: null + min: panel.yaxes[0].min ? _.toNumber(panel.yaxes[0].min) : null, + max: panel.yaxes[0].max ? _.toNumber(panel.yaxes[0].max) : null, }; options.yaxes.push(defaults); @@ -470,12 +472,13 @@ coreModule.directive('grafanaGraph', function($rootScope, timeSrv) { secondY.show = panel.yaxes[1].show; secondY.logBase = panel.yaxes[1].logBase || 1; secondY.position = 'right'; + secondY.min = panel.yaxes[1].min ? _.toNumber(panel.yaxes[1].min) : null; + secondY.max = panel.yaxes[1].max ? _.toNumber(panel.yaxes[1].max) : null; options.yaxes.push(secondY); applyLogScale(options.yaxes[1], data); configureAxisMode(options.yaxes[1], panel.percentage && panel.stack ? "percent" : panel.yaxes[1].format); } - applyLogScale(options.yaxes[0], data); configureAxisMode(options.yaxes[0], panel.percentage && panel.stack ? "percent" : panel.yaxes[0].format); } @@ -484,43 +487,67 @@ coreModule.directive('grafanaGraph', function($rootScope, timeSrv) { if (axis.logBase === 1) { return; } + if (axis.min < Number.MIN_VALUE) { + axis.min = null; + } + if (axis.max < Number.MIN_VALUE) { + axis.max = null; + } var series, i; - var max = axis.max; + var max = axis.max, min = axis.min; - if (max === null) { - for (i = 0; i < data.length; i++) { - series = data[i]; - if (series.yaxis === axis.index) { - if (max < series.stats.max) { - max = series.stats.max; - } + for (i = 0; i < data.length; i++) { + series = data[i]; + if (series.yaxis === axis.index) { + if (!max || max < series.stats.max) { + max = series.stats.max; + } + if (!min || min > series.stats.logmin) { + min = series.stats.logmin; } } - if (max === void 0) { - max = Number.MAX_VALUE; - } } - axis.min = axis.min !== null ? axis.min : 0; - axis.ticks = [0, 1]; - var nextTick = 1; + axis.transform = function(v) { return (v < Number.MIN_VALUE) ? null : Math.log(v) / Math.log(axis.logBase); }; + axis.inverseTransform = function (v) { return Math.pow(axis.logBase,v); }; - while (true) { - nextTick = nextTick * axis.logBase; - axis.ticks.push(nextTick); - if (nextTick > max) { - break; - } + if (!max && !min) { + max = axis.inverseTransform(+2); + min = axis.inverseTransform(-2); + } else if (!max) { + max = min*axis.inverseTransform(+4); + } else if (!min) { + min = max*axis.inverseTransform(-4); } - if (axis.logBase === 10) { - axis.transform = function(v) { return Math.log(v+0.1); }; - axis.inverseTransform = function (v) { return Math.pow(10,v); }; + if (axis.min) { + min = axis.inverseTransform(Math.ceil(axis.transform(axis.min))); } else { - axis.transform = function(v) { return Math.log(v+0.1) / Math.log(axis.logBase); }; - axis.inverseTransform = function (v) { return Math.pow(axis.logBase,v); }; + min = axis.min = axis.inverseTransform(Math.floor(axis.transform(min))); } + if (axis.max) { + max = axis.inverseTransform(Math.floor(axis.transform(axis.max))); + } else { + max = axis.max = axis.inverseTransform(Math.ceil(axis.transform(max))); + } + + if (!min || min < Number.MIN_VALUE || !max || max < Number.MIN_VALUE) { + return; + } + + axis.ticks = []; + var nextTick; + for (nextTick = min; nextTick <= max; nextTick *= axis.logBase) { + axis.ticks.push(nextTick); + } + axis.tickDecimals = decimalPlaces(min); + } + + function decimalPlaces(num) { + if (!num) { return 0; } + + return (num.toString().split('.')[1] || []).length; } function configureAxisMode(axis, format) { diff --git a/public/app/plugins/panel/graph/graph_tooltip.js b/public/app/plugins/panel/graph/graph_tooltip.js index 1de99c42da7..ba1458d66d0 100644 --- a/public/app/plugins/panel/graph/graph_tooltip.js +++ b/public/app/plugins/panel/graph/graph_tooltip.js @@ -162,6 +162,10 @@ function ($, core) { appEvents.emit('graph-hover', {pos: pos, panel: panel}); }); + elem.bind("plotclick", function (event, pos, item) { + appEvents.emit('graph-click', {pos: pos, panel: panel, item: item}); + }); + this.clear = function(plot) { $tooltip.detach(); plot.clearCrosshair(); diff --git a/public/app/plugins/panel/graph/legend.js b/public/app/plugins/panel/graph/legend.js index cf652950485..2cdb1821793 100644 --- a/public/app/plugins/panel/graph/legend.js +++ b/public/app/plugins/panel/graph/legend.js @@ -65,7 +65,9 @@ function (angular, _, $) { var el = $(e.currentTarget); var index = getSeriesIndexForElement(el); var seriesInfo = seriesList[index]; + var scrollPosition = $($container.children('tbody')).scrollTop(); ctrl.toggleSeries(seriesInfo, e); + $($container.children('tbody')).scrollTop(scrollPosition); } function sortLegend(e) { @@ -167,7 +169,7 @@ function (angular, _, $) { html += ''; html += '
    '; - html += '
    ' + _.escape(series.label) + ''; + html += '' + _.escape(series.label) + ''; if (panel.legend.values) { var avg = series.formatValue(series.stats.avg); diff --git a/public/app/plugins/panel/graph/module.ts b/public/app/plugins/panel/graph/module.ts index 1a8fb3c0481..01f7a485aa6 100644 --- a/public/app/plugins/panel/graph/module.ts +++ b/public/app/plugins/panel/graph/module.ts @@ -26,8 +26,7 @@ class GraphCtrl extends MetricsPanelCtrl { alertState: any; annotationsPromise: any; - datapointsCount: number; - datapointsOutside: boolean; + dataWarning: any; colors: any = []; subTabIndex: number; processor: DataProcessor; @@ -178,14 +177,26 @@ class GraphCtrl extends MetricsPanelCtrl { this.dataList = dataList; this.seriesList = this.processor.getSeriesList({dataList: dataList, range: this.range}); - this.datapointsCount = this.seriesList.reduce((prev, series) => { + this.dataWarning = null; + const datapointsCount = this.seriesList.reduce((prev, series) => { return prev + series.datapoints.length; }, 0); - this.datapointsOutside = false; - for (let series of this.seriesList) { - if (series.isOutsideRange) { - this.datapointsOutside = true; + if (datapointsCount === 0) { + this.dataWarning = { + title: 'No data points', + tip: 'No datapoints returned from data query' + }; + } else { + + for (let series of this.seriesList) { + if (series.isOutsideRange) { + this.dataWarning = { + title: 'Data points outside time range', + tip: 'Can be caused by timezone mismatch or missing time filter in query', + }; + break; + } } } diff --git a/public/app/plugins/panel/graph/plugin.json b/public/app/plugins/panel/graph/plugin.json index b2976c27e04..102c7d14e56 100644 --- a/public/app/plugins/panel/graph/plugin.json +++ b/public/app/plugins/panel/graph/plugin.json @@ -6,7 +6,7 @@ "info": { "author": { "name": "Grafana Project", - "url": "http://grafana.org" + "url": "https://grafana.com" }, "logos": { "small": "img/icn-graph-panel.svg", diff --git a/public/app/plugins/panel/graph/series_overrides_ctrl.js b/public/app/plugins/panel/graph/series_overrides_ctrl.js index 0a338ff86e8..f5dae8e3c7e 100644 --- a/public/app/plugins/panel/graph/series_overrides_ctrl.js +++ b/public/app/plugins/panel/graph/series_overrides_ctrl.js @@ -39,7 +39,7 @@ define([ // can be removed by the user if they still want lines if (item.propertyName === 'fillBelowTo') { $scope.override['lines'] = false; - $scope.addSeriesOverride({ alias: subItem.value, lines: false }); + $scope.ctrl.addSeriesOverride({alias: subItem.value, lines: false }); } $scope.updateCurrentOverrides(); diff --git a/public/app/plugins/panel/graph/specs/graph_ctrl_specs.ts b/public/app/plugins/panel/graph/specs/graph_ctrl_specs.ts index d06c66e253a..ab91b87ece9 100644 --- a/public/app/plugins/panel/graph/specs/graph_ctrl_specs.ts +++ b/public/app/plugins/panel/graph/specs/graph_ctrl_specs.ts @@ -35,7 +35,7 @@ describe('GraphCtrl', function() { }); it('should set datapointsOutside', function() { - expect(ctx.ctrl.datapointsOutside).to.be(true); + expect(ctx.ctrl.dataWarning.title).to.be('Data points outside time range'); }); }); @@ -55,21 +55,21 @@ describe('GraphCtrl', function() { }); it('should set datapointsOutside', function() { - expect(ctx.ctrl.datapointsOutside).to.be(false); + expect(ctx.ctrl.dataWarning).to.be(null); }); }); describe('datapointsCount given 2 series', function() { beforeEach(function() { var data = [ - {target: 'test.cpu1', datapoints: [[45, 1234567890], [60, 1234567899]]}, - {target: 'test.cpu2', datapoints: [[45, 1234567890]]}, + {target: 'test.cpu1', datapoints: []}, + {target: 'test.cpu2', datapoints: []}, ]; ctx.ctrl.onDataReceived(data); }); - it('should set datapointsCount to sum of datapoints', function() { - expect(ctx.ctrl.datapointsCount).to.be(3); + it('should set datapointsCount warning', function() { + expect(ctx.ctrl.dataWarning.title).to.be('No data points'); }); }); diff --git a/public/app/plugins/panel/graph/specs/graph_specs.ts b/public/app/plugins/panel/graph/specs/graph_specs.ts index 5578b3a361e..0d8c088f704 100644 --- a/public/app/plugins/panel/graph/specs/graph_specs.ts +++ b/public/app/plugins/panel/graph/specs/graph_specs.ts @@ -113,15 +113,43 @@ describe('grafanaGraph', function() { }); graphScenario('when logBase is log 10', function(ctx) { - ctx.setup(function(ctrl) { + ctx.setup(function(ctrl, data) { ctrl.panel.yaxes[0].logBase = 10; + data[0] = new TimeSeries({ + datapoints: [[2000,1],[0.002,2],[0,3],[-1,4]], + alias: 'seriesAutoscale', + }); + data[0].yaxis = 1; + ctrl.panel.yaxes[1].logBase = 10; + ctrl.panel.yaxes[1].min = '0.05'; + ctrl.panel.yaxes[1].max = '1500'; + data[1] = new TimeSeries({ + datapoints: [[2000,1],[0.002,2],[0,3],[-1,4]], + alias: 'seriesFixedscale', + }); + data[1].yaxis = 2; }); - it('should apply axis transform and ticks', function() { - var axis = ctx.plotOptions.yaxes[0]; - expect(axis.transform(100)).to.be(Math.log(100+0.1)); - expect(axis.ticks[0]).to.be(0); - expect(axis.ticks[1]).to.be(1); + it('should apply axis transform, autoscaling (if necessary) and ticks', function() { + var axisAutoscale = ctx.plotOptions.yaxes[0]; + expect(axisAutoscale.transform(100)).to.be(2); + expect(axisAutoscale.inverseTransform(-3)).to.be(0.001); + expect(axisAutoscale.min).to.be(0.001); + expect(axisAutoscale.max).to.be(10000); + expect(axisAutoscale.ticks.length).to.be(8); + expect(axisAutoscale.ticks[0]).to.be(0.001); + expect(axisAutoscale.ticks[7]).to.be(10000); + expect(axisAutoscale.tickDecimals).to.be(3); + + + var axisFixedscale = ctx.plotOptions.yaxes[1]; + expect(axisFixedscale.min).to.be(0.05); + expect(axisFixedscale.max).to.be(1500); + expect(axisFixedscale.ticks.length).to.be(5); + expect(axisFixedscale.ticks[0]).to.be(0.1); + expect(axisFixedscale.ticks[4]).to.be(1000); + expect(axisFixedscale.tickDecimals).to.be(1); + }); }); diff --git a/public/app/plugins/panel/graph/template.ts b/public/app/plugins/panel/graph/template.ts index ec6cd8d0907..6ded97ce33a 100644 --- a/public/app/plugins/panel/graph/template.ts +++ b/public/app/plugins/panel/graph/template.ts @@ -2,17 +2,8 @@ var template = `
    -
    - - No datapoints No datapoints returned from metric query - -
    - -
    - - Datapoints outside time range - Can be caused by timezone mismatch between browser and graphite server - +
    + {{ctrl.dataWarning.title}}
    diff --git a/public/app/plugins/panel/pluginlist/module.html b/public/app/plugins/panel/pluginlist/module.html index 1cf7cfac34f..1b2792b510c 100644 --- a/public/app/plugins/panel/pluginlist/module.html +++ b/public/app/plugins/panel/pluginlist/module.html @@ -22,7 +22,7 @@
    diff --git a/public/app/plugins/panel/pluginlist/plugin.json b/public/app/plugins/panel/pluginlist/plugin.json index 72f5ea06d25..0cdc506167c 100644 --- a/public/app/plugins/panel/pluginlist/plugin.json +++ b/public/app/plugins/panel/pluginlist/plugin.json @@ -6,7 +6,7 @@ "info": { "author": { "name": "Grafana Project", - "url": "http://grafana.org" + "url": "https://grafana.com" }, "logos": { "small": "img/icn-dashlist-panel.svg", diff --git a/public/app/plugins/panel/singlestat/plugin.json b/public/app/plugins/panel/singlestat/plugin.json index 197cf3ec27d..8bf45d3b690 100644 --- a/public/app/plugins/panel/singlestat/plugin.json +++ b/public/app/plugins/panel/singlestat/plugin.json @@ -6,7 +6,7 @@ "info": { "author": { "name": "Grafana Project", - "url": "http://grafana.org" + "url": "https://grafana.com" }, "logos": { "small": "img/icn-singlestat-panel.svg", diff --git a/public/app/plugins/panel/table/editor.html b/public/app/plugins/panel/table/editor.html index 5847e140c82..448669ca6a9 100644 --- a/public/app/plugins/panel/table/editor.html +++ b/public/app/plugins/panel/table/editor.html @@ -21,6 +21,10 @@
    +
    diff --git a/public/app/plugins/panel/table/module.html b/public/app/plugins/panel/table/module.html index 8d6f604dc56..9140a182541 100644 --- a/public/app/plugins/panel/table/module.html +++ b/public/app/plugins/panel/table/module.html @@ -1,6 +1,7 @@ +
    -
    -
    +
    +
    @@ -20,5 +21,10 @@
    +
    + + No datapoints No datapoints returned from metric query + +
    diff --git a/public/app/plugins/panel/table/module.ts b/public/app/plugins/panel/table/module.ts index 35448d6a296..bf872b47d27 100644 --- a/public/app/plugins/panel/table/module.ts +++ b/public/app/plugins/panel/table/module.ts @@ -42,6 +42,7 @@ class TablePanelCtrl extends MetricsPanelCtrl { scroll: true, fontSize: '100%', sort: {col: 0, desc: true}, + filterNull: false, }; /** @ngInject */ diff --git a/public/app/plugins/panel/table/plugin.json b/public/app/plugins/panel/table/plugin.json index 84a527db565..c1d2c43b128 100644 --- a/public/app/plugins/panel/table/plugin.json +++ b/public/app/plugins/panel/table/plugin.json @@ -6,7 +6,7 @@ "info": { "author": { "name": "Grafana Project", - "url": "http://grafana.org" + "url": "https://grafana.com" }, "logos": { "small": "img/icn-table-panel.svg", diff --git a/public/app/plugins/panel/table/transformers.ts b/public/app/plugins/panel/table/transformers.ts index f48932a54ac..58e9758a6b9 100644 --- a/public/app/plugins/panel/table/transformers.ts +++ b/public/app/plugins/panel/table/transformers.ts @@ -5,6 +5,7 @@ import moment from 'moment'; import flatten from '../../../core/utils/flatten'; import TimeSeries from '../../../core/time_series2'; import TableModel from '../../../core/table_model'; +import angular from "angular"; var transformers = {}; @@ -219,7 +220,8 @@ transformers['json'] = { }; function transformDataToTable(data, panel) { - var model = new TableModel(); + var model = new TableModel(), + copyData = angular.copy(data); if (!data || data.length === 0) { return model; @@ -230,7 +232,13 @@ function transformDataToTable(data, panel) { throw {message: 'Transformer ' + panel.transformer + ' not found'}; } - transformer.transform(data, panel, model); + if (panel.filterNull) { + for (var i = 0; i < copyData.length; i++) { + copyData[i].datapoints = copyData[i].datapoints.filter((dp) => dp[0] != null); + } + } + + transformer.transform(copyData, panel, model); return model; } diff --git a/public/app/plugins/panel/text/plugin.json b/public/app/plugins/panel/text/plugin.json index e63974c6167..9bc604a87bb 100644 --- a/public/app/plugins/panel/text/plugin.json +++ b/public/app/plugins/panel/text/plugin.json @@ -6,7 +6,7 @@ "info": { "author": { "name": "Grafana Project", - "url": "http://grafana.org" + "url": "https://grafana.com" }, "logos": { "small": "img/icn-text-panel.svg", diff --git a/public/img/icn-dashboard-tiny.svg b/public/img/icn-dashboard-tiny.svg new file mode 100644 index 00000000000..3d24f95cbdf --- /dev/null +++ b/public/img/icn-dashboard-tiny.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/icn-plugins-tiny.svg b/public/img/icn-plugins-tiny.svg new file mode 100644 index 00000000000..24af6e988ab --- /dev/null +++ b/public/img/icn-plugins-tiny.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + diff --git a/public/sass/_grafana.scss b/public/sass/_grafana.scss index 25db63858e8..370a5db943a 100644 --- a/public/sass/_grafana.scss +++ b/public/sass/_grafana.scss @@ -85,6 +85,5 @@ @import "pages/signup"; @import "pages/styleguide"; - @import "old_responsive"; @import "components/view_states.scss"; diff --git a/public/sass/components/_gf-form.scss b/public/sass/components/_gf-form.scss index 004ab86fb76..94691334fe2 100644 --- a/public/sass/components/_gf-form.scss +++ b/public/sass/components/_gf-form.scss @@ -66,6 +66,25 @@ $gf-form-margin: 0.25rem; } } +.gf-form-error { + padding: $input-padding-y $input-padding-x; + margin-right: $gf-form-margin; + flex-shrink: 0; + + background-color: $input-label-bg; + display: block; + font-size: $font-size-sm; + margin-right: $gf-form-margin; + + border: $input-btn-border-width solid $red; + @include border-radius($label-border-radius-sm); + + &--grow { + flex-grow: 1; + min-height: 2.60rem; + } +} + .gf-form-checkbox { flex-shrink: 0; padding: $input-padding-y $input-padding-x; @@ -267,3 +286,10 @@ $gf-form-margin: 0.25rem; select.gf-form-input ~ .gf-form-help-icon { right: 10px; } + +.gf-form-icon--right-absolute { + position: absolute; + right: $spacer; + top: 10px; + color: $text-muted; +} diff --git a/public/sass/components/_panel_graph.scss b/public/sass/components/_panel_graph.scss index 9e919072dce..179049ea220 100644 --- a/public/sass/components/_panel_graph.scss +++ b/public/sass/components/_panel_graph.scss @@ -89,6 +89,7 @@ display: block; overflow-y: auto; overflow-x: hidden; + padding-bottom: 1px; } .graph-legend-series { @@ -132,6 +133,9 @@ padding-left: 7px; text-align: left; width: 95%; + max-width: 650px; + text-overflow: ellipsis; + overflow: hidden; } .graph-legend-series:nth-child(odd) { @@ -263,7 +267,10 @@ .graph-tooltip-series-name { display: table-cell; padding: 0.15rem; - } + max-width: 650px; + text-overflow: ellipsis; + overflow: hidden; + } .graph-tooltip-value { display: table-cell; diff --git a/public/sass/components/_search.scss b/public/sass/components/_search.scss index d8c3942bc24..dc4ba998f63 100644 --- a/public/sass/components/_search.scss +++ b/public/sass/components/_search.scss @@ -107,18 +107,16 @@ } .search-button-row-explore-link { - color: $text-muted; + color: $gray-3; font-size: $font-size-sm; - padding-right: 7rem; - background: url(../img/grafana_net_logo.svg); - background-size: 6.5rem; - background-repeat: no-repeat; - background-position: right; position: relative; - top: 0.8rem; + top: 1.0rem; &:hover { color: $link-hover-color; } + img { + vertical-align: text-bottom; + } } } diff --git a/public/sass/mixins/_drop_element.scss b/public/sass/mixins/_drop_element.scss index 290e49f4cad..0f7eda19efe 100644 --- a/public/sass/mixins/_drop_element.scss +++ b/public/sass/mixins/_drop_element.scss @@ -12,6 +12,7 @@ color: $theme-color; padding: 0.65rem; font-size: $font-size-sm; + word-wrap: break-word; max-width: 20rem; &:before { diff --git a/public/sass/pages/_admin.scss b/public/sass/pages/_admin.scss index 30bc3ca8b34..b2be062849d 100644 --- a/public/sass/pages/_admin.scss +++ b/public/sass/pages/_admin.scss @@ -8,3 +8,15 @@ td.admin-settings-key { padding-left: 20px; } +.admin-list-table { + margin-bottom: 20px; +} + +.admin-list-paging { + float: right; + li { + display: inline-block; + padding-left: 10px; + margin-bottom: 5px; + } +} diff --git a/public/sass/pages/_plugins.scss b/public/sass/pages/_plugins.scss index 075a7f1649c..c93d94f40b5 100644 --- a/public/sass/pages/_plugins.scss +++ b/public/sass/pages/_plugins.scss @@ -38,18 +38,16 @@ } .get-more-plugins-link { - color: $text-muted; + color: $gray-3; font-size: $font-size-sm; - padding-right: 7rem; - background: url(../img/grafana_net_logo.svg); - background-size: 6.5rem 3rem; - background-repeat: no-repeat; - background-position: right; position: relative; top: 1.2rem; &:hover { color: $link-hover-color; } + img { + vertical-align: top; + } } @include media-breakpoint-down(sm) { diff --git a/public/test/core/time_series_specs.js b/public/test/core/time_series_specs.js index 829f74d3515..04efa8a2249 100644 --- a/public/test/core/time_series_specs.js +++ b/public/test/core/time_series_specs.js @@ -6,12 +6,16 @@ define([ describe("TimeSeries", function() { var points, series; var yAxisFormats = ['short', 'ms']; - var testData = { - alias: 'test', - datapoints: [ - [1,2],[null,3],[10,4],[8,5] - ] - }; + var testData; + + beforeEach(function() { + testData = { + alias: 'test', + datapoints: [ + [1,2],[null,3],[10,4],[8,5] + ] + }; + }); describe('when getting flot pairs', function() { it('with connected style, should ignore nulls', function() { @@ -51,40 +55,40 @@ define([ it('the delta value should account for nulls', function() { series = new TimeSeries({ - datapoints: [[1,2],[3,3],[null,4],[10,5],[15,6]] - }); + datapoints: [[1,2],[3,3],[null,4],[10,5],[15,6]] + }); series.getFlotPairs('null', yAxisFormats); expect(series.stats.delta).to.be(14); }); it('the delta value should account for nulls on first', function() { series = new TimeSeries({ - datapoints: [[null,2],[1,3],[10,4],[15,5]] - }); + datapoints: [[null,2],[1,3],[10,4],[15,5]] + }); series.getFlotPairs('null', yAxisFormats); expect(series.stats.delta).to.be(14); }); it('the delta value should account for nulls on last', function() { series = new TimeSeries({ - datapoints: [[1,2],[5,3],[10,4],[null,5]] - }); + datapoints: [[1,2],[5,3],[10,4],[null,5]] + }); series.getFlotPairs('null', yAxisFormats); expect(series.stats.delta).to.be(9); }); it('the delta value should account for resets', function() { series = new TimeSeries({ - datapoints: [[1,2],[5,3],[10,4],[0,5],[10,6]] - }); + datapoints: [[1,2],[5,3],[10,4],[0,5],[10,6]] + }); series.getFlotPairs('null', yAxisFormats); expect(series.stats.delta).to.be(19); }); it('the delta value should account for resets on last', function() { series = new TimeSeries({ - datapoints: [[1,2],[2,3],[10,4],[8,5]] - }); + datapoints: [[1,2],[2,3],[10,4],[8,5]] + }); series.getFlotPairs('null', yAxisFormats); expect(series.stats.delta).to.be(17); }); diff --git a/public/test/specs/dashboardViewStateSrv-specs.js b/public/test/specs/dashboardViewStateSrv-specs.js index 90e35810ac0..42bbf709655 100644 --- a/public/test/specs/dashboardViewStateSrv-specs.js +++ b/public/test/specs/dashboardViewStateSrv-specs.js @@ -1,6 +1,7 @@ define([ - 'app/features/dashboard/viewStateSrv' -], function() { + 'app/features/dashboard/viewStateSrv', + 'app/core/config' +], function(viewStateSrv, config) { 'use strict'; describe('when updating view state', function() { @@ -12,7 +13,13 @@ define([ orgId: 19 } }; - + beforeEach(function() { + config.bootData = { + user: { + orgId: 1 + } + }; + }); beforeEach(module('grafana.services')); beforeEach(module(function($provide) { $provide.value('timeSrv', timeSrv); @@ -31,7 +38,7 @@ define([ it('should update querystring and view state', function() { var updateState = {fullscreen: true, edit: true, panelId: 1}; viewState.update(updateState); - expect(location.search()).to.eql({fullscreen: true, edit: true, panelId: 1}); + expect(location.search()).to.eql({fullscreen: true, edit: true, panelId: 1, orgId: 1}); expect(viewState.dashboard.meta.fullscreen).to.be(true); expect(viewState.state.fullscreen).to.be(true); }); @@ -45,7 +52,5 @@ define([ expect(viewState.state.fullscreen).to.be(null); }); }); - }); - }); diff --git a/public/test/specs/datasource_srv_specs.js b/public/test/specs/datasource_srv_specs.js new file mode 100644 index 00000000000..be7c054acaa --- /dev/null +++ b/public/test/specs/datasource_srv_specs.js @@ -0,0 +1,58 @@ +define([ + 'app/core/config', + 'app/core/services/datasource_srv' +], function(config) { + 'use strict'; + + describe('datasource_srv', function() { + var _datasourceSrv; + var metricSources; + var templateSrv = {}; + + beforeEach(module('grafana.core')); + beforeEach(module(function($provide) { + $provide.value('templateSrv', templateSrv); + })); + beforeEach(module('grafana.services')); + beforeEach(inject(function(datasourceSrv) { + _datasourceSrv = datasourceSrv; + })); + + describe('when loading metric sources', function() { + var unsortedDatasources = { + 'mmm': { + type: 'test-db', + meta: { metrics: {m: 1} } + }, + '--Mixed--': { + type: 'test-db', + meta: {builtIn: true, metrics: {m: 1} } + }, + 'ZZZ': { + type: 'test-db', + meta: {metrics: {m: 1} } + }, + 'aaa': { + type: 'test-db', + meta: { metrics: {m: 1} } + }, + 'BBB': { + type: 'test-db', + meta: { metrics: {m: 1} } + }, + }; + beforeEach(function() { + config.datasources = unsortedDatasources; + metricSources = _datasourceSrv.getMetricSources({skipVariables: true}); + }); + + it('should return a list of sources sorted case insensitively with builtin sources last', function() { + expect(metricSources[0].name).to.be('aaa'); + expect(metricSources[1].name).to.be('BBB'); + expect(metricSources[2].name).to.be('mmm'); + expect(metricSources[3].name).to.be('ZZZ'); + expect(metricSources[4].name).to.be('--Mixed--'); + }); + }); + }); +}); diff --git a/public/test/specs/shareModalCtrl-specs.js b/public/test/specs/shareModalCtrl-specs.js index 4bd3760ad5e..9d3e7f941d0 100644 --- a/public/test/specs/shareModalCtrl-specs.js +++ b/public/test/specs/shareModalCtrl-specs.js @@ -2,7 +2,8 @@ define([ './helpers', 'app/features/dashboard/shareModalCtrl', 'app/features/panellinks/linkSrv', -], function(helpers) { + 'app/core/config', +], function(helpers, shareModalCtrl, linkSrv, config) { 'use strict'; describe('ShareModalCtrl', function() { @@ -12,6 +13,14 @@ define([ ctx.timeSrv.timeRange = sinon.stub().returns(range); } + beforeEach(function() { + config.bootData = { + user: { + orgId: 1 + } + }; + }); + setTime({ from: new Date(1000), to: new Date(2000) }); beforeEach(module('grafana.controllers')); @@ -25,13 +34,12 @@ define([ beforeEach(ctx.createControllerPhase('ShareModalCtrl')); describe('shareUrl with current time range and panel', function() { - it('should generate share url absolute time', function() { ctx.$location.path('/test'); ctx.scope.panel = { id: 22 }; ctx.scope.init(); - expect(ctx.scope.shareUrl).to.be('http://server/#!/test?from=1000&to=2000&panelId=22&fullscreen'); + expect(ctx.scope.shareUrl).to.be('http://server/#!/test?from=1000&to=2000&orgId=1&panelId=22&fullscreen'); }); it('should generate render url', function() { @@ -41,7 +49,7 @@ define([ ctx.scope.init(); var base = 'http://dashboards.grafana.com/render/dashboard-solo/db/my-dash'; - var params = '?from=1000&to=2000&panelId=22&width=1000&height=500&tz=UTC'; + var params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC'; expect(ctx.scope.imageUrl).to.contain(base + params); }); @@ -51,7 +59,7 @@ define([ ctx.scope.panel = null; ctx.scope.init(); - expect(ctx.scope.shareUrl).to.be('http://server/#!/test?from=1000&to=2000'); + expect(ctx.scope.shareUrl).to.be('http://server/#!/test?from=1000&to=2000&orgId=1'); }); it('should add theme when specified', function() { @@ -60,7 +68,31 @@ define([ ctx.scope.panel = null; ctx.scope.init(); - expect(ctx.scope.shareUrl).to.be('http://server/#!/test?from=1000&to=2000&theme=light'); + expect(ctx.scope.shareUrl).to.be('http://server/#!/test?from=1000&to=2000&orgId=1&theme=light'); + }); + + it('should remove fullscreen from image url when is first param in querystring and modeSharePanel is true', function() { + ctx.$location.url('/test?fullscreen&edit'); + ctx.scope.modeSharePanel = true; + ctx.scope.panel = { id: 1 }; + + ctx.scope.buildUrl(); + + expect(ctx.scope.shareUrl).to.contain('?fullscreen&edit&from=1000&to=2000&orgId=1&panelId=1'); + expect(ctx.scope.imageUrl).to.contain('?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC'); + + }); + + it('should remove edit from image url when is first param in querystring and modeSharePanel is true', function() { + ctx.$location.url('/test?edit&fullscreen'); + ctx.scope.modeSharePanel = true; + ctx.scope.panel = { id: 1 }; + + ctx.scope.buildUrl(); + + expect(ctx.scope.shareUrl).to.contain('?edit&fullscreen&from=1000&to=2000&orgId=1&panelId=1'); + expect(ctx.scope.imageUrl).to.contain('?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC'); + }); it('should include template variables in url', function() { @@ -73,9 +105,8 @@ define([ }; ctx.scope.buildUrl(); - expect(ctx.scope.shareUrl).to.be('http://server/#!/test?from=1000&to=2000&var-app=mupp&var-server=srv-01'); + expect(ctx.scope.shareUrl).to.be('http://server/#!/test?from=1000&to=2000&orgId=1&var-app=mupp&var-server=srv-01'); }); - }); }); diff --git a/public/views/index.html b/public/views/index.html index 8d231ed2b68..6d5d65e5201 100644 --- a/public/views/index.html +++ b/public/views/index.html @@ -49,24 +49,24 @@
  • - + Support Plans
  • - + Community
  • - Grafana + Grafana v[[.BuildVersion]] (commit: [[.BuildCommit]])
  • [[if .NewGrafanaVersionExists]]
  • - + New version available!
  • @@ -91,18 +91,6 @@ - [[if .GoogleAnalyticsId]] - - [[end]] - [[if .GoogleTagManagerId]]