diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000000..3a0f40dcf59 --- /dev/null +++ b/.babelrc @@ -0,0 +1,11 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { "browsers": "last 3 versions" }, + "useBuiltIns": "entry" + } + ] + ] +} diff --git a/.bra.toml b/.bra.toml index aa7a1680adc..5be42ceebbf 100644 --- a/.bra.toml +++ b/.bra.toml @@ -1,7 +1,7 @@ [run] init_cmds = [ ["go", "run", "build.go", "-dev", "build-server"], - ["./bin/grafana-server", "cfg:app_mode=development"] + ["./bin/grafana-server", "-packaging=dev", "cfg:app_mode=development"] ] watch_all = true follow_symlinks = true @@ -14,5 +14,5 @@ watch_exts = [".go", ".ini", ".toml", ".template.html"] build_delay = 1500 cmds = [ ["go", "run", "build.go", "-dev", "build-server"], - ["./bin/grafana-server", "cfg:app_mode=development"] + ["./bin/grafana-server", "-packaging=dev", "cfg:app_mode=development"] ] diff --git a/.circleci/config.yml b/.circleci/config.yml index 424744324ae..3dd8f800b94 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -162,8 +162,8 @@ jobs: name: Build Grafana.com master publisher command: 'go build -o scripts/publish scripts/build/publish.go' - run: - name: Build Grafana.com release publisher - command: 'cd scripts/build/release_publisher && go build -o release_publisher .' + name: Test and build Grafana.com release publisher + command: 'cd scripts/build/release_publisher && go test . && go build -o release_publisher .' - persist_to_workspace: root: . paths: @@ -191,6 +191,9 @@ jobs: - run: name: sha-sum packages command: 'go run build.go sha-dist' + - run: + name: Test Grafana.com release publisher + command: 'cd scripts/build/release_publisher && go test .' - persist_to_workspace: root: . paths: @@ -359,6 +362,9 @@ jobs: - run: name: deploy to gcp command: '/opt/google-cloud-sdk/bin/gsutil cp ./enterprise-dist/* gs://$GCP_BUCKET_NAME/enterprise/release' + - run: + name: Deploy to Grafana.com + command: './scripts/build/publish.sh --enterprise' deploy-master: docker: @@ -507,6 +513,7 @@ workflows: - grafana-docker-release: requires: - build-all + - build-all-enterprise - test-backend - test-frontend - codespell diff --git a/CHANGELOG.md b/CHANGELOG.md index ea6b5b9732f..ab5263eba03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,27 @@ -# 5.4.0 (unreleased) +# 5.5.0 (unreleased) + +### Minor + +* **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi) +* **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire) +* **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi) +* **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh) + +# 5.4.0 (2018-12-03) + +* **Cloudwatch**: Fix invalid time range causes segmentation fault [#14150](https://github.com/grafana/grafana/issues/14150) +* **Cloudwatch**: AWS/CodeBuild metrics and dimensions [#14167](https://github.com/grafana/grafana/issues/14167), thx [@mmcoltman](https://github.com/mmcoltman) +* **MySQL**: Fix `$__timeFrom()` and `$__timeTo()` should respect local time zone [#14228](https://github.com/grafana/grafana/issues/14228) + +### 5.4.0-beta1 fixes +* **Graph**: Fix legend always visible even if configured to be hidden [#14144](https://github.com/grafana/grafana/issues/14144) +* **Elasticsearch**: Fix regression when using datasource version 6.0+ and alerting [#14175](https://github.com/grafana/grafana/pull/14175) + +# 5.4.0-beta1 (2018-11-20) ### New Features +* **Alerting**: Introduce alert debouncing with the `FOR` setting. [#7886](https://github.com/grafana/grafana/issues/7886) & [#6202](https://github.com/grafana/grafana/issues/6202) * **Alerting**: Option to disable OK alert notifications [#12330](https://github.com/grafana/grafana/issues/12330) & [#6696](https://github.com/grafana/grafana/issues/6696), thx [@davewat](https://github.com/davewat) * **Postgres/MySQL/MSSQL**: Adds support for configuration of max open/idle connections and connection max lifetime. Also, panels with multiple SQL queries will now be executed concurrently [#11711](https://github.com/grafana/grafana/issues/11711), thx [@connection-reset](https://github.com/connection-reset) * **MySQL**: Graphical query builder [#13762](https://github.com/grafana/grafana/issues/13762), thx [svenklemm](https://github.com/svenklemm) @@ -10,29 +30,40 @@ * **Stackdriver**: Not possible to authenticate using GCE metadata server [#13669](https://github.com/grafana/grafana/issues/13669) * **Teams**: Team preferences (theme, home dashboard, timezone) support [#12550](https://github.com/grafana/grafana/issues/12550) * **Graph**: Time regions support enabling highlight of weekdays and/or certain timespans [#5930](https://github.com/grafana/grafana/issues/5930) +* **OAuth**: Automatic redirect to sign-in with OAuth [#11893](https://github.com/grafana/grafana/issues/11893), thx [@Nick-Triller](https://github.com/Nick-Triller) +* **Stackdriver**: Template query editor [#13561](https://github.com/grafana/grafana/issues/13561) ### Minor +* **Security**: Upgrade macaron session package to fix security issue. [#14043](https://github.com/grafana/grafana/pull/14043) * **Cloudwatch**: Show all available CloudWatch regions [#12308](https://github.com/grafana/grafana/issues/12308), thx [@mtanda](https://github.com/mtanda) * **Cloudwatch**: AWS/Connect metrics and dimensions [#13970](https://github.com/grafana/grafana/pull/13970), thx [@zcoffy](https://github.com/zcoffy) +* **Cloudwatch**: CloudHSM metrics and dimensions [#14129](https://github.com/grafana/grafana/pull/14129), thx [@daktari](https://github.com/daktari) +* **Cloudwatch**: Enable using variables in the stats field [#13810](https://github.com/grafana/grafana/issues/13810), thx [@mtanda](https://github.com/mtanda) * **Postgres**: Add delta window function to postgres query builder [#13925](https://github.com/grafana/grafana/issues/13925), thx [svenklemm](https://github.com/svenklemm) * **Elasticsearch**: Fix switching to/from es raw document metric query [#6367](https://github.com/grafana/grafana/issues/6367) * **Elasticsearch**: Fix deprecation warning about terms aggregation order key in Elasticsearch 6.x [#11977](https://github.com/grafana/grafana/issues/11977) +* **Graph**: Render dots when no connecting line can be made [#13605](https://github.com/grafana/grafana/issues/13605), thx [@jsferrei](https://github.com/jsferrei) * **Table**: Fix CSS alpha background-color applied twice in table cell with link [#13606](https://github.com/grafana/grafana/issues/13606), thx [@grisme](https://github.com/grisme) +* **Singlestat**: Fix XSS in prefix/postfix [#13946](https://github.com/grafana/grafana/issues/13946), thx [@cinaglia](https://github.com/cinaglia) * **Units**: New clock time format, to format ms or second values as for example `01h:59m`, [#13635](https://github.com/grafana/grafana/issues/13635), thx [@franciscocpg](https://github.com/franciscocpg) * **Alerting**: Increaste default duration for queries [#13945](https://github.com/grafana/grafana/pull/13945) * **Alerting**: More options for the Slack Alert notifier [#13993](https://github.com/grafana/grafana/issues/13993), thx [@andreykaipov](https://github.com/andreykaipov) * **Alerting**: Can't receive DingDing alert when alert is triggered [#13723](https://github.com/grafana/grafana/issues/13723), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino) +* **Alerting**: Increase Telegram captions length limit [#13876](https://github.com/grafana/grafana/pull/13876), thx [@skgsergio](https://github.com/skgsergio) * **Internal metrics**: Renamed `grafana_info` to `grafana_build_info` and added branch, goversion and revision [#13876](https://github.com/grafana/grafana/pull/13876) * **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu) +* **OAuth**: Fix Google OAuth relies on email, not google account id [#13924](https://github.com/grafana/grafana/issues/13924), thx [@vinicyusmacedo](https://github.com/vinicyusmacedo) +* **Dashboard**: Toggle legend using keyboard shortcut [#13655](https://github.com/grafana/grafana/issues/13655), thx [@davewat](https://github.com/davewat) +* **Dashboard**: Fix render dashboard row drag handle only in edit mode [#13555](https://github.com/grafana/grafana/issues/13555), thx [@praveensastry](https://github.com/praveensastry) +* **Teams**: Fix cannot select team if not included in initial search [#13425](https://github.com/grafana/grafana/issues/13425) +* **Render**: Support full height screenshots using phantomjs render script [#13352](https://github.com/grafana/grafana/pull/13352), thx [@amuraru](https://github.com/amuraru) +* **HTTP API**: Support retrieving teams by user [#14120](https://github.com/grafana/grafana/pull/14120), thx [@supercharlesliu](https://github.com/supercharlesliu) +* **Metrics**: Add basic authentication to metrics endpoint [#13577](https://github.com/grafana/grafana/issues/13577), thx [@bobmshannon](https://github.com/bobmshannon) ### Breaking changes -* Postgres/MySQL/MSSQL datasources now per default uses `max open connections` = `unlimited` (earlier 10), `max idle connections` = `2` (earlier 10) and `connection max lifetime` = `4` hours (earlier unlimited) - -# 5.3.5 (unreleased) - -* **Security**: Upgrade macaron session package to fix security issue. [#14043](https://github.com/grafana/grafana/pull/14043) +* Postgres/MySQL/MSSQL datasources now per default uses `max open connections` = `unlimited` (earlier 10), `max idle connections` = `2` (earlier 10) and `connection max lifetime` = `4` hours (earlier unlimited). # 5.3.4 (2018-11-13) @@ -140,7 +171,7 @@ See [security announcement](https://community.grafana.com/t/grafana-5-3-3-and-4- * **Alerting**: Fix rendering timeout which could cause notifications to not be sent due to rendering timing out [#12151](https://github.com/grafana/grafana/issues/12151) * **Docker**: Make it possible to set a specific plugin url [#12861](https://github.com/grafana/grafana/pull/12861), thx [ClementGautier](https://github.com/ClementGautier) * **GrafanaCli**: Fixed issue with grafana-cli install plugin resulting in corrupt http response from source error. Fixes [#13079](https://github.com/grafana/grafana/issues/13079) -* **Provisioning**: Should allow one default datasource per organisation [#12229](https://github.com/grafana/grafana/issues/12229) +* **Provisioning**: Should allow one default datasource per organization [#12229](https://github.com/grafana/grafana/issues/12229) * **Github OAuth**: Allow changes of user info at Github to be synched to Grafana when signing in [#11818](https://github.com/grafana/grafana/issues/11818), thx [@rwaweber](https://github.com/rwaweber) * **OAuth**: Fix overriding tls_skip_verify_insecure using environment variable [#12747](https://github.com/grafana/grafana/issues/12747), thx [@jangaraj](https://github.com/jangaraj) * **Prometheus**: Fix graph panel bar width issue in aligned prometheus queries [#12379](https://github.com/grafana/grafana/issues/12379) @@ -263,7 +294,7 @@ See [security announcement](https://community.grafana.com/t/grafana-5-2-3-and-4- * **Dashboard**: Prevent double-click when saving dashboard [#11963](https://github.com/grafana/grafana/issues/11963) * **Dashboard**: AutoFocus the add-panel search filter [#12189](https://github.com/grafana/grafana/pull/12189) thx [@ryantxu](https://github.com/ryantxu) * **Units**: W/m2 (energy), l/h (flow) and kPa (pressure) [#11233](https://github.com/grafana/grafana/pull/11233), thx [@flopp999](https://github.com/flopp999) -* **Units**: Litre/min (flow) and milliLitre/min (flow) [#12282](https://github.com/grafana/grafana/pull/12282), thx [@flopp999](https://github.com/flopp999) +* **Units**: Liter/min (flow) and milliLiter/min (flow) [#12282](https://github.com/grafana/grafana/pull/12282), thx [@flopp999](https://github.com/flopp999) * **Alerting**: Fix mobile notifications for Microsoft Teams alert notifier [#11484](https://github.com/grafana/grafana/pull/11484), thx [@manacker](https://github.com/manacker) * **Influxdb**: Add support for mode function [#12286](https://github.com/grafana/grafana/issues/12286) * **Cloudwatch**: Fixes panic caused by bad timerange settings [#12199](https://github.com/grafana/grafana/issues/12199) @@ -398,7 +429,7 @@ See [security announcement](https://community.grafana.com/t/grafana-5-2-3-and-4- * **Units**: Use B/s instead Bps for Bytes per second [#9342](https://github.com/grafana/grafana/pull/9342), thx [@mayli](https://github.com/mayli) * **Units**: Radiation units [#11001](https://github.com/grafana/grafana/issues/11001), thx [@victorclaessen](https://github.com/victorclaessen) * **Units**: Timeticks unit [#11183](https://github.com/grafana/grafana/pull/11183), thx [@jtyr](https://github.com/jtyr) -* **Units**: Concentration units and "Normal cubic metre" [#11211](https://github.com/grafana/grafana/issues/11211), thx [@flopp999](https://github.com/flopp999) +* **Units**: Concentration units and "Normal cubic meter" [#11211](https://github.com/grafana/grafana/issues/11211), thx [@flopp999](https://github.com/flopp999) * **Units**: New currency - Czech koruna [#11384](https://github.com/grafana/grafana/pull/11384), thx [@Rohlik](https://github.com/Rohlik) * **Avatar**: Fix DISABLE_GRAVATAR option [#11095](https://github.com/grafana/grafana/issues/11095) * **Heatmap**: Disable log scale when using time time series buckets [#10792](https://github.com/grafana/grafana/issues/10792) @@ -715,7 +746,7 @@ See [security announcement](https://community.grafana.com/t/grafana-5-2-3-and-4- ## Enhancements * **GitHub OAuth**: Support for GitHub organizations with 100+ teams. [#8846](https://github.com/grafana/grafana/issues/8846), thx [@skwashd](https://github.com/skwashd) -* **Graphite**: Calls to Graphite api /metrics/find now include panel or dashboad time range (from & until) in most cases, [#8055](https://github.com/grafana/grafana/issues/8055) +* **Graphite**: Calls to Graphite api /metrics/find now include panel or dashboard time range (from & until) in most cases, [#8055](https://github.com/grafana/grafana/issues/8055) * **Graphite**: Added new graphite 1.0 functions, available if you set version to 1.0.x in data source settings. New Functions: mapSeries, reduceSeries, isNonNull, groupByNodes, offsetToZero, grep, weightedAverage, removeEmptySeries, aggregateLine, averageOutsidePercentile, delay, exponentialMovingAverage, fallbackSeries, integralByInterval, interpolate, invert, linearRegression, movingMin, movingMax, movingSum, multiplySeriesWithWildcards, pow, powSeries, removeBetweenPercentile, squareRoot, timeSlice, closes [#8261](https://github.com/grafana/grafana/issues/8261) - **Elasticsearch**: Ad-hoc filters now use query phrase match filters instead of term filters, works on non keyword/raw fields [#9095](https://github.com/grafana/grafana/issues/9095). @@ -880,7 +911,7 @@ Pull Request: [#8472](https://github.com/grafana/grafana/pull/8472) * **InfluxDB**: Influxb Datasource test passes even if the Database doesn't exist [#7864](https://github.com/grafana/grafana/issues/7864) * **Prometheus**: Displaying Prometheus annotations is incredibly slow [#7750](https://github.com/grafana/grafana/issues/7750), thx [@mtanda](https://github.com/mtanda) * **Graphite**: grafana generates empty find query to graphite -> 422 Unprocessable Entity [#7740](https://github.com/grafana/grafana/issues/7740) -* **Admin**: make organisation filter case insensitive [#8194](https://github.com/grafana/grafana/issues/8194), thx [@Alexander-N](https://github.com/Alexander-N) +* **Admin**: make organization filter case insensitive [#8194](https://github.com/grafana/grafana/issues/8194), thx [@Alexander-N](https://github.com/Alexander-N) ## Changes * **Elasticsearch**: Changed elasticsearch Terms aggregation to default to Min Doc Count to 1, and sort order to Top [#8321](https://github.com/grafana/grafana/issues/8321) @@ -1008,7 +1039,7 @@ Pull Request: [#8472](https://github.com/grafana/grafana/pull/8472) * **CLI**: Make it possible to reset the admin password using the grafana-cli. [#5479](https://github.com/grafana/grafana/issues/5479) * **Influxdb**: Support multiple tags in InfluxDB annotations. [#4550](https://github.com/grafana/grafana/pull/4550), thx [@adrianlzt](https://github.com/adrianlzt) * **LDAP**: Basic Auth now supports LDAP username and password, [#6940](https://github.com/grafana/grafana/pull/6940), thx [@utkarshcmu](https://github.com/utkarshcmu) -* **LDAP**: Now works with Auth Proxy, role and organisation mapping & sync will regularly be performed. [#6895](https://github.com/grafana/grafana/pull/6895), thx [@Seuf](https://github.com/seuf) +* **LDAP**: Now works with Auth Proxy, role and organization mapping & sync will regularly be performed. [#6895](https://github.com/grafana/grafana/pull/6895), thx [@Seuf](https://github.com/seuf) * **Alerting**: Adds OK as no data option. [#6866](https://github.com/grafana/grafana/issues/6866) * **Alert list**: Order alerts based on state. [#6676](https://github.com/grafana/grafana/issues/6676) * **Alerting**: Add api endpoint for pausing all alerts. [#6589](https://github.com/grafana/grafana/issues/6589) @@ -1147,7 +1178,7 @@ due to too many connections/file handles on the data source backend. This proble * **Scripts**: Use restart instead of start for deb package script, closes [#5282](https://github.com/grafana/grafana/pull/5282) * **Logging**: Moved to structured logging lib, and moved to component specific level filters via config file, closes [#4590](https://github.com/grafana/grafana/issues/4590) * **OpenTSDB**: Support nested template variables in tag_values function, closes [#4398](https://github.com/grafana/grafana/issues/4398) -* **Datasource**: Pending data source requests are cancelled before new ones are issues (Graphite & Prometheus), closes [#5321](https://github.com/grafana/grafana/issues/5321) +* **Datasource**: Pending data source requests are canceled before new ones are issues (Graphite & Prometheus), closes [#5321](https://github.com/grafana/grafana/issues/5321) ### Breaking changes * **Logging** : Changed default logging output format (now structured into message, and key value pairs, with logger key acting as component). You can also no change in config to json log output. @@ -1851,7 +1882,7 @@ Read this [blog post](https://grafana.com/blog/2014/09/11/grafana-1.8.0-rc1-rele #### Fixes - [Issue #126](https://github.com/grafana/grafana/issues/126). Graphite query lexer change, can now handle regex parameters for aliasSub function -- [Issue #447](https://github.com/grafana/grafana/issues/447). Filter option loading when having muliple nested filters now works better. Options are now reloaded correctly and there are no multiple renders/refresh in between. +- [Issue #447](https://github.com/grafana/grafana/issues/447). Filter option loading when having multiple nested filters now works better. Options are now reloaded correctly and there are no multiple renders/refresh in between. - [Issue #412](https://github.com/grafana/grafana/issues/412). After a filter option is changed and a nested template param is reloaded, if the current value exists after the options are reloaded the current selected value is kept. - [Issue #460](https://github.com/grafana/grafana/issues/460). Legend Current value did not display when value was zero - [Issue #328](https://github.com/grafana/grafana/issues/328). Fix to series toggling bug that caused annotations to be hidden when toggling/hiding series. @@ -1886,7 +1917,7 @@ Read this [blog post](https://grafana.com/blog/2014/09/11/grafana-1.8.0-rc1-rele - Graphite errors are now much easier to see and troubleshoot with the new inspector ([Issue #265](https://github.com/grafana/grafana/issues/265)) - Use influxdb aliases to distinguish between multiple columns ([Issue #283](https://github.com/grafana/grafana/issues/283)) - Correction to ms axis formater, now formats days correctly. ([Issue #189](https://github.com/grafana/grafana/issues/189)) -- Css fix for Firefox and using top menu dropdowns in panel fullscren / edit mode ([Issue #106](https://github.com/grafana/grafana/issues/106)) +- Css fix for Firefox and using top menu dropdowns in panel fullscreen / edit mode ([Issue #106](https://github.com/grafana/grafana/issues/106)) - Browser page title is now Grafana - {{dashboard title}} ([Issue #294](https://github.com/grafana/grafana/issues/294)) - Disable auto refresh zooming in (every time you change to an absolute time range), refresh will be restored when you change time range back to relative ([Issue #282](https://github.com/grafana/grafana/issues/282)) - More graphite functions diff --git a/Dockerfile b/Dockerfile index 28dd71952af..65260e1a6a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,7 +50,8 @@ ENV PATH=/usr/share/grafana/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bi WORKDIR $GF_PATHS_HOME -RUN apt-get update && apt-get install -qq -y libfontconfig ca-certificates && \ +RUN apt-get update && apt-get upgrade -y && \ + apt-get install -qq -y libfontconfig ca-certificates && \ apt-get autoremove -y && \ rm -rf /var/lib/apt/lists/* diff --git a/Makefile b/Makefile index fcb740d2fac..6410714d4fc 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ build: build-go build-js build-docker-dev: @echo "\033[92mInfo:\033[0m the frontend code is expected to be built already." - go run build.go -goos linux -pkg-arch amd64 ${OPT} build package-only latest + go run build.go -goos linux -pkg-arch amd64 ${OPT} build pkg-archive latest cp dist/grafana-latest.linux-x64.tar.gz packaging/docker cd packaging/docker && docker build --tag grafana/grafana:dev . diff --git a/README.md b/README.md index 5882ea8a6a3..269c525e983 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ Choose this option to build on platforms other than linux/amd64 and/or not have The resulting image will be tagged as `grafana/grafana:dev` +Notice: If you are using Docker for MacOS, be sure to let limit of Memory bigger than 2 GiB (at docker -> Perferences -> Advanced), otherwize you may faild at `grunt build` + ### Dev config Create a custom.ini in the conf directory to override default configuration options. diff --git a/UPGRADING_DEPENDENCIES.md b/UPGRADING_DEPENDENCIES.md index f3d2adbd71a..7d489556981 100644 --- a/UPGRADING_DEPENDENCIES.md +++ b/UPGRADING_DEPENDENCIES.md @@ -47,7 +47,7 @@ Our builds run on CircleCI through our build script. ### grafana/build-container -The main build step (in CircleCI) is built using a custom build container that comes pre-baked with some of the neccesary dependencies. +The main build step (in CircleCI) is built using a custom build container that comes pre-baked with some of the necessary dependencies. Link: [grafana-build-container](https://github.com/grafana/grafana-build-container) @@ -86,4 +86,4 @@ There is a Docker build for Grafana in the root of the project that allows anyon ### Local developer environments -Please send out a notice in the grafana-dev slack channel when updating Go or Node.js to make it easier for everyone to update their local developer environments. \ No newline at end of file +Please send out a notice in the grafana-dev slack channel when updating Go or Node.js to make it easier for everyone to update their local developer environments. diff --git a/build.go b/build.go index dc789670f62..9d5216de1d0 100644 --- a/build.go +++ b/build.go @@ -128,6 +128,8 @@ func main() { if goos == linux { createLinuxPackages() } + case "pkg-archive": + grunt(gruntBuildArg("package")...) case "pkg-rpm": grunt(gruntBuildArg("release")...) diff --git a/conf/defaults.ini b/conf/defaults.ini index 679a6a88eb7..a9aa2239b16 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -34,7 +34,7 @@ protocol = http # The ip address to bind to, empty will bind to all interfaces http_addr = -# The http port to use +# The http port to use http_port = 3000 # The public facing domain name used to access grafana from a browser @@ -166,7 +166,7 @@ google_tag_manager_id = # default admin user, created on startup admin_user = admin -# default admin password, can be changed before first start of grafana, or in profile settings +# default admin password, can be changed before first start of grafana, or in profile settings admin_password = admin # used for signing @@ -372,7 +372,7 @@ templates_pattern = emails/*.html #################################### Logging ########################## [log] -# Either "console", "file", "syslog". Default is console and file +# Either "console", "file", "syslog". Default is console and file # Use space to separate multiple modes, e.g. "console file" mode = console file @@ -490,6 +490,10 @@ enabled = false enabled = true interval_seconds = 10 +#If both are set, basic auth will be required for the metrics endpoint. +basic_auth_username = +basic_auth_password = + # Send internal Grafana metrics to graphite [metrics.graphite] # Enable by setting the address setting (ex localhost:2003) @@ -561,4 +565,3 @@ enable_alpha = false [enterprise] license_path = - diff --git a/conf/provisioning/datasources/sample.yaml b/conf/provisioning/datasources/sample.yaml index 37487dc4b3b..7bef8618a31 100644 --- a/conf/provisioning/datasources/sample.yaml +++ b/conf/provisioning/datasources/sample.yaml @@ -7,7 +7,7 @@ apiVersion: 1 # orgId: 1 # # list of datasources to insert/update depending -# # on what's available in the datbase +# # on what's available in the database #datasources: # # name of the datasource. Required # - name: Graphite diff --git a/conf/sample.ini b/conf/sample.ini index c6b716a731d..3c61b2b61d1 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -145,6 +145,9 @@ log_queries = # Google Analytics universal tracking code, only enabled if you specify an id here ;google_analytics_ua_id = +# Google Tag Manager ID, only enabled if you specify an id here +;google_tag_manager_id = + #################################### Security #################################### [security] # default admin user, created on startup diff --git a/devenv/dev-dashboards/testdata_alerts.json b/devenv/dev-dashboards/testdata_alerts.json index 8c2edebf155..9f36638c012 100644 --- a/devenv/dev-dashboards/testdata_alerts.json +++ b/devenv/dev-dashboards/testdata_alerts.json @@ -1,250 +1,681 @@ { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "alert": { + "conditions": [ + { + "evaluator": { + "params": [ + 60 + ], + "type": "gt" + }, + "query": { + "params": [ + "A", + "5m", + "now" + ] + }, + "reducer": { + "params": [], + "type": "avg" + }, + "type": "query" + } + ], + "enabled": true, + "frequency": "60s", + "handler": 1, + "name": "TestData - Always OK", + "noDataState": "no_data", + "notifications": [] + }, + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "editable": true, + "error": false, + "fill": 1, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 3, + "isNew": true, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenario": "random_walk", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + } + ], + "thresholds": [ + { + "colorMode": "critical", + "fill": true, + "line": true, + "op": "gt", + "value": 60 + } + ], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Always OK", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": "", + "logBase": 1, + "max": "125", + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "alert": { + "conditions": [ + { + "evaluator": { + "params": [ + 177 + ], + "type": "gt" + }, + "query": { + "params": [ + "A", + "5m", + "now" + ] + }, + "reducer": { + "params": [], + "type": "avg" + }, + "type": "query" + } + ], + "enabled": true, + "executionErrorState": "alerting", + "for": "0m", + "frequency": "60s", + "handler": 1, + "name": "TestData - Always Alerting", + "noDataState": "no_data", + "notifications": [] + }, + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "editable": true, + "error": false, + "fill": 1, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 4, + "isNew": true, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenario": "random_walk", + "scenarioId": "csv_metric_values", + "stringInput": "200,445,100,150,200,220,190", + "target": "" + } + ], + "thresholds": [ + { + "colorMode": "critical", + "fill": true, + "line": true, + "op": "gt", + "value": 177 + } + ], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Always Alerting", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "alert": { + "conditions": [ + { + "evaluator": { + "params": [ + 1 + ], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": [ + "A", + "15m", + "now" + ] + }, + "reducer": { + "params": [], + "type": "avg" + }, + "type": "query" + } + ], + "executionErrorState": "alerting", + "for": "5m", + "frequency": "1m", + "handler": 1, + "name": "TestData - No data", + "noDataState": "no_data", + "notifications": [] + }, + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "editable": true, + "error": false, + "fill": 1, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 7 + }, + "id": 5, + "isNew": true, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenario": "random_walk", + "scenarioId": "no_data_points", + "stringInput": "", + "target": "" + } + ], + "thresholds": [ + { + "colorMode": "critical", + "fill": true, + "line": true, + "op": "gt", + "value": 1 + } + ], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "No data", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "alert": { + "conditions": [ + { + "evaluator": { + "params": [ + 177 + ], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": [ + "A", + "15m", + "now" + ] + }, + "reducer": { + "params": [], + "type": "avg" + }, + "type": "query" + } + ], + "executionErrorState": "alerting", + "for": "1m", + "frequency": "1m", + "handler": 1, + "name": "TestData - Always Pending", + "noDataState": "no_data", + "notifications": [] + }, + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "editable": true, + "error": false, + "fill": 1, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 7 + }, + "id": 6, + "isNew": true, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenario": "random_walk", + "scenarioId": "csv_metric_values", + "stringInput": "200,445,100,150,200,220,190", + "target": "" + } + ], + "thresholds": [ + { + "colorMode": "critical", + "fill": true, + "line": true, + "op": "gt", + "value": 177 + } + ], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Always Alerting with For", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "alert": { + "conditions": [ + { + "evaluator": { + "params": [ + 100 + ], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": [ + "A", + "5m", + "now" + ] + }, + "reducer": { + "params": [], + "type": "avg" + }, + "type": "query" + } + ], + "executionErrorState": "alerting", + "for": "900000h", + "frequency": "1m", + "handler": 1, + "name": "Always Pending", + "noDataState": "no_data", + "notifications": [] + }, + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "editable": true, + "error": false, + "fill": 1, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 14 + }, + "id": 7, + "isNew": true, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenario": "random_walk", + "scenarioId": "csv_metric_values", + "stringInput": "200,445,100,150,200,220,190", + "target": "" + } + ], + "thresholds": [ + { + "colorMode": "critical", + "fill": true, + "line": true, + "op": "gt", + "value": 100 + } + ], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Always Alerting with For", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], "revision": 2, - "title": "Alerting with TestData", + "schemaVersion": 16, + "style": "dark", "tags": [ "grafana-test" ], - "style": "dark", - "timezone": "browser", - "editable": true, - "hideControls": false, - "sharedCrosshair": false, - "rows": [ - { - "collapse": false, - "editable": true, - "height": 255.625, - "panels": [ - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 60 - ], - "type": "gt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "60s", - "handler": 1, - "name": "TestData - Always OK", - "noDataState": "no_data", - "notifications": [] - }, - "aliasColors": {}, - "bars": false, - "datasource": "gdev-testdata", - "editable": true, - "error": false, - "fill": 1, - "id": 3, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 6, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "scenario": "random_walk", - "scenarioId": "csv_metric_values", - "stringInput": "1,20,90,30,5,0", - "target": "" - } - ], - "thresholds": [ - { - "value": 60, - "op": "gt", - "fill": true, - "line": true, - "colorMode": "critical" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Always OK", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": "", - "logBase": 1, - "max": "125", - "min": "0", - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ] - }, - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 177 - ], - "type": "gt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "60s", - "handler": 1, - "name": "TestData - Always Alerting", - "noDataState": "no_data", - "notifications": [] - }, - "aliasColors": {}, - "bars": false, - "datasource": "gdev-testdata", - "editable": true, - "error": false, - "fill": 1, - "id": 4, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 6, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "scenario": "random_walk", - "scenarioId": "csv_metric_values", - "stringInput": "200,445,100,150,200,220,190", - "target": "" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "line": true, - "op": "gt", - "value": 177 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Always Alerting", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "short", - "label": "", - "logBase": 1, - "max": null, - "min": "0", - "show": true - }, - { - "format": "short", - "label": "", - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ] - } - ], - "title": "New row" - } - ], + "templating": { + "list": [] + }, "time": { "from": "now-6h", "to": "now" @@ -274,14 +705,8 @@ "30d" ] }, - "templating": { - "list": [] - }, - "annotations": { - "list": [] - }, - "schemaVersion": 13, - "version": 4, - "links": [], - "gnetId": null -} + "timezone": "browser", + "title": "Alerting with TestData", + "uid": "7MeksYbmk", + "version": 7 +} \ No newline at end of file diff --git a/devenv/docker/ha_test/docker-compose.yaml b/devenv/docker/ha_test/docker-compose.yaml index ce8630d88a4..1195e2a977c 100644 --- a/devenv/docker/ha_test/docker-compose.yaml +++ b/devenv/docker/ha_test/docker-compose.yaml @@ -9,7 +9,7 @@ services: - /var/run/docker.sock:/tmp/docker.sock:ro db: - image: mysql + image: mysql:5.6 environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: grafana diff --git a/devenv/docker/ha_test/grafana/provisioning/alerts.jsonnet b/devenv/docker/ha_test/grafana/provisioning/alerts.jsonnet index 86ded7e79d6..e9b8abfbb9c 100644 --- a/devenv/docker/ha_test/grafana/provisioning/alerts.jsonnet +++ b/devenv/docker/ha_test/grafana/provisioning/alerts.jsonnet @@ -39,6 +39,7 @@ local alertDashboardTemplate = { "executionErrorState": "alerting", "frequency": "10s", "handler": 1, + "for": "1m", "name": "bulk alerting", "noDataState": "no_data", "notifications": [ diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index 60e89b486a5..b2a1b1f42e7 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -230,4 +230,4 @@ By default Grafana will delete dashboards in the database if the file is removed > **Note.** Provisioning allows you to overwrite existing dashboards > which leads to problems if you re-use settings that are supposed to be unique. > Be careful not to re-use the same `title` multiple times within a folder -> or `uid` within the same installation as this will cause weird behaviours. +> or `uid` within the same installation as this will cause weird behaviors. diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index 670ef9595f8..5327039341f 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -55,6 +55,10 @@ Alert rule evaluation interval | Send reminders every | Reminder sent every (aft
+### Disable resolve message + +When checked, this option will disable resolve message [OK] that is sent when alerting state returns to false. + ## Supported Notification Types Grafana ships with the following set of notification types: @@ -132,7 +136,7 @@ In DingTalk PC Client: 2. Click "Robot Manage" item in the pop menu, there will be a new panel call "Robot Manage". -3. In the "Robot Manage" panel, select "customised: customised robot with Webhook". +3. In the "Robot Manage" panel, select "customized: customized robot with Webhook". 4. In the next new panel named "robot detail", click "Add" button. diff --git a/docs/sources/alerting/rules.md b/docs/sources/alerting/rules.md index 488619055e2..036ff97056d 100644 --- a/docs/sources/alerting/rules.md +++ b/docs/sources/alerting/rules.md @@ -39,7 +39,7 @@ Currently alerting supports a limited form of high availability. Since v4.2.0 of ## Rule Config -{{< imgbox max-width="40%" img="/img/docs/v4/alerting_conditions.png" caption="Alerting Conditions" >}} + Currently only the graph panel supports alert rules but this will be added to the **Singlestat** and **Table** panels as well in a future release. @@ -48,6 +48,19 @@ panels as well in a future release. Here you can specify the name of the alert rule and how often the scheduler should evaluate the alert rule. +### For + +> This setting is available in Grafana 5.4 and above. + +If an alert rule has a configured `For` and the query violates the configured threshold it will first go from `OK` to `Pending`. Going from `OK` to `Pending` Grafana will not send any notifications. Once the alert rule has been firing for more than `For` duration, it will change to `Alerting` and send alert notifications. + +Typically, it's always a good idea to use this setting since it's often worse to get false positive than wait a few minutes before the alert notification triggers. Looking at the `Alert list` or `Alert list panels` you will be able to see alerts in pending state. + +Below you can see an example timeline of an alert using the `For` setting. At ~16:04 the alert state changes to `Pending` and after 4 minutes it changes to `Alerting` which is when alert notifications are sent. Once the series falls back to normal the alert rule goes back to `OK`. +{{< imgbox img="/img/docs/v54/alerting-for-dark-theme.png" caption="Alerting For" >}} + +{{< imgbox max-width="40%" img="/img/docs/v4/alerting_conditions.png" caption="Alerting Conditions" >}} + ### Conditions Currently the only condition type that exists is a `Query` condition that allows you to @@ -57,11 +70,11 @@ specify a query letter, time range and an aggregation function. ### Query condition example ```sql -avg() OF query(A, 5m, now) IS BELOW 14 +avg() OF query(A, 15m, now) IS BELOW 14 ``` - `avg()` Controls how the values for **each** series should be reduced to a value that can be compared against the threshold. Click on the function to change it to another aggregation function. -- `query(A, 5m, now)` The letter defines what query to execute from the **Metrics** tab. The second two parameters define the time range, `5m, now` means 5 minutes ago to now. You can also do `10m, now-2m` to define a time range that will be 10 minutes ago to 2 minutes ago. This is useful if you want to ignore the last 2 minutes of data. +- `query(A, 15m, now)` The letter defines what query to execute from the **Metrics** tab. The second two parameters define the time range, `15m, now` means 15 minutes ago to now. You can also do `10m, now-2m` to define a time range that will be 10 minutes ago to 2 minutes ago. This is useful if you want to ignore the last 2 minutes of data. - `IS BELOW 14` Defines the type of threshold and the threshold value. You can click on `IS BELOW` to change the type of threshold. The query used in an alert rule cannot contain any template variables. Currently we only support `AND` and `OR` operators between conditions and they are executed serially. diff --git a/docs/sources/auth/ldap.md b/docs/sources/auth/ldap.md index 4a884a60d15..d0ae9094d66 100644 --- a/docs/sources/auth/ldap.md +++ b/docs/sources/auth/ldap.md @@ -3,6 +3,7 @@ title = "LDAP Authentication" description = "Grafana LDAP Authentication Guide " keywords = ["grafana", "configuration", "documentation", "ldap", "active directory"] type = "docs" +aliases = ["/installation/ldap/"] [menu.docs] name = "LDAP" identifier = "ldap" @@ -162,9 +163,9 @@ org_role = "Viewer" Setting | Required | Description | Default ------------ | ------------ | ------------- | ------------- `group_dn` | Yes | LDAP distinguished name (DN) of LDAP group. If you want to match all (or no LDAP groups) then you can use wildcard (`"*"`) | -`org_role` | Yes | Assign users of `group_dn` the organisation role `"Admin"`, `"Editor"` or `"Viewer"` | +`org_role` | Yes | Assign users of `group_dn` the organization role `"Admin"`, `"Editor"` or `"Viewer"` | `org_id` | No | The Grafana organization database id. Setting this allows for multiple group_dn's to be assigned to the same `org_role` provided the `org_id` differs | `1` (default org id) -`grafana_admin` | No | When `true` makes user of `group_dn` Grafana server admin. A Grafana server admin has admin access over all organisations and users. Available in Grafana v5.3 and above | `false` +`grafana_admin` | No | When `true` makes user of `group_dn` Grafana server admin. A Grafana server admin has admin access over all organizations and users. Available in Grafana v5.3 and above | `false` ### Nested/recursive group membership diff --git a/docs/sources/auth/overview.md b/docs/sources/auth/overview.md index a372600ac46..0480ee88adc 100644 --- a/docs/sources/auth/overview.md +++ b/docs/sources/auth/overview.md @@ -73,7 +73,18 @@ You can hide the Grafana login form using the below configuration settings. ```bash [auth] -disable_login_form ⁼ true +disable_login_form = true +``` + +### Automatic OAuth login + +Set to true to attempt login with OAuth automatically, skipping the login screen. +This setting is ignored if multiple OAuth providers are configured. +Defaults to `false`. + +```bash +[auth] +oauth_auto_login = true ``` ### Hide sign-out menu @@ -84,3 +95,12 @@ Set to the option detailed below to true to hide sign-out menu link. Useful if y [auth] disable_signout_menu = true ``` + +### URL redirect after signing out + +URL to redirect the user to after signing out from Grafana. This can for example be used to enable signout from oauth provider. + +```bash +[auth] +signout_redirect_url = +``` diff --git a/docs/sources/contribute/cla.md b/docs/sources/contribute/cla.md index a073a9a4eae..9a78b651d28 100644 --- a/docs/sources/contribute/cla.md +++ b/docs/sources/contribute/cla.md @@ -1,6 +1,6 @@ +++ -title = "Contributor Licence Agreement (CLA)" -description = "Contributor Licence Agreement (CLA)" +title = "Contributor License Agreement (CLA)" +description = "Contributor License Agreement (CLA)" type = "docs" aliases = ["/project/cla", "docs/contributing/cla.html"] [menu.docs] diff --git a/docs/sources/enterprise/index.md b/docs/sources/enterprise/index.md index f65fa55f02b..5d524dcbee2 100644 --- a/docs/sources/enterprise/index.md +++ b/docs/sources/enterprise/index.md @@ -31,7 +31,7 @@ Datasource permissions allow you to restrict query access to only specific Teams ### Premium Plugins -With a Grafana Enterprise licence you will get access to premium plugins, including: +With a Grafana Enterprise license you will get access to premium plugins, including: * [Splunk](https://grafana.com/plugins/grafana-splunk-datasource) * [AppDynamics](https://grafana.com/plugins/dlopes7-appdynamics-datasource) diff --git a/docs/sources/features/datasources/mysql.md b/docs/sources/features/datasources/mysql.md index bc4e4df6cf9..371c92cde70 100644 --- a/docs/sources/features/datasources/mysql.md +++ b/docs/sources/features/datasources/mysql.md @@ -133,9 +133,9 @@ Macro example | Description ------------ | ------------- *$__time(dateColumn)* | Will be replaced by an expression to convert to a UNIX timestamp and rename the column to `time_sec`. For example, *UNIX_TIMESTAMP(dateColumn) as time_sec* *$__timeEpoch(dateColumn)* | Will be replaced by an expression to convert to a UNIX timestamp and rename the column to `time_sec`. For example, *UNIX_TIMESTAMP(dateColumn) as time_sec* -*$__timeFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name. For example, *dateColumn BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:06:17Z'* -*$__timeFrom()* | Will be replaced by the start of the currently active time selection. For example, *'2017-04-21T05:01:17Z'* -*$__timeTo()* | Will be replaced by the end of the currently active time selection. For example, *'2017-04-21T05:06:17Z'* +*$__timeFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name. For example, *dateColumn BETWEEN FROM_UNIXTIME(1494410783) AND FROM_UNIXTIME(1494410983)* +*$__timeFrom()* | Will be replaced by the start of the currently active time selection. For example, *FROM_UNIXTIME(1494410783)* +*$__timeTo()* | Will be replaced by the end of the currently active time selection. For example, *FROM_UNIXTIME(1494410983)* *$__timeGroup(dateColumn,'5m')* | Will be replaced by an expression usable in GROUP BY clause. For example, *cast(cast(UNIX_TIMESTAMP(dateColumn)/(300) as signed)*300 as signed),* *$__timeGroup(dateColumn,'5m', 0)* | Same as above but with a fill parameter so missing points in that series will be added by grafana and 0 will be used as value. *$__timeGroup(dateColumn,'5m', NULL)* | Same as above but NULL will be used as value for missing points. diff --git a/docs/sources/features/datasources/stackdriver.md b/docs/sources/features/datasources/stackdriver.md index d19dbe4ea50..2c14d897d8e 100644 --- a/docs/sources/features/datasources/stackdriver.md +++ b/docs/sources/features/datasources/stackdriver.md @@ -158,9 +158,9 @@ Example Result: `compute.googleapis.com/instance/cpu/usage_time - server1-prod` It is also possible to resolve the name of the Monitored Resource Type. -| Alias Pattern Format | Description | Example Result | -| ------------------------ | ------------------------------------------------| ---------------- | -| `{{resource.type}}` | returns the name of the monitored resource type | `gce_instance` | +| Alias Pattern Format | Description | Example Result | +| -------------------- | ----------------------------------------------- | -------------- | +| `{{resource.type}}` | returns the name of the monitored resource type | `gce_instance` | Example Alias By: `{{resource.type}} - {{metric.type}}` @@ -177,7 +177,17 @@ types of template variables. ### Query Variable -Writing variable queries is not supported yet. +Variable of the type *Query* allows you to query Stackdriver for various types of data. The Stackdriver data source plugin provides the following `Query Types`. + +| Name | Description | +| ------------------- | ------------------------------------------------------------------------------------------------- | +| *Metric Types* | Returns a list of metric type names that are available for the specified service. | +| *Labels Keys* | Returns a list of keys for `metric label` and `resource label` in the specified metric. | +| *Labels Values* | Returns a list of values for the label in the specified metric. | +| *Resource Types* | Returns a list of resource types for the the specified metric. | +| *Aggregations* | Returns a list of aggregations (cross series reducers) for the the specified metric. | +| *Aligners* | Returns a list of aligners (per series aligners) for the the specified metric. | +| *Alignment periods* | Returns a list of all alignment periods that are available in Stackdriver query editor in Grafana | ### Using variables in queries diff --git a/docs/sources/guides/whats-new-in-v4.md b/docs/sources/guides/whats-new-in-v4.md index 7b0d2259580..1b4ed1c2a65 100644 --- a/docs/sources/guides/whats-new-in-v4.md +++ b/docs/sources/guides/whats-new-in-v4.md @@ -134,7 +134,7 @@ 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 then click it to expand the row menu. -There are 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 behavior. 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. diff --git a/docs/sources/guides/whats-new-in-v5-4.md b/docs/sources/guides/whats-new-in-v5-4.md new file mode 100644 index 00000000000..18b7b61d621 --- /dev/null +++ b/docs/sources/guides/whats-new-in-v5-4.md @@ -0,0 +1,83 @@ ++++ +title = "What's New in Grafana v5.4" +description = "Feature & improvement highlights for Grafana v5.4" +keywords = ["grafana", "new", "documentation", "5.4"] +type = "docs" +[menu.docs] +name = "Version 5.4" +identifier = "v5.4" +parent = "whatsnew" +weight = -10 ++++ + +# What's New in Grafana v5.4 + +Grafana v5.4 brings new features, many enhancements and bug fixes. This article will detail the major new features and enhancements. + +- [Alerting]({{< relref "#alerting" >}}) Limit false positives with the new `For` setting +- [Google Stackdriver]({{< relref "#google-stackdriver" >}}) Now with support for templating queries +- [MySQL]({{< relref "#mysql-query-builder" >}}) gets a new query builder! +- [Graph Panel]({{< relref "#graph-panel-enhancements" >}}) Highlight time regions and more +- [Team Preferences]({{< relref "#team-preferences" >}}) Give your teams their own home dashboard + +## Alerting + +{{< docs-imagebox img="/img/docs/v54/alerting-for-dark-theme.png" max-width="600px" class="docs-image--right" >}} + +Grafana v5.4 ships with a new alert rule setting named `For` which is great for removing false positives. If an alert rule has a configured `For` and the query violates the configured threshold it will first go from `OK` to `Pending`. Going from `OK` to `Pending` Grafana will not send any notifications. Once the alert rule has been firing for more than `For` duration, it will change to `Alerting` and send alert notifications. Typically, it's always a good idea to use this setting since it's often worse to get false positive than wait a few minutes before the alert notification triggers. + +In the screenshot you can see an example timeline of an alert using the `For` setting. At ~16:04 the alert state changes to `Pending` and after 4 minutes it changes to `Alerting` which is when alert notifications are sent. Once the series falls back to normal the alert rule goes back to `OK`. [Learn more](/alerting/rules/#for). + +Additionally, there's now support for disable the sending of `OK` alert notifications. [Learn more](/alerting/notifications/#disable-resolve-message). + +
+ +## Google Stackdriver + +{{< docs-imagebox img="/img/docs/v54/stackdriver_template_query.png" max-width="600px" class="docs-image--right" >}} + +Grafana v5.3 included built-in support for [Google Stackdriver](https://cloud.google.com/stackdriver/) which enables you to visualize your Stackdriver metrics in Grafana. +One important feature missing was support for templating queries. This is now included together with a brand new templating query editor for Stackdriver. + +The Stackdriver templating query editor lets you choose from a set of different Query Types. This will in turn reveal additional drop downs to help you +find, filter and select the templating values you're interested in, see screenshot for details. The templating query editor also supports chaining multiple variables +making it easy to define variables that's dependent on other variables. + +Stackdriver is the first datasource which has support for a custom templating query editor. But starting from Grafana v5.4 it's now possible for all datasources, including plugin datasources, to +create their very own templating query editor. + +Additionally, if Grafana is running on a Google Compute Engine (GCE) virtual machine, it is now possible for Grafana to automatically retrieve default credentials from the metadata server. +This has the advantage of not needing to generate a private key file for the service account and also not having to upload the file to Grafana. [Learn more](/features/datasources/stackdriver/#using-gce-default-service-account). + +Please read [Using Google Stackdriver in Grafana](/features/datasources/stackdriver/) for more detailed information on how to get started and use it. + +
+ +## MySQL Query Builder + +Grafana v5.4 comes with a new graphical query builder for MySQL. This brings MySQL integration more in line with some of the other datasources and makes it easier for both advanced users and beginners to work with timeseries in MySQL. Learn more about it in the [documentation](/features/datasources/mysql/#query-editor). + +{{< docs-imagebox img="/img/docs/v54/mysql_query_still.png" animated-gif="/img/docs/v54/mysql_query.gif" >}} + +## Graph Panel Enhancements + +Grafana v5.4 adds support for highlighting weekdays and/or certain timespans in the graph panel. This should make it easier to compare for example weekends, business hours and/or off work hours. + +{{< docs-imagebox img="/img/docs/v54/graph_time_regions.png" max-width= "800px" >}} + +Additionally, when rendering series as lines in the graph panel, should there be only one data point available for one series so that a connecting line cannot be established, a point will +automatically be rendered for that data point. This should make it easier to understand what's going on when only receiving a single data point. + +{{< docs-imagebox img="/img/docs/v54/graph_dot_single_point.png" max-width= "800px" >}} + +## Team Preferences + +Grafana v5.4 adds support for customizing home dashboard, timezone and theme for teams, in addition to the existing customization on Organization and user Profile level. + +1. Specifying a preference on User Profile level will override preference on Team and/or Organization level +2. Specifying a preference on Team level will override preference on Organization level. + +## 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. diff --git a/docs/sources/http_api/alerting.md b/docs/sources/http_api/alerting.md index 2d70a6d2017..0684f65f166 100644 --- a/docs/sources/http_api/alerting.md +++ b/docs/sources/http_api/alerting.md @@ -160,11 +160,14 @@ Content-Type: application/json `POST /api/admin/pause-all-alerts` +Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. + +**Example Request**: + ```http POST /api/admin/pause-all-alerts HTTP/1.1 Accept: application/json Content-Type: application/json -Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk { "paused": true diff --git a/docs/sources/http_api/index.md b/docs/sources/http_api/index.md index 2a74917a9fd..9e3d69363e8 100644 --- a/docs/sources/http_api/index.md +++ b/docs/sources/http_api/index.md @@ -26,7 +26,7 @@ dashboards, creating users and updating data sources. * [Folder Permissions API]({{< relref "http_api/folder_permissions.md" >}}) * [Folder/dashboard search API]({{< relref "/http_api/folder_dashboard_search.md" >}}) * [Data Source API]({{< relref "http_api/data_source.md" >}}) -* [Organisation API]({{< relref "http_api/org.md" >}}) +* [Organization API]({{< relref "http_api/org.md" >}}) * [Snapshot API]({{< relref "http_api/snapshot.md" >}}) * [Annotations API]({{< relref "http_api/annotations.md" >}}) * [Alerting API]({{< relref "http_api/alerting.md" >}}) diff --git a/docs/sources/http_api/org.md b/docs/sources/http_api/org.md index c55107d42f8..d1eb4ea0972 100644 --- a/docs/sources/http_api/org.md +++ b/docs/sources/http_api/org.md @@ -1,24 +1,24 @@ +++ -title = "Organisation HTTP API " -description = "Grafana Organisation HTTP API" -keywords = ["grafana", "http", "documentation", "api", "organisation"] -aliases = ["/http_api/organisation/"] +title = "Organization HTTP API " +description = "Grafana Organization HTTP API" +keywords = ["grafana", "http", "documentation", "api", "organization"] +aliases = ["/http_api/organization/"] type = "docs" [menu.docs] -name = "Organisation" +name = "Organization" parent = "http_api" +++ -# Organisation API +# Organization API -The Organisation HTTP API is divided in two resources, `/api/org` (current organisation) -and `/api/orgs` (admin organisations). One big difference between these are that -the admin of all organisations API only works with basic authentication, see [Admin Organisations API](#admin-organisations-api) for more information. +The Organization HTTP API is divided in two resources, `/api/org` (current organization) +and `/api/orgs` (admin organizations). One big difference between these are that +the admin of all organizations API only works with basic authentication, see [Admin Organizations API](#admin-organizations-api) for more information. -## Current Organisation API +## Current Organization API -### Get current Organisation +### Get current Organization `GET /api/org/` @@ -43,7 +43,7 @@ Content-Type: application/json } ``` -### Get all users within the current organisation +### Get all users within the current organization `GET /api/org/users` @@ -99,7 +99,7 @@ Content-Type: application/json {"message":"Organization user updated"} ``` -### Delete user in current organisation +### Delete user in current organization `DELETE /api/org/users/:userId` @@ -121,7 +121,7 @@ Content-Type: application/json {"message":"User removed from organization"} ``` -### Update current Organisation +### Update current Organization `PUT /api/org` @@ -147,11 +147,11 @@ Content-Type: application/json {"message":"Organization updated"} ``` -### Add a new user to the current organisation +### Add a new user to the current organization `POST /api/org/users` -Adds a global user to the current organisation. +Adds a global user to the current organization. **Example Request**: @@ -176,19 +176,19 @@ Content-Type: application/json {"message":"User added to organization"} ``` -## Admin Organisations API +## Admin Organizations API -The Admin Organisations HTTP API does not currently work with an API Token. API Tokens are currently +The Admin Organizations HTTP API does not currently work with an API Token. API Tokens are currently only linked to an organization and an organization role. They cannot be given the permission of server admin, only users can be given that permission. So in order to use these API calls you will have to use Basic Auth and the Grafana user must have the Grafana Admin permission (The default admin user is called `admin` and has permission to use this API). -### Get Organisation by Id +### Get Organization by Id `GET /api/orgs/:orgId` -Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). +Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). **Example Request**: @@ -217,11 +217,11 @@ Content-Type: application/json } } ``` -### Get Organisation by Name +### Get Organization by Name `GET /api/orgs/name/:orgName` -Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). +Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). **Example Request**: @@ -251,11 +251,11 @@ Content-Type: application/json } ``` -### Create Organisation +### Create Organization `POST /api/orgs` -Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). +Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). **Example Request**: @@ -284,11 +284,11 @@ Content-Type: application/json } ``` -### Search all Organisations +### Search all Organizations `GET /api/orgs` -Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). +Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). **Example Request**: @@ -314,12 +314,12 @@ Content-Type: application/json ] ``` -### Update Organisation +### Update Organization `PUT /api/orgs/:orgId` -Update Organisation, fields *Address 1*, *Address 2*, *City* are not implemented yet. -Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). +Update Organization, fields *Address 1*, *Address 2*, *City* are not implemented yet. +Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). **Example Request**: @@ -342,11 +342,11 @@ Content-Type: application/json {"message":"Organization updated"} ``` -### Delete Organisation +### Delete Organization `DELETE /api/orgs/:orgId` -Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). +Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). **Example Request**: @@ -364,11 +364,11 @@ Content-Type: application/json {"message":"Organization deleted"} ``` -### Get Users in Organisation +### Get Users in Organization `GET /api/orgs/:orgId/users` -Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). +Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). **Example Request**: @@ -397,11 +397,11 @@ Content-Type: application/json ] ``` -### Add User in Organisation +### Add User in Organization `POST /api/orgs/:orgId/users` -Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). +Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). **Example Request**: @@ -425,11 +425,11 @@ Content-Type: application/json {"message":"User added to organization"} ``` -### Update Users in Organisation +### Update Users in Organization `PATCH /api/orgs/:orgId/users/:userId` -Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). +Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). **Example Request**: @@ -452,11 +452,11 @@ Content-Type: application/json {"message":"Organization user updated"} ``` -### Delete User in Organisation +### Delete User in Organization `DELETE /api/orgs/:orgId/users/:userId` -Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api). +Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). **Example Request**: diff --git a/docs/sources/http_api/user.md b/docs/sources/http_api/user.md index b9047187b2d..a7b6c96aac5 100644 --- a/docs/sources/http_api/user.md +++ b/docs/sources/http_api/user.md @@ -196,7 +196,7 @@ Content-Type: application/json {"message":"User updated"} ``` -## Get Organisations for user +## Get Organizations for user `GET /api/users/:id/orgs` @@ -226,6 +226,40 @@ Content-Type: application/json ] ``` +## Get Teams for user + +`GET /api/users/:id/teams` + +**Example Request**: + +```http +GET /api/users/1/teams HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= +``` + +Requires basic authentication and that the authenticated user is a Grafana Admin. + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +[ + { + "id":1, + "orgId":1, + "name":"team1", + "email":"", + "avatarUrl":"/avatar/3fcfe295eae3bcb67a49349377428a66", + "memberCount":1 + } +] +``` + + ## User ## Actual User @@ -333,11 +367,11 @@ Content-Type: application/json {"message":"Active organization changed"} ``` -## Organisations of the actual User +## Organizations of the actual User `GET /api/user/orgs` -Return a list of all organisations of the current user. +Return a list of all organizations of the current user. **Example Request**: diff --git a/docs/sources/index.md b/docs/sources/index.md index e9a900d93f1..3e8e014e606 100644 --- a/docs/sources/index.md +++ b/docs/sources/index.md @@ -60,9 +60,9 @@ aliases = ["v1.1", "guides/reference/admin"]

Provisioning

A guide to help you automate your Grafana setup & configuration.

- }}" class="nav-cards__item nav-cards__item--guide"> -

What's new in v5.3

-

Article on all the new cool features and enhancements in v5.3

+
}}" class="nav-cards__item nav-cards__item--guide"> +

What's new in v5.4

+

Article on all the new cool features and enhancements in v5.4

}}" class="nav-cards__item nav-cards__item--guide">

Screencasts

diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 8d156e739bf..30ef020a3de 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -454,6 +454,12 @@ Ex `filters = sqlstore:debug` ### enabled Enable metrics reporting. defaults true. Available via HTTP API `/metrics`. +### basic_auth_username +If set configures the username to use for basic authentication on the metrics endpoint. + +### basic_auth_password +If set configures the password to use for basic authentication on the metrics endpoint. + ### interval_seconds Flush/Write interval when sending metrics to external TSDB. Defaults to 10s. diff --git a/docs/sources/reference/export_import.md b/docs/sources/reference/export_import.md index 31f32d890f6..501e9aca62b 100644 --- a/docs/sources/reference/export_import.md +++ b/docs/sources/reference/export_import.md @@ -107,3 +107,5 @@ it as usual and then update the data source option in the metrics tab so that th 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. +## Note +In Grafana v5.3.4+ the export modal has new checkbox for sharing for external use (other instances). If the checkbox is not checked then the `__inputs` section will not be included in the exported JSON file. diff --git a/docs/sources/reference/templating.md b/docs/sources/reference/templating.md index 403dabba8ae..f20cc0ccfc9 100644 --- a/docs/sources/reference/templating.md +++ b/docs/sources/reference/templating.md @@ -25,7 +25,7 @@ the value, using the dropdown at the top of the dashboard, your panel's metric q Panel titles and metric queries can refer to variables using two different syntaxes: -- `$` Example: apps.frontend.$server.requests.count +- `$varname` Example: apps.frontend.$server.requests.count - `[[varname]]` Example: apps.frontend.[[server]].requests.count Why two ways? The first syntax is easier to read and write but does not allow you to use a variable in the middle of word. Use diff --git a/docs/versions.json b/docs/versions.json index 48962a783ae..b1e447d5aa0 100644 --- a/docs/versions.json +++ b/docs/versions.json @@ -1,5 +1,6 @@ [ - { "version": "v5.3", "path": "/", "archived": false, "current": true }, + { "version": "v5.4", "path": "/", "archived": false, "current": true }, + { "version": "v5.3", "path": "/v5.3", "archived": true }, { "version": "v5.2", "path": "/v5.2", "archived": true }, { "version": "v5.1", "path": "/v5.1", "archived": true }, { "version": "v5.0", "path": "/v5.0", "archived": true }, diff --git a/latest.json b/latest.json index 5e72c11c1f2..d36158608bf 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "5.3.4", - "testing": "5.3.4" + "stable": "5.4.0", + "testing": "5.4.0" } diff --git a/package.json b/package.json index 68b7647fb58..d8ea4c036d4 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,18 @@ "company": "Grafana Labs" }, "name": "grafana", - "version": "5.4.0-pre1", + "version": "5.5.0-pre1", "repository": { "type": "git", "url": "http://github.com/grafana/grafana.git" }, "devDependencies": { + "@babel/core": "^7.1.2", + "@rtsao/plugin-proposal-class-properties": "^7.0.1-patch.1", + "@babel/plugin-syntax-dynamic-import": "^7.0.0", + "@babel/preset-env": "^7.1.0", + "@babel/preset-react": "^7.0.0", + "@babel/preset-typescript": "^7.1.0", "@types/d3": "^4.10.1", "@types/enzyme": "^3.1.13", "@types/jest": "^23.3.2", @@ -21,10 +27,10 @@ "angular-mocks": "1.6.6", "autoprefixer": "^6.4.0", "axios": "^0.17.1", - "babel-core": "^6.26.0", - "babel-loader": "^7.1.4", - "babel-plugin-syntax-dynamic-import": "^6.18.0", - "babel-preset-es2015": "^6.24.1", + "babel-core": "^7.0.0-bridge", + "babel-jest": "^23.6.0", + "babel-loader": "^8.0.4", + "babel-plugin-angularjs-annotate": "^0.9.0", "clean-webpack-plugin": "^0.1.19", "css-loader": "^0.28.7", "enzyme": "^3.6.0", @@ -108,18 +114,9 @@ "precommit": "lint-staged && grunt precommit" }, "lint-staged": { - "*.{ts,tsx}": [ - "prettier --write", - "git add" - ], - "*.scss": [ - "prettier --write", - "git add" - ], - "*pkg/**/*.go": [ - "gofmt -w -s", - "git add" - ] + "*.{ts,tsx}": ["prettier --write", "git add"], + "*.scss": ["prettier --write", "git add"], + "*pkg/**/*.go": ["gofmt -w -s", "git add"] }, "prettier": { "trailingComma": "es5", @@ -128,13 +125,12 @@ }, "license": "Apache-2.0", "dependencies": { + "@babel/polyfill": "^7.0.0", "angular": "1.6.6", "angular-bindonce": "0.3.1", "angular-native-dragdrop": "1.2.2", "angular-route": "1.6.6", "angular-sanitize": "1.6.6", - "babel-jest": "^23.6.0", - "babel-polyfill": "^6.26.0", "baron": "^3.0.3", "brace": "^0.10.0", "classnames": "^2.2.5", @@ -156,7 +152,7 @@ "react-custom-scrollbars": "^4.2.1", "react-dom": "^16.5.0", "react-grid-layout": "0.16.6", - "react-highlight-words": "^0.10.0", + "react-highlight-words": "0.11.0", "react-popper": "^0.7.5", "react-redux": "^5.0.7", "react-select": "2.1.0", diff --git a/packaging/deb/init.d/grafana-server b/packaging/deb/init.d/grafana-server index 567da94f881..5c1d9c8271a 100755 --- a/packaging/deb/init.d/grafana-server +++ b/packaging/deb/init.d/grafana-server @@ -56,7 +56,7 @@ if [ -f "$DEFAULT" ]; then . "$DEFAULT" fi -DAEMON_OPTS="--pidfile=${PID_FILE} --config=${CONF_FILE} cfg:default.paths.provisioning=$PROVISIONING_CFG_DIR cfg:default.paths.data=${DATA_DIR} cfg:default.paths.logs=${LOG_DIR} cfg:default.paths.plugins=${PLUGINS_DIR}" +DAEMON_OPTS="--pidfile=${PID_FILE} --config=${CONF_FILE} --packaging=deb cfg:default.paths.provisioning=$PROVISIONING_CFG_DIR cfg:default.paths.data=${DATA_DIR} cfg:default.paths.logs=${LOG_DIR} cfg:default.paths.plugins=${PLUGINS_DIR}" function checkUser() { if [ `id -u` -ne 0 ]; then diff --git a/packaging/deb/systemd/grafana-server.service b/packaging/deb/systemd/grafana-server.service index acd2a360a93..b1e2e387e4d 100644 --- a/packaging/deb/systemd/grafana-server.service +++ b/packaging/deb/systemd/grafana-server.service @@ -17,6 +17,7 @@ RuntimeDirectoryMode=0750 ExecStart=/usr/sbin/grafana-server \ --config=${CONF_FILE} \ --pidfile=${PID_FILE_DIR}/grafana-server.pid \ + --packaging=deb \ cfg:default.paths.logs=${LOG_DIR} \ cfg:default.paths.data=${DATA_DIR} \ cfg:default.paths.plugins=${PLUGINS_DIR} \ diff --git a/packaging/docker/Dockerfile b/packaging/docker/Dockerfile index dc8972b0ba0..4d4f6539972 100644 --- a/packaging/docker/Dockerfile +++ b/packaging/docker/Dockerfile @@ -25,7 +25,8 @@ ENV PATH=/usr/share/grafana/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bi WORKDIR $GF_PATHS_HOME -RUN apt-get update && apt-get install -qq -y libfontconfig ca-certificates curl && \ +RUN apt-get update && apt-get -y upgrade && \ + apt-get install -qq -y libfontconfig ca-certificates curl && \ apt-get autoremove -y && \ rm -rf /var/lib/apt/lists/* diff --git a/packaging/docker/build-enterprise.sh b/packaging/docker/build-enterprise.sh index 2f59e436d95..10c24784d5c 100755 --- a/packaging/docker/build-enterprise.sh +++ b/packaging/docker/build-enterprise.sh @@ -1,12 +1,25 @@ #!/bin/sh set -e -_grafana_tag=$1 +_raw_grafana_tag=$1 _docker_repo=${2:-grafana/grafana-enterprise} +if echo "$_raw_grafana_tag" | grep -q "^v"; then + _grafana_tag=$(echo "${_raw_grafana_tag}" | cut -d "v" -f 2) +else + _grafana_tag="${_raw_grafana_tag}" +fi + +echo "Building and deploying ${_docker_repo}:${_grafana_tag}" + docker build \ --tag "${_docker_repo}:${_grafana_tag}"\ --no-cache=true \ . docker push "${_docker_repo}:${_grafana_tag}" + +if echo "$_raw_grafana_tag" | grep -q "^v" && echo "$_raw_grafana_tag" | grep -qv "beta"; then + docker tag "${_docker_repo}:${_grafana_tag}" "${_docker_repo}:latest" + docker push "${_docker_repo}:latest" +fi diff --git a/packaging/docker/run.sh b/packaging/docker/run.sh index bc001bdf90a..63f20742c96 100755 --- a/packaging/docker/run.sh +++ b/packaging/docker/run.sh @@ -80,6 +80,7 @@ fi exec grafana-server \ --homepath="$GF_PATHS_HOME" \ --config="$GF_PATHS_CONFIG" \ + --packaging=docker \ "$@" \ cfg:default.log.mode="console" \ cfg:default.paths.data="$GF_PATHS_DATA" \ diff --git a/packaging/rpm/init.d/grafana-server b/packaging/rpm/init.d/grafana-server index cefe212116c..b7b41e58e8d 100755 --- a/packaging/rpm/init.d/grafana-server +++ b/packaging/rpm/init.d/grafana-server @@ -60,7 +60,7 @@ fi # overwrite settings from default file [ -e /etc/sysconfig/$NAME ] && . /etc/sysconfig/$NAME -DAEMON_OPTS="--pidfile=${PID_FILE} --config=${CONF_FILE} cfg:default.paths.provisioning=$PROVISIONING_CFG_DIR cfg:default.paths.data=${DATA_DIR} cfg:default.paths.logs=${LOG_DIR} cfg:default.paths.plugins=${PLUGINS_DIR}" +DAEMON_OPTS="--pidfile=${PID_FILE} --config=${CONF_FILE} --packaging=rpm cfg:default.paths.provisioning=$PROVISIONING_CFG_DIR cfg:default.paths.data=${DATA_DIR} cfg:default.paths.logs=${LOG_DIR} cfg:default.paths.plugins=${PLUGINS_DIR}" function isRunning() { status -p $PID_FILE $NAME > /dev/null 2>&1 diff --git a/packaging/rpm/systemd/grafana-server.service b/packaging/rpm/systemd/grafana-server.service index f228c8d8b14..ad5006d1d4c 100644 --- a/packaging/rpm/systemd/grafana-server.service +++ b/packaging/rpm/systemd/grafana-server.service @@ -17,6 +17,7 @@ RuntimeDirectoryMode=0750 ExecStart=/usr/sbin/grafana-server \ --config=${CONF_FILE} \ --pidfile=${PID_FILE_DIR}/grafana-server.pid \ + --packaging=rpm \ cfg:default.paths.logs=${LOG_DIR} \ cfg:default.paths.data=${DATA_DIR} \ cfg:default.paths.plugins=${PLUGINS_DIR} \ diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index dc3d390dda9..efc760d2b51 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -76,6 +76,7 @@ func AdminUpdateUserPassword(c *m.ReqContext, form dtos.AdminUpdateUserPasswordF c.JsonOK("User password updated") } +// PUT /api/admin/users/:id/permissions func AdminUpdateUserPermissions(c *m.ReqContext, form dtos.AdminUpdateUserPermissionsForm) { userID := c.ParamsInt64(":id") @@ -85,6 +86,11 @@ func AdminUpdateUserPermissions(c *m.ReqContext, form dtos.AdminUpdateUserPermis } if err := bus.Dispatch(&cmd); err != nil { + if err == m.ErrLastGrafanaAdmin { + c.JsonApiErr(400, m.ErrLastGrafanaAdmin.Error(), nil) + return + } + c.JsonApiErr(500, "Failed to update user permissions", err) return } diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go new file mode 100644 index 00000000000..0b94a64b3fb --- /dev/null +++ b/pkg/api/admin_users_test.go @@ -0,0 +1,50 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestAdminApiEndpoint(t *testing.T) { + role := m.ROLE_ADMIN + Convey("Given a server admin attempts to remove themself as an admin", t, func() { + + updateCmd := dtos.AdminUpdateUserPermissionsForm{ + IsGrafanaAdmin: false, + } + + bus.AddHandler("test", func(cmd *m.UpdateUserPermissionsCommand) error { + return m.ErrLastGrafanaAdmin + }) + + putAdminScenario("When calling PUT on", "/api/admin/users/1/permissions", "/api/admin/users/:id/permissions", role, updateCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 400) + }) + }) +} + +func putAdminScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.AdminUpdateUserPermissionsForm, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = Wrap(func(c *m.ReqContext) { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + + AdminUpdateUserPermissions(c, cmd) + }) + + sc.m.Put(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index c68cee50948..66b3b504946 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -295,7 +295,7 @@ func PauseAlert(c *m.ReqContext, dto dtos.PauseAlertCommand) Response { return Error(500, "", err) } - var response m.AlertStateType = m.AlertStatePending + var response m.AlertStateType = m.AlertStateUnknown pausedState := "un-paused" if cmd.Paused { response = m.AlertStatePaused diff --git a/pkg/api/api.go b/pkg/api/api.go index c372debdb72..0526ee80afe 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -140,6 +140,7 @@ func (hs *HTTPServer) registerRoutes() { usersRoute.Get("/", Wrap(SearchUsers)) usersRoute.Get("/search", Wrap(SearchUsersWithPaging)) usersRoute.Get("/:id", Wrap(GetUserByID)) + usersRoute.Get("/:id/teams", Wrap(GetUserTeams)) usersRoute.Get("/:id/orgs", Wrap(GetUserOrgList)) // query parameters /users/lookup?loginOrEmail=admin@example.com usersRoute.Get("/lookup", Wrap(GetUserByLoginOrEmail)) diff --git a/pkg/api/basic_auth.go b/pkg/api/basic_auth.go new file mode 100644 index 00000000000..376cfb24c91 --- /dev/null +++ b/pkg/api/basic_auth.go @@ -0,0 +1,19 @@ +package api + +import ( + "crypto/subtle" + macaron "gopkg.in/macaron.v1" +) + +// BasicAuthenticatedRequest parses the provided HTTP request for basic authentication credentials +// and returns true if the provided credentials match the expected username and password. +// Returns false if the request is unauthenticated. +// Uses constant-time comparison in order to mitigate timing attacks. +func BasicAuthenticatedRequest(req macaron.Request, expectedUser, expectedPass string) bool { + user, pass, ok := req.BasicAuth() + if !ok || subtle.ConstantTimeCompare([]byte(user), []byte(expectedUser)) != 1 || subtle.ConstantTimeCompare([]byte(pass), []byte(expectedPass)) != 1 { + return false + } + + return true +} diff --git a/pkg/api/basic_auth_test.go b/pkg/api/basic_auth_test.go new file mode 100644 index 00000000000..0b5051c3e2a --- /dev/null +++ b/pkg/api/basic_auth_test.go @@ -0,0 +1,45 @@ +package api + +import ( + "encoding/base64" + "fmt" + "net/http" + "testing" + + . "github.com/smartystreets/goconvey/convey" + "gopkg.in/macaron.v1" +) + +func TestBasicAuthenticatedRequest(t *testing.T) { + expectedUser := "prometheus" + expectedPass := "password" + + Convey("Given a valid set of basic auth credentials", t, func() { + httpReq, err := http.NewRequest("GET", "http://localhost:3000/metrics", nil) + So(err, ShouldBeNil) + req := macaron.Request{ + Request: httpReq, + } + encodedCreds := encodeBasicAuthCredentials(expectedUser, expectedPass) + req.Header.Add("Authorization", fmt.Sprintf("Basic %s", encodedCreds)) + authenticated := BasicAuthenticatedRequest(req, expectedUser, expectedPass) + So(authenticated, ShouldBeTrue) + }) + + Convey("Given an invalid set of basic auth credentials", t, func() { + httpReq, err := http.NewRequest("GET", "http://localhost:3000/metrics", nil) + So(err, ShouldBeNil) + req := macaron.Request{ + Request: httpReq, + } + encodedCreds := encodeBasicAuthCredentials("invaliduser", "invalidpass") + req.Header.Add("Authorization", fmt.Sprintf("Basic %s", encodedCreds)) + authenticated := BasicAuthenticatedRequest(req, expectedUser, expectedPass) + So(authenticated, ShouldBeFalse) + }) +} + +func encodeBasicAuthCredentials(user, pass string) string { + creds := fmt.Sprintf("%s:%s", user, pass) + return base64.StdEncoding.EncodeToString([]byte(creds)) +} diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 6abb72f1559..2789b0bf51e 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -277,10 +277,6 @@ func PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) Response { return Error(500, "Failed to save dashboard", err) } - if err == m.ErrDashboardFailedToUpdateAlertData { - return Error(500, "Invalid alert data. Cannot save dashboard", err) - } - c.TimeRequest(metrics.M_Api_Dashboard_Save) return JSON(200, util.DynMap{ "status": "success", diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 2726623c242..8ee40920cbc 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -727,7 +727,6 @@ func TestDashboardApiEndpoint(t *testing.T) { {SaveError: m.ErrDashboardTitleEmpty, ExpectedStatusCode: 400}, {SaveError: m.ErrDashboardFolderCannotHaveParent, ExpectedStatusCode: 400}, {SaveError: alerting.ValidationError{Reason: "Mu"}, ExpectedStatusCode: 422}, - {SaveError: m.ErrDashboardFailedToUpdateAlertData, ExpectedStatusCode: 500}, {SaveError: m.ErrDashboardFailedGenerateUniqueUid, ExpectedStatusCode: 500}, {SaveError: m.ErrDashboardTypeMismatch, ExpectedStatusCode: 400}, {SaveError: m.ErrDashboardFolderWithSameNameAsDashboard, ExpectedStatusCode: 400}, diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index ce28e4716ee..d4d7b41bec5 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -245,6 +245,11 @@ func (hs *HTTPServer) metricsEndpoint(ctx *macaron.Context) { return } + if hs.metricsEndpointBasicAuthEnabled() && !BasicAuthenticatedRequest(ctx.Req, hs.Cfg.MetricsEndpointBasicAuthUsername, hs.Cfg.MetricsEndpointBasicAuthPassword) { + ctx.Resp.WriteHeader(http.StatusUnauthorized) + return + } + promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{}). ServeHTTP(ctx.Resp, ctx.Req.Request) } @@ -299,3 +304,7 @@ func (hs *HTTPServer) mapStatic(m *macaron.Macaron, rootDir string, dir string, }, )) } + +func (hs *HTTPServer) metricsEndpointBasicAuthEnabled() bool { + return hs.Cfg.MetricsEndpointBasicAuthUsername != "" && hs.Cfg.MetricsEndpointBasicAuthPassword != "" +} diff --git a/pkg/api/http_server_test.go b/pkg/api/http_server_test.go new file mode 100644 index 00000000000..0f99ae82db5 --- /dev/null +++ b/pkg/api/http_server_test.go @@ -0,0 +1,30 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/setting" + . "github.com/smartystreets/goconvey/convey" +) + +func TestHTTPServer(t *testing.T) { + Convey("Given a HTTPServer", t, func() { + ts := &HTTPServer{ + Cfg: setting.NewCfg(), + } + + Convey("Given that basic auth on the metrics endpoint is enabled", func() { + ts.Cfg.MetricsEndpointBasicAuthUsername = "foo" + ts.Cfg.MetricsEndpointBasicAuthPassword = "bar" + + So(ts.metricsEndpointBasicAuthEnabled(), ShouldBeTrue) + }) + + Convey("Given that basic auth on the metrics endpoint is disabled", func() { + ts.Cfg.MetricsEndpointBasicAuthUsername = "" + ts.Cfg.MetricsEndpointBasicAuthPassword = "" + + So(ts.metricsEndpointBasicAuthEnabled(), ShouldBeFalse) + }) + }) +} diff --git a/pkg/api/login.go b/pkg/api/login.go index 1083f89adfd..05afc40e59a 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -39,6 +39,10 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) { viewData.Settings["loginError"] = loginError } + if tryOAuthAutoLogin(c) { + return + } + if !tryLoginUsingRememberCookie(c) { c.HTML(200, ViewIndex, viewData) return @@ -53,6 +57,24 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) { c.Redirect(setting.AppSubUrl + "/") } +func tryOAuthAutoLogin(c *m.ReqContext) bool { + if !setting.OAuthAutoLogin { + return false + } + oauthInfos := setting.OAuthService.OAuthInfos + if len(oauthInfos) != 1 { + log.Warn("Skipping OAuth auto login because multiple OAuth providers are configured.") + return false + } + for key := range setting.OAuthService.OAuthInfos { + redirectUrl := setting.AppSubUrl + "/login/" + key + log.Info("OAuth auto login enabled. Redirecting to " + redirectUrl) + c.Redirect(redirectUrl, 307) + return true + } + return false +} + func tryLoginUsingRememberCookie(c *m.ReqContext) bool { // Check auto-login. uname := c.GetCookie(setting.CookieUserName) diff --git a/pkg/api/password.go b/pkg/api/password.go index 7dd901c898e..4776c6a3064 100644 --- a/pkg/api/password.go +++ b/pkg/api/password.go @@ -4,10 +4,18 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) func SendResetPasswordEmail(c *m.ReqContext, form dtos.SendResetPasswordEmailForm) Response { + if setting.LdapEnabled || setting.AuthProxyEnabled { + return Error(401, "Not allowed to reset password when LDAP or Auth Proxy is enabled", nil) + } + if setting.DisableLoginForm { + return Error(401, "Not allowed to reset password when login form is disabled", nil) + } + userQuery := m.GetUserByLoginQuery{LoginOrEmail: form.UserOrEmail} if err := bus.Dispatch(&userQuery); err != nil { diff --git a/pkg/api/pluginproxy/ds_auth_provider.go b/pkg/api/pluginproxy/ds_auth_provider.go index edf43085c7c..5c5776eec07 100644 --- a/pkg/api/pluginproxy/ds_auth_provider.go +++ b/pkg/api/pluginproxy/ds_auth_provider.go @@ -51,7 +51,7 @@ func ApplyRoute(ctx context.Context, req *http.Request, proxyPath string, route if token, err := tokenProvider.getAccessToken(data); err != nil { logger.Error("Failed to get access token", "error", err) } else { - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) } } @@ -60,7 +60,7 @@ func ApplyRoute(ctx context.Context, req *http.Request, proxyPath string, route if token, err := tokenProvider.getJwtAccessToken(ctx, data); err != nil { logger.Error("Failed to get access token", "error", err) } else { - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) } } @@ -73,7 +73,7 @@ func ApplyRoute(ctx context.Context, req *http.Request, proxyPath string, route if err != nil { logger.Error("Failed to get default access token from meta data server", "error", err) } else { - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) } } } diff --git a/pkg/api/pluginproxy/pluginproxy.go b/pkg/api/pluginproxy/pluginproxy.go index ffbe470cb20..66e4498c283 100644 --- a/pkg/api/pluginproxy/pluginproxy.go +++ b/pkg/api/pluginproxy/pluginproxy.go @@ -87,7 +87,7 @@ func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPl } for key, value := range headers { - log.Trace("setting key %v value %v", key, value[0]) + log.Trace("setting key %v value ", key) req.Header.Set(key, value[0]) } } diff --git a/pkg/api/user.go b/pkg/api/user.go index 7116ad83f3f..6db9c6f6baf 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -113,7 +113,16 @@ func GetSignedInUserOrgList(c *m.ReqContext) Response { // GET /api/user/teams func GetSignedInUserTeamList(c *m.ReqContext) Response { - query := m.GetTeamsByUserQuery{OrgId: c.OrgId, UserId: c.UserId} + return getUserTeamList(c.OrgId, c.UserId) +} + +// GET /api/users/:id/teams +func GetUserTeams(c *m.ReqContext) Response { + return getUserTeamList(c.OrgId, c.ParamsInt64(":id")) +} + +func getUserTeamList(userID int64, orgID int64) Response { + query := m.GetTeamsByUserQuery{OrgId: orgID, UserId: userID} if err := bus.Dispatch(&query); err != nil { return Error(500, "Failed to get user teams", err) @@ -122,11 +131,10 @@ func GetSignedInUserTeamList(c *m.ReqContext) Response { for _, team := range query.Result { team.AvatarUrl = dtos.GetGravatarUrlWithDefault(team.Email, team.Name) } - return JSON(200, query.Result) } -// GET /api/user/:id/orgs +// GET /api/users/:id/orgs func GetUserOrgList(c *m.ReqContext) Response { return getUserOrgList(c.ParamsInt64(":id")) } diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index c7c1ff3aff7..3bdaf0cc80e 100644 --- a/pkg/cmd/grafana-server/main.go +++ b/pkg/cmd/grafana-server/main.go @@ -13,7 +13,7 @@ import ( "syscall" "time" - extensions "github.com/grafana/grafana/pkg/extensions" + "github.com/grafana/grafana/pkg/extensions" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/metrics" _ "github.com/grafana/grafana/pkg/services/alerting/conditions" @@ -39,6 +39,7 @@ var buildstamp string var configFile = flag.String("config", "", "path to config file") var homePath = flag.String("homepath", "", "path to grafana install/home path, defaults to working directory") var pidFile = flag.String("pidfile", "", "path to pid file") +var packaging = flag.String("packaging", "unknown", "describes the way Grafana was installed") func main() { v := flag.Bool("v", false, "prints current version and exits") @@ -53,7 +54,10 @@ func main() { if *profile { runtime.SetBlockProfileRate(1) go func() { - http.ListenAndServe(fmt.Sprintf("localhost:%d", *profilePort), nil) + err := http.ListenAndServe(fmt.Sprintf("localhost:%d", *profilePort), nil) + if err != nil { + panic(err) + } }() f, err := os.Create("trace.out") @@ -79,6 +83,7 @@ func main() { setting.BuildStamp = buildstampInt64 setting.BuildBranch = buildBranch setting.IsEnterprise = extensions.IsEnterprise + setting.Packaging = validPackaging(*packaging) metrics.SetBuildInformation(version, commit, buildBranch) @@ -95,6 +100,16 @@ func main() { os.Exit(code) } +func validPackaging(packaging string) string { + validTypes := []string{"dev", "deb", "rpm", "docker", "brew", "hosted", "unknown"} + for _, vt := range validTypes { + if packaging == vt { + return packaging + } + } + return "unknown" +} + func listenToSystemSignals(server *GrafanaServerImpl) { signalChan := make(chan os.Signal, 1) sighupChan := make(chan os.Signal, 1) diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go index 2c67a06a843..d9fd1e62ce7 100644 --- a/pkg/cmd/grafana-server/server.go +++ b/pkg/cmd/grafana-server/server.go @@ -67,6 +67,7 @@ type GrafanaServerImpl struct { } func (g *GrafanaServerImpl) Run() error { + var err error g.loadConfiguration() g.writePIDFile() @@ -74,20 +75,38 @@ func (g *GrafanaServerImpl) Run() error { social.NewOAuthService() serviceGraph := inject.Graph{} - serviceGraph.Provide(&inject.Object{Value: bus.GetBus()}) - serviceGraph.Provide(&inject.Object{Value: g.cfg}) - serviceGraph.Provide(&inject.Object{Value: routing.NewRouteRegister(middleware.RequestMetrics, middleware.RequestTracing)}) - serviceGraph.Provide(&inject.Object{Value: cache.New(5*time.Minute, 10*time.Minute)}) + err = serviceGraph.Provide(&inject.Object{Value: bus.GetBus()}) + if err != nil { + return fmt.Errorf("Failed to provide object to the graph: %v", err) + } + err = serviceGraph.Provide(&inject.Object{Value: g.cfg}) + if err != nil { + return fmt.Errorf("Failed to provide object to the graph: %v", err) + } + err = serviceGraph.Provide(&inject.Object{Value: routing.NewRouteRegister(middleware.RequestMetrics, middleware.RequestTracing)}) + if err != nil { + return fmt.Errorf("Failed to provide object to the graph: %v", err) + } + err = serviceGraph.Provide(&inject.Object{Value: cache.New(5*time.Minute, 10*time.Minute)}) + if err != nil { + return fmt.Errorf("Failed to provide object to the graph: %v", err) + } // self registered services services := registry.GetServices() // Add all services to dependency graph for _, service := range services { - serviceGraph.Provide(&inject.Object{Value: service.Instance}) + err = serviceGraph.Provide(&inject.Object{Value: service.Instance}) + if err != nil { + return fmt.Errorf("Failed to provide object to the graph: %v", err) + } } - serviceGraph.Provide(&inject.Object{Value: g}) + err = serviceGraph.Provide(&inject.Object{Value: g}) + if err != nil { + return fmt.Errorf("Failed to provide object to the graph: %v", err) + } // Inject dependencies to services if err := serviceGraph.Populate(); err != nil { @@ -144,6 +163,7 @@ func (g *GrafanaServerImpl) Run() error { } sendSystemdNotification("READY=1") + return g.childRoutines.Wait() } diff --git a/pkg/components/dynmap/dynmap.go b/pkg/components/dynmap/dynmap.go index 96effb24332..f247aca959f 100644 --- a/pkg/components/dynmap/dynmap.go +++ b/pkg/components/dynmap/dynmap.go @@ -1,5 +1,5 @@ // uses code from https://github.com/antonholmquist/jason/blob/master/jason.go -// MIT Licence +// MIT License package dynmap diff --git a/pkg/components/dynmap/dynmap_test.go b/pkg/components/dynmap/dynmap_test.go index 62d356bd67d..68d938214a3 100644 --- a/pkg/components/dynmap/dynmap_test.go +++ b/pkg/components/dynmap/dynmap_test.go @@ -1,5 +1,5 @@ // uses code from https://github.com/antonholmquist/jason/blob/master/jason.go -// MIT Licence +// MIT License package dynmap diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 5709e3e3213..326514a9687 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -313,7 +313,7 @@ func init() { // SetBuildInformation sets the build information for this binary func SetBuildInformation(version, revision, branch string) { - // We export this info twice for backwards compability. + // We export this info twice for backwards compatibility. // Once this have been released for some time we should be able to remote `M_Grafana_Version` // The reason we added a new one is that its common practice in the prometheus community // to name this metric `*_build_info` so its easy to do aggregation on all programs. @@ -397,11 +397,12 @@ func sendUsageStats(oauthProviders map[string]bool) { metrics := map[string]interface{}{} report := map[string]interface{}{ - "version": version, - "metrics": metrics, - "os": runtime.GOOS, - "arch": runtime.GOARCH, - "edition": getEdition(), + "version": version, + "metrics": metrics, + "os": runtime.GOOS, + "arch": runtime.GOARCH, + "edition": getEdition(), + "packaging": setting.Packaging, } statsQuery := models.GetSystemStatsQuery{} @@ -447,6 +448,8 @@ func sendUsageStats(oauthProviders map[string]bool) { } metrics["stats.ds.other.count"] = dsOtherCount + metrics["stats.packaging."+setting.Packaging+".count"] = 1 + dsAccessStats := models.GetDataSourceAccessStatsQuery{} if err := bus.Dispatch(&dsAccessStats); err != nil { metricsLogger.Error("Failed to get datasource access stats", "error", err) diff --git a/pkg/metrics/metrics_test.go b/pkg/metrics/metrics_test.go index 43739221f1e..c27d6f64b8c 100644 --- a/pkg/metrics/metrics_test.go +++ b/pkg/metrics/metrics_test.go @@ -176,6 +176,7 @@ func TestMetrics(t *testing.T) { setting.BasicAuthEnabled = true setting.LdapEnabled = true setting.AuthProxyEnabled = true + setting.Packaging = "deb" wg.Add(1) sendUsageStats(oauthProviders) @@ -243,6 +244,8 @@ func TestMetrics(t *testing.T) { So(metrics.Get("stats.auth_enabled.oauth_google.count").MustInt(), ShouldEqual, 1) So(metrics.Get("stats.auth_enabled.oauth_generic_oauth.count").MustInt(), ShouldEqual, 1) So(metrics.Get("stats.auth_enabled.oauth_grafana_com.count").MustInt(), ShouldEqual, 1) + + So(metrics.Get("stats.packaging.deb.count").MustInt(), ShouldEqual, 1) }) }) diff --git a/pkg/middleware/recovery.go b/pkg/middleware/recovery.go index eef07c8c24a..b8ca85fb45b 100644 --- a/pkg/middleware/recovery.go +++ b/pkg/middleware/recovery.go @@ -115,6 +115,7 @@ func Recovery() macaron.Handler { c.Data["Title"] = "Server Error" c.Data["AppSubUrl"] = setting.AppSubUrl + c.Data["Theme"] = setting.DefaultTheme if setting.Env == setting.DEV { if theErr, ok := err.(error); ok { diff --git a/pkg/models/alert.go b/pkg/models/alert.go index aaf9c50197a..e8171f8cf0b 100644 --- a/pkg/models/alert.go +++ b/pkg/models/alert.go @@ -19,6 +19,7 @@ const ( AlertStateAlerting AlertStateType = "alerting" AlertStateOK AlertStateType = "ok" AlertStatePending AlertStateType = "pending" + AlertStateUnknown AlertStateType = "unknown" ) const ( @@ -39,7 +40,12 @@ var ( ) func (s AlertStateType) IsValid() bool { - return s == AlertStateOK || s == AlertStateNoData || s == AlertStatePaused || s == AlertStatePending + return s == AlertStateOK || + s == AlertStateNoData || + s == AlertStatePaused || + s == AlertStatePending || + s == AlertStateAlerting || + s == AlertStateUnknown } func (s NoDataOption) IsValid() bool { @@ -66,12 +72,13 @@ type Alert struct { PanelId int64 Name string Message string - Severity string + Severity string //Unused State AlertStateType - Handler int64 + Handler int64 //Unused Silenced bool ExecutionError string Frequency int64 + For time.Duration EvalData *simplejson.Json NewStateDate time.Time diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index e8aebb1d1f4..3a8010e797b 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -21,7 +21,6 @@ var ( ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else") ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty") ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder") - ErrDashboardFailedToUpdateAlertData = errors.New("Failed to save alert data") ErrDashboardsWithSameSlugExists = errors.New("Multiple dashboards with the same slug exists") ErrDashboardFailedGenerateUniqueUid = errors.New("Failed to generate unique dashboard id") ErrDashboardTypeMismatch = errors.New("Dashboard cannot be changed to a folder") diff --git a/pkg/models/user.go b/pkg/models/user.go index e3c7b556d35..69031e40338 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -7,7 +7,8 @@ import ( // Typed errors var ( - ErrUserNotFound = errors.New("User not found") + ErrUserNotFound = errors.New("User not found") + ErrLastGrafanaAdmin = errors.New("Cannot remove last grafana admin") ) type Password string diff --git a/pkg/services/alerting/eval_context.go b/pkg/services/alerting/eval_context.go index d0441d379b7..4db942e0a55 100644 --- a/pkg/services/alerting/eval_context.go +++ b/pkg/services/alerting/eval_context.go @@ -68,8 +68,13 @@ func (c *EvalContext) GetStateModel() *StateDescription { Color: "#D63232", Text: "Alerting", } + case m.AlertStateUnknown: + return &StateDescription{ + Color: "#888888", + Text: "Unknown", + } default: - panic("Unknown rule state " + c.Rule.State) + panic("Unknown rule state for alert " + c.Rule.State) } } @@ -113,7 +118,26 @@ func (c *EvalContext) GetRuleUrl() (string, error) { return fmt.Sprintf(urlFormat, m.GetFullDashboardUrl(ref.Uid, ref.Slug), c.Rule.PanelId, c.Rule.OrgId), nil } +// GetNewState returns the new state from the alert rule evaluation func (c *EvalContext) GetNewState() m.AlertStateType { + ns := getNewStateInternal(c) + if ns != m.AlertStateAlerting || c.Rule.For == 0 { + return ns + } + + since := time.Since(c.Rule.LastStateChange) + if c.PrevAlertState == m.AlertStatePending && since > c.Rule.For { + return m.AlertStateAlerting + } + + if c.PrevAlertState == m.AlertStateAlerting { + return m.AlertStateAlerting + } + + return m.AlertStatePending +} + +func getNewStateInternal(c *EvalContext) m.AlertStateType { if c.Error != nil { c.log.Error("Alert Rule Result Error", "ruleId", c.Rule.Id, @@ -125,11 +149,13 @@ func (c *EvalContext) GetNewState() m.AlertStateType { return c.PrevAlertState } return c.Rule.ExecutionErrorState.ToAlertState() + } - } else if c.Firing { + if c.Firing { return m.AlertStateAlerting + } - } else if c.NoDataFound { + if c.NoDataFound { c.log.Info("Alert Rule returned no data", "ruleId", c.Rule.Id, "name", c.Rule.Name, diff --git a/pkg/services/alerting/eval_context_test.go b/pkg/services/alerting/eval_context_test.go index 750fa959683..af7e66b2f07 100644 --- a/pkg/services/alerting/eval_context_test.go +++ b/pkg/services/alerting/eval_context_test.go @@ -2,11 +2,11 @@ package alerting import ( "context" - "fmt" + "errors" "testing" + "time" "github.com/grafana/grafana/pkg/models" - . "github.com/smartystreets/goconvey/convey" ) func TestStateIsUpdatedWhenNeeded(t *testing.T) { @@ -31,71 +31,176 @@ func TestStateIsUpdatedWhenNeeded(t *testing.T) { }) } -func TestAlertingEvalContext(t *testing.T) { - Convey("Should compute and replace properly new rule state", t, func() { +func TestGetStateFromEvalContext(t *testing.T) { + tcs := []struct { + name string + expected models.AlertStateType + applyFn func(ec *EvalContext) + }{ + { + name: "ok -> alerting", + expected: models.AlertStateAlerting, + applyFn: func(ec *EvalContext) { + ec.Firing = true + ec.PrevAlertState = models.AlertStateOK + }, + }, + { + name: "ok -> error(alerting)", + expected: models.AlertStateAlerting, + applyFn: func(ec *EvalContext) { + ec.PrevAlertState = models.AlertStateOK + ec.Error = errors.New("test error") + ec.Rule.ExecutionErrorState = models.ExecutionErrorSetAlerting + }, + }, + { + name: "ok -> pending. since its been firing for less than FOR", + expected: models.AlertStatePending, + applyFn: func(ec *EvalContext) { + ec.PrevAlertState = models.AlertStateOK + ec.Firing = true + ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 2) + ec.Rule.For = time.Minute * 5 + }, + }, + { + name: "ok -> pending. since it has to be pending longer than FOR and prev state is ok", + expected: models.AlertStatePending, + applyFn: func(ec *EvalContext) { + ec.PrevAlertState = models.AlertStateOK + ec.Firing = true + ec.Rule.LastStateChange = time.Now().Add(-(time.Hour * 5)) + ec.Rule.For = time.Minute * 2 + }, + }, + { + name: "pending -> alerting. since its been firing for more than FOR and prev state is pending", + expected: models.AlertStateAlerting, + applyFn: func(ec *EvalContext) { + ec.PrevAlertState = models.AlertStatePending + ec.Firing = true + ec.Rule.LastStateChange = time.Now().Add(-(time.Hour * 5)) + ec.Rule.For = time.Minute * 2 + }, + }, + { + name: "alerting -> alerting. should not update regardless of FOR", + expected: models.AlertStateAlerting, + applyFn: func(ec *EvalContext) { + ec.PrevAlertState = models.AlertStateAlerting + ec.Firing = true + ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 5) + ec.Rule.For = time.Minute * 2 + }, + }, + { + name: "ok -> ok. should not update regardless of FOR", + expected: models.AlertStateOK, + applyFn: func(ec *EvalContext) { + ec.PrevAlertState = models.AlertStateOK + ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 5) + ec.Rule.For = time.Minute * 2 + }, + }, + { + name: "ok -> error(keep_last)", + expected: models.AlertStateOK, + applyFn: func(ec *EvalContext) { + ec.PrevAlertState = models.AlertStateOK + ec.Error = errors.New("test error") + ec.Rule.ExecutionErrorState = models.ExecutionErrorKeepState + }, + }, + { + name: "pending -> error(keep_last)", + expected: models.AlertStatePending, + applyFn: func(ec *EvalContext) { + ec.PrevAlertState = models.AlertStatePending + ec.Error = errors.New("test error") + ec.Rule.ExecutionErrorState = models.ExecutionErrorKeepState + }, + }, + { + name: "ok -> no_data(alerting)", + expected: models.AlertStateAlerting, + applyFn: func(ec *EvalContext) { + ec.PrevAlertState = models.AlertStateOK + ec.Rule.NoDataState = models.NoDataSetAlerting + ec.NoDataFound = true + }, + }, + { + name: "ok -> no_data(keep_last)", + expected: models.AlertStateOK, + applyFn: func(ec *EvalContext) { + ec.PrevAlertState = models.AlertStateOK + ec.Rule.NoDataState = models.NoDataKeepState + ec.NoDataFound = true + }, + }, + { + name: "pending -> no_data(keep_last)", + expected: models.AlertStatePending, + applyFn: func(ec *EvalContext) { + ec.PrevAlertState = models.AlertStatePending + ec.Rule.NoDataState = models.NoDataKeepState + ec.NoDataFound = true + }, + }, + { + name: "pending -> no_data(alerting) with for duration have not passed", + expected: models.AlertStatePending, + applyFn: func(ec *EvalContext) { + ec.PrevAlertState = models.AlertStatePending + ec.Rule.NoDataState = models.NoDataSetAlerting + ec.NoDataFound = true + ec.Rule.For = time.Minute * 5 + ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 2) + }, + }, + { + name: "pending -> no_data(alerting) should set alerting since time passed FOR", + expected: models.AlertStateAlerting, + applyFn: func(ec *EvalContext) { + ec.PrevAlertState = models.AlertStatePending + ec.Rule.NoDataState = models.NoDataSetAlerting + ec.NoDataFound = true + ec.Rule.For = time.Minute * 2 + ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 5) + }, + }, + { + name: "pending -> error(alerting) with for duration have not passed ", + expected: models.AlertStatePending, + applyFn: func(ec *EvalContext) { + ec.PrevAlertState = models.AlertStatePending + ec.Rule.ExecutionErrorState = models.ExecutionErrorSetAlerting + ec.Error = errors.New("test error") + ec.Rule.For = time.Minute * 5 + ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 2) + }, + }, + { + name: "pending -> error(alerting) should set alerting since time passed FOR", + expected: models.AlertStateAlerting, + applyFn: func(ec *EvalContext) { + ec.PrevAlertState = models.AlertStatePending + ec.Rule.ExecutionErrorState = models.ExecutionErrorSetAlerting + ec.Error = errors.New("test error") + ec.Rule.For = time.Minute * 2 + ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 5) + }, + }, + } + + for _, tc := range tcs { ctx := NewEvalContext(context.TODO(), &Rule{Conditions: []Condition{&conditionStub{firing: true}}}) - dummieError := fmt.Errorf("dummie error") - Convey("ok -> alerting", func() { - ctx.PrevAlertState = models.AlertStateOK - ctx.Firing = true - - ctx.Rule.State = ctx.GetNewState() - So(ctx.Rule.State, ShouldEqual, models.AlertStateAlerting) - }) - - Convey("ok -> error(alerting)", func() { - ctx.PrevAlertState = models.AlertStateOK - ctx.Error = dummieError - ctx.Rule.ExecutionErrorState = models.ExecutionErrorSetAlerting - - ctx.Rule.State = ctx.GetNewState() - So(ctx.Rule.State, ShouldEqual, models.AlertStateAlerting) - }) - - Convey("ok -> error(keep_last)", func() { - ctx.PrevAlertState = models.AlertStateOK - ctx.Error = dummieError - ctx.Rule.ExecutionErrorState = models.ExecutionErrorKeepState - - ctx.Rule.State = ctx.GetNewState() - So(ctx.Rule.State, ShouldEqual, models.AlertStateOK) - }) - - Convey("pending -> error(keep_last)", func() { - ctx.PrevAlertState = models.AlertStatePending - ctx.Error = dummieError - ctx.Rule.ExecutionErrorState = models.ExecutionErrorKeepState - - ctx.Rule.State = ctx.GetNewState() - So(ctx.Rule.State, ShouldEqual, models.AlertStatePending) - }) - - Convey("ok -> no_data(alerting)", func() { - ctx.PrevAlertState = models.AlertStateOK - ctx.Rule.NoDataState = models.NoDataSetAlerting - ctx.NoDataFound = true - - ctx.Rule.State = ctx.GetNewState() - So(ctx.Rule.State, ShouldEqual, models.AlertStateAlerting) - }) - - Convey("ok -> no_data(keep_last)", func() { - ctx.PrevAlertState = models.AlertStateOK - ctx.Rule.NoDataState = models.NoDataKeepState - ctx.NoDataFound = true - - ctx.Rule.State = ctx.GetNewState() - So(ctx.Rule.State, ShouldEqual, models.AlertStateOK) - }) - - Convey("pending -> no_data(keep_last)", func() { - ctx.PrevAlertState = models.AlertStatePending - ctx.Rule.NoDataState = models.NoDataKeepState - ctx.NoDataFound = true - - ctx.Rule.State = ctx.GetNewState() - So(ctx.Rule.State, ShouldEqual, models.AlertStatePending) - }) - }) + tc.applyFn(ctx) + have := ctx.GetNewState() + if have != tc.expected { + t.Errorf("failed: %s \n expected '%s' have '%s'\n", tc.name, tc.expected, string(have)) + } + } } diff --git a/pkg/services/alerting/extractor.go b/pkg/services/alerting/extractor.go index 0abacc91313..e33e3dc2af3 100644 --- a/pkg/services/alerting/extractor.go +++ b/pkg/services/alerting/extractor.go @@ -2,8 +2,8 @@ package alerting import ( "errors" - "fmt" + "time" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" @@ -115,6 +115,15 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json, return nil, ValidationError{Reason: "Could not parse frequency"} } + rawFor := jsonAlert.Get("for").MustString() + var forValue time.Duration + if rawFor != "" { + forValue, err = time.ParseDuration(rawFor) + if err != nil { + return nil, ValidationError{Reason: "Could not parse for"} + } + } + alert := &m.Alert{ DashboardId: e.Dash.Id, OrgId: e.OrgID, @@ -124,6 +133,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json, Handler: jsonAlert.Get("handler").MustInt64(), Message: jsonAlert.Get("message").MustString(), Frequency: frequency, + For: forValue, } for _, condition := range jsonAlert.Get("conditions").MustArray() { diff --git a/pkg/services/alerting/extractor_test.go b/pkg/services/alerting/extractor_test.go index 0890b9e1bd1..9665a657bb7 100644 --- a/pkg/services/alerting/extractor_test.go +++ b/pkg/services/alerting/extractor_test.go @@ -3,6 +3,7 @@ package alerting import ( "io/ioutil" "testing" + "time" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" @@ -46,7 +47,7 @@ func TestAlertRuleExtraction(t *testing.T) { return nil }) - json, err := ioutil.ReadFile("./test-data/graphite-alert.json") + json, err := ioutil.ReadFile("./testdata/graphite-alert.json") So(err, ShouldBeNil) Convey("Extractor should not modify the original json", func() { @@ -118,6 +119,11 @@ func TestAlertRuleExtraction(t *testing.T) { So(alerts[1].PanelId, ShouldEqual, 4) }) + Convey("should extract for param", func() { + So(alerts[0].For, ShouldEqual, time.Minute*2) + So(alerts[1].For, ShouldEqual, time.Duration(0)) + }) + Convey("should extract name and desc", func() { So(alerts[0].Name, ShouldEqual, "name1") So(alerts[0].Message, ShouldEqual, "desc1") @@ -140,7 +146,7 @@ func TestAlertRuleExtraction(t *testing.T) { }) Convey("Panels missing id should return error", func() { - panelWithoutId, err := ioutil.ReadFile("./test-data/panels-missing-id.json") + panelWithoutId, err := ioutil.ReadFile("./testdata/panels-missing-id.json") So(err, ShouldBeNil) dashJson, err := simplejson.NewJson(panelWithoutId) @@ -156,7 +162,7 @@ func TestAlertRuleExtraction(t *testing.T) { }) Convey("Panel with id set to zero should return error", func() { - panelWithIdZero, err := ioutil.ReadFile("./test-data/panel-with-id-0.json") + panelWithIdZero, err := ioutil.ReadFile("./testdata/panel-with-id-0.json") So(err, ShouldBeNil) dashJson, err := simplejson.NewJson(panelWithIdZero) @@ -172,7 +178,7 @@ func TestAlertRuleExtraction(t *testing.T) { }) Convey("Parse alerts from dashboard without rows", func() { - json, err := ioutil.ReadFile("./test-data/v5-dashboard.json") + json, err := ioutil.ReadFile("./testdata/v5-dashboard.json") So(err, ShouldBeNil) dashJson, err := simplejson.NewJson(json) @@ -192,7 +198,7 @@ func TestAlertRuleExtraction(t *testing.T) { }) Convey("Parse and validate dashboard containing influxdb alert", func() { - json, err := ioutil.ReadFile("./test-data/influxdb-alert.json") + json, err := ioutil.ReadFile("./testdata/influxdb-alert.json") So(err, ShouldBeNil) dashJson, err := simplejson.NewJson(json) @@ -221,7 +227,7 @@ func TestAlertRuleExtraction(t *testing.T) { }) Convey("Should be able to extract collapsed panels", func() { - json, err := ioutil.ReadFile("./test-data/collapsed-panels.json") + json, err := ioutil.ReadFile("./testdata/collapsed-panels.json") So(err, ShouldBeNil) dashJson, err := simplejson.NewJson(json) @@ -242,7 +248,7 @@ func TestAlertRuleExtraction(t *testing.T) { }) Convey("Parse and validate dashboard without id and containing an alert", func() { - json, err := ioutil.ReadFile("./test-data/dash-without-id.json") + json, err := ioutil.ReadFile("./testdata/dash-without-id.json") So(err, ShouldBeNil) dashJSON, err := simplejson.NewJson(json) diff --git a/pkg/services/alerting/notifiers/alertmanager_test.go b/pkg/services/alerting/notifiers/alertmanager_test.go index 3549b536e48..7510742ed17 100644 --- a/pkg/services/alerting/notifiers/alertmanager_test.go +++ b/pkg/services/alerting/notifiers/alertmanager_test.go @@ -1,13 +1,60 @@ package notifiers import ( + "context" "testing" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/log" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" . "github.com/smartystreets/goconvey/convey" ) +func TestWhenAlertManagerShouldNotify(t *testing.T) { + tcs := []struct { + prevState m.AlertStateType + newState m.AlertStateType + + expect bool + }{ + { + prevState: m.AlertStatePending, + newState: m.AlertStateOK, + expect: false, + }, + { + prevState: m.AlertStateAlerting, + newState: m.AlertStateOK, + expect: true, + }, + { + prevState: m.AlertStateOK, + newState: m.AlertStatePending, + expect: false, + }, + { + prevState: m.AlertStateUnknown, + newState: m.AlertStatePending, + expect: false, + }, + } + + for _, tc := range tcs { + am := &AlertmanagerNotifier{log: log.New("test.logger")} + evalContext := alerting.NewEvalContext(context.TODO(), &alerting.Rule{ + State: tc.prevState, + }) + + evalContext.Rule.State = tc.newState + + res := am.ShouldNotify(context.TODO(), evalContext, &m.AlertNotificationState{}) + if res != tc.expect { + t.Errorf("got %v expected %v", res, tc.expect) + } + } +} + func TestAlertmanagerNotifier(t *testing.T) { Convey("Alertmanager notifier tests", t, func() { diff --git a/pkg/services/alerting/notifiers/base.go b/pkg/services/alerting/notifiers/base.go index d141d6cd257..d4a9975bcba 100644 --- a/pkg/services/alerting/notifiers/base.go +++ b/pkg/services/alerting/notifiers/base.go @@ -67,6 +67,16 @@ func (n *NotifierBase) ShouldNotify(ctx context.Context, context *alerting.EvalC } // Do not notify when we become OK for the first time. + if context.PrevAlertState == models.AlertStateUnknown && context.Rule.State == models.AlertStateOK { + return false + } + + // Do not notify when we become OK for the first time. + if context.PrevAlertState == models.AlertStateUnknown && context.Rule.State == models.AlertStatePending { + return false + } + + // Do not notify when we become OK from pending if context.PrevAlertState == models.AlertStatePending && context.Rule.State == models.AlertStateOK { return false } diff --git a/pkg/services/alerting/notifiers/base_test.go b/pkg/services/alerting/notifiers/base_test.go index 5062828cb4f..3fd4447eefe 100644 --- a/pkg/services/alerting/notifiers/base_test.go +++ b/pkg/services/alerting/notifiers/base_test.go @@ -29,7 +29,6 @@ func TestShouldSendAlertNotification(t *testing.T) { newState: m.AlertStateOK, prevState: m.AlertStatePending, sendReminder: false, - state: &m.AlertNotificationState{}, expect: false, }, @@ -38,7 +37,6 @@ func TestShouldSendAlertNotification(t *testing.T) { newState: m.AlertStateAlerting, prevState: m.AlertStateOK, sendReminder: false, - state: &m.AlertNotificationState{}, expect: true, }, @@ -47,7 +45,6 @@ func TestShouldSendAlertNotification(t *testing.T) { newState: m.AlertStatePending, prevState: m.AlertStateOK, sendReminder: false, - state: &m.AlertNotificationState{}, expect: false, }, @@ -56,7 +53,6 @@ func TestShouldSendAlertNotification(t *testing.T) { newState: m.AlertStateOK, prevState: m.AlertStateOK, sendReminder: false, - state: &m.AlertNotificationState{}, expect: false, }, @@ -65,7 +61,6 @@ func TestShouldSendAlertNotification(t *testing.T) { newState: m.AlertStateOK, prevState: m.AlertStateOK, sendReminder: true, - state: &m.AlertNotificationState{}, expect: false, }, @@ -74,7 +69,6 @@ func TestShouldSendAlertNotification(t *testing.T) { newState: m.AlertStateOK, prevState: m.AlertStateAlerting, sendReminder: false, - state: &m.AlertNotificationState{}, expect: true, }, @@ -94,7 +88,6 @@ func TestShouldSendAlertNotification(t *testing.T) { prevState: m.AlertStateAlerting, frequency: time.Minute * 10, sendReminder: true, - state: &m.AlertNotificationState{}, expect: true, }, @@ -132,6 +125,27 @@ func TestShouldSendAlertNotification(t *testing.T) { prevState: m.AlertStateOK, state: &m.AlertNotificationState{State: m.AlertNotificationStatePending, UpdatedAt: tnow.Add(-2 * time.Minute).Unix()}, + expect: true, + }, + { + name: "unknown -> ok", + prevState: m.AlertStateUnknown, + newState: m.AlertStateOK, + + expect: false, + }, + { + name: "unknown -> pending", + prevState: m.AlertStateUnknown, + newState: m.AlertStatePending, + + expect: false, + }, + { + name: "unknown -> alerting", + prevState: m.AlertStateUnknown, + newState: m.AlertStateAlerting, + expect: true, }, } @@ -141,6 +155,10 @@ func TestShouldSendAlertNotification(t *testing.T) { State: tc.prevState, }) + if tc.state == nil { + tc.state = &m.AlertNotificationState{} + } + evalContext.Rule.State = tc.newState nb := &NotifierBase{SendReminder: tc.sendReminder, Frequency: tc.frequency} diff --git a/pkg/services/alerting/result_handler.go b/pkg/services/alerting/result_handler.go index 420ffeb9a55..ce12a8a6b96 100644 --- a/pkg/services/alerting/result_handler.go +++ b/pkg/services/alerting/result_handler.go @@ -73,6 +73,9 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error { // when two servers are raising. This makes sure that the server // with the last state change always sends a notification. evalContext.Rule.StateChanges = cmd.Result.StateChanges + + // Update the last state change of the alert rule in memory + evalContext.Rule.LastStateChange = time.Now() } // save annotation diff --git a/pkg/services/alerting/rule.go b/pkg/services/alerting/rule.go index 999611f15c4..d2a505145ac 100644 --- a/pkg/services/alerting/rule.go +++ b/pkg/services/alerting/rule.go @@ -4,6 +4,7 @@ import ( "fmt" "regexp" "strconv" + "time" "github.com/grafana/grafana/pkg/components/simplejson" @@ -18,6 +19,8 @@ type Rule struct { Frequency int64 Name string Message string + LastStateChange time.Time + For time.Duration NoDataState m.NoDataOption ExecutionErrorState m.ExecutionErrorOption State m.AlertStateType @@ -100,6 +103,8 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) { model.Message = ruleDef.Message model.Frequency = ruleDef.Frequency model.State = ruleDef.State + model.LastStateChange = ruleDef.NewStateDate + model.For = ruleDef.For model.NoDataState = m.NoDataOption(ruleDef.Settings.Get("noDataState").MustString("no_data")) model.ExecutionErrorState = m.ExecutionErrorOption(ruleDef.Settings.Get("executionErrorState").MustString("alerting")) model.StateChanges = ruleDef.StateChanges diff --git a/pkg/services/alerting/test-data/collapsed-panels.json b/pkg/services/alerting/testdata/collapsed-panels.json similarity index 100% rename from pkg/services/alerting/test-data/collapsed-panels.json rename to pkg/services/alerting/testdata/collapsed-panels.json diff --git a/pkg/services/alerting/test-data/dash-without-id.json b/pkg/services/alerting/testdata/dash-without-id.json similarity index 100% rename from pkg/services/alerting/test-data/dash-without-id.json rename to pkg/services/alerting/testdata/dash-without-id.json diff --git a/pkg/services/alerting/test-data/graphite-alert.json b/pkg/services/alerting/testdata/graphite-alert.json similarity index 98% rename from pkg/services/alerting/test-data/graphite-alert.json rename to pkg/services/alerting/testdata/graphite-alert.json index 5f23e224f9a..3cb4ae1dd22 100644 --- a/pkg/services/alerting/test-data/graphite-alert.json +++ b/pkg/services/alerting/testdata/graphite-alert.json @@ -23,6 +23,7 @@ "message": "desc1", "handler": 1, "frequency": "60s", + "for": "2m", "conditions": [ { "type": "query", diff --git a/pkg/services/alerting/test-data/influxdb-alert.json b/pkg/services/alerting/testdata/influxdb-alert.json similarity index 100% rename from pkg/services/alerting/test-data/influxdb-alert.json rename to pkg/services/alerting/testdata/influxdb-alert.json diff --git a/pkg/services/alerting/test-data/panel-with-id-0.json b/pkg/services/alerting/testdata/panel-with-id-0.json similarity index 100% rename from pkg/services/alerting/test-data/panel-with-id-0.json rename to pkg/services/alerting/testdata/panel-with-id-0.json diff --git a/pkg/services/alerting/test-data/panels-missing-id.json b/pkg/services/alerting/testdata/panels-missing-id.json similarity index 100% rename from pkg/services/alerting/test-data/panels-missing-id.json rename to pkg/services/alerting/testdata/panels-missing-id.json diff --git a/pkg/services/alerting/test-data/v5-dashboard.json b/pkg/services/alerting/testdata/v5-dashboard.json similarity index 100% rename from pkg/services/alerting/test-data/v5-dashboard.json rename to pkg/services/alerting/testdata/v5-dashboard.json diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/dashboard_service.go index b52d1845a0b..7e334ff656f 100644 --- a/pkg/services/dashboards/dashboard_service.go +++ b/pkg/services/dashboards/dashboard_service.go @@ -165,7 +165,7 @@ func (dr *dashboardServiceImpl) updateAlerting(cmd *models.SaveDashboardCommand, } if err := bus.Dispatch(&alertCmd); err != nil { - return models.ErrDashboardFailedToUpdateAlertData + return err } return nil diff --git a/pkg/services/sqlstore/alert.go b/pkg/services/sqlstore/alert.go index 2f17402b80c..62ab348664f 100644 --- a/pkg/services/sqlstore/alert.go +++ b/pkg/services/sqlstore/alert.go @@ -193,7 +193,8 @@ func updateAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *DBS if alertToUpdate.ContainsUpdates(alert) { alert.Updated = timeNow() alert.State = alertToUpdate.State - sess.MustCols("message") + sess.MustCols("message", "for") + _, err := sess.ID(alert.Id).Update(alert) if err != nil { return err @@ -204,7 +205,7 @@ func updateAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *DBS } else { alert.Updated = timeNow() alert.Created = timeNow() - alert.State = m.AlertStatePending + alert.State = m.AlertStateUnknown alert.NewStateDate = timeNow() _, err := sess.Insert(alert) @@ -299,7 +300,7 @@ func PauseAlert(cmd *m.PauseAlertCommand) error { params = append(params, string(m.AlertStatePaused)) params = append(params, timeNow()) } else { - params = append(params, string(m.AlertStatePending)) + params = append(params, string(m.AlertStateUnknown)) params = append(params, timeNow()) } @@ -323,7 +324,7 @@ func PauseAllAlerts(cmd *m.PauseAllAlertCommand) error { if cmd.Paused { newState = string(m.AlertStatePaused) } else { - newState = string(m.AlertStatePending) + newState = string(m.AlertStateUnknown) } res, err := sess.Exec(`UPDATE alert SET state = ?, new_state_date = ?`, newState, timeNow()) diff --git a/pkg/services/sqlstore/alert_test.go b/pkg/services/sqlstore/alert_test.go index d97deb45f0e..40867e96b4d 100644 --- a/pkg/services/sqlstore/alert_test.go +++ b/pkg/services/sqlstore/alert_test.go @@ -109,7 +109,7 @@ func TestAlertingDataAccess(t *testing.T) { So(alert.DashboardId, ShouldEqual, testDash.Id) So(alert.PanelId, ShouldEqual, 1) So(alert.Name, ShouldEqual, "Alerting title") - So(alert.State, ShouldEqual, "pending") + So(alert.State, ShouldEqual, m.AlertStateUnknown) So(alert.NewStateDate, ShouldNotBeNil) So(alert.EvalData, ShouldNotBeNil) So(alert.EvalData.Get("test").MustString(), ShouldEqual, "test") @@ -154,7 +154,7 @@ func TestAlertingDataAccess(t *testing.T) { So(query.Result[0].Name, ShouldEqual, "Name") Convey("Alert state should not be updated", func() { - So(query.Result[0].State, ShouldEqual, "pending") + So(query.Result[0].State, ShouldEqual, m.AlertStateUnknown) }) }) diff --git a/pkg/services/sqlstore/migrations/alert_mig.go b/pkg/services/sqlstore/migrations/alert_mig.go index 198a47b50ff..b5aeb26483c 100644 --- a/pkg/services/sqlstore/migrations/alert_mig.go +++ b/pkg/services/sqlstore/migrations/alert_mig.go @@ -133,4 +133,8 @@ func addAlertMigrations(mg *Migrator) { mg.AddMigration("create alert_notification_state table v1", NewAddTableMigration(alert_notification_state)) mg.AddMigration("add index alert_notification_state org_id & alert_id & notifier_id", NewAddIndexMigration(alert_notification_state, alert_notification_state.Indices[0])) + + mg.AddMigration("Add for to alert table", NewAddColumnMigration(alertV1, &Column{ + Name: "for", Type: DB_BigInt, Nullable: true, + })) } diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index c02686c24ba..7e966106b96 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/pkg/services/sqlstore/org_test.go @@ -187,7 +187,7 @@ func TestAccountDataAccess(t *testing.T) { err := DeleteOrg(&m.DeleteOrgCommand{Id: ac2.OrgId}) So(err, ShouldBeNil) - // remove frome ac2 from ac1 org + // remove ac2 user from ac1 org remCmd := m.RemoveOrgUserCommand{OrgId: ac1.OrgId, UserId: ac2.Id, ShouldDeleteOrphanedUser: true} err = RemoveOrgUser(&remCmd) So(err, ShouldBeNil) diff --git a/pkg/services/sqlstore/quota.go b/pkg/services/sqlstore/quota.go index 7005b341268..e90b7fec131 100644 --- a/pkg/services/sqlstore/quota.go +++ b/pkg/services/sqlstore/quota.go @@ -99,14 +99,14 @@ func UpdateOrgQuota(cmd *m.UpdateOrgQuotaCmd) error { return inTransaction(func(sess *DBSession) error { //Check if quota is already defined in the DB quota := m.Quota{ - Target: cmd.Target, - OrgId: cmd.OrgId, - Updated: time.Now(), + Target: cmd.Target, + OrgId: cmd.OrgId, } has, err := sess.Get("a) if err != nil { return err } + quota.Updated = time.Now() quota.Limit = cmd.Limit if !has { quota.Created = time.Now() @@ -201,14 +201,14 @@ func UpdateUserQuota(cmd *m.UpdateUserQuotaCmd) error { return inTransaction(func(sess *DBSession) error { //Check if quota is already defined in the DB quota := m.Quota{ - Target: cmd.Target, - UserId: cmd.UserId, - Updated: time.Now(), + Target: cmd.Target, + UserId: cmd.UserId, } has, err := sess.Get("a) if err != nil { return err } + quota.Updated = time.Now() quota.Limit = cmd.Limit if !has { quota.Created = time.Now() diff --git a/pkg/services/sqlstore/quota_test.go b/pkg/services/sqlstore/quota_test.go index 49e028e9cd3..976d54d10e2 100644 --- a/pkg/services/sqlstore/quota_test.go +++ b/pkg/services/sqlstore/quota_test.go @@ -2,6 +2,7 @@ package sqlstore import ( "testing" + "time" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" @@ -168,5 +169,69 @@ func TestQuotaCommandsAndQueries(t *testing.T) { So(query.Result.Limit, ShouldEqual, 5) So(query.Result.Used, ShouldEqual, 1) }) + + // related: https://github.com/grafana/grafana/issues/14342 + Convey("Should org quota updating is successful even if it called multiple time", func() { + orgCmd := m.UpdateOrgQuotaCmd{ + OrgId: orgId, + Target: "org_user", + Limit: 5, + } + err := UpdateOrgQuota(&orgCmd) + So(err, ShouldBeNil) + + query := m.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 1} + err = GetOrgQuotaByTarget(&query) + So(err, ShouldBeNil) + So(query.Result.Limit, ShouldEqual, 5) + + // XXX: resolution of `Updated` column is 1sec, so this makes delay + time.Sleep(1 * time.Second) + + orgCmd = m.UpdateOrgQuotaCmd{ + OrgId: orgId, + Target: "org_user", + Limit: 10, + } + err = UpdateOrgQuota(&orgCmd) + So(err, ShouldBeNil) + + query = m.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 1} + err = GetOrgQuotaByTarget(&query) + So(err, ShouldBeNil) + So(query.Result.Limit, ShouldEqual, 10) + }) + + // related: https://github.com/grafana/grafana/issues/14342 + Convey("Should user quota updating is successful even if it called multiple time", func() { + userQuotaCmd := m.UpdateUserQuotaCmd{ + UserId: userId, + Target: "org_user", + Limit: 5, + } + err := UpdateUserQuota(&userQuotaCmd) + So(err, ShouldBeNil) + + query := m.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 1} + err = GetUserQuotaByTarget(&query) + So(err, ShouldBeNil) + So(query.Result.Limit, ShouldEqual, 5) + + // XXX: resolution of `Updated` column is 1sec, so this makes delay + time.Sleep(1 * time.Second) + + userQuotaCmd = m.UpdateUserQuotaCmd{ + UserId: userId, + Target: "org_user", + Limit: 10, + } + err = UpdateUserQuota(&userQuotaCmd) + So(err, ShouldBeNil) + + query = m.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 1} + err = GetUserQuotaByTarget(&query) + So(err, ShouldBeNil) + So(query.Result.Limit, ShouldEqual, 10) + }) }) } diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 99a77ecabc3..a3ccb93b30c 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -504,8 +504,18 @@ func UpdateUserPermissions(cmd *m.UpdateUserPermissionsCommand) error { user.IsAdmin = cmd.IsGrafanaAdmin sess.UseBool("is_admin") + _, err := sess.ID(user.Id).Update(&user) - return err + if err != nil { + return err + } + + // validate that after update there is at least one server admin + if err := validateOneAdminLeft(sess); err != nil { + return err + } + + return nil }) } @@ -522,3 +532,17 @@ func SetUserHelpFlag(cmd *m.SetUserHelpFlagCommand) error { return err }) } + +func validateOneAdminLeft(sess *DBSession) error { + // validate that there is an admin user left + count, err := sess.Where("is_admin=?", true).Count(&m.User{}) + if err != nil { + return err + } + + if count == 0 { + return m.ErrLastGrafanaAdmin + } + + return nil +} diff --git a/pkg/services/sqlstore/user_test.go b/pkg/services/sqlstore/user_test.go index b26dd235772..627f2ab1ca5 100644 --- a/pkg/services/sqlstore/user_test.go +++ b/pkg/services/sqlstore/user_test.go @@ -155,6 +155,32 @@ func TestUserDataAccess(t *testing.T) { }) }) }) + + Convey("Given one grafana admin user", func() { + var err error + createUserCmd := &m.CreateUserCommand{ + Email: fmt.Sprint("admin", "@test.com"), + Name: fmt.Sprint("admin"), + Login: fmt.Sprint("admin"), + IsAdmin: true, + } + err = CreateUser(context.Background(), createUserCmd) + So(err, ShouldBeNil) + + Convey("Cannot make themselves a non-admin", func() { + updateUserPermsCmd := m.UpdateUserPermissionsCommand{IsGrafanaAdmin: false, UserId: 1} + updatePermsError := UpdateUserPermissions(&updateUserPermsCmd) + + So(updatePermsError, ShouldEqual, m.ErrLastGrafanaAdmin) + + query := m.GetUserByIdQuery{Id: createUserCmd.Result.Id} + getUserError := GetUserById(&query) + + So(getUserError, ShouldBeNil) + + So(query.Result.IsAdmin, ShouldEqual, true) + }) + }) }) } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index afae642f5b3..1417392fdf8 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -57,6 +57,9 @@ var ( IsEnterprise bool ApplicationName string + // packaging + Packaging = "unknown" + // Paths HomePath string PluginsPath string @@ -112,6 +115,7 @@ var ( ExternalUserMngLinkUrl string ExternalUserMngLinkName string ExternalUserMngInfo string + OAuthAutoLogin bool ViewersCanEdit bool // Http auth @@ -215,6 +219,8 @@ type Cfg struct { DisableBruteForceLoginProtection bool TempDataLifetime time.Duration MetricsEndpointEnabled bool + MetricsEndpointBasicAuthUsername string + MetricsEndpointBasicAuthPassword string EnableAlphaPanels bool EnterpriseLicensePath string } @@ -626,6 +632,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { auth := iniFile.Section("auth") DisableLoginForm = auth.Key("disable_login_form").MustBool(false) DisableSignoutMenu = auth.Key("disable_signout_menu").MustBool(false) + OAuthAutoLogin = auth.Key("oauth_auto_login").MustBool(false) SignoutRedirectUrl = auth.Key("signout_redirect_url").String() // anonymous access @@ -676,6 +683,8 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { cfg.PhantomDir = filepath.Join(HomePath, "tools/phantomjs") cfg.TempDataLifetime = iniFile.Section("paths").Key("temp_data_lifetime").MustDuration(time.Second * 3600 * 24) cfg.MetricsEndpointEnabled = iniFile.Section("metrics").Key("enabled").MustBool(true) + cfg.MetricsEndpointBasicAuthUsername = iniFile.Section("metrics").Key("basic_auth_username").String() + cfg.MetricsEndpointBasicAuthPassword = iniFile.Section("metrics").Key("basic_auth_password").String() analytics := iniFile.Section("analytics") ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true) diff --git a/pkg/social/google_oauth.go b/pkg/social/google_oauth.go index e9ab08305f6..05ae2a481f2 100644 --- a/pkg/social/google_oauth.go +++ b/pkg/social/google_oauth.go @@ -32,6 +32,7 @@ func (s *SocialGoogle) IsSignupAllowed() bool { func (s *SocialGoogle) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { var data struct { + Id string `json:"id"` Name string `json:"name"` Email string `json:"email"` } @@ -47,6 +48,7 @@ func (s *SocialGoogle) UserInfo(client *http.Client, token *oauth2.Token) (*Basi } return &BasicUserInfo{ + Id: data.Id, Name: data.Name, Email: data.Email, Login: data.Email, diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index 437457df52a..8bb1ab6c928 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -126,6 +126,18 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo } eg.Go(func() error { + defer func() { + if err := recover(); err != nil { + plog.Error("Execute Query Panic", "error", err, "stack", log.Stack(1)) + if theErr, ok := err.(error); ok { + resultChan <- &tsdb.QueryResult{ + RefId: query.RefId, + Error: theErr, + } + } + } + }() + queryRes, err := e.executeQuery(ectx, query, queryContext) if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" { return err @@ -146,6 +158,17 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo for region, getMetricDataQuery := range getMetricDataQueries { q := getMetricDataQuery eg.Go(func() error { + defer func() { + if err := recover(); err != nil { + plog.Error("Execute Get Metric Data Query Panic", "error", err, "stack", log.Stack(1)) + if theErr, ok := err.(error); ok { + resultChan <- &tsdb.QueryResult{ + Error: theErr, + } + } + } + }() + queryResponses, err := e.executeGetMetricDataQuery(ectx, region, q, queryContext) if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" { return err @@ -188,8 +211,8 @@ func (e *CloudWatchExecutor) executeQuery(ctx context.Context, query *CloudWatch return nil, err } - if endTime.Before(startTime) { - return nil, fmt.Errorf("Invalid time range: End time can't be before start time") + if !startTime.Before(endTime) { + return nil, fmt.Errorf("Invalid time range: Start time must be before end time") } params := &cloudwatch.GetMetricStatisticsInput{ diff --git a/pkg/tsdb/cloudwatch/cloudwatch_test.go b/pkg/tsdb/cloudwatch/cloudwatch_test.go index 32b8c910f2b..3b0b073eb06 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch_test.go +++ b/pkg/tsdb/cloudwatch/cloudwatch_test.go @@ -1,9 +1,13 @@ package cloudwatch import ( + "context" "testing" "time" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/tsdb" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/grafana/grafana/pkg/components/null" @@ -14,6 +18,24 @@ import ( func TestCloudWatch(t *testing.T) { Convey("CloudWatch", t, func() { + Convey("executeQuery", func() { + e := &CloudWatchExecutor{ + DataSource: &models.DataSource{ + JsonData: simplejson.New(), + }, + } + + Convey("End time before start time should result in error", func() { + _, err := e.executeQuery(context.Background(), &CloudWatchQuery{}, &tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("now-1h", "now-2h")}) + So(err.Error(), ShouldEqual, "Invalid time range: Start time must be before end time") + }) + + Convey("End time equals start time should result in error", func() { + _, err := e.executeQuery(context.Background(), &CloudWatchQuery{}, &tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("now-1h", "now-1h")}) + So(err.Error(), ShouldEqual, "Invalid time range: Start time must be before end time") + }) + }) + Convey("can parse cloudwatch json model", func() { json := ` { diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index 1a860519f2b..dd026bbb79e 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -46,6 +46,8 @@ func init() { "AWS/Billing": {"EstimatedCharges"}, "AWS/CloudFront": {"Requests", "BytesDownloaded", "BytesUploaded", "TotalErrorRate", "4xxErrorRate", "5xxErrorRate"}, "AWS/CloudSearch": {"SuccessfulRequests", "SearchableDocuments", "IndexUtilization", "Partitions"}, + "AWS/CloudHSM": {"HsmUnhealthy", "HsmTemperature", "HsmKeysSessionOccupied", "HsmKeysTokenOccupied", "HsmSslCtxsOccupied", "HsmSessionCount", "HsmUsersAvailable", "HsmUsersMax", "InterfaceEth2OctetsInput", "InterfaceEth2OctetsOutput"}, + "AWS/CodeBuild": {"BuildDuration", "Builds", "DownloadSourceDuration", "Duration", "FailedBuilds", "FinalizingDuration", "InstallDuration", "PostBuildDuration", "PreBuildDuration", "ProvisioningDuration", "QueuedDuration", "SubmittedDuration", "SucceededBuilds", "UploadArtifactsDuration"}, "AWS/Connect": {"CallsBreachingConcurrencyQuota", "CallBackNotDialableNumber", "CallRecordingUploadError", "CallsPerInterval", "ConcurrentCalls", "ConcurrentCallsPercentage", "ContactFlowErrors", "ContactFlowFatalErrors", "LongestQueueWaitTime", "MissedCalls", "MisconfiguredPhoneNumbers", "PublicSigningKeyUsage", "QueueCapacityExceededError", "QueueSize", "ThrottledCalls", "ToInstancePacketLossRate"}, "AWS/DMS": {"FreeableMemory", "WriteIOPS", "ReadIOPS", "WriteThroughput", "ReadThroughput", "WriteLatency", "ReadLatency", "SwapUsage", "NetworkTransmitThroughput", "NetworkReceiveThroughput", "FullLoadThroughputBandwidthSource", "FullLoadThroughputBandwidthTarget", "FullLoadThroughputRowsSource", "FullLoadThroughputRowsTarget", "CDCIncomingChanges", "CDCChangesMemorySource", "CDCChangesMemoryTarget", "CDCChangesDiskSource", "CDCChangesDiskTarget", "CDCThroughputBandwidthTarget", "CDCThroughputRowsSource", "CDCThroughputRowsTarget", "CDCLatencySource", "CDCLatencyTarget"}, "AWS/DX": {"ConnectionState", "ConnectionBpsEgress", "ConnectionBpsIngress", "ConnectionPpsEgress", "ConnectionPpsIngress", "ConnectionCRCErrorCount", "ConnectionLightLevelTx", "ConnectionLightLevelRx"}, @@ -121,6 +123,8 @@ func init() { "AWS/Billing": {"ServiceName", "LinkedAccount", "Currency"}, "AWS/CloudFront": {"DistributionId", "Region"}, "AWS/CloudSearch": {}, + "AWS/CloudHSM": {"Region", "ClusterId", "HsmId"}, + "AWS/CodeBuild": {"ProjectName"}, "AWS/Connect": {"InstanceId", "MetricGroup", "Participant", "QueueName", "Stream Type", "Type of Connection"}, "AWS/DMS": {"ReplicationInstanceIdentifier", "ReplicationTaskIdentifier"}, "AWS/DX": {"ConnectionId"}, diff --git a/pkg/tsdb/elasticsearch/client/client.go b/pkg/tsdb/elasticsearch/client/client.go index 4ebe0db8f89..f5a200f60ce 100644 --- a/pkg/tsdb/elasticsearch/client/client.go +++ b/pkg/tsdb/elasticsearch/client/client.go @@ -65,7 +65,7 @@ var NewClient = func(ctx context.Context, ds *models.DataSource, timeRange *tsdb clientLog.Debug("Creating new client", "version", version, "timeField", timeField, "indices", strings.Join(indices, ", ")) switch version { - case 2, 5, 56: + case 2, 5, 56, 60: return &baseClientImpl{ ctx: ctx, ds: ds, diff --git a/pkg/tsdb/elasticsearch/client/client_test.go b/pkg/tsdb/elasticsearch/client/client_test.go index 540a999688a..7002aa5db4f 100644 --- a/pkg/tsdb/elasticsearch/client/client_test.go +++ b/pkg/tsdb/elasticsearch/client/client_test.go @@ -90,6 +90,19 @@ func TestClient(t *testing.T) { So(err, ShouldBeNil) So(c.GetVersion(), ShouldEqual, 56) }) + + Convey("When version 60 should return v6.0 client", func() { + ds := &models.DataSource{ + JsonData: simplejson.NewFromAny(map[string]interface{}{ + "esVersion": 60, + "timeField": "@timestamp", + }), + } + + c, err := NewClient(context.Background(), ds, nil) + So(err, ShouldBeNil) + So(c.GetVersion(), ShouldEqual, 60) + }) }) Convey("Given a fake http client", func() { @@ -153,8 +166,6 @@ func TestClient(t *testing.T) { jBody, err := simplejson.NewJson(bodyBytes) So(err, ShouldBeNil) - fmt.Println("body", string(headerBytes)) - So(jHeader.Get("index").MustString(), ShouldEqual, "metrics-2018.05.15") So(jHeader.Get("ignore_unavailable").MustBool(false), ShouldEqual, true) So(jHeader.Get("search_type").MustString(), ShouldEqual, "count") @@ -209,8 +220,6 @@ func TestClient(t *testing.T) { jBody, err := simplejson.NewJson(bodyBytes) So(err, ShouldBeNil) - fmt.Println("body", string(headerBytes)) - So(jHeader.Get("index").MustString(), ShouldEqual, "metrics-2018.05.15") So(jHeader.Get("ignore_unavailable").MustBool(false), ShouldEqual, true) So(jHeader.Get("search_type").MustString(), ShouldEqual, "query_then_fetch") @@ -265,8 +274,6 @@ func TestClient(t *testing.T) { jBody, err := simplejson.NewJson(bodyBytes) So(err, ShouldBeNil) - fmt.Println("body", string(headerBytes)) - So(jHeader.Get("index").MustString(), ShouldEqual, "metrics-2018.05.15") So(jHeader.Get("ignore_unavailable").MustBool(false), ShouldEqual, true) So(jHeader.Get("search_type").MustString(), ShouldEqual, "query_then_fetch") diff --git a/pkg/tsdb/elasticsearch/client/models.go b/pkg/tsdb/elasticsearch/client/models.go index a0d257d01a6..5307dc34b27 100644 --- a/pkg/tsdb/elasticsearch/client/models.go +++ b/pkg/tsdb/elasticsearch/client/models.go @@ -240,6 +240,7 @@ type DateHistogramAgg struct { Missing *string `json:"missing,omitempty"` ExtendedBounds *ExtendedBounds `json:"extended_bounds"` Format string `json:"format"` + Offset string `json:"offset,omitempty"` } // FiltersAggregation represents a filters aggregation diff --git a/pkg/tsdb/elasticsearch/response_parser.go b/pkg/tsdb/elasticsearch/response_parser.go index 0837c3dd9d5..b2c724a9b93 100644 --- a/pkg/tsdb/elasticsearch/response_parser.go +++ b/pkg/tsdb/elasticsearch/response_parser.go @@ -541,7 +541,7 @@ func getErrorFromElasticResponse(response *es.SearchResponse) *tsdb.QueryResult } else if reason != "" { result.ErrorString = reason } else { - result.ErrorString = "Unkown elasticsearch error response" + result.ErrorString = "Unknown elasticsearch error response" } return result diff --git a/pkg/tsdb/elasticsearch/time_series_query.go b/pkg/tsdb/elasticsearch/time_series_query.go index 869e23e21ce..a25c3cf693d 100644 --- a/pkg/tsdb/elasticsearch/time_series_query.go +++ b/pkg/tsdb/elasticsearch/time_series_query.go @@ -134,6 +134,10 @@ func addDateHistogramAgg(aggBuilder es.AggBuilder, bucketAgg *BucketAgg, timeFro a.Interval = "$__interval" } + if offset, err := bucketAgg.Settings.Get("offset").String(); err == nil { + a.Offset = offset + } + if missing, err := bucketAgg.Settings.Get("missing").String(); err == nil { a.Missing = &missing } diff --git a/pkg/tsdb/influxdb/query_part.go b/pkg/tsdb/influxdb/query_part.go index 77f565a8597..29a77f15617 100644 --- a/pkg/tsdb/influxdb/query_part.go +++ b/pkg/tsdb/influxdb/query_part.go @@ -32,6 +32,7 @@ func init() { renders["median"] = QueryDefinition{Renderer: functionRenderer} renders["sum"] = QueryDefinition{Renderer: functionRenderer} renders["mode"] = QueryDefinition{Renderer: functionRenderer} + renders["cumulative_sum"] = QueryDefinition{Renderer: functionRenderer} renders["holt_winters"] = QueryDefinition{ Renderer: functionRenderer, diff --git a/pkg/tsdb/influxdb/query_part_test.go b/pkg/tsdb/influxdb/query_part_test.go index 08bcff9b727..76daf6446d8 100644 --- a/pkg/tsdb/influxdb/query_part_test.go +++ b/pkg/tsdb/influxdb/query_part_test.go @@ -23,6 +23,7 @@ func TestInfluxdbQueryPart(t *testing.T) { {mode: "alias", params: []string{"test"}, input: "mean(value)", expected: `mean(value) AS "test"`}, {mode: "count", params: []string{}, input: "distinct(value)", expected: `count(distinct(value))`}, {mode: "mode", params: []string{}, input: "value", expected: `mode(value)`}, + {mode: "cumulative_sum", params: []string{}, input: "mean(value)", expected: `cumulative_sum(mean(value))`}, } queryContext := &tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("5m", "now")} diff --git a/pkg/tsdb/mssql/macros.go b/pkg/tsdb/mssql/macros.go index 0a260f7ad70..dac18f3ac03 100644 --- a/pkg/tsdb/mssql/macros.go +++ b/pkg/tsdb/mssql/macros.go @@ -66,6 +66,10 @@ func (m *msSqlMacroEngine) evaluateMacro(name string, args []string) (string, er } return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil + case "__timeFrom": + return fmt.Sprintf("'%s'", m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil + case "__timeTo": + return fmt.Sprintf("'%s'", m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil case "__timeGroup": if len(args) < 2 { return "", fmt.Errorf("macro %v needs time column and interval", name) diff --git a/pkg/tsdb/mssql/macros_test.go b/pkg/tsdb/mssql/macros_test.go index 7456238efa4..43cbe9fefda 100644 --- a/pkg/tsdb/mssql/macros_test.go +++ b/pkg/tsdb/mssql/macros_test.go @@ -52,6 +52,20 @@ func TestMacroEngine(t *testing.T) { So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) }) + Convey("interpolate __timeFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select '2018-04-12T18:00:00Z'") + }) + + Convey("interpolate __timeTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeTo()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select '2018-04-12T18:05:00Z'") + }) + Convey("interpolate __timeGroup function", func() { sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m')") So(err, ShouldBeNil) diff --git a/pkg/tsdb/mysql/macros.go b/pkg/tsdb/mysql/macros.go index 839f805568e..c0ed64aa65c 100644 --- a/pkg/tsdb/mysql/macros.go +++ b/pkg/tsdb/mysql/macros.go @@ -61,6 +61,10 @@ func (m *mySqlMacroEngine) evaluateMacro(name string, args []string) (string, er } return fmt.Sprintf("%s BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", args[0], m.timeRange.GetFromAsSecondsEpoch(), m.timeRange.GetToAsSecondsEpoch()), nil + case "__timeFrom": + return fmt.Sprintf("FROM_UNIXTIME(%d)", m.timeRange.GetFromAsSecondsEpoch()), nil + case "__timeTo": + return fmt.Sprintf("FROM_UNIXTIME(%d)", m.timeRange.GetToAsSecondsEpoch()), nil case "__timeGroup": if len(args) < 2 { return "", fmt.Errorf("macro %v needs time column and interval", name) diff --git a/pkg/tsdb/mysql/macros_test.go b/pkg/tsdb/mysql/macros_test.go index 24bf18873d5..49633347a4c 100644 --- a/pkg/tsdb/mysql/macros_test.go +++ b/pkg/tsdb/mysql/macros_test.go @@ -63,6 +63,20 @@ func TestMacroEngine(t *testing.T) { So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix())) }) + Convey("interpolate __timeFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", from.Unix())) + }) + + Convey("interpolate __timeTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeTo()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", to.Unix())) + }) + Convey("interpolate __unixEpochFilter function", func() { sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") So(err, ShouldBeNil) diff --git a/pkg/tsdb/mysql/mysql_test.go b/pkg/tsdb/mysql/mysql_test.go index 476e3ba6586..fe59a4b9d9d 100644 --- a/pkg/tsdb/mysql/mysql_test.go +++ b/pkg/tsdb/mysql/mysql_test.go @@ -761,7 +761,7 @@ func TestMySQL(t *testing.T) { { DataSource: &models.DataSource{JsonData: simplejson.New()}, Model: simplejson.NewFromAny(map[string]interface{}{ - "rawSql": `SELECT time FROM metric_values WHERE time > $__timeFrom() OR time < $__timeFrom() OR 1 < $__unixEpochFrom() OR $__unixEpochTo() > 1 ORDER BY 1`, + "rawSql": `SELECT time FROM metric_values WHERE time > $__timeFrom() OR time < $__timeTo() OR 1 < $__unixEpochFrom() OR $__unixEpochTo() > 1 ORDER BY 1`, "format": "time_series", }), RefId: "A", @@ -773,7 +773,7 @@ func TestMySQL(t *testing.T) { So(err, ShouldBeNil) queryResult := resp.Results["A"] So(queryResult.Error, ShouldBeNil) - So(queryResult.Meta.Get("sql").MustString(), ShouldEqual, "SELECT time FROM metric_values WHERE time > '2018-03-15T12:55:00Z' OR time < '2018-03-15T12:55:00Z' OR 1 < 1521118500 OR 1521118800 > 1 ORDER BY 1") + So(queryResult.Meta.Get("sql").MustString(), ShouldEqual, "SELECT time FROM metric_values WHERE time > FROM_UNIXTIME(1521118500) OR time < FROM_UNIXTIME(1521118800) OR 1 < 1521118500 OR 1521118800 > 1 ORDER BY 1") }) diff --git a/pkg/tsdb/opentsdb/opentsdb.go b/pkg/tsdb/opentsdb/opentsdb.go index 16da764de54..a810d3c7338 100644 --- a/pkg/tsdb/opentsdb/opentsdb.go +++ b/pkg/tsdb/opentsdb/opentsdb.go @@ -84,7 +84,7 @@ func (e *OpenTsdbExecutor) createRequest(dsInfo *models.DataSource, data OpenTsd postData, err := json.Marshal(data) if err != nil { - plog.Info("Failed marshalling data", "error", err) + plog.Info("Failed marshaling data", "error", err) return nil, fmt.Errorf("Failed to create request. error: %v", err) } diff --git a/pkg/tsdb/postgres/macros.go b/pkg/tsdb/postgres/macros.go index 0fa5d8077e1..26a3d1e53ee 100644 --- a/pkg/tsdb/postgres/macros.go +++ b/pkg/tsdb/postgres/macros.go @@ -87,6 +87,10 @@ func (m *postgresMacroEngine) evaluateMacro(name string, args []string) (string, } return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil + case "__timeFrom": + return fmt.Sprintf("'%s'", m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil + case "__timeTo": + return fmt.Sprintf("'%s'", m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil case "__timeGroup": if len(args) < 2 { return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name) diff --git a/pkg/tsdb/postgres/macros_test.go b/pkg/tsdb/postgres/macros_test.go index 8a3699f82b2..6a71caedeb9 100644 --- a/pkg/tsdb/postgres/macros_test.go +++ b/pkg/tsdb/postgres/macros_test.go @@ -44,6 +44,20 @@ func TestMacroEngine(t *testing.T) { So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) }) + Convey("interpolate __timeFrom function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select '2018-04-12T18:00:00Z'") + }) + + Convey("interpolate __timeTo function", func() { + sql, err := engine.Interpolate(query, timeRange, "select $__timeTo()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select '2018-04-12T18:05:00Z'") + }) + Convey("interpolate __timeGroup function pre 5.3 compatibility", func() { sql, err := engine.Interpolate(query, timeRange, "SELECT $__timeGroup(time_column,'5m'), value") diff --git a/pkg/tsdb/sql_engine.go b/pkg/tsdb/sql_engine.go index 1a4e2bd3943..ab7230e9b02 100644 --- a/pkg/tsdb/sql_engine.go +++ b/pkg/tsdb/sql_engine.go @@ -196,8 +196,6 @@ var Interpolate = func(query *Query, timeRange *TimeRange, sql string) (string, sql = strings.Replace(sql, "$__interval_ms", strconv.FormatInt(interval.Milliseconds(), 10), -1) sql = strings.Replace(sql, "$__interval", interval.Text, -1) - sql = strings.Replace(sql, "$__timeFrom()", fmt.Sprintf("'%s'", timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), -1) - sql = strings.Replace(sql, "$__timeTo()", fmt.Sprintf("'%s'", timeRange.GetToAsTimeUTC().Format(time.RFC3339)), -1) sql = strings.Replace(sql, "$__unixEpochFrom()", fmt.Sprintf("%d", timeRange.GetFromAsSecondsEpoch()), -1) sql = strings.Replace(sql, "$__unixEpochTo()", fmt.Sprintf("%d", timeRange.GetToAsSecondsEpoch()), -1) diff --git a/pkg/tsdb/sql_engine_test.go b/pkg/tsdb/sql_engine_test.go index bfcc82aac47..bc77a95734d 100644 --- a/pkg/tsdb/sql_engine_test.go +++ b/pkg/tsdb/sql_engine_test.go @@ -44,20 +44,6 @@ func TestSqlEngine(t *testing.T) { So(sql, ShouldEqual, "select 60000 ") }) - Convey("interpolate __timeFrom function", func() { - sql, err := Interpolate(query, timeRange, "select $__timeFrom()") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339))) - }) - - Convey("interpolate __timeTo function", func() { - sql, err := Interpolate(query, timeRange, "select $__timeTo()") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339))) - }) - Convey("interpolate __unixEpochFrom function", func() { sql, err := Interpolate(query, timeRange, "select $__unixEpochFrom()") So(err, ShouldBeNil) diff --git a/public/app/app.ts b/public/app/app.ts index 9647fbe5416..2b94ec0fe33 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -1,4 +1,4 @@ -import 'babel-polyfill'; +import '@babel/polyfill'; import 'file-saver'; import 'lodash'; import 'jquery'; diff --git a/public/app/core/components/PermissionList/AddPermission.tsx b/public/app/core/components/PermissionList/AddPermission.tsx index 71cc937ddfa..d6da7c68544 100644 --- a/public/app/core/components/PermissionList/AddPermission.tsx +++ b/public/app/core/components/PermissionList/AddPermission.tsx @@ -84,7 +84,7 @@ class AddPermissions extends Component { render() { const { onCancel } = this.props; const newItem = this.state; - const pickerClassName = 'width-20'; + const pickerClassName = 'min-width-20'; const isValid = this.isValid(); return (
diff --git a/public/app/core/components/Picker/UserPicker.tsx b/public/app/core/components/Picker/UserPicker.tsx index f78cf69bf5e..f80a3fc135f 100644 --- a/public/app/core/components/Picker/UserPicker.tsx +++ b/public/app/core/components/Picker/UserPicker.tsx @@ -40,7 +40,7 @@ export class UserPicker extends Component { .then(result => { return result.map(user => ({ id: user.userId, - label: `${user.login} - ${user.email}`, + label: user.login === user.email ? user.login : `${user.login} - ${user.email}`, avatarUrl: user.avatarUrl, login: user.login, })); diff --git a/public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap b/public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap index 748fcbee4aa..b376ab24934 100644 --- a/public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap +++ b/public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap @@ -14,3 +14,4 @@ exports[`PickerOption renders correctly 1`] = `
`; + \ No newline at end of file diff --git a/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx b/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx new file mode 100644 index 00000000000..1e9ae4732df --- /dev/null +++ b/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx @@ -0,0 +1,68 @@ +import React, { SFC, ReactNode, PureComponent, ReactElement } from 'react'; + +interface ToggleButtonGroupProps { + onChange: (value) => void; + value?: any; + label?: string; + render: (props) => void; +} + +export default class ToggleButtonGroup extends PureComponent { + getValues() { + const { children } = this.props; + return React.Children.toArray(children).map((c: ReactElement) => c.props.value); + } + + smallChildren() { + const { children } = this.props; + return React.Children.toArray(children).every((c: ReactElement) => c.props.className.includes('small')); + } + + handleToggle(toggleValue) { + const { value, onChange } = this.props; + if (value && value === toggleValue) { + return; + } + onChange(toggleValue); + } + + render() { + const { value, label } = this.props; + const values = this.getValues(); + const selectedValue = value || values[0]; + const labelClassName = `gf-form-label ${this.smallChildren() ? 'small' : ''}`; + + return ( +
+
+ {label && } + {this.props.render({ selectedValue, onChange: this.handleToggle.bind(this) })} +
+
+ ); + } +} + +interface ToggleButtonProps { + onChange?: (value) => void; + selected?: boolean; + value: any; + className?: string; + children: ReactNode; +} + +export const ToggleButton: SFC = ({ children, selected, className = '', value, onChange }) => { + const handleChange = event => { + event.stopPropagation(); + if (onChange) { + onChange(value); + } + }; + + const btnClassName = `btn ${className} ${selected ? 'active' : ''}`; + return ( + + ); +}; diff --git a/public/app/core/components/code_editor/theme-grafana-dark.js b/public/app/core/components/code_editor/theme-grafana-dark.js index a48715e698e..33d4a84b527 100644 --- a/public/app/core/components/code_editor/theme-grafana-dark.js +++ b/public/app/core/components/code_editor/theme-grafana-dark.js @@ -14,7 +14,7 @@ ace.define("ace/theme/grafana-dark",["require","exports","module","ace/lib/dom"] background: #555651\ }\ .gf-code-dark {\ - background-color: #111;\ + background-color: #09090b;\ color: #e0e0e0\ }\ .gf-code-dark .ace_cursor {\ diff --git a/public/app/core/config.ts b/public/app/core/config.ts index 1473f8a91f8..13d84772ecf 100644 --- a/public/app/core/config.ts +++ b/public/app/core/config.ts @@ -54,7 +54,11 @@ export class Settings { } } -const bootData = (window as any).grafanaBootData || { settings: {} }; +const bootData = (window as any).grafanaBootData || { + settings: {}, + user: {}, +}; + const options = bootData.settings; options.bootData = bootData; diff --git a/public/app/core/controllers/reset_password_ctrl.ts b/public/app/core/controllers/reset_password_ctrl.ts index 933655399e8..9ad6864ece2 100644 --- a/public/app/core/controllers/reset_password_ctrl.ts +++ b/public/app/core/controllers/reset_password_ctrl.ts @@ -1,4 +1,5 @@ import coreModule from '../core_module'; +import config from 'app/core/config'; export class ResetPasswordCtrl { /** @ngInject */ @@ -6,6 +7,9 @@ export class ResetPasswordCtrl { contextSrv.sidemenu = false; $scope.formModel = {}; $scope.mode = 'send'; + $scope.ldapEnabled = config.ldapEnabled; + $scope.authProxyEnabled = config.authProxyEnabled; + $scope.disableLoginForm = config.disableLoginForm; const params = $location.search(); if (params.code) { diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index ab0a3f26a88..09f5bb3a916 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -1,27 +1,28 @@ import _ from 'lodash'; import { TimeSeries } from 'app/core/core'; -import colors from 'app/core/utils/colors'; +import colors, { getThemeColor } from 'app/core/utils/colors'; export enum LogLevel { - crit = 'crit', - warn = 'warn', + crit = 'critical', + critical = 'critical', + warn = 'warning', + warning = 'warning', err = 'error', error = 'error', info = 'info', debug = 'debug', trace = 'trace', - none = 'none', + unkown = 'unkown', } export const LogLevelColor = { - [LogLevel.crit]: colors[7], - [LogLevel.warn]: colors[1], - [LogLevel.err]: colors[4], + [LogLevel.critical]: colors[7], + [LogLevel.warning]: colors[1], [LogLevel.error]: colors[4], [LogLevel.info]: colors[0], - [LogLevel.debug]: colors[3], - [LogLevel.trace]: colors[3], - [LogLevel.none]: '#eee', + [LogLevel.debug]: colors[5], + [LogLevel.trace]: colors[2], + [LogLevel.unkown]: getThemeColor('#8e8e8e', '#dde4ed'), }; export interface LogSearchMatch { @@ -31,24 +32,40 @@ export interface LogSearchMatch { } export interface LogRow { + duplicates?: number; entry: string; key: string; // timestamp + labels - labels: string; + labels: LogsStreamLabels; logLevel: LogLevel; searchWords?: string[]; timestamp: string; // ISO with nanosec precision timeFromNow: string; timeEpochMs: number; timeLocal: string; - uniqueLabels?: string; + uniqueLabels?: LogsStreamLabels; +} + +export interface LogsLabelStat { + active?: boolean; + count: number; + proportion: number; + value: string; +} + +export enum LogsMetaKind { + Number, + String, + LabelsMap, } export interface LogsMetaItem { label: string; - value: string; + value: string | number | LogsStreamLabels; + kind: LogsMetaKind; } export interface LogsModel { + id: string; // Identify one logs result from another meta?: LogsMetaItem[]; rows: LogRow[]; series?: TimeSeries[]; @@ -59,7 +76,7 @@ export interface LogsStream { entries: LogsStreamEntry[]; search?: string; parsedLabels?: LogsStreamLabels; - uniqueLabels?: string; + uniqueLabels?: LogsStreamLabels; } export interface LogsStreamEntry { @@ -71,17 +88,171 @@ export interface LogsStreamLabels { [key: string]: string; } +export enum LogsDedupStrategy { + none = 'none', + exact = 'exact', + numbers = 'numbers', + signature = 'signature', +} + +export interface LogsParser { + /** + * Value-agnostic matcher for a field label. + * Used to filter rows, and first capture group contains the value. + */ + buildMatcher: (label: string) => RegExp; + /** + * Regex to find a field in the log line. + * First capture group contains the label value, second capture group the value. + */ + fieldRegex: RegExp; + /** + * Function to verify if this is a valid parser for the given line. + * The parser accepts the line unless it returns undefined. + */ + test: (line: string) => any; +} + +export const LogsParsers: { [name: string]: LogsParser } = { + JSON: { + buildMatcher: label => new RegExp(`(?:{|,)\\s*"${label}"\\s*:\\s*"([^"]*)"`), + fieldRegex: /"(\w+)"\s*:\s*"([^"]*)"/, + test: line => { + try { + return JSON.parse(line); + } catch (error) {} + }, + }, + logfmt: { + buildMatcher: label => new RegExp(`(?:^|\\s)${label}=("[^"]*"|\\S+)`), + fieldRegex: /(?:^|\s)(\w+)=("[^"]*"|\S+)/, + test: line => LogsParsers.logfmt.fieldRegex.test(line), + }, +}; + +export function calculateFieldStats(rows: LogRow[], extractor: RegExp): LogsLabelStat[] { + // Consider only rows that satisfy the matcher + const rowsWithField = rows.filter(row => extractor.test(row.entry)); + const rowCount = rowsWithField.length; + + // Get field value counts for eligible rows + const countsByValue = _.countBy(rowsWithField, row => (row as LogRow).entry.match(extractor)[1]); + const sortedCounts = _.chain(countsByValue) + .map((count, value) => ({ count, value, proportion: count / rowCount })) + .sortBy('count') + .reverse() + .value(); + + return sortedCounts; +} + +export function calculateLogsLabelStats(rows: LogRow[], label: string): LogsLabelStat[] { + // Consider only rows that have the given label + const rowsWithLabel = rows.filter(row => row.labels[label] !== undefined); + const rowCount = rowsWithLabel.length; + + // Get label value counts for eligible rows + const countsByValue = _.countBy(rowsWithLabel, row => (row as LogRow).labels[label]); + const sortedCounts = _.chain(countsByValue) + .map((count, value) => ({ count, value, proportion: count / rowCount })) + .sortBy('count') + .reverse() + .value(); + + return sortedCounts; +} + +const isoDateRegexp = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-6]\d[,\.]\d+([+-][0-2]\d:[0-5]\d|Z)/g; +function isDuplicateRow(row: LogRow, other: LogRow, strategy: LogsDedupStrategy): boolean { + switch (strategy) { + case LogsDedupStrategy.exact: + // Exact still strips dates + return row.entry.replace(isoDateRegexp, '') === other.entry.replace(isoDateRegexp, ''); + + case LogsDedupStrategy.numbers: + return row.entry.replace(/\d/g, '') === other.entry.replace(/\d/g, ''); + + case LogsDedupStrategy.signature: + return row.entry.replace(/\w/g, '') === other.entry.replace(/\w/g, ''); + + default: + return false; + } +} + +export function dedupLogRows(logs: LogsModel, strategy: LogsDedupStrategy): LogsModel { + if (strategy === LogsDedupStrategy.none) { + return logs; + } + + const dedupedRows = logs.rows.reduce((result: LogRow[], row: LogRow, index, list) => { + const previous = result[result.length - 1]; + if (index > 0 && isDuplicateRow(row, previous, strategy)) { + previous.duplicates++; + } else { + row.duplicates = 0; + result.push(row); + } + return result; + }, []); + + return { + ...logs, + rows: dedupedRows, + }; +} + +export function getParser(line: string): LogsParser { + let parser; + try { + if (LogsParsers.JSON.test(line)) { + parser = LogsParsers.JSON; + } + } catch (error) {} + if (!parser && LogsParsers.logfmt.test(line)) { + parser = LogsParsers.logfmt; + } + return parser; +} + +export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set): LogsModel { + if (hiddenLogLevels.size === 0) { + return logs; + } + + const filteredRows = logs.rows.reduce((result: LogRow[], row: LogRow, index, list) => { + if (!hiddenLogLevels.has(row.logLevel)) { + result.push(row); + } + return result; + }, []); + + return { + ...logs, + rows: filteredRows, + }; +} + export function makeSeriesForLogs(rows: LogRow[], intervalMs: number): TimeSeries[] { + // currently interval is rangeMs / resolution, which is too low for showing series as bars. + // need at least 10px per bucket, so we multiply interval by 10. Should be solved higher up the chain + // when executing queries & interval calculated and not here but this is a temporary fix. + // intervalMs = intervalMs * 10; + // Graph time series by log level const seriesByLevel = {}; - rows.forEach(row => { + const bucketSize = intervalMs * 10; + + for (const row of rows) { if (!seriesByLevel[row.logLevel]) { seriesByLevel[row.logLevel] = { lastTs: null, datapoints: [], alias: row.logLevel }; } + const levelSeries = seriesByLevel[row.logLevel]; // Bucket to nearest minute - const time = Math.round(row.timeEpochMs / intervalMs / 10) * intervalMs * 10; + const time = Math.round(row.timeEpochMs / bucketSize) * bucketSize; + // Entry for time if (time === levelSeries.lastTs) { levelSeries.datapoints[levelSeries.datapoints.length - 1][0]++; @@ -89,7 +260,7 @@ export function makeSeriesForLogs(rows: LogRow[], intervalMs: number): TimeSerie levelSeries.datapoints.push([1, time]); levelSeries.lastTs = time; } - }); + } return Object.keys(seriesByLevel).reduce((acc, level) => { if (seriesByLevel[level]) { diff --git a/public/app/core/services/analytics.ts b/public/app/core/services/analytics.ts index 40e20b16a29..be4371adb26 100644 --- a/public/app/core/services/analytics.ts +++ b/public/app/core/services/analytics.ts @@ -26,7 +26,7 @@ export class Analytics { init() { this.$rootScope.$on('$viewContentLoaded', () => { - const track = { location: this.$location.url() }; + const track = { page: this.$location.url() }; const ga = (window as any).ga || this.gaInit(); ga('set', track); ga('send', 'pageview'); diff --git a/public/app/core/specs/logs_model.test.ts b/public/app/core/specs/logs_model.test.ts new file mode 100644 index 00000000000..85f75b50ed0 --- /dev/null +++ b/public/app/core/specs/logs_model.test.ts @@ -0,0 +1,280 @@ +import { + calculateFieldStats, + calculateLogsLabelStats, + dedupLogRows, + getParser, + LogsDedupStrategy, + LogsModel, + LogsParsers, +} from '../logs_model'; + +describe('dedupLogRows()', () => { + test('should return rows as is when dedup is set to none', () => { + const logs = { + rows: [ + { + entry: 'WARN test 1.23 on [xxx]', + }, + { + entry: 'WARN test 1.23 on [xxx]', + }, + ], + }; + expect(dedupLogRows(logs as LogsModel, LogsDedupStrategy.none).rows).toMatchObject(logs.rows); + }); + + test('should dedup on exact matches', () => { + const logs = { + rows: [ + { + entry: 'WARN test 1.23 on [xxx]', + }, + { + entry: 'WARN test 1.23 on [xxx]', + }, + { + entry: 'INFO test 2.44 on [xxx]', + }, + { + entry: 'WARN test 1.23 on [xxx]', + }, + ], + }; + expect(dedupLogRows(logs as LogsModel, LogsDedupStrategy.exact).rows).toEqual([ + { + duplicates: 1, + entry: 'WARN test 1.23 on [xxx]', + }, + { + duplicates: 0, + entry: 'INFO test 2.44 on [xxx]', + }, + { + duplicates: 0, + entry: 'WARN test 1.23 on [xxx]', + }, + ]); + }); + + test('should dedup on number matches', () => { + const logs = { + rows: [ + { + entry: 'WARN test 1.2323423 on [xxx]', + }, + { + entry: 'WARN test 1.23 on [xxx]', + }, + { + entry: 'INFO test 2.44 on [xxx]', + }, + { + entry: 'WARN test 1.23 on [xxx]', + }, + ], + }; + expect(dedupLogRows(logs as LogsModel, LogsDedupStrategy.numbers).rows).toEqual([ + { + duplicates: 1, + entry: 'WARN test 1.2323423 on [xxx]', + }, + { + duplicates: 0, + entry: 'INFO test 2.44 on [xxx]', + }, + { + duplicates: 0, + entry: 'WARN test 1.23 on [xxx]', + }, + ]); + }); + + test('should dedup on signature matches', () => { + const logs = { + rows: [ + { + entry: 'WARN test 1.2323423 on [xxx]', + }, + { + entry: 'WARN test 1.23 on [xxx]', + }, + { + entry: 'INFO test 2.44 on [xxx]', + }, + { + entry: 'WARN test 1.23 on [xxx]', + }, + ], + }; + expect(dedupLogRows(logs as LogsModel, LogsDedupStrategy.signature).rows).toEqual([ + { + duplicates: 3, + entry: 'WARN test 1.2323423 on [xxx]', + }, + ]); + }); +}); + +describe('calculateFieldStats()', () => { + test('should return no stats for empty rows', () => { + expect(calculateFieldStats([], /foo=(.*)/)).toEqual([]); + }); + + test('should return no stats if extractor does not match', () => { + const rows = [ + { + entry: 'foo=bar', + }, + ]; + + expect(calculateFieldStats(rows as any, /baz=(.*)/)).toEqual([]); + }); + + test('should return stats for found field', () => { + const rows = [ + { + entry: 'foo="42 + 1"', + }, + { + entry: 'foo=503 baz=foo', + }, + { + entry: 'foo="42 + 1"', + }, + { + entry: 't=2018-12-05T07:44:59+0000 foo=503', + }, + ]; + + expect(calculateFieldStats(rows as any, /foo=("[^"]*"|\S+)/)).toMatchObject([ + { + value: '"42 + 1"', + count: 2, + }, + { + value: '503', + count: 2, + }, + ]); + }); +}); + +describe('calculateLogsLabelStats()', () => { + test('should return no stats for empty rows', () => { + expect(calculateLogsLabelStats([], '')).toEqual([]); + }); + + test('should return no stats of label is not found', () => { + const rows = [ + { + entry: 'foo 1', + labels: { + foo: 'bar', + }, + }, + ]; + + expect(calculateLogsLabelStats(rows as any, 'baz')).toEqual([]); + }); + + test('should return stats for found labels', () => { + const rows = [ + { + entry: 'foo 1', + labels: { + foo: 'bar', + }, + }, + { + entry: 'foo 0', + labels: { + foo: 'xxx', + }, + }, + { + entry: 'foo 2', + labels: { + foo: 'bar', + }, + }, + ]; + + expect(calculateLogsLabelStats(rows as any, 'foo')).toMatchObject([ + { + value: 'bar', + count: 2, + }, + { + value: 'xxx', + count: 1, + }, + ]); + }); +}); + +describe('getParser()', () => { + test('should return no parser on empty line', () => { + expect(getParser('')).toBeUndefined(); + }); + + test('should return no parser on unknown line pattern', () => { + expect(getParser('To Be or not to be')).toBeUndefined(); + }); + + test('should return logfmt parser on key value patterns', () => { + expect(getParser('foo=bar baz="41 + 1')).toEqual(LogsParsers.logfmt); + }); + + test('should return JSON parser on JSON log lines', () => { + // TODO implement other JSON value types than string + expect(getParser('{"foo": "bar", "baz": "41 + 1"}')).toEqual(LogsParsers.JSON); + }); +}); + +describe('LogsParsers', () => { + describe('logfmt', () => { + const parser = LogsParsers.logfmt; + + test('should detect format', () => { + expect(parser.test('foo')).toBeFalsy(); + expect(parser.test('foo=bar')).toBeTruthy(); + }); + + test('should have a valid fieldRegex', () => { + const match = 'foo=bar'.match(parser.fieldRegex); + expect(match).toBeDefined(); + expect(match[1]).toBe('foo'); + expect(match[2]).toBe('bar'); + }); + + test('should build a valid value matcher', () => { + const matcher = parser.buildMatcher('foo'); + const match = 'foo=bar'.match(matcher); + expect(match).toBeDefined(); + expect(match[1]).toBe('bar'); + }); + }); + + describe('JSON', () => { + const parser = LogsParsers.JSON; + + test('should detect format', () => { + expect(parser.test('foo')).toBeFalsy(); + expect(parser.test('{"foo":"bar"}')).toBeTruthy(); + }); + + test('should have a valid fieldRegex', () => { + const match = '{"foo":"bar"}'.match(parser.fieldRegex); + expect(match).toBeDefined(); + expect(match[1]).toBe('foo'); + expect(match[2]).toBe('bar'); + }); + + test('should build a valid value matcher', () => { + const matcher = parser.buildMatcher('foo'); + const match = '{"foo":"bar"}'.match(matcher); + expect(match).toBeDefined(); + expect(match[1]).toBe('bar'); + }); + }); +}); diff --git a/public/app/core/utils/colors.ts b/public/app/core/utils/colors.ts index 16214679996..34508e94a9f 100644 --- a/public/app/core/utils/colors.ts +++ b/public/app/core/utils/colors.ts @@ -1,5 +1,6 @@ import _ from 'lodash'; import tinycolor from 'tinycolor2'; +import config from 'app/core/config'; export const PALETTE_ROWS = 4; export const PALETTE_COLUMNS = 14; @@ -7,6 +8,7 @@ export const DEFAULT_ANNOTATION_COLOR = 'rgba(0, 211, 255, 1)'; export const OK_COLOR = 'rgba(11, 237, 50, 1)'; export const ALERTING_COLOR = 'rgba(237, 46, 24, 1)'; export const NO_DATA_COLOR = 'rgba(150, 150, 150, 1)'; +export const PENDING_COLOR = 'rgba(247, 149, 32, 1)'; export const REGION_FILL_ALPHA = 0.09; const colors = [ @@ -89,5 +91,9 @@ export function hslToHex(color) { return tinycolor(color).toHexString(); } +export function getThemeColor(dark: string, light: string): string { + return config.bootData.user.lightTheme ? light : dark; +} + export let sortedColors = sortColorsByHue(colors); export default colors; diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index 4252730338d..7ceebbd8047 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -1,5 +1,13 @@ -import { DEFAULT_RANGE, serializeStateToUrlParam, parseUrlState } from './explore'; +import { + DEFAULT_RANGE, + serializeStateToUrlParam, + parseUrlState, + updateHistory, + clearHistory, + hasNonEmptyQuery, +} from './explore'; import { ExploreState } from 'app/types/explore'; +import store from 'app/core/store'; const DEFAULT_EXPLORE_STATE: ExploreState = { datasource: null, @@ -8,9 +16,9 @@ const DEFAULT_EXPLORE_STATE: ExploreState = { datasourceMissing: false, datasourceName: '', exploreDatasources: [], - graphRange: DEFAULT_RANGE, + graphInterval: 1000, history: [], - queries: [], + initialQueries: [], queryTransactions: [], range: DEFAULT_RANGE, showingGraph: true, @@ -33,10 +41,10 @@ describe('state functions', () => { it('returns a valid Explore state from URL parameter', () => { const paramValue = - '%7B"datasource":"Local","queries":%5B%7B"query":"metric"%7D%5D,"range":%7B"from":"now-1h","to":"now"%7D%7D'; + '%7B"datasource":"Local","queries":%5B%7B"expr":"metric"%7D%5D,"range":%7B"from":"now-1h","to":"now"%7D%7D'; expect(parseUrlState(paramValue)).toMatchObject({ datasource: 'Local', - queries: [{ query: 'metric' }], + queries: [{ expr: 'metric' }], range: { from: 'now-1h', to: 'now', @@ -45,10 +53,10 @@ describe('state functions', () => { }); it('returns a valid Explore state from a compact URL parameter', () => { - const paramValue = '%5B"now-1h","now","Local","metric"%5D'; + const paramValue = '%5B"now-1h","now","Local",%7B"expr":"metric"%7D%5D'; expect(parseUrlState(paramValue)).toMatchObject({ datasource: 'Local', - queries: [{ query: 'metric' }], + queries: [{ expr: 'metric' }], range: { from: 'now-1h', to: 'now', @@ -66,18 +74,20 @@ describe('state functions', () => { from: 'now-5h', to: 'now', }, - queries: [ + initialQueries: [ { - query: 'metric{test="a/b"}', + refId: '1', + expr: 'metric{test="a/b"}', }, { - query: 'super{foo="x/z"}', + refId: '2', + expr: 'super{foo="x/z"}', }, ], }; expect(serializeStateToUrlParam(state)).toBe( - '{"datasource":"foo","queries":[{"query":"metric{test=\\"a/b\\"}"},' + - '{"query":"super{foo=\\"x/z\\"}"}],"range":{"from":"now-5h","to":"now"}}' + '{"datasource":"foo","queries":[{"expr":"metric{test=\\"a/b\\"}"},' + + '{"expr":"super{foo=\\"x/z\\"}"}],"range":{"from":"now-5h","to":"now"}}' ); }); @@ -89,17 +99,19 @@ describe('state functions', () => { from: 'now-5h', to: 'now', }, - queries: [ + initialQueries: [ { - query: 'metric{test="a/b"}', + refId: '1', + expr: 'metric{test="a/b"}', }, { - query: 'super{foo="x/z"}', + refId: '2', + expr: 'super{foo="x/z"}', }, ], }; expect(serializeStateToUrlParam(state, true)).toBe( - '["now-5h","now","foo","metric{test=\\"a/b\\"}","super{foo=\\"x/z\\"}"]' + '["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"}]' ); }); }); @@ -113,12 +125,14 @@ describe('state functions', () => { from: 'now - 5h', to: 'now', }, - queries: [ + initialQueries: [ { - query: 'metric{test="a/b"}', + refId: '1', + expr: 'metric{test="a/b"}', }, { - query: 'super{foo="x/z"}', + refId: '2', + expr: 'super{foo="x/z"}', }, ], }; @@ -126,14 +140,50 @@ describe('state functions', () => { const parsed = parseUrlState(serialized); // Account for datasource vs datasourceName - const { datasource, ...rest } = parsed; - const sameState = { + const { datasource, queries, ...rest } = parsed; + const resultState = { ...rest, datasource: DEFAULT_EXPLORE_STATE.datasource, datasourceName: datasource, + initialQueries: queries, }; - expect(state).toMatchObject(sameState); + expect(state).toMatchObject(resultState); }); }); }); + +describe('updateHistory()', () => { + const datasourceId = 'myDatasource'; + const key = `grafana.explore.history.${datasourceId}`; + + beforeEach(() => { + clearHistory(datasourceId); + expect(store.exists(key)).toBeFalsy(); + }); + + test('should save history item to localStorage', () => { + const expected = [ + { + query: { refId: '1', expr: 'metric' }, + }, + ]; + expect(updateHistory([], datasourceId, [{ refId: '1', expr: 'metric' }])).toMatchObject(expected); + expect(store.exists(key)).toBeTruthy(); + expect(store.getObject(key)).toMatchObject(expected); + }); +}); + +describe('hasNonEmptyQuery', () => { + test('should return true if one query is non-empty', () => { + expect(hasNonEmptyQuery([{ refId: '1', key: '2', expr: 'foo' }])).toBeTruthy(); + }); + + test('should return false if query is empty', () => { + expect(hasNonEmptyQuery([{ refId: '1', key: '2' }])).toBeFalsy(); + }); + + test('should return false if no queries exist', () => { + expect(hasNonEmptyQuery([])).toBeFalsy(); + }); +}); diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index ecd11a495ad..26b6a527d95 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -1,11 +1,23 @@ +import _ from 'lodash'; + import { renderUrl } from 'app/core/utils/url'; -import { ExploreState, ExploreUrlState } from 'app/types/explore'; +import kbn from 'app/core/utils/kbn'; +import store from 'app/core/store'; +import colors from 'app/core/utils/colors'; +import { parse as parseDate } from 'app/core/utils/datemath'; + +import TimeSeries from 'app/core/time_series2'; +import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; +import { ExploreState, ExploreUrlState, HistoryItem, QueryTransaction } from 'app/types/explore'; +import { DataQuery, RawTimeRange, IntervalValues, DataSourceApi } from 'app/types/series'; export const DEFAULT_RANGE = { from: 'now-6h', to: 'now', }; +const MAX_HISTORY_ITEMS = 100; + /** * Returns an Explore-URL that contains a panel's queries and the dashboard time range. * @@ -23,7 +35,7 @@ export async function getExploreUrl( timeSrv: any ) { let exploreDatasource = panelDatasource; - let exploreTargets = panelTargets; + let exploreTargets: DataQuery[] = panelTargets; let url; // Mixed datasources need to choose only one datasource @@ -57,6 +69,8 @@ export async function getExploreUrl( return url; } +const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest; + export function parseUrlState(initial: string | undefined): ExploreUrlState { if (initial) { try { @@ -70,7 +84,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState { to: parsed[1], }; const datasource = parsed[2]; - const queries = parsed.slice(3).map(query => ({ query })); + const queries = parsed.slice(3); return { datasource, queries, range }; } return parsed; @@ -84,16 +98,129 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState { export function serializeStateToUrlParam(state: ExploreState, compact?: boolean): string { const urlState: ExploreUrlState = { datasource: state.datasourceName, - queries: state.queries.map(q => ({ query: q.query })), + queries: state.initialQueries.map(clearQueryKeys), range: state.range, }; if (compact) { - return JSON.stringify([ - urlState.range.from, - urlState.range.to, - urlState.datasource, - ...urlState.queries.map(q => q.query), - ]); + return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]); } return JSON.stringify(urlState); } + +export function generateKey(index = 0): string { + return `Q-${Date.now()}-${Math.random()}-${index}`; +} + +export function generateRefId(index = 0): string { + return `${index + 1}`; +} + +export function generateQueryKeys(index = 0): { refId: string; key: string } { + return { refId: generateRefId(index), key: generateKey(index) }; +} + +/** + * Ensure at least one target exists and that targets have the necessary keys + */ +export function ensureQueries(queries?: DataQuery[]): DataQuery[] { + if (queries && typeof queries === 'object' && queries.length > 0) { + return queries.map((query, i) => ({ ...query, ...generateQueryKeys(i) })); + } + return [{ ...generateQueryKeys() }]; +} + +/** + * A target is non-empty when it has keys (with non-empty values) other than refId and key. + */ +export function hasNonEmptyQuery(queries: DataQuery[]): boolean { + return queries.some( + query => + Object.keys(query) + .map(k => query[k]) + .filter(v => v).length > 2 + ); +} + +export function calculateResultsFromQueryTransactions( + queryTransactions: QueryTransaction[], + datasource: any, + graphInterval: number +) { + const graphResult = _.flatten( + queryTransactions.filter(qt => qt.resultType === 'Graph' && qt.done && qt.result).map(qt => qt.result) + ); + const tableResult = mergeTablesIntoModel( + new TableModel(), + ...queryTransactions.filter(qt => qt.resultType === 'Table' && qt.done && qt.result).map(qt => qt.result) + ); + const logsResult = + datasource && datasource.mergeStreams + ? datasource.mergeStreams( + _.flatten( + queryTransactions.filter(qt => qt.resultType === 'Logs' && qt.done && qt.result).map(qt => qt.result) + ), + graphInterval + ) + : undefined; + + return { + graphResult, + tableResult, + logsResult, + }; +} + +export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, resolution: number): IntervalValues { + if (!datasource || !resolution) { + return { interval: '1s', intervalMs: 1000 }; + } + + const absoluteRange: RawTimeRange = { + from: parseDate(range.from, false), + to: parseDate(range.to, true), + }; + + return kbn.calculateInterval(absoluteRange, resolution, datasource.interval); +} + +export function makeTimeSeriesList(dataList) { + return dataList.map((seriesData, index) => { + const datapoints = seriesData.datapoints || []; + const alias = seriesData.target; + const colorIndex = index % colors.length; + const color = colors[colorIndex]; + + const series = new TimeSeries({ + datapoints, + alias, + color, + unit: seriesData.unit, + }); + + return series; + }); +} + +/** + * Update the query history. Side-effect: store history in local storage + */ +export function updateHistory(history: HistoryItem[], datasourceId: string, queries: DataQuery[]): HistoryItem[] { + const ts = Date.now(); + queries.forEach(query => { + history = [{ query, ts }, ...history]; + }); + + if (history.length > MAX_HISTORY_ITEMS) { + history = history.slice(0, MAX_HISTORY_ITEMS); + } + + // Combine all queries of a datasource type into one history + const historyKey = `grafana.explore.history.${datasourceId}`; + store.setObject(historyKey, history); + return history; +} + +export function clearHistory(datasourceId: string) { + const historyKey = `grafana.explore.history.${datasourceId}`; + store.delete(historyKey); +} diff --git a/public/app/core/utils/kbn.ts b/public/app/core/utils/kbn.ts index 398ad9bb3e7..e0b98cb803c 100644 --- a/public/app/core/utils/kbn.ts +++ b/public/app/core/utils/kbn.ts @@ -428,10 +428,16 @@ kbn.valueFormats.hex0x = (value, decimals) => { }; kbn.valueFormats.sci = (value, decimals) => { + if (value == null) { + return ''; + } return value.toExponential(decimals); }; kbn.valueFormats.locale = (value, decimals) => { + if (value == null) { + return ''; + } return value.toLocaleString(undefined, { maximumFractionDigits: decimals }); }; @@ -584,8 +590,8 @@ kbn.valueFormats.flowcms = kbn.formatBuilders.fixedUnit('cms'); kbn.valueFormats.flowcfs = kbn.formatBuilders.fixedUnit('cfs'); kbn.valueFormats.flowcfm = kbn.formatBuilders.fixedUnit('cfm'); kbn.valueFormats.litreh = kbn.formatBuilders.fixedUnit('l/h'); -kbn.valueFormats.flowlpm = kbn.formatBuilders.decimalSIPrefix('L'); -kbn.valueFormats.flowmlpm = kbn.formatBuilders.decimalSIPrefix('L', -1); +kbn.valueFormats.flowlpm = kbn.formatBuilders.fixedUnit('l/min'); +kbn.valueFormats.flowmlpm = kbn.formatBuilders.fixedUnit('mL/min'); // Angle kbn.valueFormats.degree = kbn.formatBuilders.fixedUnit('°'); diff --git a/public/app/core/utils/text.test.ts b/public/app/core/utils/text.test.ts index 4f9d8367218..206e8507f9d 100644 --- a/public/app/core/utils/text.test.ts +++ b/public/app/core/utils/text.test.ts @@ -16,9 +16,20 @@ describe('findMatchesInText()', () => { expect(findMatchesInText(' foo ', 'foo')).toEqual([{ length: 3, start: 1, text: 'foo', end: 4 }]); }); - expect(findMatchesInText(' foo foo bar ', 'foo|bar')).toEqual([ - { length: 3, start: 1, text: 'foo', end: 4 }, - { length: 3, start: 5, text: 'foo', end: 8 }, - { length: 3, start: 9, text: 'bar', end: 12 }, - ]); + test('should find all matches for a complete regex', () => { + expect(findMatchesInText(' foo foo bar ', 'foo|bar')).toEqual([ + { length: 3, start: 1, text: 'foo', end: 4 }, + { length: 3, start: 5, text: 'foo', end: 8 }, + { length: 3, start: 9, text: 'bar', end: 12 }, + ]); + }); + + test('not fail on incomplete regex', () => { + expect(findMatchesInText(' foo foo bar ', 'foo|')).toEqual([ + { length: 3, start: 1, text: 'foo', end: 4 }, + { length: 3, start: 5, text: 'foo', end: 8 }, + ]); + expect(findMatchesInText('foo foo bar', '(')).toEqual([]); + expect(findMatchesInText('foo foo bar', '(foo|')).toEqual([]); + }); }); diff --git a/public/app/core/utils/text.ts b/public/app/core/utils/text.ts index 5d7591a31e2..4e948116dba 100644 --- a/public/app/core/utils/text.ts +++ b/public/app/core/utils/text.ts @@ -8,6 +8,10 @@ export function findHighlightChunksInText({ searchWords, textToHighlight }) { return findMatchesInText(textToHighlight, searchWords.join(' ')); } +const cleanNeedle = (needle: string): string => { + return needle.replace(/[[{(][\w,.-?:*+]+$/, ''); +}; + /** * Returns a list of substring regexp matches. */ @@ -16,17 +20,25 @@ export function findMatchesInText(haystack: string, needle: string): TextMatch[] if (!haystack || !needle) { return []; } - const regexp = new RegExp(`(?:${needle})`, 'g'); const matches = []; - let match = regexp.exec(haystack); - while (match) { - matches.push({ - text: match[0], - start: match.index, - length: match[0].length, - end: match.index + match[0].length, - }); - match = regexp.exec(haystack); + const cleaned = cleanNeedle(needle); + let regexp; + try { + regexp = new RegExp(`(?:${cleaned})`, 'g'); + } catch (error) { + return matches; } + haystack.replace(regexp, (substring, ...rest) => { + if (substring) { + const offset = rest[rest.length - 2]; + matches.push({ + text: substring, + start: offset, + length: substring.length, + end: offset + substring.length, + }); + } + return ''; + }); return matches; } diff --git a/public/app/features/alerting/AlertRuleList.tsx b/public/app/features/alerting/AlertRuleList.tsx index d25fc659af5..f94134f3ee1 100644 --- a/public/app/features/alerting/AlertRuleList.tsx +++ b/public/app/features/alerting/AlertRuleList.tsx @@ -29,6 +29,7 @@ export class AlertRuleList extends PureComponent { { text: 'Alerting', value: 'alerting' }, { text: 'No Data', value: 'no_data' }, { text: 'Paused', value: 'paused' }, + { text: 'Pending', value: 'pending' }, ]; componentDidMount() { diff --git a/public/app/features/alerting/AlertTabCtrl.ts b/public/app/features/alerting/AlertTabCtrl.ts index 146b7026353..ef68ddcf4a5 100644 --- a/public/app/features/alerting/AlertTabCtrl.ts +++ b/public/app/features/alerting/AlertTabCtrl.ts @@ -169,6 +169,7 @@ export class AlertTabCtrl { alert.frequency = alert.frequency || '1m'; alert.handler = alert.handler || 1; alert.notifications = alert.notifications || []; + alert.for = alert.for || '0m'; const defaultName = this.panel.title + ' alert'; alert.name = alert.name || defaultName; @@ -217,7 +218,7 @@ export class AlertTabCtrl { buildDefaultCondition() { return { type: 'query', - query: { params: ['A', '15m', 'now'] }, + query: { params: ['A', '5m', 'now'] }, reducer: { type: 'avg', params: [] }, evaluator: { type: 'gt', params: [null] }, operator: { type: 'and' }, @@ -354,6 +355,7 @@ export class AlertTabCtrl { enable() { this.panel.alert = {}; this.initModel(); + this.panel.alert.for = '5m'; //default value for new alerts. for existing alerts we use 0m to avoid breaking changes } evaluatorParamsChanged() { diff --git a/public/app/features/alerting/__snapshots__/AlertRuleList.test.tsx.snap b/public/app/features/alerting/__snapshots__/AlertRuleList.test.tsx.snap index 4ae27213e1e..b753a852e92 100644 --- a/public/app/features/alerting/__snapshots__/AlertRuleList.test.tsx.snap +++ b/public/app/features/alerting/__snapshots__/AlertRuleList.test.tsx.snap @@ -81,6 +81,12 @@ exports[`Render should render alert rules 1`] = ` > Paused + @@ -230,6 +236,12 @@ exports[`Render should render component 1`] = ` > Paused + diff --git a/public/app/features/alerting/partials/alert_tab.html b/public/app/features/alerting/partials/alert_tab.html index cb101672aa4..2ebe2b53a76 100644 --- a/public/app/features/alerting/partials/alert_tab.html +++ b/public/app/features/alerting/partials/alert_tab.html @@ -1,147 +1,159 @@
- -
-
-
- {{ctrl.error}} -
+
+
+
+ {{ctrl.error}} +
-
-
Alert Config
-
- Name - - Evaluate every - -
-
+
+
Alert Config
+
+ Name + +
+
+
+ Evaluate every + +
+
+ + + + If an alert rule has a configured For and the query violates the configured threshold it will first go from OK to Pending. + Going from OK to Pending Grafana will not send any notifications. Once the alert rule has been firing for more than For duration, it will change to Alerting and send alert notifications. + +
+
+
-
-
Conditions
-
-
- - WHEN -
-
- - - OF -
-
- - -
-
- - - - -
-
- -
-
+
+
Conditions
+
+
+ + WHEN +
+
+ + + OF +
+
+ + +
+
+ + + + +
+
+ +
+
-
- -
-
+
+ +
+
-
-
- If no data or all values are null - SET STATE TO -
- -
-
+
+
+ If no data or all values are null + SET STATE TO +
+ +
+
-
- If execution error or timeout - SET STATE TO -
- -
-
+
+ If execution error or timeout + SET STATE TO +
+ +
+
-
- -
-
+
+ +
+
-
- Evaluating rule -
+
+ Evaluating rule +
-
- -
-
+
+ +
+
-
-
Notifications
-
-
- Send to - -  {{nc.name}}  - - - -
-
-
- Message - -
-
+
+
Notifications
+
+
+ Send to + +  {{nc.name}}  + + + +
+
+
+ Message + +
+
-
- -
- State history (last 50 state changes) -
+
+ +
+ State history (last 50 state changes) +
-
-
- No state changes recorded -
+
+
+ No state changes recorded +
  1. diff --git a/public/app/features/alerting/state/alertDef.ts b/public/app/features/alerting/state/alertDef.ts index 11d2aafaa7f..8e50697d33c 100644 --- a/public/app/features/alerting/state/alertDef.ts +++ b/public/app/features/alerting/state/alertDef.ts @@ -8,9 +8,9 @@ const alertQueryDef = new QueryPartDef({ { name: 'from', type: 'string', - options: ['1s', '10s', '1m', '5m', '10m', '15m', '1h', '24h', '48h'], + options: ['10s', '1m', '5m', '10m', '15m', '1h', '24h', '48h'], }, - { name: 'to', type: 'string', options: ['now'] }, + { name: 'to', type: 'string', options: ['now', 'now-1m', 'now-5m', 'now-10m', 'now-1h'] }, ], defaultParams: ['#A', '15m', 'now', 'avg'], }); @@ -99,6 +99,13 @@ function getStateDisplayModel(state) { stateClass: 'alert-state-warning', }; } + case 'unknown': { + return { + text: 'UNKNOWN', + iconClass: 'fa fa-question', + stateClass: 'alert-state-paused', + }; + } } throw { message: 'Unknown alert state' }; diff --git a/public/app/features/annotations/annotation_tooltip.ts b/public/app/features/annotations/annotation_tooltip.ts index 16c18005204..fbe85856f31 100644 --- a/public/app/features/annotations/annotation_tooltip.ts +++ b/public/app/features/annotations/annotation_tooltip.ts @@ -32,7 +32,7 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, contextSrv, if (event.alertId) { const stateModel = alertDef.getStateDisplayModel(event.newState); titleStateClass = stateModel.stateClass; - title = ` ${stateModel.text}`; + title = ` ${stateModel.text}`; text = alertDef.getAlertAnnotationInfo(event); if (event.text) { text = text + '
    ' + event.text; diff --git a/public/app/features/annotations/event_manager.ts b/public/app/features/annotations/event_manager.ts index ef74ca193d4..db748e639a1 100644 --- a/public/app/features/annotations/event_manager.ts +++ b/public/app/features/annotations/event_manager.ts @@ -7,6 +7,7 @@ import { OK_COLOR, ALERTING_COLOR, NO_DATA_COLOR, + PENDING_COLOR, DEFAULT_ANNOTATION_COLOR, REGION_FILL_ALPHA, } from 'app/core/utils/colors'; @@ -71,6 +72,11 @@ export class EventManager { position: 'BOTTOM', markerSize: 5, }, + $__pending: { + color: PENDING_COLOR, + position: 'BOTTOM', + markerSize: 5, + }, $__editing: { color: DEFAULT_ANNOTATION_COLOR, position: 'BOTTOM', diff --git a/public/app/features/dashboard/dashboard_model.ts b/public/app/features/dashboard/dashboard_model.ts index 3320783ec67..18a16d5c1d4 100644 --- a/public/app/features/dashboard/dashboard_model.ts +++ b/public/app/features/dashboard/dashboard_model.ts @@ -223,6 +223,8 @@ export class DashboardModel { } panelInitialized(panel: PanelModel) { + panel.initialized(); + if (!this.otherPanelInFullscreen(panel)) { panel.refresh(); } diff --git a/public/app/features/dashboard/panel_model.ts b/public/app/features/dashboard/panel_model.ts index ed032a118fe..737841be7e8 100644 --- a/public/app/features/dashboard/panel_model.ts +++ b/public/app/features/dashboard/panel_model.ts @@ -95,7 +95,7 @@ export class PanelModel { setViewMode(fullscreen: boolean, isEditing: boolean) { this.fullscreen = fullscreen; this.isEditing = isEditing; - this.events.emit('panel-size-changed'); + this.events.emit('view-mode-changed'); } updateGridPos(newPos: GridPos) { @@ -132,7 +132,7 @@ export class PanelModel { } } - panelInitialized() { + initialized() { this.events.emit('panel-initialized'); } diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index ba367efb497..8a271994ab0 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -4,78 +4,41 @@ import Select from 'react-select'; import _ from 'lodash'; import { DataSource } from 'app/types/datasources'; -import { ExploreState, ExploreUrlState, HistoryItem, Query, QueryTransaction, ResultType } from 'app/types/explore'; +import { + ExploreState, + ExploreUrlState, + QueryTransaction, + ResultType, + QueryHintGetter, + QueryHint, +} from 'app/types/explore'; import { RawTimeRange, DataQuery } from 'app/types/series'; -import kbn from 'app/core/utils/kbn'; -import colors from 'app/core/utils/colors'; import store from 'app/core/store'; -import TimeSeries from 'app/core/time_series2'; -import { parse as parseDate } from 'app/core/utils/datemath'; -import { DEFAULT_RANGE } from 'app/core/utils/explore'; +import { + DEFAULT_RANGE, + calculateResultsFromQueryTransactions, + ensureQueries, + getIntervals, + generateKey, + generateQueryKeys, + hasNonEmptyQuery, + makeTimeSeriesList, + updateHistory, +} from 'app/core/utils/explore'; import ResetStyles from 'app/core/components/Picker/ResetStyles'; import PickerOption from 'app/core/components/Picker/PickerOption'; import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer'; import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage'; -import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; +import TableModel from 'app/core/table_model'; import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; +import Panel from './Panel'; import QueryRows from './QueryRows'; import Graph from './Graph'; import Logs from './Logs'; import Table from './Table'; import ErrorBoundary from './ErrorBoundary'; -import TimePicker from './TimePicker'; -import { ensureQueries, generateQueryKey, hasQuery } from './utils/query'; - -const MAX_HISTORY_ITEMS = 100; - -function getIntervals(range: RawTimeRange, datasource, resolution: number): { interval: string; intervalMs: number } { - if (!datasource || !resolution) { - return { interval: '1s', intervalMs: 1000 }; - } - const absoluteRange: RawTimeRange = { - from: parseDate(range.from, false), - to: parseDate(range.to, true), - }; - return kbn.calculateInterval(absoluteRange, resolution, datasource.interval); -} - -function makeTimeSeriesList(dataList, options) { - return dataList.map((seriesData, index) => { - const datapoints = seriesData.datapoints || []; - const alias = seriesData.target; - const colorIndex = index % colors.length; - const color = colors[colorIndex]; - - const series = new TimeSeries({ - datapoints, - alias, - color, - unit: seriesData.unit, - }); - - return series; - }); -} - -/** - * Update the query history. Side-effect: store history in local storage - */ -function updateHistory(history: HistoryItem[], datasourceId: string, queries: string[]): HistoryItem[] { - const ts = Date.now(); - queries.forEach(query => { - history = [{ query, ts }, ...history]; - }); - - if (history.length > MAX_HISTORY_ITEMS) { - history = history.slice(0, MAX_HISTORY_ITEMS); - } - - // Combine all queries of a datasource type into one history - const historyKey = `grafana.explore.history.${datasourceId}`; - store.setObject(historyKey, history); - return history; -} +import TimePicker, { parseTime } from './TimePicker'; interface ExploreProps { datasourceSrv: DatasourceSrv; @@ -88,31 +51,73 @@ interface ExploreProps { urlState: ExploreUrlState; } +/** + * Explore provides an area for quick query iteration for a given datasource. + * Once a datasource is selected it populates the query section at the top. + * When queries are run, their results are being displayed in the main section. + * The datasource determines what kind of query editor it brings, and what kind + * of results viewers it supports. + * + * QUERY HANDLING + * + * TLDR: to not re-render Explore during edits, query editing is not "controlled" + * in a React sense: values need to be pushed down via `initialQueries`, while + * edits travel up via `this.modifiedQueries`. + * + * By default the query rows start without prior state: `initialQueries` will + * contain one empty DataQuery. While the user modifies the DataQuery, the + * modifications are being tracked in `this.modifiedQueries`, which need to be + * used whenever a query is sent to the datasource to reflect what the user sees + * on the screen. Query rows can be initialized or reset using `initialQueries`, + * by giving the respective row a new key. This wipes the old row and its state. + * This property is also used to govern how many query rows there are (minimum 1). + * + * This flow makes sure that a query row can be arbitrarily complex without the + * fear of being wiped or re-initialized via props. The query row is free to keep + * its own state while the user edits or builds a query. Valid queries can be sent + * up to Explore via the `onChangeQuery` prop. + * + * DATASOURCE REQUESTS + * + * A click on Run Query creates transactions for all DataQueries for all expanded + * result viewers. New runs are discarding previous runs. Upon completion a transaction + * saves the result. The result viewers construct their data from the currently existing + * transactions. + * + * The result viewers determine some of the query options sent to the datasource, e.g., + * `format`, to indicate eventual transformations by the datasources' result transformers. + */ export class Explore extends React.PureComponent { el: any; /** * Current query expressions of the rows including their modifications, used for running queries. * Not kept in component state to prevent edit-render roundtrips. - * TODO: make this generic (other datasources might not have string representations of current query state) */ - queryExpressions: string[]; + modifiedQueries: DataQuery[]; /** * Local ID cache to compare requested vs selected datasource */ requestedDatasourceId: string; + scanTimer: NodeJS.Timer; + /** + * Timepicker to control scanning + */ + timepickerRef: React.RefObject; constructor(props) { super(props); const splitState: ExploreState = props.splitState; - let initialQueries: Query[]; + let initialQueries: DataQuery[]; if (splitState) { // Split state overrides everything this.state = splitState; - initialQueries = splitState.queries; + initialQueries = splitState.initialQueries; } else { const { datasource, queries, range } = props.urlState as ExploreUrlState; initialQueries = ensureQueries(queries); - const initialRange = range || { ...DEFAULT_RANGE }; + const initialRange = { from: parseTime(range.from), to: parseTime(range.to) } || { ...DEFAULT_RANGE }; + // Millies step for helper bar charts + const initialGraphInterval = 15 * 1000; this.state = { datasource: null, datasourceError: null, @@ -120,20 +125,26 @@ export class Explore extends React.PureComponent { datasourceMissing: false, datasourceName: datasource, exploreDatasources: [], - graphRange: initialRange, + graphInterval: initialGraphInterval, + graphResult: [], + initialQueries, history: [], - queries: initialQueries, + logsResult: null, queryTransactions: [], range: initialRange, + scanning: false, showingGraph: true, showingLogs: true, + showingStartPage: false, showingTable: true, supportsGraph: null, supportsLogs: null, supportsTable: null, + tableResult: new TableModel(), }; } - this.queryExpressions = initialQueries.map(q => q.query); + this.modifiedQueries = initialQueries.slice(); + this.timepickerRef = React.createRef(); } async componentDidMount() { @@ -166,7 +177,13 @@ export class Explore extends React.PureComponent { } } + componentWillUnmount() { + clearTimeout(this.scanTimer); + } + async setDatasource(datasource: any, origin?: DataSource) { + const { initialQueries, range } = this.state; + const supportsGraph = datasource.meta.metrics; const supportsLogs = datasource.meta.logs; const supportsTable = datasource.meta.metrics; @@ -196,48 +213,48 @@ export class Explore extends React.PureComponent { } // Check if queries can be imported from previously selected datasource - let queryExpressions = this.queryExpressions; + let modifiedQueries = this.modifiedQueries; if (origin) { if (origin.meta.id === datasource.meta.id) { // Keep same queries if same type of datasource - queryExpressions = [...this.queryExpressions]; + modifiedQueries = [...this.modifiedQueries]; } else if (datasource.importQueries) { - // Datasource-specific importers, wrapping to satisfy interface - const wrappedQueries: DataQuery[] = this.queryExpressions.map((query, index) => ({ - refId: String(index), - expr: query, - })); - const modifiedQueries: DataQuery[] = await datasource.importQueries(wrappedQueries, origin.meta); - queryExpressions = modifiedQueries.map(({ expr }) => expr); + // Datasource-specific importers + modifiedQueries = await datasource.importQueries(this.modifiedQueries, origin.meta); } else { // Default is blank queries - queryExpressions = this.queryExpressions.map(() => ''); + modifiedQueries = ensureQueries(); } } // Reset edit state with new queries - const nextQueries = this.state.queries.map((q, i) => ({ - ...q, - key: generateQueryKey(i), - query: queryExpressions[i], + const nextQueries = initialQueries.map((q, i) => ({ + ...modifiedQueries[i], + ...generateQueryKeys(i), })); - this.queryExpressions = queryExpressions; + this.modifiedQueries = modifiedQueries; // Custom components const StartPage = datasource.pluginExports.ExploreStartPage; + // Calculate graph bucketing interval + const graphInterval = getIntervals(range, datasource, this.el ? this.el.offsetWidth : 0).intervalMs; + this.setState( { StartPage, datasource, datasourceError, + graphInterval, history, supportsGraph, supportsLogs, supportsTable, datasourceLoading: false, datasourceName: datasource.name, - queries: nextQueries, + initialQueries: nextQueries, + logsHighlighterExpressions: undefined, + showingStartPage: Boolean(StartPage), }, () => { if (datasourceError === null) { @@ -253,16 +270,15 @@ export class Explore extends React.PureComponent { onAddQueryRow = index => { // Local cache - this.queryExpressions[index + 1] = ''; + this.modifiedQueries[index + 1] = { ...generateQueryKeys(index + 1) }; this.setState(state => { - const { queries, queryTransactions } = state; + const { initialQueries, queryTransactions } = state; - // Add row by generating new react key const nextQueries = [ - ...queries.slice(0, index + 1), - { query: '', key: generateQueryKey() }, - ...queries.slice(index + 1), + ...initialQueries.slice(0, index + 1), + { ...this.modifiedQueries[index + 1] }, + ...initialQueries.slice(index + 1), ]; // Ongoing transactions need to update their row indices @@ -276,7 +292,11 @@ export class Explore extends React.PureComponent { return qt; }); - return { queries: nextQueries, queryTransactions: nextQueryTransactions }; + return { + initialQueries: nextQueries, + logsHighlighterExpressions: undefined, + queryTransactions: nextQueryTransactions, + }; }); }; @@ -293,46 +313,60 @@ export class Explore extends React.PureComponent { this.setDatasource(datasource as any, origin); }; - onChangeQuery = (value: string, index: number, override?: boolean) => { + onChangeQuery = (value: DataQuery, index: number, override?: boolean) => { + // Null value means reset + if (value === null) { + value = { ...generateQueryKeys(index) }; + } + // Keep current value in local cache - this.queryExpressions[index] = value; + this.modifiedQueries[index] = value; if (override) { this.setState(state => { - // Replace query row - const { queries, queryTransactions } = state; - const nextQuery: Query = { - key: generateQueryKey(index), - query: value, + // Replace query row by injecting new key + const { initialQueries, queryTransactions } = state; + const query: DataQuery = { + ...value, + ...generateQueryKeys(index), }; - const nextQueries = [...queries]; - nextQueries[index] = nextQuery; + const nextQueries = [...initialQueries]; + nextQueries[index] = query; + this.modifiedQueries = [...nextQueries]; // Discard ongoing transaction related to row query const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); return { - queries: nextQueries, + initialQueries: nextQueries, queryTransactions: nextQueryTransactions, }; }, this.onSubmit); + } else if (this.state.datasource.getHighlighterExpression && this.modifiedQueries.length === 1) { + // Live preview of log search matches. Can only work on single row query for now + this.updateLogsHighlights(value); } }; - onChangeTime = (nextRange: RawTimeRange) => { + onChangeTime = (nextRange: RawTimeRange, scanning?: boolean) => { const range: RawTimeRange = { ...nextRange, }; - this.setState({ range }, () => this.onSubmit()); + if (this.state.scanning && !scanning) { + this.onStopScanning(); + } + this.setState({ range, scanning }, () => this.onSubmit()); }; onClickClear = () => { - this.queryExpressions = ['']; + this.onStopScanning(); + this.modifiedQueries = ensureQueries(); this.setState( - { - queries: ensureQueries(), + prevState => ({ + initialQueries: [...this.modifiedQueries], queryTransactions: [], - }, + showingStartPage: Boolean(prevState.StartPage), + }), this.saveState ); }; @@ -383,10 +417,10 @@ export class Explore extends React.PureComponent { }; // Use this in help pages to set page to a single query - onClickQuery = query => { - const nextQueries = [{ query, key: generateQueryKey() }]; - this.queryExpressions = nextQueries.map(q => q.query); - this.setState({ queries: nextQueries }, this.onSubmit); + onClickExample = (query: DataQuery) => { + const nextQueries = [{ ...query, ...generateQueryKeys() }]; + this.modifiedQueries = [...nextQueries]; + this.setState({ initialQueries: nextQueries }, this.onSubmit); }; onClickSplit = () => { @@ -401,12 +435,19 @@ export class Explore extends React.PureComponent { this.setState( state => { const showingTable = !state.showingTable; - let nextQueryTransactions = state.queryTransactions; - if (!showingTable) { - // Discard transactions related to Table query - nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Table'); + if (showingTable) { + return { showingTable, queryTransactions: state.queryTransactions }; } - return { queryTransactions: nextQueryTransactions, showingTable }; + + // Toggle off needs discarding of table queries + const nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Table'); + const results = calculateResultsFromQueryTransactions( + nextQueryTransactions, + state.datasource, + state.graphInterval + ); + + return { ...results, queryTransactions: nextQueryTransactions, showingTable }; }, () => { if (this.state.showingTable) { @@ -416,8 +457,8 @@ export class Explore extends React.PureComponent { ); }; - onClickTableCell = (columnKey: string, rowValue: string) => { - this.onModifyQueries({ type: 'ADD_FILTER', key: columnKey, value: rowValue }); + onClickLabel = (key: string, value: string) => { + this.onModifyQueries({ type: 'ADD_FILTER', key, value }); }; onModifyQueries = (action, index?: number) => { @@ -426,28 +467,28 @@ export class Explore extends React.PureComponent { const preventSubmit = action.preventSubmit; this.setState( state => { - const { queries, queryTransactions } = state; - let nextQueries; + const { initialQueries, queryTransactions } = state; + let nextQueries: DataQuery[]; let nextQueryTransactions; if (index === undefined) { // Modify all queries - nextQueries = queries.map((q, i) => ({ - key: generateQueryKey(i), - query: datasource.modifyQuery(this.queryExpressions[i], action), + nextQueries = initialQueries.map((query, i) => ({ + ...datasource.modifyQuery(this.modifiedQueries[i], action), + ...generateQueryKeys(i), })); // Discard all ongoing transactions nextQueryTransactions = []; } else { // Modify query only at index - nextQueries = queries.map((q, i) => { + nextQueries = initialQueries.map((query, i) => { // Synchronise all queries with local query cache to ensure consistency - q.query = this.queryExpressions[i]; + // TODO still needed? return i === index ? { - key: generateQueryKey(index), - query: datasource.modifyQuery(q.query, action), + ...datasource.modifyQuery(this.modifiedQueries[i], action), + ...generateQueryKeys(i), } - : q; + : query; }); nextQueryTransactions = queryTransactions // Consume the hint corresponding to the action @@ -460,9 +501,9 @@ export class Explore extends React.PureComponent { // Preserve previous row query transaction to keep results visible if next query is incomplete .filter(qt => preventSubmit || qt.rowIndex !== index); } - this.queryExpressions = nextQueries.map(q => q.query); + this.modifiedQueries = [...nextQueries]; return { - queries: nextQueries, + initialQueries: nextQueries, queryTransactions: nextQueryTransactions, }; }, @@ -474,22 +515,29 @@ export class Explore extends React.PureComponent { onRemoveQueryRow = index => { // Remove from local cache - this.queryExpressions = [...this.queryExpressions.slice(0, index), ...this.queryExpressions.slice(index + 1)]; + this.modifiedQueries = [...this.modifiedQueries.slice(0, index), ...this.modifiedQueries.slice(index + 1)]; this.setState( state => { - const { queries, queryTransactions } = state; - if (queries.length <= 1) { + const { initialQueries, queryTransactions } = state; + if (initialQueries.length <= 1) { return null; } // Remove row from react state - const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)]; + const nextQueries = [...initialQueries.slice(0, index), ...initialQueries.slice(index + 1)]; // Discard transactions related to row query const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); + const results = calculateResultsFromQueryTransactions( + nextQueryTransactions, + state.datasource, + state.graphInterval + ); return { - queries: nextQueries, + ...results, + initialQueries: nextQueries, + logsHighlighterExpressions: undefined, queryTransactions: nextQueryTransactions, }; }, @@ -497,57 +545,92 @@ export class Explore extends React.PureComponent { ); }; + onStartScanning = () => { + this.setState({ scanning: true }, this.scanPreviousRange); + }; + + scanPreviousRange = () => { + const scanRange = this.timepickerRef.current.move(-1, true); + this.setState({ scanRange }); + }; + + onStopScanning = () => { + clearTimeout(this.scanTimer); + this.setState(state => { + const { queryTransactions } = state; + const nextQueryTransactions = queryTransactions.filter(qt => qt.scanning && !qt.done); + return { queryTransactions: nextQueryTransactions, scanning: false, scanRange: undefined }; + }); + }; + onSubmit = () => { const { showingLogs, showingGraph, showingTable, supportsGraph, supportsLogs, supportsTable } = this.state; + // Keep table queries first since they need to return quickly if (showingTable && supportsTable) { - this.runTableQuery(); + this.runQueries( + 'Table', + { + format: 'table', + instant: true, + valueWithRefId: true, + }, + data => data[0] + ); } if (showingGraph && supportsGraph) { - this.runGraphQueries(); + this.runQueries( + 'Graph', + { + format: 'time_series', + instant: false, + }, + makeTimeSeriesList + ); } if (showingLogs && supportsLogs) { - this.runLogsQuery(); + this.runQueries('Logs', { format: 'logs' }); } this.saveState(); }; - buildQueryOptions( - query: string, - rowIndex: number, - targetOptions: { format: string; hinting?: boolean; instant?: boolean } - ) { + buildQueryOptions(query: DataQuery, queryOptions: { format: string; hinting?: boolean; instant?: boolean }) { const { datasource, range } = this.state; const { interval, intervalMs } = getIntervals(range, datasource, this.el.offsetWidth); - const targets = [ + + const configuredQueries = [ { - ...targetOptions, - // Target identifier is needed for table transformations - refId: rowIndex + 1, - expr: query, + ...query, + ...queryOptions, }, ]; // Clone range for query request const queryRange: RawTimeRange = { ...range }; + // Datasource is using `panelId + query.refId` for cancellation logic. + // Using `format` here because it relates to the view panel that the request is for. + const panelId = queryOptions.format; + return { interval, intervalMs, - targets, + panelId, + targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key. range: queryRange, }; } - startQueryTransaction(query: string, rowIndex: number, resultType: ResultType, options: any): QueryTransaction { - const queryOptions = this.buildQueryOptions(query, rowIndex, options); + startQueryTransaction(query: DataQuery, rowIndex: number, resultType: ResultType, options: any): QueryTransaction { + const queryOptions = this.buildQueryOptions(query, options); const transaction: QueryTransaction = { query, resultType, rowIndex, - id: generateQueryKey(), + id: generateKey(), // reusing for unique ID done: false, latency: 0, options: queryOptions, + scanning: this.state.scanning, }; // Using updater style because we might be modifying queryTransactions in quick succession @@ -561,8 +644,17 @@ export class Explore extends React.PureComponent { // Append new transaction const nextQueryTransactions = [...remainingTransactions, transaction]; + const results = calculateResultsFromQueryTransactions( + nextQueryTransactions, + state.datasource, + state.graphInterval + ); + return { + ...results, queryTransactions: nextQueryTransactions, + showingStartPage: false, + graphInterval: queryOptions.intervalMs, }; }); @@ -573,7 +665,7 @@ export class Explore extends React.PureComponent { transactionId: string, result: any, latency: number, - queries: string[], + queries: DataQuery[], datasourceId: string ) { const { datasource } = this.state; @@ -583,7 +675,7 @@ export class Explore extends React.PureComponent { } this.setState(state => { - const { history, queryTransactions } = state; + const { history, queryTransactions, scanning } = state; // Transaction might have been discarded const transaction = queryTransactions.find(qt => qt.id === transactionId); @@ -592,8 +684,8 @@ export class Explore extends React.PureComponent { } // Get query hints - let hints; - if (datasource.getQueryHints) { + let hints: QueryHint[]; + if (datasource.getQueryHints as QueryHintGetter) { hints = datasource.getQueryHints(transaction.query, result); } @@ -611,42 +703,62 @@ export class Explore extends React.PureComponent { return qt; }); + const results = calculateResultsFromQueryTransactions( + nextQueryTransactions, + state.datasource, + state.graphInterval + ); + const nextHistory = updateHistory(history, datasourceId, queries); + // Keep scanning for results if this was the last scanning transaction + if (_.size(result) === 0 && scanning) { + const other = nextQueryTransactions.find(qt => qt.scanning && !qt.done); + if (!other) { + this.scanTimer = setTimeout(this.scanPreviousRange, 1000); + } + } + return { + ...results, history: nextHistory, queryTransactions: nextQueryTransactions, }; }); } - discardTransactions(rowIndex: number) { - this.setState(state => { - const remainingTransactions = state.queryTransactions.filter(qt => qt.rowIndex !== rowIndex); - return { queryTransactions: remainingTransactions }; - }); - } - failQueryTransaction(transactionId: string, response: any, datasourceId: string) { const { datasource } = this.state; - if (datasource.meta.id !== datasourceId) { + if (datasource.meta.id !== datasourceId || response.cancelled) { // Navigated away, queries did not matter return; } console.error(response); - let error: string | JSX.Element = response; + let error: string | JSX.Element; if (response.data) { - error = response.data.error; - if (response.data.response) { - error = ( - <> - {response.data.error} -
    {response.data.response}
    - - ); + if (typeof response.data === 'string') { + error = response.data; + } else if (response.data.error) { + error = response.data.error; + if (response.data.response) { + error = ( + <> + {response.data.error} +
    {response.data.response}
    + + ); + } + } else { + throw new Error('Could not handle error response'); } + } else if (response.message) { + error = response.message; + } else if (typeof response === 'string') { + error = response; + } else { + error = 'Unknown error during query transaction. Please check JS console logs.'; } this.setState(state => { @@ -673,98 +785,48 @@ export class Explore extends React.PureComponent { }); } - async runGraphQueries() { - const queries = [...this.queryExpressions]; - if (!hasQuery(queries)) { + async runQueries(resultType: ResultType, queryOptions: any, resultGetter?: any) { + const queries = [...this.modifiedQueries]; + if (!hasNonEmptyQuery(queries)) { + this.setState({ + queryTransactions: [], + }); return; } const { datasource } = this.state; const datasourceId = datasource.meta.id; // Run all queries concurrently queries.forEach(async (query, rowIndex) => { - if (query) { - const transaction = this.startQueryTransaction(query, rowIndex, 'Graph', { - format: 'time_series', - instant: false, - }); - try { - const now = Date.now(); - const res = await datasource.query(transaction.options); - const latency = Date.now() - now; - const results = makeTimeSeriesList(res.data, transaction.options); - this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId); - this.setState({ graphRange: transaction.options.range }); - } catch (response) { - this.failQueryTransaction(transaction.id, response, datasourceId); - } - } else { - this.discardTransactions(rowIndex); + const transaction = this.startQueryTransaction(query, rowIndex, resultType, queryOptions); + try { + const now = Date.now(); + const res = await datasource.query(transaction.options); + const latency = Date.now() - now; + const results = resultGetter ? resultGetter(res.data) : res.data; + this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId); + } catch (response) { + this.failQueryTransaction(transaction.id, response, datasourceId); } }); } - async runTableQuery() { - const queries = [...this.queryExpressions]; - if (!hasQuery(queries)) { - return; - } - const { datasource } = this.state; - const datasourceId = datasource.meta.id; - // Run all queries concurrently - queries.forEach(async (query, rowIndex) => { - if (query) { - const transaction = this.startQueryTransaction(query, rowIndex, 'Table', { - format: 'table', - instant: true, - valueWithRefId: true, - }); - try { - const now = Date.now(); - const res = await datasource.query(transaction.options); - const latency = Date.now() - now; - const results = res.data[0]; - this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId); - } catch (response) { - this.failQueryTransaction(transaction.id, response, datasourceId); - } - } else { - this.discardTransactions(rowIndex); + updateLogsHighlights = _.debounce((value: DataQuery, index: number) => { + this.setState(state => { + const { datasource } = state; + if (datasource.getHighlighterExpression) { + const logsHighlighterExpressions = [state.datasource.getHighlighterExpression(value)]; + return { logsHighlighterExpressions }; } + return null; }); - } - - async runLogsQuery() { - const queries = [...this.queryExpressions]; - if (!hasQuery(queries)) { - return; - } - const { datasource } = this.state; - const datasourceId = datasource.meta.id; - // Run all queries concurrently - queries.forEach(async (query, rowIndex) => { - if (query) { - const transaction = this.startQueryTransaction(query, rowIndex, 'Logs', { format: 'logs' }); - try { - const now = Date.now(); - const res = await datasource.query(transaction.options); - const latency = Date.now() - now; - const results = res.data; - this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId); - } catch (response) { - this.failQueryTransaction(transaction.id, response, datasourceId); - } - } else { - this.discardTransactions(rowIndex); - } - }); - } + }, 500); cloneState(): ExploreState { // Copy state, but copy queries including modifications return { ...this.state, queryTransactions: [], - queries: ensureQueries(this.queryExpressions.map(query => ({ query }))), + initialQueries: [...this.modifiedQueries], }; } @@ -782,49 +844,31 @@ export class Explore extends React.PureComponent { datasourceLoading, datasourceMissing, exploreDatasources, - graphRange, + graphResult, history, - queries, + initialQueries, + logsHighlighterExpressions, + logsResult, queryTransactions, range, + scanning, + scanRange, showingGraph, showingLogs, + showingStartPage, showingTable, supportsGraph, supportsLogs, supportsTable, + tableResult, } = this.state; - const showingBoth = showingGraph && showingTable; - const graphHeight = showingBoth ? '200px' : '400px'; - const graphButtonActive = showingBoth || showingGraph ? 'active' : ''; - const logsButtonActive = showingLogs ? 'active' : ''; - const tableButtonActive = showingBoth || showingTable ? 'active' : ''; + const graphHeight = showingGraph && showingTable ? '200px' : '400px'; const exploreClass = split ? 'explore explore-split' : 'explore'; const selectedDatasource = datasource ? exploreDatasources.find(d => d.label === datasource.name) : undefined; - const graphRangeIntervals = getIntervals(graphRange, datasource, this.el ? this.el.offsetWidth : 0); const graphLoading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done); const tableLoading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done); const logsLoading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done); - // TODO don't recreate those on each re-render - const graphResult = _.flatten( - queryTransactions.filter(qt => qt.resultType === 'Graph' && qt.done && qt.result).map(qt => qt.result) - ); - const tableResult = mergeTablesIntoModel( - new TableModel(), - ...queryTransactions.filter(qt => qt.resultType === 'Table' && qt.done && qt.result).map(qt => qt.result) - ); - const logsResult = - datasource && datasource.mergeStreams - ? datasource.mergeStreams( - _.flatten( - queryTransactions.filter(qt => qt.resultType === 'Logs' && qt.done && qt.result).map(qt => qt.result) - ), - graphRangeIntervals.intervalMs - ) - : undefined; const loading = queryTransactions.some(qt => !qt.done); - const showStartPages = StartPage && queryTransactions.length === 0; - const viewModeCount = [supportsGraph, supportsLogs, supportsTable].filter(m => m).length; return (
    @@ -874,7 +918,7 @@ export class Explore extends React.PureComponent {
    ) : null} - +
    - ) : null} - {supportsTable ? ( - - ) : null} - {supportsLogs ? ( - - ) : null} -
    - )} - - {supportsGraph && - showingGraph && ( + {supportsGraph && ( + - )} - {supportsTable && showingTable ? ( -
    - - - ) : null} - {supportsLogs && showingLogs ? ( - - ) : null} + + )} + {supportsTable && ( + +
    + + )} + {supportsLogs && ( + + + + )} )} diff --git a/public/app/features/explore/Graph.tsx b/public/app/features/explore/Graph.tsx index 9e4fea0d3de..10f3faa1267 100644 --- a/public/app/features/explore/Graph.tsx +++ b/public/app/features/explore/Graph.tsx @@ -13,6 +13,7 @@ import * as dateMath from 'app/core/utils/datemath'; import TimeSeries from 'app/core/time_series2'; import Legend from './Legend'; +import { equal, intersect } from './utils/set'; const MAX_NUMBER_OF_TIME_SERIES = 20; @@ -77,22 +78,29 @@ interface GraphProps { data: any[]; height?: string; // e.g., '200px' id?: string; - loading?: boolean; range: RawTimeRange; split?: boolean; size?: { width: number; height: number }; userOptions?: any; onChangeTime?: (range: RawTimeRange) => void; + onToggleSeries?: (alias: string, hiddenSeries: Set) => void; } interface GraphState { + /** + * Type parameter refers to the `alias` property of a `TimeSeries`. + * Consequently, all series sharing the same alias will share visibility state. + */ + hiddenSeries: Set; showAllTimeSeries: boolean; } export class Graph extends PureComponent { $el: any; + dynamicOptions = null; state = { + hiddenSeries: new Set(), showAllTimeSeries: false, }; @@ -108,13 +116,14 @@ export class Graph extends PureComponent { this.$el.bind('plotselected', this.onPlotSelected); } - componentDidUpdate(prevProps: GraphProps) { + componentDidUpdate(prevProps: GraphProps, prevState: GraphState) { if ( prevProps.data !== this.props.data || prevProps.range !== this.props.range || prevProps.split !== this.props.split || prevProps.height !== this.props.height || - (prevProps.size && prevProps.size.width !== this.props.size.width) + (prevProps.size && prevProps.size.width !== this.props.size.width) || + !equal(prevState.hiddenSeries, this.state.hiddenSeries) ) { this.draw(); } @@ -134,30 +143,8 @@ export class Graph extends PureComponent { } }; - onShowAllTimeSeries = () => { - this.setState( - { - showAllTimeSeries: true, - }, - this.draw - ); - }; - - draw() { - const { range, size, userOptions = {} } = this.props; - const data = this.getGraphData(); - - const $el = $(`#${this.props.id}`); - let series = [{ data: [[0, 0]] }]; - - if (data && data.length > 0) { - series = data.map((ts: TimeSeries) => ({ - color: ts.color, - label: ts.label, - data: ts.getFlotPairs('null'), - })); - } - + getDynamicOptions() { + const { range, size } = this.props; const ticks = (size.width || 0) / 100; let { from, to } = range; if (!moment.isMoment(from)) { @@ -168,7 +155,7 @@ export class Graph extends PureComponent { } const min = from.valueOf(); const max = to.valueOf(); - const dynamicOptions = { + return { xaxis: { mode: 'time', min: min, @@ -179,21 +166,83 @@ export class Graph extends PureComponent { timeformat: time_format(ticks, min, max), }, }; + } + + onShowAllTimeSeries = () => { + this.setState( + { + showAllTimeSeries: true, + }, + this.draw + ); + }; + + onToggleSeries = (series: TimeSeries, exclusive: boolean) => { + this.setState((state, props) => { + const { data, onToggleSeries } = props; + const { hiddenSeries } = state; + + // Deduplicate series as visibility tracks the alias property + const oneSeriesVisible = hiddenSeries.size === new Set(data.map(d => d.alias)).size - 1; + + let nextHiddenSeries = new Set(); + if (exclusive) { + if (hiddenSeries.has(series.alias) || !oneSeriesVisible) { + nextHiddenSeries = new Set(data.filter(d => d.alias !== series.alias).map(d => d.alias)); + } + } else { + // Prune hidden series no longer part of those available from the most recent query + const availableSeries = new Set(data.map(d => d.alias)); + nextHiddenSeries = intersect(new Set(hiddenSeries), availableSeries); + if (nextHiddenSeries.has(series.alias)) { + nextHiddenSeries.delete(series.alias); + } else { + nextHiddenSeries.add(series.alias); + } + } + if (onToggleSeries) { + onToggleSeries(series.alias, nextHiddenSeries); + } + return { + hiddenSeries: nextHiddenSeries, + }; + }, this.draw); + }; + + draw() { + const { userOptions = {} } = this.props; + const { hiddenSeries } = this.state; + const data = this.getGraphData(); + + const $el = $(`#${this.props.id}`); + let series = [{ data: [[0, 0]] }]; + + if (data && data.length > 0) { + series = data.filter((ts: TimeSeries) => !hiddenSeries.has(ts.alias)).map((ts: TimeSeries) => ({ + color: ts.color, + label: ts.label, + data: ts.getFlotPairs('null'), + })); + } + + this.dynamicOptions = this.getDynamicOptions(); + const options = { ...FLOT_OPTIONS, - ...dynamicOptions, + ...this.dynamicOptions, ...userOptions, }; + $.plot($el, series, options); } render() { - const { height = '100px', id = 'graph', loading = false } = this.props; + const { height = '100px', id = 'graph' } = this.props; + const { hiddenSeries } = this.state; const data = this.getGraphData(); return ( -
    - {loading &&
    } + <> {this.props.data && this.props.data.length > MAX_NUMBER_OF_TIME_SERIES && !this.state.showAllTimeSeries && ( @@ -206,8 +255,8 @@ export class Graph extends PureComponent {
    )}
    - -
    + + ); } } diff --git a/public/app/features/explore/Legend.tsx b/public/app/features/explore/Legend.tsx index 439b6c3e54f..3b67aa74d91 100644 --- a/public/app/features/explore/Legend.tsx +++ b/public/app/features/explore/Legend.tsx @@ -1,23 +1,65 @@ -import React, { PureComponent } from 'react'; +import React, { MouseEvent, PureComponent } from 'react'; +import classNames from 'classnames'; +import { TimeSeries } from 'app/core/core'; -const LegendItem = ({ series }) => ( - -); +interface LegendProps { + data: TimeSeries[]; + hiddenSeries: Set; + onToggleSeries?: (series: TimeSeries, exclusive: boolean) => void; +} + +interface LegendItemProps { + hidden: boolean; + onClickLabel?: (series: TimeSeries, event: MouseEvent) => void; + series: TimeSeries; +} + +class LegendItem extends PureComponent { + onClickLabel = e => this.props.onClickLabel(this.props.series, e); -export default class Legend extends PureComponent { render() { - const { className = '', data } = this.props; - const items = data || []; + const { hidden, series } = this.props; + const seriesClasses = classNames({ + 'graph-legend-series-hidden': hidden, + }); return ( -
    - {items.map(series => )} + + ); + } +} + +export default class Legend extends PureComponent { + static defaultProps = { + onToggleSeries: () => {}, + }; + + onClickLabel = (series: TimeSeries, event: MouseEvent) => { + const { onToggleSeries } = this.props; + const exclusive = event.ctrlKey || event.metaKey || event.shiftKey; + onToggleSeries(series, !exclusive); + }; + + render() { + const { data, hiddenSeries } = this.props; + const items = data || []; + return ( +
    + {items.map((series, i) => ( +
    ); } diff --git a/public/app/features/explore/LogLabels.tsx b/public/app/features/explore/LogLabels.tsx new file mode 100644 index 00000000000..eb9c39050f6 --- /dev/null +++ b/public/app/features/explore/LogLabels.tsx @@ -0,0 +1,147 @@ +import _ from 'lodash'; +import React, { PureComponent } from 'react'; +import classnames from 'classnames'; + +import { calculateLogsLabelStats, LogsLabelStat, LogsStreamLabels, LogRow } from 'app/core/logs_model'; + +function StatsRow({ active, count, proportion, value }: LogsLabelStat) { + const percent = `${Math.round(proportion * 100)}%`; + const barStyle = { width: percent }; + const className = classnames('logs-stats-row', { 'logs-stats-row--active': active }); + + return ( +
    +
    +
    {value}
    +
    {count}
    +
    {percent}
    +
    +
    +
    +
    +
    + ); +} + +const STATS_ROW_LIMIT = 5; +export class Stats extends PureComponent<{ + stats: LogsLabelStat[]; + label: string; + value: string; + rowCount: number; + onClickClose: () => void; +}> { + render() { + const { label, rowCount, stats, value, onClickClose } = this.props; + const topRows = stats.slice(0, STATS_ROW_LIMIT); + let activeRow = topRows.find(row => row.value === value); + let otherRows = stats.slice(STATS_ROW_LIMIT); + const insertActiveRow = !activeRow; + // Remove active row from other to show extra + if (insertActiveRow) { + activeRow = otherRows.find(row => row.value === value); + otherRows = otherRows.filter(row => row.value !== value); + } + const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0); + const topCount = topRows.reduce((sum, row) => sum + row.count, 0); + const total = topCount + otherCount; + const otherProportion = otherCount / total; + + return ( +
    +
    + + {label}: {total} of {rowCount} rows have that label + + +
    +
    + {topRows.map(stat => )} + {insertActiveRow && activeRow && } + {otherCount > 0 && ( + + )} +
    +
    + ); + } +} + +class Label extends PureComponent< + { + allRows?: LogRow[]; + label: string; + plain?: boolean; + value: string; + onClickLabel?: (label: string, value: string) => void; + }, + { showStats: boolean; stats: LogsLabelStat[] } +> { + state = { + stats: null, + showStats: false, + }; + + onClickClose = () => { + this.setState({ showStats: false }); + }; + + onClickLabel = () => { + const { onClickLabel, label, value } = this.props; + if (onClickLabel) { + onClickLabel(label, value); + } + }; + + onClickStats = () => { + this.setState(state => { + if (state.showStats) { + return { showStats: false, stats: null }; + } + const stats = calculateLogsLabelStats(this.props.allRows, this.props.label); + return { showStats: true, stats }; + }); + }; + + render() { + const { allRows, label, plain, value } = this.props; + const { showStats, stats } = this.state; + const tooltip = `${label}: ${value}`; + return ( + + + {value} + + {!plain && ( + + )} + {!plain && allRows && } + {showStats && ( + + + + )} + + ); + } +} + +export default class LogLabels extends PureComponent<{ + allRows?: LogRow[]; + labels: LogsStreamLabels; + plain?: boolean; + onClickLabel?: (label: string, value: string) => void; +}> { + render() { + const { allRows, labels, onClickLabel, plain } = this.props; + return Object.keys(labels).map(key => ( +
    - {syncEnabled ? this.renderLabels(member.labels) : null} + {syncEnabled && this.renderLabels(member.labels)} @@ -115,7 +115,7 @@ export class TeamMembers extends PureComponent {
    Add Team Member
    - + {this.state.newTeamMember && (
    - {syncEnabled ? diff --git a/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap index 5a143f19038..d486657d352 100644 --- a/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap +++ b/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap @@ -96,7 +96,7 @@ exports[`Render should render component 1`] = ` Sync LDAP or OAuth groups with your Grafana teams. Learn more diff --git a/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap index 0e9b4332cca..d0a88bd97b0 100644 --- a/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap +++ b/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap @@ -58,7 +58,7 @@ exports[`Render should render component 1`] = ` className="gf-form-inline" > @@ -152,7 +152,7 @@ exports[`Render should render team members 1`] = ` className="gf-form-inline" > @@ -372,7 +372,7 @@ exports[`Render should render team members when sync enabled 1`] = ` className="gf-form-inline" > diff --git a/public/app/features/templating/DefaultVariableQueryEditor.tsx b/public/app/features/templating/DefaultVariableQueryEditor.tsx new file mode 100644 index 00000000000..ea5f2acaade --- /dev/null +++ b/public/app/features/templating/DefaultVariableQueryEditor.tsx @@ -0,0 +1,34 @@ +import React, { PureComponent } from 'react'; +import { VariableQueryProps } from 'app/types/plugins'; + +export default class DefaultVariableQueryEditor extends PureComponent { + constructor(props) { + super(props); + this.state = { value: props.query }; + } + + handleChange(event) { + this.setState({ value: event.target.value }); + } + + handleBlur(event) { + this.props.onChange(event.target.value, event.target.value); + } + + render() { + return ( +
    + Query + this.handleChange(e)} + onBlur={e => this.handleBlur(e)} + placeholder="metric name or tags query" + required + /> +
    + ); + } +} diff --git a/public/app/features/templating/editor_ctrl.ts b/public/app/features/templating/editor_ctrl.ts index cef7c9cc912..fdab5587b42 100644 --- a/public/app/features/templating/editor_ctrl.ts +++ b/public/app/features/templating/editor_ctrl.ts @@ -72,6 +72,7 @@ export class VariableEditorCtrl { if ( $scope.current.type === 'query' && + _.isString($scope.current.query) && $scope.current.query.match(new RegExp('\\$' + $scope.current.name + '(/| |$)')) ) { appEvents.emit('alert-warning', [ @@ -106,11 +107,20 @@ export class VariableEditorCtrl { }); }; + $scope.onQueryChange = (query, definition) => { + $scope.current.query = query; + $scope.current.definition = definition; + $scope.runQuery(); + }; + $scope.edit = variable => { $scope.current = variable; $scope.currentIsNew = false; $scope.mode = 'edit'; $scope.validate(); + datasourceSrv.get($scope.current.datasource).then(ds => { + $scope.currentDatasource = ds; + }); }; $scope.duplicate = variable => { @@ -171,6 +181,13 @@ export class VariableEditorCtrl { $scope.showMoreOptions = () => { $scope.optionsLimit += 20; }; + + $scope.datasourceChanged = async () => { + datasourceSrv.get($scope.current.datasource).then(ds => { + $scope.current.query = ''; + $scope.currentDatasource = ds; + }); + }; } } diff --git a/public/app/features/templating/partials/editor.html b/public/app/features/templating/partials/editor.html index c4463972177..15984eba7d6 100644 --- a/public/app/features/templating/partials/editor.html +++ b/public/app/features/templating/partials/editor.html @@ -17,14 +17,16 @@
    What do variables do?
    -

    Variables enable more interactive and dynamic dashboards. Instead of hard-coding things like server or sensor names - in your metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the top of - the dashboard. These dropdowns make it easy to change the data being displayed in your dashboard. +

    Variables enable more interactive and dynamic dashboards. Instead of hard-coding things like server or sensor + names + in your metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the + top of + the dashboard. These dropdowns make it easy to change the data being displayed in your dashboard. - Check out the - - Templating documentation - for more information. + Check out the + + Templating documentation + for more information.

    @@ -32,7 +34,7 @@
    {member.login} {member.email} this.onRemoveMember(member)} /> Name Email : ''} + {syncEnabled && }
    @@ -51,7 +53,7 @@ @@ -77,7 +79,8 @@
    Name - +
    @@ -87,13 +90,15 @@
    - +
    - Template names cannot begin with '__', that's reserved for Grafana's global variables + Template names cannot begin with '__', that's reserved for + Grafana's global variables
    @@ -115,7 +120,8 @@
    Values - +
    @@ -127,14 +133,16 @@ 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 - +
    @@ -143,7 +151,8 @@
    Custom Options
    Values separated by comma - +
    @@ -168,15 +177,17 @@
    - Data source + Data source
    -
    +
    - + Refresh When to update the values of this variable. @@ -187,28 +198,32 @@
    + + + + + +
    - Query - -
    -
    - + Regex Optional, if you want to extract part of a series name or metric node segment. - +
    - + Sort How to sort the values of this variable.
    - +
    @@ -219,7 +234,8 @@
    - +
    @@ -234,7 +250,8 @@ - + @@ -243,7 +260,8 @@
    Data source
    -
    @@ -253,18 +271,11 @@
    Selection Options
    - + - +
    @@ -279,11 +290,13 @@
    Tags query - +
  2. Tag values query
  3. - +
    @@ -291,11 +304,11 @@
    Preview of values
    - {{option.text}} -
    -
    - Show more -
    + {{option.text}} +
    +
    + Show more +
    @@ -309,5 +322,4 @@ - - + \ No newline at end of file diff --git a/public/app/features/templating/query_variable.ts b/public/app/features/templating/query_variable.ts index d3f39023cfb..0aec1d8f412 100644 --- a/public/app/features/templating/query_variable.ts +++ b/public/app/features/templating/query_variable.ts @@ -23,6 +23,7 @@ export class QueryVariable implements Variable { tagValuesQuery: string; tags: any[]; skipUrlSync: boolean; + definition: string; defaults = { type: 'query', @@ -44,6 +45,7 @@ export class QueryVariable implements Variable { tagsQuery: '', tagValuesQuery: '', skipUrlSync: false, + definition: '', }; /** @ngInject */ diff --git a/public/app/features/templating/variable.ts b/public/app/features/templating/variable.ts index 1994e86eff0..930d2c49228 100644 --- a/public/app/features/templating/variable.ts +++ b/public/app/features/templating/variable.ts @@ -1,3 +1,4 @@ +import _ from 'lodash'; import { assignModelProperties } from 'app/core/utils/model_utils'; /* @@ -28,6 +29,7 @@ export { assignModelProperties }; export function containsVariable(...args: any[]) { const variableName = args[args.length - 1]; + args[0] = _.isString(args[0]) ? args[0] : Object['values'](args[0]).join(' '); const variableString = args.slice(0, -1).join(' '); const matches = variableString.match(variableRegex); const isMatchingVariable = diff --git a/public/app/partials/login.html b/public/app/partials/login.html index a2bc8173766..f4237e7b1ec 100644 --- a/public/app/partials/login.html +++ b/public/app/partials/login.html @@ -22,7 +22,7 @@ -
    - {{variable.query}} + {{variable.definition ? variable.definition : variable.query}}