diff --git a/CHANGELOG.md b/CHANGELOG.md index fdea0378b1b..01315d1b773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,10 @@ * **OpenTSDB**: Add support for explicitTags for OpenTSDB>=2.3, closes [#6360](https://github.com/grafana/grafana/pull/6361) * **OAuth**: Add support for generic oauth, closes [#4718](https://github.com/grafana/grafana/pull/4718) * **Cloudwatch**: Add support to expand multi select template variable, closes [#5003](https://github.com/grafana/grafana/pull/5003) -* **Graph Panel**: Now supports flexible lower/upper bounds on Y-Max and Y-Min, PR [#5720](https://github.com/grafana/grafana/pull/5720) * **Background Tasks**: Now support automatic purging of old snapshots, closes [#4087](https://github.com/grafana/grafana/issues/4087) * **Background Tasks**: Now support automatic purging of old rendered images, closes [#2172](https://github.com/grafana/grafana/issues/2172) +* **Dashboard**: After inactivity hide nav/row actions, fade to nice clean view, can be toggled with `d v`, also added kiosk mode, toggled via `d k` [#6476](https://github.com/grafana/grafana/issues/6476) +* **Dashboard**: Improved dashboard row menu & add panel UX [#6442](https://github.com/grafana/grafana/issues/6442) ### Breaking changes * **SystemD**: Change systemd description, closes [#5971](https://github.com/grafana/grafana/pull/5971) @@ -30,6 +31,8 @@ * **Elasticsearch**: Fix for query template variable when looking up terms without query, no longer relies on elasticsearch default field, fixes [#3887](https://github.com/grafana/grafana/pull/3887) * **Elasticsearch**: Fix for displaying IP address used in terms aggregations, fixes [#4393](https://github.com/grafana/grafana/pull/4393) * **PNG Rendering**: Fix for server side rendering when using auth proxy, fixes [#5906](https://github.com/grafana/grafana/pull/5906) +* **OpenTSDB**: Fixed multi-value nested templating for opentsdb, fixes [#6455](https://github.com/grafana/grafana/pull/6455) +* **Playlist**: Remove playlist items when dashboard is removed, fixes [#6292](https://github.com/grafana/grafana/issues/6292) # 3.1.2 (unreleased) * **Templating**: Fixed issue when combining row & panel repeats, fixes [#5790](https://github.com/grafana/grafana/issues/5790) diff --git a/docker/blocks/elastic/fig b/docker/blocks/elastic/fig index 357352cabaa..fa79a9af59c 100644 --- a/docker/blocks/elastic/fig +++ b/docker/blocks/elastic/fig @@ -1,5 +1,5 @@ elasticsearch: - image: elasticsearch:latest + image: elasticsearch:2.4.1 command: elasticsearch -Des.network.host=0.0.0.0 ports: - "9200:9200" diff --git a/docs/sources/alerting/alerting.md b/docs/sources/alerting/alerting.md deleted file mode 100644 index 308c7881436..00000000000 --- a/docs/sources/alerting/alerting.md +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/sources/alerting/index.md b/docs/sources/alerting/index.md new file mode 100644 index 00000000000..1317048a499 --- /dev/null +++ b/docs/sources/alerting/index.md @@ -0,0 +1,9 @@ ++++ +title = "Alerting" +type = "docs" +[menu.docs] +identifier = "alerting" +parent = "features" +weight = 6 ++++ + diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md new file mode 100644 index 00000000000..455d61cf535 --- /dev/null +++ b/docs/sources/alerting/notifications.md @@ -0,0 +1,100 @@ ++++ +title = "Alerting Notifications" +description = "Alerting Notifications Guide" +keywords = ["Grafana", "alerting", "guide", "notifications"] +type = "docs" +[menu.docs] +name = "Notifications" +parent = "alerting" +weight = 2 ++++ + + +# Alert Notifications + +{{< imgbox max-width="40%" img="/img/docs/v4/alert_notifications_menu.png" caption="Alerting notifications" >}} + +> Alerting is only available in Grafana v4.0 and above. + +When an alert changes state it sends out notifications. Each alert rule can have +multiple notifications. But in order to add a notification to an alert rule you first need +to add and configure a `notification` object. This is done from the Alerting/Notifications page. + +## Notification Setup + +On the notifications list page hit the `New Notification` button to go the the page where you +can configure and setup a new notification. + +You specify name and type, and type specific options. You can also test the notification to make +sure it's working and setup correctly. + +### Send on all alerts + +When checked this option will make this notification used for all alert rules, existing and new. + +## Supported notification types + +Grafana ships with a set of notification types. More will be added in future releases. + +### Email + +To enable email notification you have to setup [SMTP settings](/installation/configuration/#smtp) +in the Grafana config. Email notification will upload an image of the alert graph to an +external image destination if available or fallback on attaching the image in the email. + +### Slack + +{{< imgbox max-width="40%" img="/img/docs/v4/slack_notification.png" caption="Alerting Slack Notification" >}} + +To set up slack you need to configure an incoming webhook url at slack. You can follow their guide for how +to do that https://api.slack.com/incoming-webhooks If you want to include screenshots of the firing alerts +in the slack messages you have to configure the [external image destination](#external-image-store) in Grafana. + +Setting | Description +---------- | ----------- +Recipient | allows you to override the slack recipient. +Mention | make it possible to include a mention in the slack notification sent by Grafana. Ex @here or @channel + +### Webhook + +The webhook notification is a simple way to send information about an state change over HTTP to a custom endpoint. +Using this notification you could integrated Grafana into any system you choose, by yourself. + +Example json body: +```json +{ + "title": "My alert", + "ruleId": 1, + "ruleName": "Load peaking!", + "ruleUrl": "http://url.to.grafana/db/dashboard/my_dashboard?panelId=2", + "state": "Alerting", + "imageUrl": "http://s3.image.url", + "evalMatches": [ + { + "metric": "requests", + "tags": {}, + "value": 122 + } + ] +} +``` + +### PagerDuty + +To set up PagerDuty, all you have to do is to provide an api key. + +> Our pagerduty integration only support trigger events at the moment. You have to resolve them by yourself. + + +# Enable images in notifications {#external-image-store} + +Grafan can render the panel associated with the alert rule and include that in the notification. Some types +of notifications require that this image be publicly accessable (Slack for example). In order to support +images in notifications like Slack Grafana can upload the image to an image store. It currently supports +Amazon S3 for this and Webdav. So to set that up you need to configure the +[external image uploader](/installation/configuration/#external-image-storage) in your grafana-server ini +config file. + +This is not an optional requirement, you can get slack and email notifications without setting this up. + + diff --git a/docs/sources/alerting/rules.md b/docs/sources/alerting/rules.md new file mode 100644 index 00000000000..a30bf885650 --- /dev/null +++ b/docs/sources/alerting/rules.md @@ -0,0 +1,143 @@ ++++ +title = "Alerting Engine & Rules Guide" +description = "Configuring Alert Rules" +keywords = ["grafana", "alerting", "guide", "rules"] +type = "docs" +[menu.docs] +name = "Engine & Rules" +parent = "alerting" +weight = 1 ++++ + +# Alerting Engine & Rules Guide + +> Alerting is only available in Grafana v4.0 and above. + +## Introduction + +{{< imgbox max-width="40%" img="/img/docs/v4/drag_handles_gif.gif" caption="Alerting overview" >}} + +Alerting in Grafana allows you to attach rules to your dashboard panels. When you save the dashboard +Grafana will extract the alert rules into a separate alert rule storage and schedule them for evaluation. + +In the alert tab of the graph panel you can configure how often the alert rule should be evaluated +and the conditions that need to be met for the alert to change state and trigger its +[notifications]({{< relref "notifications.md" >}}). + +## Execution + +The alert rules are evaluated in the Grafana backend in a scheduler and query execution engine that is part +of core Grafana. Only some data soures are supported right now. They include `Graphite`, `Prometheus`, +`InfluxDB` and `OpenTSDB`. + +### Clustering + +We have not implemented clustering yet. So if you run multiple instances of grafana-server +you have to make sure [execute_alerts]({{< relref "/installation/configuration.md#alerting" >}}) +is true on only one instance or otherwise you will get duplicated notifications. + +
+ +## 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. + +### Name & Evaluation interval + +Here you can specify the name of the alert rule and how often the scheduler should evaluate the alert rule. + +### Conditions + +Currently the only condition type that exists is a `Query` condition that allows you to +specify a query letter, time range and an aggregation function. The letter refers to +a query you already have added in the **Metrics** tab. The result from the query and the aggregation function is +a single value that is then used in the threshold check. The query used in an alert rule cannot +contain any template variables. Currently we only support `AND` operator between conditions. + +We plan to add other condition types in the future, like `Other Alert`, where you can include the state +of another alert in your conditions, and `Time Of Day`. + +#### Multiple Series + +If a query returns multiple series then the aggregation function and threshold check will be evaluated for each series. +What Grafana does not do currently is track alert rule state **per series**. This has implications that is exemplified +in the scenario below. + +- Alert condition with query that returns 2 series: **server1** and **server2** +- **server1** series cause the alert rule to fire and switch to state `Alerting` +- Notifications are sent out with message: _load peaking (server1)_ +- In a subsequence evaluation of the same alert rule the **server2** series also cause the alert rule to fire +- No new notifications are sent as the alert rule is already in state `Alerting`. + +So as you can see from the above scenario Grafana will not send out notifications when other series cause the alert +to fire if the rule already is in state ´Alerting`. To improve support for queries that return multiple series +we plan to track state **per series** in a future release. + +### No Data / Null values + +Below you condition you can configure how the rule evaluation engine should handle queries that return no data or only null valued +data. + +No Data Option | Description +------------ | ------------- +NoData | Set alert rule state to `NoData` +Alerting | Set alert rule state to `Alerting` +Keep Last State | Keep the current alert rule state, what ever it is. + +### Execution errors or timeouts + +The last option is how to handle execution or timeout errors. + +Error or timeout option | Description +------------ | ------------- +Alerting | Set alert rule state to `Alerting` +Keep Last State | Keep the current alert rule state, what ever it is. + +If you an unreliable time series store that where queries sometime timeout or fail randomly you can set this option +t `Keep Last State` to basically ignore them. + +## Notifications + +In alert tab you can also specify alert rule notifications along with a detailed messsage about the alert rule. +The message can contain anything, information about how you might solve the issue, link to runbook etc. + +The actual notifications are configured and shared between multiple alerts. Read the +[Notifications]({{< relref "notifications.md" >}}) guide for how to configure and setup notifications. + +## Troubleshooting + +{{< imgbox max-width="40%" img="/img/docs/v4/alert_test_rule.png" caption="Test Rule" >}} + +First level of troubleshooting you can do is hit the **Test Rule** button. You will get result back that you can expand +to the point where you can see the raw data that was returned form your query. + +Further troubleshooting can also be done by inspecting the grafana-server log. If it's not an error or for some reason +the log does not say anything you can enable debug logging for some relevant components. This is done +in Grafana's ini config file. + +Example showing loggers that could be relevant when troubleshooting alerting. + +```ini +[log] +filters = alerting.scheduler:debug \ + alerting.engine:debug \ + alerting.resultHandler:debug \ + alerting.evalHandler:debug \ + alerting.evalContext:debug \ + alerting.extractor:debug \ + alerting.notifier:debug \ + alerting.notifier.slack:debug \ + alerting.notifier.pagerduty:debug \ + alerting.notifier.email:debug \ + alerting.notifier.webhook:debug \ + tsdb.graphite:debug \ + tsdb.prometheus:debug \ + tsdb.opentsdb:debug \ + tsdb.influxdb:debug \ +``` + +If you want to log raw query sent to your TSDB and raw response in log you also have to set grafana.ini option `app_mode` to +`development`. diff --git a/docs/sources/datasources/cloudwatch.md b/docs/sources/datasources/cloudwatch.md index 2c2acb2ca56..4a25a2b475b 100644 --- a/docs/sources/datasources/cloudwatch.md +++ b/docs/sources/datasources/cloudwatch.md @@ -16,7 +16,7 @@ Grafana ships with built in support for CloudWatch. You just have to add it as a be ready to build dashboards for you CloudWatch metrics. ## Adding the data source -![](img/docs/cloudwatch/cloudwatch_add.png) +![](/img/docs/cloudwatch/cloudwatch_add.png) 1. Open the side menu by clicking the the Grafana icon in the top header. 2. In the side menu under the `Dashboards` link you should find a link named `Data Sources`. @@ -25,6 +25,7 @@ be ready to build dashboards for you CloudWatch metrics. 3. Click the `Add new` link in the top header. 4. Select `CloudWatch` from the dropdown. + > NOTE: If at any moment you have issues with getting this datasource to work and grafana is giving you undescriptive errors then dont forget to check your log file (try looking in /var/log/grafana/). Name | Description ------------ | ------------- @@ -47,6 +48,7 @@ Checkout AWS docs on [IAM Roles](http://docs.aws.amazon.com/AWSEC2/latest/UserGu ### AWS credentials file Create a file at `~/.aws/credentials`. That is the `HOME` path for user running grafana-server. + > NOTE: If you think you have the credentials file in the right place but it is still not working then you might try moving your .aws file to '/usr/share/grafana/' and make sure your credentials file has at most 0644 permissions. Example content: @@ -58,7 +60,7 @@ Example content: ## Metric Query Editor -![](img/docs/cloudwatch/query_editor.png) +![](/img/docs/cloudwatch/query_editor.png) You need to specify a namespace, metric, at least one stat, and at least one dimension. @@ -99,7 +101,7 @@ Example `ec2_instance_attribute()` query ec2_instance_attribute(us-east-1, InstanceId, { "tag:Environment": [ "production" ] }) -![](img/docs/v2/cloudwatch_templating.png) +![](/img/docs/v2/cloudwatch_templating.png) ## Cost diff --git a/docs/sources/datasources/elasticsearch.md b/docs/sources/datasources/elasticsearch.md index 3bc7d00e980..345f68a9220 100644 --- a/docs/sources/datasources/elasticsearch.md +++ b/docs/sources/datasources/elasticsearch.md @@ -17,7 +17,7 @@ also annotate your graphs with log events stored in elasticsearch. ## Adding the data source -![](img/docs/v2/add_Graphite.jpg) +![](/img/docs/v2/add_Graphite.jpg) 1. Open the side menu by clicking the the Grafana icon in the top header. 2. In the side menu under the `Dashboards` link you should find a link named `Data Sources`. @@ -47,14 +47,14 @@ Elasticsearch from the browser. You do this by specifying these to options in yo ### Index settings -![](img/docs/elasticsearch/elasticsearch_ds_details.png) +![](/img/docs/elasticsearch/elasticsearch_ds_details.png) Here you can specify a default for the `time field` and specify the name of your elasticsearch index. You can use a time pattern for the index name or a wildcard. ## Metric Query editor -![](img/docs/elasticsearch/query_editor.png) +![](/img/docs/elasticsearch/query_editor.png) The Elasticsearch query editor allows you to select multiple metrics and group by multiple terms or filters. Use the plus and minus icons to the right to add / remove metrics or group bys. Some metrics and group by have options, click the option text to expand the the row to view and edit metric or group by options. @@ -66,7 +66,7 @@ If you have Elasticsearch 2.x and Grafana 2.6 or above then you can use pipeline to hide metrics from appearing in the graph. This is useful for metrics you only have in the query to be used in a pipeline metric. -![](img/docs/elasticsearch/pipeline_metrics_editor.png) +![](/img/docs/elasticsearch/pipeline_metrics_editor.png) ## Templating diff --git a/docs/sources/datasources/graphite.md b/docs/sources/datasources/graphite.md index e73dd591967..44a4dff3761 100644 --- a/docs/sources/datasources/graphite.md +++ b/docs/sources/datasources/graphite.md @@ -17,7 +17,7 @@ change function parameters and much more. The editor can handle all types of gra queries through the use of query references. ## Adding the data source -![](img/docs/v2/add_Graphite.jpg) +![](/img/docs/v2/add_Graphite.jpg) 1. Open the side menu by clicking the the Grafana icon in the top header. 2. In the side menu under the `Dashboards` link you should find a link named `Data Sources`. @@ -46,7 +46,7 @@ Direct access is still supported because in some cases it may be useful to acces Click the ``Select metric`` link to start navigating the metric space. One you start you can continue using the mouse or keyboard arrow keys. You can select a wildcard and still continue. -![](img/docs/animated_gifs/graphite_query1.gif) +![](/img/docs/animated_gifs/graphite_query1.gif) ### Functions Click the plus icon to the right to add a function. You can search for the function or select it from the menu. Once @@ -54,13 +54,13 @@ a function is selected it will be added and your focus will be in the text box o a parameter just click on it and it will turn into a text box. To delete a function click the function name followed by the x icon. -![](img/docs/animated_gifs/graphite_query2.gif) +![](/img/docs/animated_gifs/graphite_query2.gif) ### Optional parameters Some functions like aliasByNode support an optional second argument. To add this parameter specify for example 3,-2 as the first parameter and the function editor will adapt and move the -2 to a second parameter. To remove the second optional parameter just click on it and leave it blank and the editor will remove it. -![](img/docs/animated_gifs/func_editor_optional_params.gif) +![](/img/docs/animated_gifs/func_editor_optional_params.gif) ## Point consolidation @@ -80,7 +80,7 @@ values that exists in the wildcard position. You can also create nested variables that use other variables in their definition. For example `apps.$app.servers.*` uses the variable `$app` in its query definition. -![](img/docs/v2/templated_variable_parameter.png) +![](/img/docs/v2/templated_variable_parameter.png) ## Query Reference diff --git a/docs/sources/datasources/influxdb.md b/docs/sources/datasources/influxdb.md index 9191ffb1201..a5eab80af06 100644 --- a/docs/sources/datasources/influxdb.md +++ b/docs/sources/datasources/influxdb.md @@ -14,7 +14,7 @@ weight = 3 Grafana ships with very a feature data source plugin for InfluxDB. Supporting a feature rich query editor, annotation and templating queries. ## Adding the data source -![](img/docs/v2/add_Influx.jpg) +![](/img/docs/v2/add_Influx.jpg) 1. Open the side menu by clicking the the Grafana icon in the top header. 2. In the side menu under the `Dashboards` link you should find a link named `Data Sources`. @@ -41,7 +41,7 @@ Password | Database user's password ## Query Editor -![](assets/img/blog/v2.6/influxdb_editor_v3.gif) +![](/assets/img/blog/v2.6/influxdb_editor_v3.gif) You find the InfluxDB editor in the metrics tab in Graph or Singlestat panel's edit mode. You enter edit mode by clicking the panel title, then edit. The editor allows you to select metrics and tags. @@ -60,7 +60,7 @@ In the `SELECT` row you can specify what fields and functions you want to use. I group by time you need an aggregation function. Some functions like derivative require an aggregation function. The editor tries simplify and unify this part of the query. For example: -![](img/docs/influxdb/select_editor.png) +![](/img/docs/influxdb/select_editor.png) The above will generate the following InfluxDB `SELECT` clause: @@ -93,7 +93,7 @@ You can switch to raw query mode by clicking hamburger icon and then `Switch edi ### Table query / raw data -![](assets/img/blog/v2.6/table_influxdb_logs.png) +![](/assets/img/blog/v2.6/table_influxdb_logs.png) You can remove the group by time by clicking on the `time` part and then the `x` icon. You can change the option `Format As` to `Table` if you want to show raw data in the `Table` panel. diff --git a/docs/sources/datasources/kairosdb.md b/docs/sources/datasources/kairosdb.md index 799a7e85a85..d27105d0ee1 100644 --- a/docs/sources/datasources/kairosdb.md +++ b/docs/sources/datasources/kairosdb.md @@ -8,7 +8,7 @@ page_keywords: grafana, kairosdb, documentation Grafana v2.1 brings initial support for KairosDB Datasources. While the process of adding the datasource is similar to adding a Graphite or OpenTSDB datasource type, Kairos DB does have a few different options for building queries. ## Adding the data source to Grafana -![](img/v2/add_KairosDB.jpg) +![](/img/v2/add_KairosDB.jpg) 1. Open the side menu by clicking the the Grafana icon in the top header. 2. In the side menu under the `Dashboards` link you should find a link named `Data Sources`. @@ -30,7 +30,7 @@ Access | Proxy = access via Grafana backend, Direct = access directly from brows ## Query editor Open a graph in edit mode by click the title. -![](img/v2/kairos_query_editor.jpg) +![](/img/v2/kairos_query_editor.jpg) For details on KairosDB metric queries checkout the official. - [Query Metrics - KairosDB 0.9.4 documentation](http://kairosdb.github.io/kairosdocs/restapi/QueryMetrics.html). diff --git a/docs/sources/datasources/opentsdb.md b/docs/sources/datasources/opentsdb.md index 4d7f8f8f6c7..7e81ebe1483 100644 --- a/docs/sources/datasources/opentsdb.md +++ b/docs/sources/datasources/opentsdb.md @@ -11,7 +11,7 @@ weight = 5 # Using OpenTSDB in Grafana -{{< docs-imagebox img="img/docs/v2/add_OpenTSDB.png" max-width="14rem" >}} +{{< docs-imagebox img="/img/docs/v2/add_OpenTSDB.png" max-width="14rem" >}} The newest release of Grafana adds additional functionality when using an OpenTSDB Data source. @@ -37,7 +37,7 @@ Open a graph in edit mode by click the title. Query editor will differ if the da > Note: While using Opentsdb 2.2 datasource, make sure you use either Filters or Tags as they are mutually exclusive. If used together, might give you weird results. -![](img/docs/v2/opentsdb_query_editor.png) +![](/img/docs/v2/opentsdb_query_editor.png) ### Auto complete suggestions As soon as you start typing metric names, tag names and tag values , you should see highlighted auto complete suggestions for them. diff --git a/docs/sources/datasources/prometheus.md b/docs/sources/datasources/prometheus.md index 8483be7d38a..fd14d0539e7 100644 --- a/docs/sources/datasources/prometheus.md +++ b/docs/sources/datasources/prometheus.md @@ -15,7 +15,7 @@ weight = 2 Grafana includes support for Prometheus Datasources. While the process of adding the datasource is similar to adding a Graphite or OpenTSDB datasource type, Prometheus does have a few different options for building queries. ## Adding the data source to Grafana -![](img/v2/add_Prometheus.png) +![](/img/v2/add_Prometheus.png) 1. Open the side menu by clicking the the Grafana icon in the top header. 2. In the side menu under the `Dashboards` link you should find a link named `Data Sources`. @@ -42,7 +42,7 @@ Password | Database user's password ## Query editor Open a graph in edit mode by click the title. -![](img/v2/prometheus_editor.png) +![](/img/v2/prometheus_editor.png) For details on Prometheus metric queries check out the Prometheus documentation - [Query Metrics - Prometheus documentation](http://prometheus.io/docs/querying/basics/). @@ -72,4 +72,4 @@ label_values(hostname) You can also use raw queries & regular expressions to extract anything you might need. -![](img/v2/prometheus_templating.png) +![](/img/v2/prometheus_templating.png) diff --git a/docs/sources/features/shortcuts.md b/docs/sources/features/shortcuts.md new file mode 100644 index 00000000000..ec7250b7a5a --- /dev/null +++ b/docs/sources/features/shortcuts.md @@ -0,0 +1,53 @@ ++++ +title = "Keyboard Shortcuts" +type = "docs" +[menu.docs] +parent = "features" +weight = 7 ++++ + +# Keyboard shortcuts + +{{< docs-imagebox img="/img/docs/v4/shortcuts.png" max-width="20rem" >}} + +Grafana v4 introduces a number of really powerful keyboard shortcuts. You can now focus a panel +by hovering over it with your mouse. With a panel focused you can simple hit `e` to toggle panel +edit mode, or `v` to toggle fullscreen mode. `p` `r` removes the panel. `p` `s` opens share +modal. + +Hit `?` on your keyboard to open the shortcuts help modal. + +### Global + +- `g` `h` Go to Home Dashboard +- `g` `p` Go to Profile +- `s` `o` Open search +- `s` `s` Open search with starred filter +- `s` `t` Open search in tags view +- `esc` Exit edit/setting views + +### Dashboard + +- `mod+s` Save dashboard +- `mod+h` Hide row controls +- `d` `r` Refresh all panels +- `d` `s` Dashboard settings +- `d` `v` Toggle in-active / view mode +- `d` `k` Toggle kiosk mode (hides top nav) +- `mod+o` Toggle shared graph crosshair + +### Focused Panel +- `e` Toggle panel edit view +- `v` Toggle panel fullscreen view +- `p` `s` Open Panel Share Modal +- `p` `r` Remove Panel + +### Focused Row +- `r` `c` Collapse Row +- `r` `r` Remove Row + +### Time Range +- `t` `z` Zoom out time range +- `t` Move time range back +- `t` Move time range forward + diff --git a/docs/sources/guides/getting_started.md b/docs/sources/guides/getting_started.md index f91ed1aae7c..3f039f97ae4 100644 --- a/docs/sources/guides/getting_started.md +++ b/docs/sources/guides/getting_started.md @@ -22,7 +22,7 @@ Read the [Basic Concepts](/guides/basic_concepts) document to get a crash course Let's start with creating a new Dashboard. You can find the new Dashboard link at the bottom of the Dashboard picker. You now have a blank Dashboard. - + The image above shows you the top header for a Dashboard. @@ -39,7 +39,7 @@ Dashboards are at the core of what Grafana is all about. Dashboards are composed ## Adding & Editing Graphs and Panels -![](img/docs/v2/graph_metrics_tab_graphite.png) +![](/img/docs/v2/graph_metrics_tab_graphite.png) 1. You add panels via row menu. The row menu is the green icon to the left of each row. 2. To edit the graph you click on the graph title to open the panel menu, then `Edit`. @@ -48,7 +48,7 @@ Dashboards are at the core of what Grafana is all about. Dashboards are composed When you click the `Metrics` tab, you are presented with a Query Editor that is specific to the Panel Data Source. Use the Query Editor to build your queries and Grafana will visualize them in real time. - + 1. Zoom out time range 2. Time picker dropdown. Here you can access relative time range options, auto refresh options and set custom absolute time ranges. @@ -61,7 +61,7 @@ When you click the `Metrics` tab, you are presented with a Query Editor that is You can Drag-and-Drop Panels within and between Rows. Click and hold the Panel title, and drag it to its new location. You can also easily resize panels by clicking the (-) and (+) icons. -![](img/docs/animated_gifs/drag_drop.gif) +![](/img/docs/animated_gifs/drag_drop.gif) ## Tips and shortcuts @@ -79,15 +79,3 @@ You can Drag-and-Drop Panels within and between Rows. Click and hold the Panel t * Ctrl+H Hides all controls (good for tv displays) * Hit Escape to exit graph when in fullscreen or edit mode - - - - - - - - - - - - diff --git a/docs/sources/guides/whats-new-in-v2-1.md b/docs/sources/guides/whats-new-in-v2-1.md index efe35fd387a..68da4f60226 100644 --- a/docs/sources/guides/whats-new-in-v2-1.md +++ b/docs/sources/guides/whats-new-in-v2-1.md @@ -23,7 +23,7 @@ A template variable with Multi-Value enabled allows for the selection of multipl These variables can then be used in any Panel to make them more dynamic, and to give you the perfect view of your data. Multi-Value variables is also enabling the new `row repeat` and `panel repeat` feature described below. -![Multi-Value Select](img/docs/v2/multi-select.gif "Multi-Value Select") +![Multi-Value Select](/img/docs/v2/multi-select.gif "Multi-Value Select")

### Repeating Rows and Panels @@ -31,7 +31,7 @@ It’s now possible to create a dashboard that automatically adds (or removes) b on selected variable values. Any row or any panel can be configured to repeat (duplicate itself) based on a multi-value template variable.

-![Repeating Rows and Panels](img/docs/v2/panel-row-repeat.gif "Repeating Rows and Panels") +![Repeating Rows and Panels](/img/docs/v2/panel-row-repeat.gif "Repeating Rows and Panels")

### Dashboard Links & Navigation @@ -39,7 +39,7 @@ To support better navigation between dashboards, it's now possible to create cus panels to appropriate Dashboards. You also have the ability to create flexible top-level links on any given dashboard thanks to the new dashboard navigation bar feature. -![Dashboard Links](img/docs/v2/dash_links.png "Dashboard Links") +![Dashboard Links](/img/docs/v2/dash_links.png "Dashboard Links") Dashboard links can be added under dashboard settings. Either defined as static URLs with a custom icon or as dynamic dashboard links or dropdowns based on custom dashboard search query. These links appear in the same @@ -88,7 +88,7 @@ The Viewer role has been modified in Grafana 2.1 so that users assigned this rol Grafana 2.1 now comes with full support for InfluxDB 0.9. There is a new query editor designed from scratch for the new features InfluxDB 0.9 enables. -![InfluxDB Editor](img/docs/v2/influx_09_editor_anim.gif "InfluxDB Editor") +![InfluxDB Editor](/img/docs/v2/influx_09_editor_anim.gif "InfluxDB Editor")
@@ -116,15 +116,15 @@ Define series color using regex rule. This is useful when you have templated gra that change depending selected template variables. Using a regex style override rule you could for example make all series that contain the word **CPU** `red` and assigned to the second y axis. -![Define series color using regex rule](img/docs/v2/regex_color_override.png "Define series color using regex rule") +![Define series color using regex rule](/img/docs/v2/regex_color_override.png "Define series color using regex rule") New series style override, negative-y transform and stack groups. Negative y transform is very useful if you want to plot a series on the negative y scale without affecting the legend values like min or max or the values shown in the hover tooltip. -![Negative-y Transform](img/docs/v2/negative-y.png "Negative-y Transform") +![Negative-y Transform](/img/docs/v2/negative-y.png "Negative-y Transform") -![Negative-y Transform](img/docs/v2/negative-y-form.png "Negative-y Transform") +![Negative-y Transform](/img/docs/v2/negative-y-form.png "Negative-y Transform") ### Singlestat Panel Now support string values. Useful for time series database like InfluxDB that supports diff --git a/docs/sources/guides/whats-new-in-v2-5.md b/docs/sources/guides/whats-new-in-v2-5.md index ea9666f9e90..93cdd59cb50 100644 --- a/docs/sources/guides/whats-new-in-v2-5.md +++ b/docs/sources/guides/whats-new-in-v2-5.md @@ -18,7 +18,7 @@ fixes and enhancements to all areas of Grafana, like new Data Sources, a new and resize handles and improved InfluxDB and OpenTSDB support. ### New time range controls -New Time picker +New Time picker A new timepicker with room for more quick ranges as well as new types of relative ranges, like `Today`, `The day so far` and `This day last week`. Also an improved time & calendar picker that now works @@ -26,7 +26,7 @@ correctly in UTC mode. ### Elasticsearch -Elasticsearch example +Elasticsearch example
This release brings a fully featured query editor for Elasticsearch. You will now be able to visualize @@ -46,7 +46,7 @@ Try the new Elasticsearch query editor on the [play.grafana.org](http://play.gra ### CloudWatch -Cloudwatch editor +Cloudwatch editor Grafana 2.5 ships with a new CloudWatch datasource that will allow you to query and visualize CloudWatch metrics directly from Grafana. @@ -57,14 +57,14 @@ metrics directly from Grafana. ### Prometheus -Prometheus editor +Prometheus editor Grafana 2.5 ships with a new Prometheus datasource that will allow you to query and visualize data stored in Prometheus. ### Mix different data sources -Mix data sources in the same dashboard or in the same graph! +Mix data sources in the same dashboard or in the same graph! In previous releases you have been able to mix different data sources on the same dashboard. In v2.5 you will be able to mix then on the same graph! You can enable this by selecting the built in `-- Mixed --` data source. @@ -73,12 +73,12 @@ to plot metrics from different Graphite servers on the same Graph or plot data f data from Prometheus. Mixing different data sources on the same graph works for any data source, even custom ones. ### Panel Resize handles - + This release adds resize handles to the the bottom right corners of panels making is easy to resize both width and height. ### User invites - + This version also brings some new features for user management. diff --git a/docs/sources/guides/whats-new-in-v2-6.md b/docs/sources/guides/whats-new-in-v2-6.md index 281c2b5e4be..0b1e6688e60 100644 --- a/docs/sources/guides/whats-new-in-v2-6.md +++ b/docs/sources/guides/whats-new-in-v2-6.md @@ -17,7 +17,7 @@ The release includes a new Table panel, a new InfluxDB query editor, support for support for multiple Cloudwatch credentials. ## Table Panel - + The new table panel is very flexible, supporting both multiple modes for time series as well as for table, annotation and raw JSON data. It also provides date formating and value formating and coloring options. @@ -27,7 +27,7 @@ table, annotation and raw JSON data. It also provides date formating and value f In the most simple mode you can turn time series to rows. This means you get a `Time`, `Metric` and a `Value` column. Where `Metric` is the name of the time series. - + ### Table Transform Above you see the options tab for the **Table Panel**. The most important option is the `To Table Transform`. @@ -40,7 +40,7 @@ The column styles allow you control how dates and numbers are formatted. This transform allows you to take multiple time series and group them by time. Which will result in a `Time` column and a column for each time series. - + In the screenshot above you can see how the same time series query as in the previous example can be transformed into a different table by changing the `To Table Transform` to `Time series to columns`. @@ -49,7 +49,7 @@ a different table by changing the `To Table Transform` to `Time series to colum This transform works very similar to the legend values in the Graph panel. Each series gets its own row. In the Options tab you can select which aggregations you want using the plus button the Columns section. - + You have to think about how accurate the aggregations will be. It depends on what aggregation is used in the time series query, how many data points are fetched, etc. The time series aggregations are calculated by Grafana after aggregation is performed @@ -59,39 +59,39 @@ by the time series database. If you want to show documents from Elasticsearch pick `Raw Document` as the first metric. - + This in combination with the `JSON Data` table transform will allow you to pick which fields in the document you want to show in the table. - + ### Elasticsearch aggregations You can also make Elasticsearch aggregation queries without a `Date Histogram`. This allows you to use Elasticsearch metric aggregations to get accurate aggregations for the selected time range. - + ### Annotations The table can also show any annotations you have enabled in the dashboard. - + ## The New InfluxDB Editor The new InfluxDB editor is a lot more flexible and powerful. It supports nested functions, like `derivative`. It also uses the same technique as the Graphite query editor in that it presents nested functions as chain of function transformations. It tries to simplify and unify the complicated nature of InfluxDB's query language. - + In the `SELECT` row you can specify what fields and functions you want to use. If you have a group by time you need an aggregation function. Some functions like derivative require an aggregation function. The editor tries simplify and unify this part of the query. For example: -![](img/docs/influxdb/select_editor.png) +![](/img/docs/influxdb/select_editor.png) The above will generate the following InfluxDB `SELECT` clause: @@ -110,7 +110,7 @@ You can remove the group by by clicking on the `tag` and then click on the x ico The new editor also allows you to remove group by time and select `raw` table data. Which is very useful in combination with the new Table panel to show raw log data stored in InfluxDB. - + ## Pipeline metrics diff --git a/docs/sources/guides/whats-new-in-v2.md b/docs/sources/guides/whats-new-in-v2.md index 8ad24e70fd1..bd92128a12e 100644 --- a/docs/sources/guides/whats-new-in-v2.md +++ b/docs/sources/guides/whats-new-in-v2.md @@ -55,7 +55,7 @@ even zoom in). Also they are fast to load as they aren't actually connected to a They're a great way to communicate about a particular incident with specific people who aren't Users of your Grafana instance. You can also use them to show off your dashboards over the Internet. -![](img/docs/v2/dashboard_snapshot_dialog.png) +![](/img/docs/v2/dashboard_snapshot_dialog.png) ### Publish snapshots @@ -67,11 +67,11 @@ Either way, anyone with the link (and access to your Grafana instance for local In Grafana v2.x you can now override the relative time range for individual panels, causing them to be different than what is selected in the Dashboard time picker in the upper right. You can also add a time shift to individual panels. This allows you to show metrics from different time periods or days at the same time. -![](img/docs/v2/panel_time_override.jpg) +![](/img/docs/v2/panel_time_override.jpg) You control these overrides in panel editor mode and the new tab `Time Range`. -![](img/docs/v2/time_range_tab.jpg) +![](/img/docs/v2/time_range_tab.jpg) When you zoom or change the Dashboard time to a custom absolute time range, all panel overrides will be disabled. The panel relative time override is only active when the dashboard time is also relative. The panel timeshift override however is always active, even when the dashboard time is absolute. @@ -96,7 +96,7 @@ This feature makes it easy to include interactive visualizations from your Grafa The top header has gotten a major streamlining in Grafana V2.0. - + 1. `Side menubar toggle` Toggle the side menubar on or off. This allows you to focus on the data presented on the Dashboard. The side menubar provides access to features unrelated to a Dashboard such as Users, Organizations, and Data Sources. 2. `Dashboard dropdown` The main dropdown shows you which Dashboard you are currently viewing, and allows you to easily switch to a new Dashboard. From here you can also create a new Dashboard, Import existing Dashboards, and manage the Playlist. @@ -121,7 +121,7 @@ You can easily collapse or re-open the side menubar at any time by clicking the ## New search view & starring dashboards -![](img/docs/v2/dashboard_search.jpg) +![](/img/docs/v2/dashboard_search.jpg) The dashboard search view has gotten a big overhaul. You can now see and filter by which dashboard you have personally starred. @@ -130,11 +130,11 @@ The dashboard search view has gotten a big overhaul. You can now see and filter The Graph panel now supports 3 logarithmic scales, `log base 10`, `log base 32`, `log base 1024`. Logarithmic y-axis scales are very useful when rendering many series of different order of magnitude on the same scale (eg. latency, network traffic, and storage) -![](img/docs/v2/graph_logbase10_ms.png) +![](/img/docs/v2/graph_logbase10_ms.png) ## Dashlist panel -![](img/docs/v2/dashlist_starred.png) +![](/img/docs/v2/dashlist_starred.png) The dashlist is a new panel in Grafana v2.0. It allows you to show your personal starred dashboards, as well as do custom searches based on search strings or tags. @@ -153,7 +153,7 @@ In addition, connections to Data Sources can be better controlled and secured, a A commonly reported problem has been graphs dipping to zero at the the end, because metric data for the last interval has yet to be written to the Data Source. These graphs then "self correct" once the data comes in, but can look deceiving or alarming at times. You can avoid this problem by adding a `now delay` in `Dashboard Settings` > `Time Picker` tab. This new feature will cause Grafana to ignore the most recent data up to the set delay. -![](img/docs/v2/timepicker_now_delay.jpg) +![](/img/docs/v2/timepicker_now_delay.jpg) The delay that may be necessary depends on how much latency you have in your collection pipeline. @@ -161,7 +161,7 @@ The delay that may be necessary depends on how much latency you have in your col Grafana v2.0 protects Users from accidentally overwriting each others Dashboard changes. Similar protections are in place if you try to create a new Dashboard with the same name as an existing one. -![](img/docs/v2/overwrite_protection.jpg) +![](/img/docs/v2/overwrite_protection.jpg) These protections are only the first step; we will be building out additional capabilities around dashboard versioning and management in future versions of Grafana. @@ -177,6 +177,6 @@ Grafana now supports server-side PNG rendering. From the Panel share dialog you > **Note** This requires that your Data Source is accessible from your Grafana instance. -![](img/docs/v2/share_dialog_image_highlight.jpg) +![](/img/docs/v2/share_dialog_image_highlight.jpg) diff --git a/docs/sources/guides/whats-new-in-v3-1.md b/docs/sources/guides/whats-new-in-v3-1.md index 65b5f8e4882..5dd23dff156 100644 --- a/docs/sources/guides/whats-new-in-v3-1.md +++ b/docs/sources/guides/whats-new-in-v3-1.md @@ -16,21 +16,21 @@ weight = 1 The export feature is now accessed from the share menu. - + Dashboards exported from Grafana 3.1 are now more portable and easier for others to import than before. The export process extracts information data source types used by panels and adds these to a new `inputs` section in the dashboard json. So when you or another person tries to import the dashboard they will be asked to select data source and optional metrix prefix options. - + The above screenshot shows the new import modal that gives you 3 options for how to import a dashboard. One notable new addition here is the ability to import directly from Dashboards shared on [Grafana.net](https://grafana.net). The next step in the import process: - + Here you can change the name of the dashboard and also pick what data sources you want the dashboard to use. The above screenshot shows a CollectD dashboard for Graphite that requires a metric prefix be specified. @@ -41,7 +41,7 @@ On [Grafana.net](https://grafana.net) you can now browse & search for dashboards more are being uploaded every day. To import a dashboard just copy the dashboard url and head back to Grafana, then Dashboard Search -> Import -> Paste Grafana.net Dashboard URL. - + ## Constant template variables diff --git a/docs/sources/guides/whats-new-in-v3.md b/docs/sources/guides/whats-new-in-v3.md index f86c7133676..32912754fe9 100644 --- a/docs/sources/guides/whats-new-in-v3.md +++ b/docs/sources/guides/whats-new-in-v3.md @@ -43,7 +43,7 @@ entire experience right within Grafana. ## Grafana.net - + [Grafana.net](https://grafana.net) offers a central repository where the community can come together to discover, create and share plugins (data sources, panels, apps) and dashboards. @@ -102,7 +102,7 @@ periodically and remotely. You can also make Playlists dynamic by using Dashboard **tags** to define the Playlist. - + ## Improved UI @@ -122,11 +122,11 @@ are literally hundreds of UI improvements and refinements. Here’s the new side menu in action: - + And here's the new look for Dashboard settings: - + Check out the Play Site to get a feel for some of the UI changes. @@ -138,7 +138,7 @@ over the link and click the annotation text. This feature is very useful for linking to particular commits or tickets where more detailed information can be presented to the user. - + ## Data source variables @@ -146,11 +146,11 @@ This has been a top requested feature for very long we are exited to finally pro this feature. You can now add a new `Data source` type variable. That will automatically be filled with instance names of your data sources. - + You can then use this variable as the panel data source: - + This will allow you to quickly change data source server and reuse the same dashboard for different instances of your metrics backend. For example @@ -168,7 +168,7 @@ The Prometheus Data Source now supports annotations. ### InfluxDB You can now select the InfluxDB policy from the query editor. - + Grafana 3.0 also comes with support for InfluxDB 0.11 and InfluxDB 0.12. @@ -201,23 +201,23 @@ are a couple that I incurage you try! #### [Clock Panel](https://grafana.net/plugins/grafana-clock-panel) Support's both current time and count down mode. - + #### [Pie Chart Panel](https://grafana.net/plugins/grafana-piechart-panel) A simple pie chart panel is now available as an external plugin. - + #### [WorldPing App](https://grafana.net/plugins/raintank-worldping-app) This is full blown Grafana App that adds new panels, data sources and pages to give feature rich global performance monitoring directly from your on-prem Grafana. - + #### [Zabbix App](https://grafana.net/plugins/alexanderzobnin-zabbix-app) This app contains the already very pouplar Zabbix data source plugin, 2 dashboards and a triggers panel. It is created and maintained by [Alexander Zobnin](https://github.com/alexanderzobnin/grafana-zabbix). - + Checkout the full list of plugins on [Grafana.net](https://grafana.net/plugins) diff --git a/docs/sources/guides/whats-new-in-v4.md b/docs/sources/guides/whats-new-in-v4.md new file mode 100644 index 00000000000..39b2437d054 --- /dev/null +++ b/docs/sources/guides/whats-new-in-v4.md @@ -0,0 +1,180 @@ ++++ +title = "What's New in Grafana v4.0" +description = "Feature & improvement highlights for Grafana v4.0" +keywords = ["grafana", "new", "documentation", "4.0"] +type = "docs" +[menu.docs] +name = "Version 4.0" +identifier = "v4.0" +parent = "whatsnew" +weight = -1 ++++ + +# What's New in Grafana v4.0 + +As usual this release contains a ton of minor new features, fixes and improved UX. But on top of the usual new goodies +is a core new feature: Alerting! Read on below for a detailed description of what's new in v4.0. + +## Alerting + +{{< imgbox max-width="40%" img="/img/docs/v4/drag_handles_gif.gif" caption="Alerting overview" >}} + +Alerting is a really revolutionary feature for Grafana. It transforms Grafana from a +visualization tool into a truly mission critical monitoring tool. The alert rules are very easy to +configure using your existing graph panels and threshold levels can be set simply by dragging handles to +the right side of the graph. The rules will continually be evaluated by grafana-server and +notifications will be sent out when the rule conditions are met. + +This feature has been worked on for over a year with many iterations and rewrites +just to make sure the foundations are really solid. We are really proud to finally release it! +Since the alerting execution is processed in the backend all data source plugins are not supported. +Right now Graphite, Prometheus, InfluxDB and OpenTSDB are supported. Elasticsearch is being worked +on but will be not ready for v4 release. + +
+ +### Rules + +{{< imgbox max-width="40%" img="/img/docs/v4/alerting_conditions.png" caption="Alerting Conditions" >}} + +The rule config allows you to specify a name, how often the rule should be evaluated and a series +of conditions that all need to be true for the alert to fire. + +Currently the only condition type that exists is a `Query` condition that allows you to +specify a query letter, time range and an aggregation function. The letter refers to +a query you already have added in the **Metrics** tab. The result from the +query and the aggregation function is a single value that is then used in the threshold check. + +We plan to add other condition types in the future, like `Other Alert`, where you can include the state +of another alert in your conditions, and `Time Of Day`. + +### Notifications + +{{< imgbox max-width="40%" img="/img/docs/v4/slack_notification.png" caption="Alerting Slack Notification" >}} + +Alerting would not be very useful if there was no way to send notifications when rules trigger and change state. You +can setup notifications of different types. We currently have `Slack`, `PagerDuty`, `Email` and `Webhook` with more in the +pipe that will be added during beta period. The notifications can then be added to your alert rules. +If you have configured an external image store in the grafana.ini config file (s3 and webdav options available) +you can get very rich notifications with an image of the graph and the metric +values all included in the notification. + +### Annotations + +Alert state changes are recorded in a new annotation store that is built into Grafana. This store +currently only supports storing annotations in Grafana's own internal database (mysql, postgres or sqlite). +The Grafana annotation storage is currently only used for alert state changes but we hope to add the ability for users +to add graph comments in the form of annotations directly from within Grafana in a future release. + +### Alert List Panel + +{{< imgbox max-width="30%" img="/img/docs/v4/alert_list_panel.png" caption="Alert List Panel" >}} + +This new panel allows you to show alert rules or a history of alert rule state changes. You can filter based on states your +interested in. Very useful panel for overview style dashboards. + +
+ +## Ad-hoc filter variable + +{{< imgbox max-width="30%" img="/img/docs/v4/adhoc_filters.gif" caption="Ad-hoc filters variable" >}} + +This is a new and very different type of template variable. It will allow you to create new key/value filters on the fly. +With autocomplete for both key and values. The filter condition will be automatically applied to all +queries that use that data source. This feature opens up more exploratory dashboards. In the gif animation to the right +you have a dashboard for Elasticsearch log data. It uses one query variable that allow you to quickly change how the data +is grouped, and an interval variable for controlling the granularity of the time buckets. What was missing +was a way to dynamically apply filters to the log query. With the `Ad-Hoc Filters` variable you can +dynamically add filters to any log property! + +## UX Improvements + +We always try to bring some UX/UI refinements & polish in every release. + +### TV-mode & Kiosk mode + + +
+
+

+ Grafana is so often used on wall mounted TVs that we figured a clean TV mode would be + really nice. In TV mode the top navbar, row & panel controls will all fade to transparent. +

+ +

+ This happens automatically after one minute of user inactivity but can also be toggled manually + with the d v sequence shortcut. Any mouse movement or keyboard action will + restore navbar & controls. +

+ +

+ Another feature is the kiosk mode. This can be enabled with d k + shortcut or by adding &kiosk to the URL when you load a dashboard. + In kiosk mode the navbar is completely hidden/removed from view. +

+
+
+ {{< lightboxhelper max-width="100%" img="/img/docs/v4/tvmode.png" caption="TV mode" >}} + +
+
+ +### New row menu & add panel experience + +{{< imgbox max-width="50%" img="/img/docs/v4/add_panel.gif" caption="Add Panel flow" >}} + +We spent a lot of time improving the dashboard building experience. Trying to make it both +more efficient and easier for beginners. After many good but not great experiments +with a `build mode` we eventually decided to just improve the green row menu and +continue work on a `build mode` for a future release. + +The new row menu automatically slides out when you mouse over the edge of the row. You no longer need +to hover over the small green icon and the click it to expand the row menu. + +There is some minor improvements to drag and drop behaviour. Now when dragging a panel from one row +to another you will insert the panel and Grafana will automatically make room for it. +When you drag a panel within a row you will simply reorder the panels. + +If you look at the animation to the right you can see that you can drag and drop a new panel. This is not +required, you can also just click the panel type and it will be inserted at the end of the row +automatically. Dragging a new panel has an advantage in that you can insert a new panel where ever you want +not just at the end of the row. + +We plan to further improve dashboard building in the future with a more rich grid & layout system. + +### Keyboard shortcuts + +{{< imgbox max-width="40%" img="/img/docs/v4/shortcuts.png" caption="Shortcuts" >}} + +Grafana v4 introduces a number of really powerful keyboard shortcuts. You can now focus a panel +by hovering over it with your mouse. With a panel focused you can simple hit `e` to toggle panel +edit mode, or `v` to toggle fullscreen mode. `p r` removes the panel. `p s` opens share +modal. + +Some nice navigation shortcuts are: + +- `g h` for go to home dashboard +- `s s` open search with starred pre-selected +- `s t` open search in tags list view + +
+ +## Upgrade & Breaking changes + +There are no breaking changes. Old dashboards and features should work the same. Grafana-server will automatically upgrade it's db +schema on restart. It's advisable to do a backup of Grafana's database before updating. + +If your are using plugins make sure to update your plugins as some might not work perfectly v4. + +You can update plugins using grafana-cli + + grafana-cli plugins update-all + +## 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/auth.md b/docs/sources/http_api/auth.md index 503791b4879..df874d1653e 100644 --- a/docs/sources/http_api/auth.md +++ b/docs/sources/http_api/auth.md @@ -25,7 +25,7 @@ curl example: Open the sidemenu and click the organization dropdown and select the `API Keys` option. -![](img/v2/orgdropdown_api_keys.png) +![](/img/v2/orgdropdown_api_keys.png) You use the token in all requests in the `Authorization` header, like this: diff --git a/docs/sources/index.md b/docs/sources/index.md index a2b19fe75da..0324bf888ad 100644 --- a/docs/sources/index.md +++ b/docs/sources/index.md @@ -1,8 +1,8 @@ +++ -title = "Grafana Installation" +title = "Grafana Documentation Site" description = "Install guide for Grafana" keywords = ["grafana", "installation", "documentation"] -type = "docs_root" +type = "docs" [menu.docs] name = "Welcome to the Docs" identifier = "root" diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 8ae9d4cd18c..2fc727d659f 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -339,7 +339,7 @@ your Grafana instance. For example scopes = user:email,read:org auth_url = https://github.com/login/oauth/authorize token_url = https://github.com/login/oauth/access_token - allow_sign_up = false + allow_sign_up = true # space-delimited organization names allowed_organizations = github google @@ -579,3 +579,37 @@ Enabled to automatically remove expired snapshots ### remove snapshots after 90 days Time to live for snapshots. + +## [external_image_storage] +These options control how images should be made public so they can be shared on services like slack. + +### provider +You can choose between (s3, webdav). If left empty Grafana will ignore the upload action. + +## [external_image_storage.s3] + +### bucket_url +bucket url for s3. ex http://grafana.s3.amazonaws.com/ + +### access_key +access key. ex AAAAAAAAAAAAAAAAAAAA + +### secret_key +secret key. ex AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + +## [external_image_storage.webdav] + +### url +Url to where Grafana will send PUT request with images + +### username +basic auth username + +### password +basic auth password + +## [alerting] + +### execute_alerts = true + +Makes it possible to turn off alert rule execution. diff --git a/docs/sources/installation/migrating_to2.md b/docs/sources/installation/migrating_to2.md index 961933681db..e7e2e96b6ad 100644 --- a/docs/sources/installation/migrating_to2.md +++ b/docs/sources/installation/migrating_to2.md @@ -61,7 +61,7 @@ sure your Elasticsearch data source is added. Specify the Elasticsearch index name where your existing Grafana v1.x dashboards are stored (the default is `grafana-dash`). -![](img/docs/v2/datasource_edit_elastic.jpg) +![](/img/docs/v2/datasource_edit_elastic.jpg) ### Importing dashboards from InfluxDB @@ -74,7 +74,7 @@ your Grafana v1.x dashboards are stored, the default is `grafana`. Go to the `Dashboards` view and click on the dashboards search drop down. Click the `Import` button at the bottom of the search drop down. -![](img/docs/v2/dashboard_import.jpg) +![](/img/docs/v2/dashboard_import.jpg) ### Import view @@ -82,7 +82,7 @@ In the Import view you find the section `Migrate dashboards`. Pick the data source you added (from Elasticsearch or InfluxDB), and click the `Import` button. -![](img/docs/v2/migrate_dashboards.jpg) +![](/img/docs/v2/migrate_dashboards.jpg) Your dashboards should be automatically imported into the Grafana 2.0 back-end. diff --git a/docs/sources/installation/troubleshooting.md b/docs/sources/installation/troubleshooting.md index c2716c6478f..6da8c4f64f8 100644 --- a/docs/sources/installation/troubleshooting.md +++ b/docs/sources/installation/troubleshooting.md @@ -23,7 +23,7 @@ with Grafana being unable to query Graphite, OpenTSDB or InfluxDB. You might not be able to get metric name completion or the graph might show an error like this: -![](img/docs/v1/graph_timestore_error.png) +![](/img/docs/v1/graph_timestore_error.png) For some types of errors, the `View details` link will show you error details. For many types of HTTP connection errors, however, there is very @@ -31,7 +31,7 @@ little information. The best way to troubleshoot these issues is use the [Chrome developer tools](https://developer.chrome.com/devtools/index). By pressing `F12` you can bring up the chrome dev tools. -![](img/docs/v1/toubleshooting_chrome_dev_tools.png) +![](/img/docs/v1/toubleshooting_chrome_dev_tools.png) There are two important tabs in the Chrome developer tools: `Network` and `Console`. The `Console` tab will show you Javascript errors and @@ -49,7 +49,7 @@ chrome console error, request and response information from the ### Inspecting Grafana metric requests -![](img/docs/v1/toubleshooting_chrome_dev_tools_network.png) +![](/img/docs/v1/toubleshooting_chrome_dev_tools_network.png) After opening the Chrome developer tools for the first time the `Network` tab is empty. You will need to refresh the page to get diff --git a/docs/sources/plugins/apps.md b/docs/sources/plugins/apps.md index bb39d33bb9d..74038a9feb9 100644 --- a/docs/sources/plugins/apps.md +++ b/docs/sources/plugins/apps.md @@ -11,7 +11,7 @@ App plugins is a new kind of grafana plugin that can bundle datasource and panel Datasource and panel plugins will show up like normal plugins. The app pages will be available in the main menu. - + ## Enabling app plugins After installing an app it have to be enabled before it show up as an datasource or panel. You can do that on the app page in the config tab. diff --git a/docs/sources/reference/annotations.md b/docs/sources/reference/annotations.md index 26cb037fdfa..8f2170ba145 100644 --- a/docs/sources/reference/annotations.md +++ b/docs/sources/reference/annotations.md @@ -13,7 +13,7 @@ weight = 2 Annotations provide a way to mark points on the graph with rich events. When you hover over an annotation you can get title, tags, and text information for the event. -![](img/docs/v1/annotated_graph1.png) +![](/img/docs/v1/annotated_graph1.png) To add an annotation query click dashboard settings icon in top menu and select `Annotations` from the dropdown. This will open the `Annotations` edit view. Click the `Add` tab to add a new annotation query. @@ -28,7 +28,7 @@ Graphite supports two ways to query annotations. - Graphite events query, use the `Graphite event tags` text input, specify an tag or wildcard (leave empty should also work) ## Elasticsearch annotations -![](img/docs/v2/annotations_es.png) +![](/img/docs/v2/annotations_es.png) Grafana can query any Elasticsearch index for annotation events. The index name can be the name of an alias or an index wildcard pattern. You can leave the search query blank or specify a lucene query. @@ -39,14 +39,14 @@ as the name for the fields that should be used for the annotation title, tags an > **Note** The annotation timestamp field in elasticsearch need to be in UTC format. ## InfluxDB Annotations -![](img/docs/v2/annotations_influxdb.png) +![](/img/docs/v2/annotations_influxdb.png) For InfluxDB you need to enter a query like in the above screenshot. You need to have the ```where $timeFilter``` part. If you only select one column you will not need to enter anything in the column mapping fields. ## Prometheus Annotations -![](img/docs/v3/annotations_prom.png) +![](/img/docs/v3/annotations_prom.png) Prometheus supports two ways to query annotations. diff --git a/docs/sources/reference/dashlist.md b/docs/sources/reference/dashlist.md index 9c5baa55b34..5377c7c1941 100644 --- a/docs/sources/reference/dashlist.md +++ b/docs/sources/reference/dashlist.md @@ -13,7 +13,7 @@ weight = 4 The dashboard list panel allows you to display dynamic links to other dashboards. The list can be configured to use starred dashboards, a search query and/or dashboard tags. - + > On each dashboard load, the dashlist panel will re-query the dashboard list, always providing the most up to date results. @@ -21,7 +21,7 @@ The dashboard list panel allows you to display dynamic links to other dashboards The `starred` dashboard selection displays starred dashboards, up to the number specified in the `Limit Number to` field, in alphabetical order. On dashboard load, the dashlist panel will re-query the favorites to appear in dashboard list panel, always providing the most up to date results. - + ## Mode: Search Dashboards @@ -42,11 +42,11 @@ Limit number to | Specify the maximum number of dashboards ### Search by string To search by a string, enter a search query in the `Search Options: Query` field. Queries are case-insensitive, and partial values are accepted. - + ### Search by tag To search by one or more tags, enter your selection in the `Search Options: Tags:` field. Note that existing tags will not appear as you type, and *are* case sensitive. To see a list of existing tags, you can always return to the dashboard, open the Dashboard Picker at the top and click `tags` link in the search bar. - + > When multiple tags and strings appear, the dashboard list will display those matching ALL conditions. diff --git a/docs/sources/reference/export_import.md b/docs/sources/reference/export_import.md index a05ce2f0c6b..7430f4891a6 100644 --- a/docs/sources/reference/export_import.md +++ b/docs/sources/reference/export_import.md @@ -17,7 +17,7 @@ Dashboards are exported in Grafana JSON format, and contain everything you need The export feature is accessed from the share menu. - + ### Making a dashboard portable @@ -31,12 +31,12 @@ the dashboard, and will also be added as an required input when the dashboard is To import a dashboard open dashboard search and then hit the import button. - + From here you can upload a dashboard json file, paste a [Grafana.net](https://grafana.net) dashboard url or paste dashboard json text directly into the text area. - + In step 2 of the import process Grafana will let you change the name of the dashboard, pick what data source you want the dashboard to use and specify any metric prefixes (if the dashboard use any). @@ -45,7 +45,7 @@ data source you want the dashboard to use and specify any metric prefixes (if th Find dashboads for common server applications at [Grafana.net/dashboards](https://grafana.net/dashboards). - + ## Import & Sharing with Grafana 2.x or 3.0 diff --git a/docs/sources/reference/graph.md b/docs/sources/reference/graph.md index 62c42f78b92..bb4138b6a42 100644 --- a/docs/sources/reference/graph.md +++ b/docs/sources/reference/graph.md @@ -12,13 +12,13 @@ weight = 1 The main panel in Grafana is simply named Graph. It provides a very rich set of graphing options. - + Clicking the title for a panel exposes a menu. The `edit` option opens additional configuration options for the panel. ## General -![](img/docs/v2/graph_general.png) +![](/img/docs/v2/graph_general.png) The general tab allows customization of a panel's appearance and menu options. @@ -56,7 +56,7 @@ options. ## Axes & Grid -![](img/docs/v2/graph_axes_grid_options.png) +![](/img/docs/v2/graph_axes_grid_options.png) The Axes & Grid tab controls the display of axes, grids and legend. @@ -99,7 +99,7 @@ It is just the sum of all data points received by Grafana. ## Display styles -![](img/docs/v2/graph_display_styles.png) +![](/img/docs/v2/graph_display_styles.png) Display styles controls properties of the graph. @@ -144,4 +144,4 @@ a thicker line width to make it standout. ## Time range -![](img/docs/v2/graph_time_range.png) +![](/img/docs/v2/graph_time_range.png) diff --git a/docs/sources/reference/keyboard_shortcuts.md b/docs/sources/reference/keyboard_shortcuts.md index 96709dfb725..edd2e85f43b 100644 --- a/docs/sources/reference/keyboard_shortcuts.md +++ b/docs/sources/reference/keyboard_shortcuts.md @@ -15,7 +15,7 @@ No mouse? No problem. Grafana has extensive keyboard shortcuts to allow you to n Press `Shift`+`?` to open the keyboard shortcut dialog from anywhere within the dashboard views. - + |Shortcut|Action| diff --git a/docs/sources/reference/search.md b/docs/sources/reference/search.md index 04d317d38e0..9fc4d47893c 100644 --- a/docs/sources/reference/search.md +++ b/docs/sources/reference/search.md @@ -12,7 +12,7 @@ weight = 5 Dashboards can be searched by the dashboard name, filtered by one (or many) tags or filtered by starred status. The dashboard search is accessed through the dashboard picker, available in the dashboard top nav area. - + 1. `Dashboard Picker`: The Dashboard Picker is your primary navigation tool to move between dashboards. It is present on all dashboards, and open the Dashboard Search. The dashboard picker also doubles as the title of the current dashboard. 2. `Search Bar`: The search bar allows you to enter any string and search both database and file based dashboards in real-time. @@ -23,7 +23,7 @@ When using only a keyboard, you can use your keyboard arrow keys to navigate the ## Find by dashboard name - + To search and load dashboards click the open folder icon in the header or use the shortcut `CTRL`+`F`. Begin typing any part of the desired dashboard names. Search will return results for for any partial string match in real-time, as you type. @@ -38,11 +38,11 @@ Tags are a great way to organize your dashboards, especially as the number of da To filter the dashboard list by tag, click on any tag appearing in the right column. The list may be further filtered by clicking on additional tags: - + Alternately, to see a list of all available tags, click the tags link in the search bar. All tags will be shown, and when a tag is selected, the dashboard search will be instantly filtered: - + When using only a keyboard: `tab` to focus on the *tags* link, `▼` down arrow key to find a tag and select with the `Enter` key. @@ -53,6 +53,6 @@ When using only a keyboard: `tab` to focus on the *tags* link, `▼` down arrow Starring is a great way to organize and find commonly used dashboards. To show only starred dashboards in the list, click the *starred* link in the search bar: - + When using only a keyboard: `tab` to focus on the *stars* link, `▼` down arrow key to find a tag and select with the `Enter` key. diff --git a/docs/sources/reference/sharing.md b/docs/sources/reference/sharing.md index 08bfcf4c9d1..5210b2811df 100644 --- a/docs/sources/reference/sharing.md +++ b/docs/sources/reference/sharing.md @@ -22,7 +22,7 @@ A dashboard snapshot is an instant way to share an interactive dashboard publicl (metric, template and annotation) and panel links, leaving only the visible metric data and series names embedded into your dashboard. Dashboard snapshots can be accessed by anyone who has the link and can reach the URL. -![](img/docs/v2/dashboard_snapshot_dialog.png) +![](/img/docs/v2/dashboard_snapshot_dialog.png) ### Publish snapshots You can publish snapshots to you local instance or to [snapshot.raintank.io](http://snapshot.raintank.io). The later is a free service diff --git a/docs/sources/reference/singlestat.md b/docs/sources/reference/singlestat.md index 8c6cdc6c727..3e84ee84aff 100644 --- a/docs/sources/reference/singlestat.md +++ b/docs/sources/reference/singlestat.md @@ -11,7 +11,7 @@ weight = 2 # Singlestat Panel -![](img/docs/v1/singlestat_panel2.png) +![](/img/docs/v1/singlestat_panel2.png) The Singlestat Panel allows you to show the one main summary stat of a SINGLE series. It reduces the series into a single number (by looking at the max, min, average, or sum of values in the series). Singlestat also provides thresholds to color the stat or the Panel background. It can also translate the single number into a text value, and show a sparkline summary of the series. @@ -19,7 +19,7 @@ The Singlestat Panel allows you to show the one main summary stat of a SINGLE se The singlestat panel has a normal query editor to allow you define your exact metric queries like many other Panels. Through the Options tab, you can access the Singlestat-specific functionality. - + 1. `Big Value`: Big Value refers to how we display the main stat for the Singlestat Panel. This is always a single value that is displayed in the Panel in between two strings, `Prefix` and `Suffix`. The single number is calculated by choosing a function (min,max,average,current,total) of your metric query. This functions reduces your query into a single numeric value. 2. `Font Size`: You can use this section to select the font size of the different texts in the Singlestat Panel, i.e. prefix, value and postfix. @@ -32,19 +32,19 @@ The singlestat panel has a normal query editor to allow you define your exact me The coloring options of the Singlestat Panel config allow you to dynamically change the colors based on the Singlestat value. - + 1. `Background`: This checkbox applies the configured thresholds and colors to the entirety of the Singlestat Panel background. 2. `Value`: This checkbox applies the configured thresholds and colors to the summary stat. 3. `Thresholds`: Change the background and value colors dynamically within the panel, depending on the Singlestat value. The threshold field accepts **2 comma-separated** values which represent 3 ranges that correspond to the three colors directly to the right. For example: if the thresholds are 70, 90 then the first color represents < 70, the second color represents between 70 and 90 and the third color represents > 90. 4. `Colors`: Select a color and opacity -5. `Invert order`: This link toggles the threshold color order.
For example: Green, Orange, Red () will become Red, Orange, Green (). +5. `Invert order`: This link toggles the threshold color order.
For example: Green, Orange, Red () will become Red, Orange, Green (). ### Spark Lines Sparklines are a great way of seeing the historical data related to the summary stat, providing valuable context at a glance. Sparklines act differently than traditional Graph Panels and do not include x or y axis, coordinates, a legend, or ability to interact with the graph. - + 1. `Show`: The show checkbox will toggle whether the spark line is shown in the Panel. When unselected, only the Singlestat value will appear. 2. `Background`: Check if you want the sparklines to take up the full panel width, or uncheck if they should be below the main Singlestat value. @@ -57,13 +57,13 @@ Sparklines are a great way of seeing the historical data related to the summary Value to text mapping allows you to translate the value of the summary stat into explicit text. The text will respect all styling, thresholds and customization defined for the value. This can be useful to translate the number of the main Singlestat value into a context-specific human-readable word or message. - + ## Troubleshooting ### Multiple Series Error - + Grafana 2.5 introduced stricter checking for multiple-series on singlestat panels. In previous versions, the panel logic did not verify that only a single series was used, and instead, displayed the first series encountered. Depending on your data source, this could have lead to inconsistent data being shown and/or a general confusion about which metric was being displayed. diff --git a/docs/sources/reference/table_panel.md b/docs/sources/reference/table_panel.md index 616909c86e2..249886e1f57 100644 --- a/docs/sources/reference/table_panel.md +++ b/docs/sources/reference/table_panel.md @@ -11,7 +11,7 @@ weight = 2 # Table Panel - + The new table panel is very flexible, supporting both multiple modes for time series as well as for table, annotation and raw JSON data. It also provides date formatting and value formatting and coloring options. @@ -22,7 +22,7 @@ To view table panels in action and test different configurations with sample dat The table panel has many ways to manipulate your data for optimal presentation. - + 1. `Data`: Control how your query is transformed into a table. 2. `Table Display`: Table display options. @@ -30,7 +30,7 @@ The table panel has many ways to manipulate your data for optimal presentation. ## Data to Table - + The data section contains the **To Table Transform (1)**. This is the primary option for how your data/metric query should be transformed into a table format. The **Columns (2)** option allows you to select what columns @@ -38,38 +38,38 @@ you want in the table. Only applicable for some transforms. ### Time series to rows - + In the most simple mode you can turn time series to rows. This means you get a `Time`, `Metric` and a `Value` column. Where `Metric` is the name of the time series. ### Time series to columns -![](img/docs/v2/table_ts_to_columns2.png) +![](/img/docs/v2/table_ts_to_columns2.png) This transform allows you to take multiple time series and group them by time. Which will result in the primary column being `Time` and a column for each time series. ### Time series aggregations -![](img/docs/v2/table_ts_to_aggregations2.png) +![](/img/docs/v2/table_ts_to_aggregations2.png) This table transformation will lay out your table into rows by metric, allowing columns of `Avg`, `Min`, `Max`, `Total`, `Current` and `Count`. More than one column can be added. ### Annotations -![](img/docs/v2/table_annotations.png) +![](/img/docs/v2/table_annotations.png) If you have annotations enabled in the dashboard you can have the table show them. If you configure this mode then any queries you have in the metrics tab will be ignored. ### JSON Data -![](img/docs/v2/table_json_data.png) +![](/img/docs/v2/table_json_data.png) If you have an Elasticsearch **Raw Document** query or an Elasticsearch query without a `date histogram` use this transform mode and pick the columns using the **Columns** section. -![](img/docs/v2/elastic_raw_doc.png) +![](/img/docs/v2/elastic_raw_doc.png) ## Table Display - + 1. `Pagination (Page Size)`: The table display fields allow you to control The `Pagination` (page size) is the threshold at which the table rows will be broken into pages. For example, if your table had 95 records with a pagination value of 10, your table would be split across 9 pages. 2. `Scroll`: The `scroll bar` checkbox toggles the ability to scroll within the panel, when unchecked, the panel height will grow to display all rows. @@ -80,7 +80,7 @@ transform mode and pick the columns using the **Columns** section. The column styles allow you control how dates and numbers are formatted. - + 1. `Name or regex`: The Name or Regex field controls what columns the rule should be applied to. The regex or name filter will be matched against the column name not against column values. 2. `Type`: The three supported types of types are `Number`, `String` and `Date`. diff --git a/docs/sources/reference/templating.md b/docs/sources/reference/templating.md index 2ca8b47b9f9..636cc62a0c8 100644 --- a/docs/sources/reference/templating.md +++ b/docs/sources/reference/templating.md @@ -10,7 +10,7 @@ weight = 1 # Templating - + Dashboard Templating allows you to make your Dashboards more interactive and dynamic. @@ -49,7 +49,7 @@ For example, if you were using Templating to list all 20 of your applications, y > Note: Multi-Select Tagging functionality is currently experimental but is part of Grafana 2.1. To enable this feature click the enable icon when editing Template options for a particular variable. - + Grafana gets the list of tags and the list of values in each tag by performing two queries on your metric namespace. @@ -61,13 +61,13 @@ Note: a proof of concept shim that translates the metric query into a SQL call i Once configured, Multi-Select Tagging provides a convenient way to group and your template variables, and slice your data in the exact way you want. The Tags can be seen on the right side of the template pull-down. -![](img/docs/v2/multi-select.gif) +![](/img/docs/v2/multi-select.gif) ### Interval Use the `Interval` type to create Template variables around time ranges (eg. `1m`,`1h`, `1d`). There is also a special `auto` option that will change depending on the current time range, you can specify how many times the current time range should be divided to calculate the current `auto` range. -![](img/docs/v2/templated_variable_parameter.png) +![](/img/docs/v2/templated_variable_parameter.png) ### Custom diff --git a/docs/sources/reference/timerange.md b/docs/sources/reference/timerange.md index 0063a0ee361..a4d6fc62336 100644 --- a/docs/sources/reference/timerange.md +++ b/docs/sources/reference/timerange.md @@ -13,7 +13,7 @@ weight = 7 Grafana provides numerous ways to manage the time ranges of the data being visualized, both at the Dashboard-level and the Panel-level. - + In the top right, you have the master Dashboard time picker (it's in between the 'Zoom out' and the 'Refresh' links). @@ -43,7 +43,7 @@ Previous Month | `now-1M/M` | `now-1M/M` There are two settings available from the Dashboard Settings area, allowing customization of the auto-refresh intervals and the definition of `now`. - + ### Auto-Refresh Options @@ -59,11 +59,11 @@ Users often ask, [when will then be now](https://www.youtube.com/watch?v=VeZ9HhH You can override the relative time range for individual panels, causing them to be different than what is selected in the Dashboard time picker in the upper right. This allows you to show metrics from different time periods or days at the same time. - + You control these overrides in panel editor mode and the tab `Time Range`. - + When you zoom or change the Dashboard time to a custom absolute time range, all panel overrides will be disabled. The panel relative time override is only active when the dashboard time is also relative. The panel timeshift override is always active, even when the dashboard time is absolute. diff --git a/docs/sources/tutorials/hubot_howto.md b/docs/sources/tutorials/hubot_howto.md index 2478e0569f0..17658fbaecd 100644 --- a/docs/sources/tutorials/hubot_howto.md +++ b/docs/sources/tutorials/hubot_howto.md @@ -22,7 +22,7 @@ take you to the graph. > is so Hipchat and Slack can show them reliably (they require the image to be publicly available).
- +
## What is Hubot? @@ -70,7 +70,7 @@ To verify that this feature works try the `Direct link to rendered image` link i If you do not get an image when opening this link verify that the required font packages are installed for phantomjs to work. ### Grafana API Key - + You need to set the environment variable `HUBOT_GRAFANA_API_KEY` to a Grafana API Key. You can add these from the API Keys page which you find in the Organization dropdown. @@ -115,7 +115,7 @@ Now you can add an alias like this:
Using the alias:
- +
## Summary diff --git a/package.json b/package.json index bc356554859..e1b009ecf24 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "es6-promise": "^3.0.2", "es6-shim": "^0.35.1", "expect.js": "~0.2.0", - "glob": "~3.2.7", + "glob": "~7.1.1", "grunt": "~0.4.0", "grunt-angular-templates": "^0.5.5", "grunt-cli": "~0.1.13", @@ -68,17 +68,20 @@ "license": "Apache-2.0", "dependencies": { "eventemitter3": "^1.2.0", + "gaze": "^1.1.2", "grunt-jscs": "~1.5.x", "grunt-sass-lint": "^0.2.0", "grunt-sync": "^0.4.1", "karma-sinon": "^1.0.3", "lodash": "^2.4.1", + "mousetrap": "^1.6.0", "remarkable": "^1.6.2", "sinon": "1.16.1", "systemjs-builder": "^0.15.13", "tether": "^1.2.0", "tether-drop": "^1.4.2", "tslint": "^3.4.0", - "typescript": "^1.7.5" + "typescript": "^1.7.5", + "virtual-scroll": "^1.1.1" } } diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index 56c02baa744..ab9f4b7c80b 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -175,10 +175,10 @@ func GetAlertNotifications(c *middleware.Context) Response { return ApiError(500, "Failed to get alert notifications", err) } - var result []dtos.AlertNotification + result := make([]*dtos.AlertNotification, 0) for _, notification := range query.Result { - result = append(result, dtos.AlertNotification{ + result = append(result, &dtos.AlertNotification{ Id: notification.Id, Name: notification.Name, Type: notification.Type, diff --git a/pkg/api/cloudwatch/metrics.go b/pkg/api/cloudwatch/metrics.go index 5f87eccc87b..68843ba5774 100644 --- a/pkg/api/cloudwatch/metrics.go +++ b/pkg/api/cloudwatch/metrics.go @@ -140,7 +140,7 @@ func init() { func handleGetRegions(req *cwRequest, c *middleware.Context) { regions := []string{ "ap-northeast-1", "ap-northeast-2", "ap-southeast-1", "ap-southeast-2", "cn-north-1", - "eu-central-1", "eu-west-1", "sa-east-1", "us-east-1", "us-west-1", "us-west-2", + "eu-central-1", "eu-west-1", "sa-east-1", "us-east-1", "us-west-1", "us-west-2", "us-gov-west-1", } result := []interface{}{} diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index d599dd10735..384dd7f0ea1 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -127,6 +127,7 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro "name": panel.Name, "id": panel.Id, "info": panel.Info, + "sort": getPanelSort(panel.Id), } } @@ -150,6 +151,25 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro return jsonObj, nil } +func getPanelSort(id string) int { + sort := 100 + switch id { + case "graph": + sort = 1 + case "singlestat": + sort = 2 + case "table": + sort = 3 + case "text": + sort = 4 + case "alertlist": + sort = 5 + case "dashlist": + sort = 6 + } + return sort +} + func GetFrontendSettings(c *middleware.Context) { settings, err := getFrontendSettingsMap(c) if err != nil { diff --git a/pkg/api/playlist.go b/pkg/api/playlist.go index 7aa3c7d0057..a6c2da26dd8 100644 --- a/pkg/api/playlist.go +++ b/pkg/api/playlist.go @@ -26,6 +26,18 @@ func ValidateOrgPlaylist(c *middleware.Context) { c.JsonApiErr(403, "You are not allowed to edit/view playlist", nil) return } + + items, itemsErr := LoadPlaylistItemDTOs(id) + + if itemsErr != nil { + c.JsonApiErr(404, "Playlist items not found", err) + return + } + + if len(items) == 0 { + c.JsonApiErr(404, "Playlist is empty", itemsErr) + return + } } func SearchPlaylists(c *middleware.Context) Response { diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 1cdcc71e132..25fa43d8d2f 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -9,43 +9,44 @@ func init() { } var ( - M_Instance_Start Counter - M_Page_Status_200 Counter - M_Page_Status_500 Counter - M_Page_Status_404 Counter - M_Page_Status_Unknown Counter - M_Api_Status_200 Counter - M_Api_Status_404 Counter - M_Api_Status_500 Counter - M_Api_Status_Unknown Counter - M_Proxy_Status_200 Counter - M_Proxy_Status_404 Counter - M_Proxy_Status_500 Counter - M_Proxy_Status_Unknown Counter - M_Api_User_SignUpStarted Counter - M_Api_User_SignUpCompleted Counter - M_Api_User_SignUpInvite Counter - M_Api_Dashboard_Save Timer - M_Api_Dashboard_Get Timer - M_Api_Dashboard_Search Timer - M_Api_Admin_User_Create Counter - M_Api_Login_Post Counter - M_Api_Login_OAuth Counter - M_Api_Org_Create Counter - M_Api_Dashboard_Snapshot_Create Counter - M_Api_Dashboard_Snapshot_External Counter - M_Api_Dashboard_Snapshot_Get Counter - M_Models_Dashboard_Insert Counter - M_Alerting_Result_State_Alerting Counter - M_Alerting_Result_State_Ok Counter - M_Alerting_Result_State_Paused Counter - M_Alerting_Result_State_NoData Counter - M_Alerting_Result_State_ExecError Counter - M_Alerting_Result_State_Pending Counter - M_Alerting_Active_Alerts Counter - M_Alerting_Notification_Sent_Slack Counter - M_Alerting_Notification_Sent_Email Counter - M_Alerting_Notification_Sent_Webhook Counter + M_Instance_Start Counter + M_Page_Status_200 Counter + M_Page_Status_500 Counter + M_Page_Status_404 Counter + M_Page_Status_Unknown Counter + M_Api_Status_200 Counter + M_Api_Status_404 Counter + M_Api_Status_500 Counter + M_Api_Status_Unknown Counter + M_Proxy_Status_200 Counter + M_Proxy_Status_404 Counter + M_Proxy_Status_500 Counter + M_Proxy_Status_Unknown Counter + M_Api_User_SignUpStarted Counter + M_Api_User_SignUpCompleted Counter + M_Api_User_SignUpInvite Counter + M_Api_Dashboard_Save Timer + M_Api_Dashboard_Get Timer + M_Api_Dashboard_Search Timer + M_Api_Admin_User_Create Counter + M_Api_Login_Post Counter + M_Api_Login_OAuth Counter + M_Api_Org_Create Counter + M_Api_Dashboard_Snapshot_Create Counter + M_Api_Dashboard_Snapshot_External Counter + M_Api_Dashboard_Snapshot_Get Counter + M_Models_Dashboard_Insert Counter + M_Alerting_Result_State_Alerting Counter + M_Alerting_Result_State_Ok Counter + M_Alerting_Result_State_Paused Counter + M_Alerting_Result_State_NoData Counter + M_Alerting_Result_State_Pending Counter + M_Alerting_Active_Alerts Counter + M_Alerting_Notification_Sent_Slack Counter + M_Alerting_Notification_Sent_Email Counter + M_Alerting_Notification_Sent_Webhook Counter + M_Alerting_Notification_Sent_PagerDuty Counter + // Timers M_DataSource_ProxyReq_Timer Timer @@ -102,13 +103,13 @@ func initMetricVars(settings *MetricSettings) { M_Alerting_Result_State_Ok = RegCounter("alerting.result", "state", "ok") M_Alerting_Result_State_Paused = RegCounter("alerting.result", "state", "paused") M_Alerting_Result_State_NoData = RegCounter("alerting.result", "state", "no_data") - M_Alerting_Result_State_ExecError = RegCounter("alerting.result", "state", "exec_error") M_Alerting_Result_State_Pending = RegCounter("alerting.result", "state", "pending") M_Alerting_Active_Alerts = RegCounter("alerting.active_alerts") M_Alerting_Notification_Sent_Slack = RegCounter("alerting.notifications_sent", "type", "slack") M_Alerting_Notification_Sent_Email = RegCounter("alerting.notifications_sent", "type", "email") M_Alerting_Notification_Sent_Webhook = RegCounter("alerting.notifications_sent", "type", "webhook") + M_Alerting_Notification_Sent_PagerDuty = RegCounter("alerting.notifications_sent", "type", "pagerduty") // Timers M_DataSource_ProxyReq_Timer = RegTimer("api.dataproxy.request.all") diff --git a/pkg/models/alert.go b/pkg/models/alert.go index ec2e8e18841..67170a0dd36 100644 --- a/pkg/models/alert.go +++ b/pkg/models/alert.go @@ -9,35 +9,47 @@ import ( type AlertStateType string type AlertSeverityType string type NoDataOption string +type ExecutionErrorOption string const ( - AlertStateNoData AlertStateType = "no_data" - AlertStateExecError AlertStateType = "execution_error" - AlertStatePaused AlertStateType = "paused" - AlertStateAlerting AlertStateType = "alerting" - AlertStateOK AlertStateType = "ok" - AlertStatePending AlertStateType = "pending" + AlertStateNoData AlertStateType = "no_data" + AlertStatePaused AlertStateType = "paused" + AlertStateAlerting AlertStateType = "alerting" + AlertStateOK AlertStateType = "ok" + AlertStatePending AlertStateType = "pending" ) const ( NoDataSetNoData NoDataOption = "no_data" NoDataSetAlerting NoDataOption = "alerting" - NoDataSetOK NoDataOption = "ok" NoDataKeepState NoDataOption = "keep_state" ) +const ( + ExecutionErrorSetAlerting ExecutionErrorOption = "alerting" + ExecutionErrorKeepState ExecutionErrorOption = "keep_state" +) + func (s AlertStateType) IsValid() bool { - return s == AlertStateOK || s == AlertStateNoData || s == AlertStateExecError || s == AlertStatePaused || s == AlertStatePending + return s == AlertStateOK || s == AlertStateNoData || s == AlertStatePaused || s == AlertStatePending } func (s NoDataOption) IsValid() bool { - return s == NoDataSetNoData || s == NoDataSetAlerting || s == NoDataSetOK || s == NoDataKeepState + return s == NoDataSetNoData || s == NoDataSetAlerting || s == NoDataKeepState } func (s NoDataOption) ToAlertState() AlertStateType { return AlertStateType(s) } +func (s ExecutionErrorOption) IsValid() bool { + return s == ExecutionErrorSetAlerting || s == ExecutionErrorKeepState +} + +func (s ExecutionErrorOption) ToAlertState() AlertStateType { + return AlertStateType(s) +} + type Alert struct { Id int64 Version int64 diff --git a/pkg/models/notifications.go b/pkg/models/notifications.go index 80803b3be08..c43024da691 100644 --- a/pkg/models/notifications.go +++ b/pkg/models/notifications.go @@ -16,14 +16,6 @@ type SendEmailCommandSync struct { SendEmailCommand } -type SendWebhook struct { - Url string - User string - Password string - Body string - HttpMethod string -} - type SendWebhookSync struct { Url string User string diff --git a/pkg/services/alerting/conditions/query.go b/pkg/services/alerting/conditions/query.go index a9a99ba919e..b73db9d590e 100644 --- a/pkg/services/alerting/conditions/query.go +++ b/pkg/services/alerting/conditions/query.go @@ -33,15 +33,17 @@ type AlertQuery struct { To string } -func (c *QueryCondition) Eval(context *alerting.EvalContext) { +func (c *QueryCondition) Eval(context *alerting.EvalContext) (*alerting.ConditionResult, error) { timeRange := tsdb.NewTimeRange(c.Query.From, c.Query.To) + seriesList, err := c.executeQuery(context, timeRange) if err != nil { - context.Error = err - return + return nil, err } emptySerieCount := 0 + evalMatchCount := 0 + var matches []*alerting.EvalMatch for _, series := range seriesList { reducedValue := c.Reducer.Reduce(series) evalMatch := c.Evaluator.Eval(reducedValue) @@ -58,15 +60,20 @@ func (c *QueryCondition) Eval(context *alerting.EvalContext) { } if evalMatch { - context.EvalMatches = append(context.EvalMatches, &alerting.EvalMatch{ + evalMatchCount++ + + matches = append(matches, &alerting.EvalMatch{ Metric: series.Name, Value: reducedValue.Float64, }) } } - context.NoDataFound = emptySerieCount == len(seriesList) - context.Firing = len(context.EvalMatches) > 0 + return &alerting.ConditionResult{ + Firing: evalMatchCount > 0, + NoDataFound: emptySerieCount == len(seriesList), + EvalMatches: matches, + }, nil } func (c *QueryCondition) executeQuery(context *alerting.EvalContext, timeRange *tsdb.TimeRange) (tsdb.TimeSeriesSlice, error) { diff --git a/pkg/services/alerting/conditions/query_test.go b/pkg/services/alerting/conditions/query_test.go index 43e0381a80c..c3797beaf37 100644 --- a/pkg/services/alerting/conditions/query_test.go +++ b/pkg/services/alerting/conditions/query_test.go @@ -46,19 +46,19 @@ func TestQueryCondition(t *testing.T) { Convey("should fire when avg is above 100", func() { points := tsdb.NewTimeSeriesPointsFromArgs(120, 0) ctx.series = tsdb.TimeSeriesSlice{tsdb.NewTimeSeries("test1", points)} - ctx.exec() + cr, err := ctx.exec() - So(ctx.result.Error, ShouldBeNil) - So(ctx.result.Firing, ShouldBeTrue) + So(err, ShouldBeNil) + So(cr.Firing, ShouldBeTrue) }) Convey("Should not fire when avg is below 100", func() { points := tsdb.NewTimeSeriesPointsFromArgs(90, 0) ctx.series = tsdb.TimeSeriesSlice{tsdb.NewTimeSeries("test1", points)} - ctx.exec() + cr, err := ctx.exec() - So(ctx.result.Error, ShouldBeNil) - So(ctx.result.Firing, ShouldBeFalse) + So(err, ShouldBeNil) + So(cr.Firing, ShouldBeFalse) }) Convey("Should fire if only first serie matches", func() { @@ -66,10 +66,10 @@ func TestQueryCondition(t *testing.T) { tsdb.NewTimeSeries("test1", tsdb.NewTimeSeriesPointsFromArgs(120, 0)), tsdb.NewTimeSeries("test2", tsdb.NewTimeSeriesPointsFromArgs(0, 0)), } - ctx.exec() + cr, err := ctx.exec() - So(ctx.result.Error, ShouldBeNil) - So(ctx.result.Firing, ShouldBeTrue) + So(err, ShouldBeNil) + So(cr.Firing, ShouldBeTrue) }) Convey("Empty series", func() { @@ -78,10 +78,10 @@ func TestQueryCondition(t *testing.T) { tsdb.NewTimeSeries("test1", tsdb.NewTimeSeriesPointsFromArgs()), tsdb.NewTimeSeries("test2", tsdb.NewTimeSeriesPointsFromArgs()), } - ctx.exec() + cr, err := ctx.exec() - So(ctx.result.Error, ShouldBeNil) - So(ctx.result.NoDataFound, ShouldBeTrue) + So(err, ShouldBeNil) + So(cr.NoDataFound, ShouldBeTrue) }) Convey("Should set NoDataFound both series contains null", func() { @@ -89,10 +89,10 @@ func TestQueryCondition(t *testing.T) { tsdb.NewTimeSeries("test1", tsdb.TimeSeriesPoints{tsdb.TimePoint{null.FloatFromPtr(nil), null.FloatFrom(0)}}), tsdb.NewTimeSeries("test2", tsdb.TimeSeriesPoints{tsdb.TimePoint{null.FloatFromPtr(nil), null.FloatFrom(0)}}), } - ctx.exec() + cr, err := ctx.exec() - So(ctx.result.Error, ShouldBeNil) - So(ctx.result.NoDataFound, ShouldBeTrue) + So(err, ShouldBeNil) + So(cr.NoDataFound, ShouldBeTrue) }) Convey("Should not set NoDataFound if one serie is empty", func() { @@ -100,10 +100,10 @@ func TestQueryCondition(t *testing.T) { tsdb.NewTimeSeries("test1", tsdb.NewTimeSeriesPointsFromArgs()), tsdb.NewTimeSeries("test2", tsdb.NewTimeSeriesPointsFromArgs(120, 0)), } - ctx.exec() + cr, err := ctx.exec() - So(ctx.result.Error, ShouldBeNil) - So(ctx.result.NoDataFound, ShouldBeFalse) + So(err, ShouldBeNil) + So(cr.NoDataFound, ShouldBeFalse) }) }) }) @@ -120,7 +120,7 @@ type queryConditionTestContext struct { type queryConditionScenarioFunc func(c *queryConditionTestContext) -func (ctx *queryConditionTestContext) exec() { +func (ctx *queryConditionTestContext) exec() (*alerting.ConditionResult, error) { jsonModel, err := simplejson.NewJson([]byte(`{ "type": "query", "query": { @@ -146,7 +146,7 @@ func (ctx *queryConditionTestContext) exec() { }, nil } - condition.Eval(ctx.result) + return condition.Eval(ctx.result) } func queryConditionScenario(desc string, fn queryConditionScenarioFunc) { diff --git a/pkg/services/alerting/conditions/reducer.go b/pkg/services/alerting/conditions/reducer.go index a982fa63d33..988a640591d 100644 --- a/pkg/services/alerting/conditions/reducer.go +++ b/pkg/services/alerting/conditions/reducer.go @@ -62,6 +62,15 @@ func (s *SimpleReducer) Reduce(series *tsdb.TimeSeries) null.Float { case "count": value = float64(len(series.Points)) allNull = false + case "last": + points := series.Points + for i := len(points) - 1; i >= 0; i-- { + if points[i][0].Valid { + value = points[i][0].Float64 + allNull = false + break + } + } } if allNull { diff --git a/pkg/services/alerting/conditions/reducer_test.go b/pkg/services/alerting/conditions/reducer_test.go index 198a52b746a..dfd229e1374 100644 --- a/pkg/services/alerting/conditions/reducer_test.go +++ b/pkg/services/alerting/conditions/reducer_test.go @@ -35,6 +35,12 @@ func TestSimpleReducer(t *testing.T) { result := testReducer("count", 1, 2, 3000) So(result, ShouldEqual, float64(3)) }) + + Convey("last", func() { + result := testReducer("last", 1, 2, 3000) + So(result, ShouldEqual, float64(3000)) + }) + }) } diff --git a/pkg/services/alerting/eval_context.go b/pkg/services/alerting/eval_context.go index cf698d8f7a4..711416970c5 100644 --- a/pkg/services/alerting/eval_context.go +++ b/pkg/services/alerting/eval_context.go @@ -26,10 +26,23 @@ type EvalContext struct { ImagePublicUrl string ImageOnDiskPath string NoDataFound bool + PrevAlertState m.AlertStateType Ctx context.Context } +func NewEvalContext(alertCtx context.Context, rule *Rule) *EvalContext { + return &EvalContext{ + Ctx: alertCtx, + StartTime: time.Now(), + Rule: rule, + Logs: make([]*ResultLogEntry, 0), + EvalMatches: make([]*EvalMatch, 0), + log: log.New("alerting.evalContext"), + PrevAlertState: rule.State, + } +} + type StateDescription struct { Color string Text string @@ -48,11 +61,6 @@ func (c *EvalContext) GetStateModel() *StateDescription { Color: "#888888", Text: "No Data", } - case m.AlertStateExecError: - return &StateDescription{ - Color: "#000", - Text: "Execution Error", - } case m.AlertStateAlerting: return &StateDescription{ Color: "#D63232", @@ -63,6 +71,18 @@ func (c *EvalContext) GetStateModel() *StateDescription { } } +func (c *EvalContext) ShouldUpdateAlertState() bool { + return c.Rule.State != c.PrevAlertState +} + +func (c *EvalContext) ShouldSendNotification() bool { + if (c.PrevAlertState == m.AlertStatePending) && (c.Rule.State == m.AlertStateOK) { + return false + } + + return true +} + func (a *EvalContext) GetDurationMs() float64 { return float64(a.EndTime.Nanosecond()-a.StartTime.Nanosecond()) / float64(1000000) } @@ -97,14 +117,3 @@ func (c *EvalContext) GetRuleUrl() (string, error) { return ruleUrl, nil } } - -func NewEvalContext(alertCtx context.Context, rule *Rule) *EvalContext { - return &EvalContext{ - Ctx: alertCtx, - StartTime: time.Now(), - Rule: rule, - Logs: make([]*ResultLogEntry, 0), - EvalMatches: make([]*EvalMatch, 0), - log: log.New("alerting.evalContext"), - } -} diff --git a/pkg/services/alerting/eval_context_test.go b/pkg/services/alerting/eval_context_test.go new file mode 100644 index 00000000000..5cff2996ca6 --- /dev/null +++ b/pkg/services/alerting/eval_context_test.go @@ -0,0 +1,48 @@ +package alerting + +import ( + "context" + "testing" + + "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestAlertingEvalContext(t *testing.T) { + Convey("Eval context", t, func() { + ctx := NewEvalContext(context.TODO(), &Rule{Conditions: []Condition{&conditionStub{firing: true}}}) + + Convey("Should update alert state", func() { + + Convey("ok -> alerting", func() { + ctx.PrevAlertState = models.AlertStateOK + ctx.Rule.State = models.AlertStateAlerting + + So(ctx.ShouldUpdateAlertState(), ShouldBeTrue) + }) + + Convey("ok -> ok", func() { + ctx.PrevAlertState = models.AlertStateOK + ctx.Rule.State = models.AlertStateOK + + So(ctx.ShouldUpdateAlertState(), ShouldBeFalse) + }) + }) + + Convey("Should send notifications", func() { + Convey("pending -> ok", func() { + ctx.PrevAlertState = models.AlertStatePending + ctx.Rule.State = models.AlertStateOK + + So(ctx.ShouldSendNotification(), ShouldBeFalse) + }) + + Convey("ok -> alerting", func() { + ctx.PrevAlertState = models.AlertStateOK + ctx.Rule.State = models.AlertStateAlerting + + So(ctx.ShouldSendNotification(), ShouldBeTrue) + }) + }) + }) +} diff --git a/pkg/services/alerting/eval_handler.go b/pkg/services/alerting/eval_handler.go index 74054ba8191..538c639abb8 100644 --- a/pkg/services/alerting/eval_handler.go +++ b/pkg/services/alerting/eval_handler.go @@ -20,8 +20,12 @@ func NewEvalHandler() *DefaultEvalHandler { } func (e *DefaultEvalHandler) Eval(context *EvalContext) { + firing := true for _, condition := range context.Rule.Conditions { - condition.Eval(context) + cr, err := condition.Eval(context) + if err != nil { + context.Error = err + } // break if condition could not be evaluated if context.Error != nil { @@ -29,11 +33,15 @@ func (e *DefaultEvalHandler) Eval(context *EvalContext) { } // break if result has not triggered yet - if context.Firing == false { + if cr.Firing == false { + firing = false break } + + context.EvalMatches = append(context.EvalMatches, cr.EvalMatches...) } + context.Firing = firing context.EndTime = time.Now() elapsedTime := context.EndTime.Sub(context.StartTime) / time.Millisecond metrics.M_Alerting_Exeuction_Time.Update(elapsedTime) diff --git a/pkg/services/alerting/eval_handler_test.go b/pkg/services/alerting/eval_handler_test.go index b69e62f9622..4c2ec24b506 100644 --- a/pkg/services/alerting/eval_handler_test.go +++ b/pkg/services/alerting/eval_handler_test.go @@ -8,11 +8,12 @@ import ( ) type conditionStub struct { - firing bool + firing bool + matches []*EvalMatch } -func (c *conditionStub) Eval(context *EvalContext) { - context.Firing = c.firing +func (c *conditionStub) Eval(context *EvalContext) (*ConditionResult, error) { + return &ConditionResult{Firing: c.firing, EvalMatches: c.matches}, nil } func TestAlertingExecutor(t *testing.T) { @@ -30,10 +31,10 @@ func TestAlertingExecutor(t *testing.T) { So(context.Firing, ShouldEqual, true) }) - Convey("Show return false with not passing condition", func() { + Convey("Show return false with not passing asdf", func() { context := NewEvalContext(context.TODO(), &Rule{ Conditions: []Condition{ - &conditionStub{firing: true}, + &conditionStub{firing: true, matches: []*EvalMatch{&EvalMatch{}, &EvalMatch{}}}, &conditionStub{firing: false}, }, }) diff --git a/pkg/services/alerting/extractor.go b/pkg/services/alerting/extractor.go index d78e84f6974..786fae846ad 100644 --- a/pkg/services/alerting/extractor.go +++ b/pkg/services/alerting/extractor.go @@ -3,6 +3,8 @@ package alerting import ( "errors" + "fmt" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/log" @@ -104,7 +106,8 @@ func (e *DashAlertExtractor) GetAlerts() ([]*m.Alert, error) { panelQuery := findPanelQueryByRefId(panel, queryRefId) if panelQuery == nil { - return nil, ValidationError{Reason: "Alert refes to query that cannot be found"} + reason := fmt.Sprintf("Alert on PanelId: %v refers to query(%s) that cannot be found", alert.PanelId, queryRefId) + return nil, ValidationError{Reason: reason} } dsName := "" diff --git a/pkg/services/alerting/interfaces.go b/pkg/services/alerting/interfaces.go index 583e12a120d..cc2561473e3 100644 --- a/pkg/services/alerting/interfaces.go +++ b/pkg/services/alerting/interfaces.go @@ -21,6 +21,12 @@ type Notifier interface { GetIsDefault() bool } +type ConditionResult struct { + Firing bool + NoDataFound bool + EvalMatches []*EvalMatch +} + type Condition interface { - Eval(result *EvalContext) + Eval(result *EvalContext) (*ConditionResult, error) } diff --git a/pkg/services/alerting/models.go b/pkg/services/alerting/models.go index 39e7eb83166..140c545fb2f 100644 --- a/pkg/services/alerting/models.go +++ b/pkg/services/alerting/models.go @@ -14,9 +14,9 @@ type ResultLogEntry struct { } type EvalMatch struct { - Value float64 - Metric string - Tags map[string]string + Value float64 `json:"value"` + Metric string `json:"metric"` + Tags map[string]string `json:"tags"` } type Level struct { diff --git a/pkg/services/alerting/notifier.go b/pkg/services/alerting/notifier.go index 2017d9d7670..3fe7661b78c 100644 --- a/pkg/services/alerting/notifier.go +++ b/pkg/services/alerting/notifier.go @@ -49,7 +49,7 @@ func (n *RootNotifier) Notify(context *EvalContext) error { return err } - n.log.Info("Sending notifications for", "ruleId", context.Rule.Id, "Amount to send", len(notifiers)) + n.log.Info("Sending notifications for", "ruleId", context.Rule.Id, "sent count", len(notifiers)) if len(notifiers) == 0 { return nil diff --git a/pkg/services/alerting/notifiers/pagerduty.go b/pkg/services/alerting/notifiers/pagerduty.go new file mode 100644 index 00000000000..1de34e07e5a --- /dev/null +++ b/pkg/services/alerting/notifiers/pagerduty.go @@ -0,0 +1,83 @@ +package notifiers + +import ( + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/metrics" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" +) + +func init() { + alerting.RegisterNotifier("pagerduty", NewPagerdutyNotifier) +} + +var ( + pagerdutyEventApiUrl string = "https://events.pagerduty.com/generic/2010-04-15/create_event.json" +) + +func NewPagerdutyNotifier(model *m.AlertNotification) (alerting.Notifier, error) { + key := model.Settings.Get("integrationKey").MustString() + if key == "" { + return nil, alerting.ValidationError{Reason: "Could not find integration key property in settings"} + } + + return &PagerdutyNotifier{ + NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings), + Key: key, + log: log.New("alerting.notifier.pagerduty"), + }, nil +} + +type PagerdutyNotifier struct { + NotifierBase + Key string + log log.Logger +} + +func (this *PagerdutyNotifier) Notify(evalContext *alerting.EvalContext) error { + this.log.Info("Notifying Pagerduty") + metrics.M_Alerting_Notification_Sent_PagerDuty.Inc(1) + + if evalContext.Rule.State == m.AlertStateAlerting { + bodyJSON := simplejson.New() + bodyJSON.Set("service_key", this.Key) + bodyJSON.Set("description", evalContext.Rule.Name+" - "+evalContext.Rule.Message) + bodyJSON.Set("client", "Grafana") + bodyJSON.Set("event_type", "trigger") + + ruleUrl, err := evalContext.GetRuleUrl() + if err != nil { + this.log.Error("Failed get rule link", "error", err) + return err + } + bodyJSON.Set("client_url", ruleUrl) + + if evalContext.ImagePublicUrl != "" { + var contexts []interface{} + imageJSON := simplejson.New() + imageJSON.Set("type", "image") + imageJSON.Set("src", evalContext.ImagePublicUrl) + contexts[0] = imageJSON + bodyJSON.Set("contexts", contexts) + } + + body, _ := bodyJSON.MarshalJSON() + + cmd := &m.SendWebhookSync{ + Url: pagerdutyEventApiUrl, + Body: string(body), + HttpMethod: "POST", + } + + if err := bus.Dispatch(cmd); err != nil { + this.log.Error("Failed to send notification to Pagerduty", "error", err, "body", string(body)) + } + + } else { + this.log.Info("Not sending a trigger to Pagerduty", "state", evalContext.Rule.State) + } + + return nil +} diff --git a/pkg/services/alerting/notifiers/pagerduty_test.go b/pkg/services/alerting/notifiers/pagerduty_test.go new file mode 100644 index 00000000000..522bb133d77 --- /dev/null +++ b/pkg/services/alerting/notifiers/pagerduty_test.go @@ -0,0 +1,53 @@ +package notifiers + +import ( + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestPagerdutyNotifier(t *testing.T) { + Convey("Pagerduty notifier tests", t, func() { + + Convey("Parsing alert notification from settings", func() { + Convey("empty settings should return error", func() { + json := `{ }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "pageduty_testing", + Type: "pagerduty", + Settings: settingsJSON, + } + + _, err := NewPagerdutyNotifier(model) + So(err, ShouldNotBeNil) + }) + + Convey("settings should trigger incident", func() { + json := ` + { + "integrationKey": "abcdefgh0123456789" + }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "pagerduty_testing", + Type: "pagerduty", + Settings: settingsJSON, + } + + not, err := NewPagerdutyNotifier(model) + pagerdutyNotifier := not.(*PagerdutyNotifier) + + So(err, ShouldBeNil) + So(pagerdutyNotifier.Name, ShouldEqual, "pagerduty_testing") + So(pagerdutyNotifier.Type, ShouldEqual, "pagerduty") + So(pagerdutyNotifier.Key, ShouldEqual, "abcdefgh0123456789") + }) + + }) + }) +} diff --git a/pkg/services/alerting/notifiers/slack.go b/pkg/services/alerting/notifiers/slack.go index 0e29af39e79..f266c8cd095 100644 --- a/pkg/services/alerting/notifiers/slack.go +++ b/pkg/services/alerting/notifiers/slack.go @@ -23,20 +23,23 @@ func NewSlackNotifier(model *m.AlertNotification) (alerting.Notifier, error) { } recipient := model.Settings.Get("recipient").MustString() + mention := model.Settings.Get("mention").MustString() return &SlackNotifier{ NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings), Url: url, - Recipient: recipient, + Recipient: recipient, + Mention: mention, log: log.New("alerting.notifier.slack"), }, nil } type SlackNotifier struct { NotifierBase - Url string + Url string Recipient string - log log.Logger + Mention string + log log.Logger } func (this *SlackNotifier) Notify(evalContext *alerting.EvalContext) error { @@ -70,9 +73,9 @@ func (this *SlackNotifier) Notify(evalContext *alerting.EvalContext) error { }) } - message := "" + message := this.Mention if evalContext.Rule.State != m.AlertStateOK { //dont add message when going back to alert state ok. - message = evalContext.Rule.Message + message += " " + evalContext.Rule.Message } body := map[string]interface{}{ diff --git a/pkg/services/alerting/notifiers/slack_test.go b/pkg/services/alerting/notifiers/slack_test.go new file mode 100644 index 00000000000..5b1763064aa --- /dev/null +++ b/pkg/services/alerting/notifiers/slack_test.go @@ -0,0 +1,81 @@ +package notifiers + +import ( + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestSlackNotifier(t *testing.T) { + Convey("Slack notifier tests", t, func() { + + Convey("Parsing alert notification from settings", func() { + Convey("empty settings should return error", func() { + json := `{ }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "ops", + Type: "slack", + Settings: settingsJSON, + } + + _, err := NewSlackNotifier(model) + So(err, ShouldNotBeNil) + }) + + Convey("from settings", func() { + json := ` + { + "url": "http://google.com" + }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "ops", + Type: "slack", + Settings: settingsJSON, + } + + not, err := NewSlackNotifier(model) + slackNotifier := not.(*SlackNotifier) + + So(err, ShouldBeNil) + So(slackNotifier.Name, ShouldEqual, "ops") + So(slackNotifier.Type, ShouldEqual, "slack") + So(slackNotifier.Url, ShouldEqual, "http://google.com") + So(slackNotifier.Recipient, ShouldEqual, "") + So(slackNotifier.Mention, ShouldEqual, "") + }) + + Convey("from settings with Recipient and Mention", func() { + json := ` + { + "url": "http://google.com", + "recipient": "#ds-opentsdb", + "mention": "@carl" + }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "ops", + Type: "slack", + Settings: settingsJSON, + } + + not, err := NewSlackNotifier(model) + slackNotifier := not.(*SlackNotifier) + + So(err, ShouldBeNil) + So(slackNotifier.Name, ShouldEqual, "ops") + So(slackNotifier.Type, ShouldEqual, "slack") + So(slackNotifier.Url, ShouldEqual, "http://google.com") + So(slackNotifier.Recipient, ShouldEqual, "#ds-opentsdb") + So(slackNotifier.Mention, ShouldEqual, "@carl") + }) + + }) + }) +} diff --git a/pkg/services/alerting/notifiers/webhook.go b/pkg/services/alerting/notifiers/webhook.go index fb236c91c13..5a8163eecf3 100644 --- a/pkg/services/alerting/notifiers/webhook.go +++ b/pkg/services/alerting/notifiers/webhook.go @@ -51,11 +51,11 @@ func (this *WebhookNotifier) Notify(evalContext *alerting.EvalContext) error { ruleUrl, err := evalContext.GetRuleUrl() if err == nil { - bodyJSON.Set("rule_url", ruleUrl) + bodyJSON.Set("ruleUrl", ruleUrl) } if evalContext.ImagePublicUrl != "" { - bodyJSON.Set("image_url", evalContext.ImagePublicUrl) + bodyJSON.Set("imageUrl", evalContext.ImagePublicUrl) } body, _ := bodyJSON.MarshalJSON() diff --git a/pkg/services/alerting/notifiers/webhook_test.go b/pkg/services/alerting/notifiers/webhook_test.go index 6147f54d773..eb25130e4e1 100644 --- a/pkg/services/alerting/notifiers/webhook_test.go +++ b/pkg/services/alerting/notifiers/webhook_test.go @@ -40,12 +40,12 @@ func TestWebhookNotifier(t *testing.T) { } not, err := NewWebHookNotifier(model) - emailNotifier := not.(*WebhookNotifier) + webhookNotifier := not.(*WebhookNotifier) So(err, ShouldBeNil) - So(emailNotifier.Name, ShouldEqual, "ops") - So(emailNotifier.Type, ShouldEqual, "email") - So(emailNotifier.Url, ShouldEqual, "http://google.com") + So(webhookNotifier.Name, ShouldEqual, "ops") + So(webhookNotifier.Type, ShouldEqual, "email") + So(webhookNotifier.Url, ShouldEqual, "http://google.com") }) }) }) diff --git a/pkg/services/alerting/result_handler.go b/pkg/services/alerting/result_handler.go index d2ad9345416..2a7c6528b97 100644 --- a/pkg/services/alerting/result_handler.go +++ b/pkg/services/alerting/result_handler.go @@ -27,32 +27,55 @@ func NewResultHandler() *DefaultResultHandler { } } -func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error { - oldState := evalContext.Rule.State - - executionError := "" - annotationData := simplejson.New() +func (handler *DefaultResultHandler) GetStateFromEvaluation(evalContext *EvalContext) m.AlertStateType { if evalContext.Error != nil { - handler.log.Error("Alert Rule Result Error", "ruleId", evalContext.Rule.Id, "error", evalContext.Error) - evalContext.Rule.State = m.AlertStateExecError - executionError = evalContext.Error.Error() - annotationData.Set("errorMessage", executionError) - } else if evalContext.Firing { - evalContext.Rule.State = m.AlertStateAlerting - annotationData = simplejson.NewFromAny(evalContext.EvalMatches) - } else { - if evalContext.NoDataFound { - if evalContext.Rule.NoDataState != m.NoDataKeepState { - evalContext.Rule.State = evalContext.Rule.NoDataState.ToAlertState() - } + handler.log.Error("Alert Rule Result Error", + "ruleId", evalContext.Rule.Id, + "name", evalContext.Rule.Name, + "error", evalContext.Error, + "changing state to", evalContext.Rule.ExecutionErrorState.ToAlertState()) + + if evalContext.Rule.ExecutionErrorState == m.ExecutionErrorKeepState { + return evalContext.PrevAlertState } else { - evalContext.Rule.State = m.AlertStateOK + return evalContext.Rule.ExecutionErrorState.ToAlertState() + } + } else if evalContext.Firing { + return m.AlertStateAlerting + } else if evalContext.NoDataFound { + handler.log.Info("Alert Rule returned no data", + "ruleId", evalContext.Rule.Id, + "name", evalContext.Rule.Name, + "changing state to", evalContext.Rule.NoDataState.ToAlertState()) + + if evalContext.Rule.NoDataState == m.NoDataKeepState { + return evalContext.PrevAlertState + } else { + return evalContext.Rule.NoDataState.ToAlertState() } } + return m.AlertStateOK +} + +func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error { + executionError := "" + annotationData := simplejson.New() + + evalContext.Rule.State = handler.GetStateFromEvaluation(evalContext) + + if evalContext.Error != nil { + executionError = evalContext.Error.Error() + annotationData.Set("errorMessage", executionError) + } + + if evalContext.Firing { + annotationData = simplejson.NewFromAny(evalContext.EvalMatches) + } + countStateResult(evalContext.Rule.State) - if handler.shouldUpdateAlertState(evalContext, oldState) { - handler.log.Info("New state change", "alertId", evalContext.Rule.Id, "newState", evalContext.Rule.State, "oldState", oldState) + if evalContext.ShouldUpdateAlertState() { + handler.log.Info("New state change", "alertId", evalContext.Rule.Id, "newState", evalContext.Rule.State, "prev state", evalContext.PrevAlertState) cmd := &m.SetAlertStateCommand{ AlertId: evalContext.Rule.Id, @@ -76,7 +99,7 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error { Title: evalContext.Rule.Name, Text: evalContext.GetStateModel().Text, NewState: string(evalContext.Rule.State), - PrevState: string(oldState), + PrevState: string(evalContext.PrevAlertState), Epoch: time.Now().Unix(), Data: annotationData, } @@ -86,21 +109,14 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error { handler.log.Error("Failed to save annotation for new alert state", "error", err) } - if (oldState == m.AlertStatePending) && (evalContext.Rule.State == m.AlertStateOK) { - handler.log.Info("Notfication not sent", "oldState", oldState, "newState", evalContext.Rule.State) - } else { + if evalContext.ShouldSendNotification() { handler.notifier.Notify(evalContext) } - } return nil } -func (handler *DefaultResultHandler) shouldUpdateAlertState(evalContext *EvalContext, oldState m.AlertStateType) bool { - return evalContext.Rule.State != oldState -} - func countStateResult(state m.AlertStateType) { switch state { case m.AlertStatePending: @@ -113,7 +129,5 @@ func countStateResult(state m.AlertStateType) { metrics.M_Alerting_Result_State_Paused.Inc(1) case m.AlertStateNoData: metrics.M_Alerting_Result_State_NoData.Inc(1) - case m.AlertStateExecError: - metrics.M_Alerting_Result_State_ExecError.Inc(1) } } diff --git a/pkg/services/alerting/result_handler_test.go b/pkg/services/alerting/result_handler_test.go index 7a1abc6d1ef..321838d4f4c 100644 --- a/pkg/services/alerting/result_handler_test.go +++ b/pkg/services/alerting/result_handler_test.go @@ -1,30 +1,90 @@ package alerting -// import ( -// "context" -// "testing" -// -// "github.com/grafana/grafana/pkg/models" -// . "github.com/smartystreets/goconvey/convey" -// ) -// -// func TestAlertResultHandler(t *testing.T) { -// Convey("Test result Handler", t, func() { -// -// handler := NewResultHandler() -// evalContext := NewEvalContext(context.TODO(), &Rule{}) -// -// Convey("Should update", func() { -// -// Convey("when no earlier alert state", func() { -// oldState := models.AlertStateOK -// -// evalContext.Rule.State = models.AlertStateAlerting -// evalContext.Rule.NoDataState = models.NoDataKeepState -// evalContext.NoDataFound = true -// -// So(handler.shouldUpdateAlertState(evalContext, oldState), ShouldBeFalse) -// }) -// }) -// }) -// } +import ( + "context" + "testing" + + "fmt" + + "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestAlertingResultHandler(t *testing.T) { + Convey("Result handler", t, func() { + ctx := NewEvalContext(context.TODO(), &Rule{Conditions: []Condition{&conditionStub{firing: true}}}) + dummieError := fmt.Errorf("dummie") + handler := NewResultHandler() + + Convey("Should update alert state", func() { + + Convey("ok -> alerting", func() { + ctx.PrevAlertState = models.AlertStateOK + ctx.Firing = true + + So(handler.GetStateFromEvaluation(ctx), ShouldEqual, models.AlertStateAlerting) + So(ctx.ShouldUpdateAlertState(), ShouldBeTrue) + }) + + Convey("ok -> error(alerting)", func() { + ctx.PrevAlertState = models.AlertStateOK + ctx.Error = dummieError + ctx.Rule.ExecutionErrorState = models.ExecutionErrorSetAlerting + + ctx.Rule.State = handler.GetStateFromEvaluation(ctx) + So(ctx.Rule.State, ShouldEqual, models.AlertStateAlerting) + So(ctx.ShouldUpdateAlertState(), ShouldBeTrue) + }) + + Convey("ok -> error(keep_last)", func() { + ctx.PrevAlertState = models.AlertStateOK + ctx.Error = dummieError + ctx.Rule.ExecutionErrorState = models.ExecutionErrorKeepState + + ctx.Rule.State = handler.GetStateFromEvaluation(ctx) + So(ctx.Rule.State, ShouldEqual, models.AlertStateOK) + So(ctx.ShouldUpdateAlertState(), ShouldBeFalse) + }) + + Convey("pending -> error(keep_last)", func() { + ctx.PrevAlertState = models.AlertStatePending + ctx.Error = dummieError + ctx.Rule.ExecutionErrorState = models.ExecutionErrorKeepState + + ctx.Rule.State = handler.GetStateFromEvaluation(ctx) + So(ctx.Rule.State, ShouldEqual, models.AlertStatePending) + So(ctx.ShouldUpdateAlertState(), ShouldBeFalse) + }) + + Convey("ok -> no_data(alerting)", func() { + ctx.PrevAlertState = models.AlertStateOK + ctx.Rule.NoDataState = models.NoDataSetAlerting + ctx.NoDataFound = true + + ctx.Rule.State = handler.GetStateFromEvaluation(ctx) + So(ctx.Rule.State, ShouldEqual, models.AlertStateAlerting) + So(ctx.ShouldUpdateAlertState(), ShouldBeTrue) + }) + + Convey("ok -> no_data(keep_last)", func() { + ctx.PrevAlertState = models.AlertStateOK + ctx.Rule.NoDataState = models.NoDataKeepState + ctx.NoDataFound = true + + ctx.Rule.State = handler.GetStateFromEvaluation(ctx) + So(ctx.Rule.State, ShouldEqual, models.AlertStateOK) + So(ctx.ShouldUpdateAlertState(), ShouldBeFalse) + }) + + Convey("pending -> no_data(keep_last)", func() { + ctx.PrevAlertState = models.AlertStatePending + ctx.Rule.NoDataState = models.NoDataKeepState + ctx.NoDataFound = true + + ctx.Rule.State = handler.GetStateFromEvaluation(ctx) + So(ctx.Rule.State, ShouldEqual, models.AlertStatePending) + So(ctx.ShouldUpdateAlertState(), ShouldBeFalse) + }) + }) + }) +} diff --git a/pkg/services/alerting/rule.go b/pkg/services/alerting/rule.go index 2ef090717ff..809640ed4a7 100644 --- a/pkg/services/alerting/rule.go +++ b/pkg/services/alerting/rule.go @@ -11,17 +11,18 @@ import ( ) type Rule struct { - Id int64 - OrgId int64 - DashboardId int64 - PanelId int64 - Frequency int64 - Name string - Message string - NoDataState m.NoDataOption - State m.AlertStateType - Conditions []Condition - Notifications []int64 + Id int64 + OrgId int64 + DashboardId int64 + PanelId int64 + Frequency int64 + Name string + Message string + NoDataState m.NoDataOption + ExecutionErrorState m.ExecutionErrorOption + State m.AlertStateType + Conditions []Condition + Notifications []int64 } type ValidationError struct { @@ -77,6 +78,7 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) { model.Frequency = ruleDef.Frequency model.State = ruleDef.State model.NoDataState = m.NoDataOption(ruleDef.Settings.Get("noDataState").MustString("no_data")) + model.ExecutionErrorState = m.ExecutionErrorOption(ruleDef.Settings.Get("executionErrorState").MustString("alerting")) for _, v := range ruleDef.Settings.Get("notifications").MustArray() { jsonModel := simplejson.NewFromAny(v) diff --git a/pkg/services/alerting/rule_test.go b/pkg/services/alerting/rule_test.go index 6144c01d54d..f8761efcd90 100644 --- a/pkg/services/alerting/rule_test.go +++ b/pkg/services/alerting/rule_test.go @@ -10,7 +10,9 @@ import ( type FakeCondition struct{} -func (f *FakeCondition) Eval(context *EvalContext) {} +func (f *FakeCondition) Eval(context *EvalContext) (*ConditionResult, error) { + return &ConditionResult{}, nil +} func TestAlertRuleModel(t *testing.T) { Convey("Testing alert rule", t, func() { diff --git a/pkg/services/notifications/mailer.go b/pkg/services/notifications/mailer.go index 9e54e906d99..4bcce804ee4 100644 --- a/pkg/services/notifications/mailer.go +++ b/pkg/services/notifications/mailer.go @@ -99,7 +99,7 @@ func createDialer() (*gomail.Dialer, error) { tlsconfig.Certificates = []tls.Certificate{cert} } - d := gomail.NewPlainDialer(host, iPort, setting.Smtp.User, setting.Smtp.Password) + d := gomail.NewDialer(host, iPort, setting.Smtp.User, setting.Smtp.Password) d.TLSConfig = tlsconfig return d, nil } diff --git a/pkg/services/notifications/notifications.go b/pkg/services/notifications/notifications.go index 8a13eec2dc8..85bda1643d1 100644 --- a/pkg/services/notifications/notifications.go +++ b/pkg/services/notifications/notifications.go @@ -31,7 +31,6 @@ func Init() error { bus.AddCtxHandler("email", sendEmailCommandHandlerSync) - bus.AddHandler("webhook", sendWebhook) bus.AddCtxHandler("webhook", SendWebhookSync) bus.AddEventListener(signUpStartedHandler) @@ -69,18 +68,6 @@ func SendWebhookSync(ctx context.Context, cmd *m.SendWebhookSync) error { }) } -func sendWebhook(cmd *m.SendWebhook) error { - addToWebhookQueue(&Webhook{ - Url: cmd.Url, - User: cmd.User, - Password: cmd.Password, - Body: cmd.Body, - HttpMethod: cmd.HttpMethod, - }) - - return nil -} - func subjectTemplateFunc(obj map[string]interface{}, value string) string { obj["value"] = value return "" diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index e9f98a04471..7bd65ac4da8 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -233,6 +233,7 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error { "DELETE FROM dashboard_tag WHERE dashboard_id = ? ", "DELETE FROM star WHERE dashboard_id = ? ", "DELETE FROM dashboard WHERE id = ?", + "DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?", } for _, sql := range deletes { diff --git a/pkg/tsdb/graphite/graphite.go b/pkg/tsdb/graphite/graphite.go index 89cea55938f..3d6af5beb5b 100644 --- a/pkg/tsdb/graphite/graphite.go +++ b/pkg/tsdb/graphite/graphite.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "path" + "regexp" "strings" "golang.org/x/net/context/ctxhttp" @@ -49,9 +50,9 @@ func (e *GraphiteExecutor) Execute(ctx context.Context, queries tsdb.QuerySlice, for _, query := range queries { if fullTarget, err := query.Model.Get("targetFull").String(); err == nil { - formData["target"] = []string{fullTarget} + formData["target"] = []string{fixIntervalFormat(fullTarget)} } else { - formData["target"] = []string{query.Model.Get("target").MustString()} + formData["target"] = []string{fixIntervalFormat(query.Model.Get("target").MustString())} } } @@ -141,3 +142,17 @@ func formatTimeRange(input string) string { } return strings.Replace(strings.Replace(input, "m", "min", -1), "M", "mon", -1) } + +func fixIntervalFormat(target string) string { + rMinute := regexp.MustCompile(`'(\d+)m'`) + rMin := regexp.MustCompile("m") + target = rMinute.ReplaceAllStringFunc(target, func(m string) string { + return rMin.ReplaceAllString(m, "min") + }) + rMonth := regexp.MustCompile(`'(\d+)M'`) + rMon := regexp.MustCompile("M") + target = rMonth.ReplaceAllStringFunc(target, func(M string) string { + return rMon.ReplaceAllString(M, "mon") + }) + return target +} diff --git a/pkg/tsdb/graphite/graphite_test.go b/pkg/tsdb/graphite/graphite_test.go index 7a3bba9035b..c1a2736293b 100644 --- a/pkg/tsdb/graphite/graphite_test.go +++ b/pkg/tsdb/graphite/graphite_test.go @@ -1 +1,61 @@ package graphite + +import ( + . "github.com/smartystreets/goconvey/convey" + "testing" +) + +func TestGraphiteFunctions(t *testing.T) { + Convey("Testing Graphite Functions", t, func() { + + Convey("formatting time range for now", func() { + + timeRange := formatTimeRange("now") + So(timeRange, ShouldEqual, "now") + + }) + + Convey("formatting time range for now-1m", func() { + + timeRange := formatTimeRange("now-1m") + So(timeRange, ShouldEqual, "now-1min") + + }) + + Convey("formatting time range for now-1M", func() { + + timeRange := formatTimeRange("now-1M") + So(timeRange, ShouldEqual, "now-1mon") + + }) + + Convey("fix interval format in query for 1m", func() { + + timeRange := fixIntervalFormat("aliasByNode(hitcount(averageSeries(app.grafana.*.dashboards.views.count), '1m'), 4)") + So(timeRange, ShouldEqual, "aliasByNode(hitcount(averageSeries(app.grafana.*.dashboards.views.count), '1min'), 4)") + + }) + + Convey("fix interval format in query for 1M", func() { + + timeRange := fixIntervalFormat("aliasByNode(hitcount(averageSeries(app.grafana.*.dashboards.views.count), '1M'), 4)") + So(timeRange, ShouldEqual, "aliasByNode(hitcount(averageSeries(app.grafana.*.dashboards.views.count), '1mon'), 4)") + + }) + + Convey("should not override query for 1M", func() { + + timeRange := fixIntervalFormat("app.grafana.*.dashboards.views.1M.count") + So(timeRange, ShouldEqual, "app.grafana.*.dashboards.views.1M.count") + + }) + + Convey("should not override query for 1m", func() { + + timeRange := fixIntervalFormat("app.grafana.*.dashboards.views.1m.count") + So(timeRange, ShouldEqual, "app.grafana.*.dashboards.views.1m.count") + + }) + + }) +} diff --git a/pkg/tsdb/influxdb/query_builder.go b/pkg/tsdb/influxdb/query_builder.go index bf0dc084c49..a4b122cbbd2 100644 --- a/pkg/tsdb/influxdb/query_builder.go +++ b/pkg/tsdb/influxdb/query_builder.go @@ -2,6 +2,7 @@ package influxdb import ( "fmt" + "strconv" "strings" "github.com/grafana/grafana/pkg/tsdb" @@ -42,7 +43,18 @@ func (qb *QueryBuilder) renderTags(query *Query) []string { str += " " } - res = append(res, fmt.Sprintf(`%s"%s" %s '%s'`, str, tag.Key, tag.Operator, tag.Value)) + value := tag.Value + nValue, err := strconv.ParseFloat(tag.Value, 64) + + if tag.Operator == "=~" || tag.Operator == "!~" { + value = fmt.Sprintf("%s", value) + } else if err == nil { + value = fmt.Sprintf("%v", nValue) + } else { + value = fmt.Sprintf("'%s'", value) + } + + res = append(res, fmt.Sprintf(`%s"%s" %s %s`, str, tag.Key, tag.Operator, value)) } return res diff --git a/pkg/tsdb/influxdb/query_builder_test.go b/pkg/tsdb/influxdb/query_builder_test.go index 4552f85b28a..d13fe3a3c6f 100644 --- a/pkg/tsdb/influxdb/query_builder_test.go +++ b/pkg/tsdb/influxdb/query_builder_test.go @@ -3,6 +3,8 @@ package influxdb import ( "testing" + "strings" + "github.com/grafana/grafana/pkg/tsdb" . "github.com/smartystreets/goconvey/convey" ) @@ -83,5 +85,23 @@ func TestInfluxdbQueryBuilder(t *testing.T) { So(err, ShouldBeNil) So(rawQuery, ShouldEqual, `Raw query`) }) + + Convey("can render regex tags", func() { + query := &Query{Tags: []*Tag{&Tag{Operator: "=~", Value: "value", Key: "key"}}} + + So(strings.Join(builder.renderTags(query), ""), ShouldEqual, `"key" =~ value`) + }) + + Convey("can render number tags", func() { + query := &Query{Tags: []*Tag{&Tag{Operator: "=", Value: "1", Key: "key"}}} + + So(strings.Join(builder.renderTags(query), ""), ShouldEqual, `"key" = 1`) + }) + + Convey("can render string tags", func() { + query := &Query{Tags: []*Tag{&Tag{Operator: "=", Value: "value", Key: "key"}}} + + So(strings.Join(builder.renderTags(query), ""), ShouldEqual, `"key" = 'value'`) + }) }) } diff --git a/pkg/tsdb/prometheus/prometheus.go b/pkg/tsdb/prometheus/prometheus.go index 2ec03210279..ec5b098cbaf 100644 --- a/pkg/tsdb/prometheus/prometheus.go +++ b/pkg/tsdb/prometheus/prometheus.go @@ -81,6 +81,10 @@ func (e *PrometheusExecutor) Execute(ctx context.Context, queries tsdb.QuerySlic func formatLegend(metric pmodel.Metric, query *PrometheusQuery) string { reg, _ := regexp.Compile(`\{\{\s*(.+?)\s*\}\}`) + if query.LegendFormat == "" { + return metric.String() + } + result := reg.ReplaceAllFunc([]byte(query.LegendFormat), func(in []byte) []byte { labelName := strings.Replace(string(in), "{{", "", 1) labelName = strings.Replace(labelName, "}}", "", 1) @@ -108,10 +112,7 @@ func parseQuery(queries tsdb.QuerySlice, queryContext *tsdb.QueryContext) (*Prom return nil, err } - format, err := queryModel.Model.Get("legendFormat").String() - if err != nil { - return nil, err - } + format := queryModel.Model.Get("legendFormat").MustString("") start, err := queryContext.TimeRange.ParseFrom() if err != nil { @@ -156,9 +157,3 @@ func parseResponse(value pmodel.Value, query *PrometheusQuery) (map[string]*tsdb queryResults["A"] = queryRes return queryResults, nil } - -/* -func resultWithError(result *tsdb.BatchResult, err error) *tsdb.BatchResult { - result.Error = err - return result -}*/ diff --git a/pkg/tsdb/prometheus/prometheus_test.go b/pkg/tsdb/prometheus/prometheus_test.go index a4c38cae582..d66ef75e479 100644 --- a/pkg/tsdb/prometheus/prometheus_test.go +++ b/pkg/tsdb/prometheus/prometheus_test.go @@ -22,5 +22,19 @@ func TestPrometheus(t *testing.T) { So(formatLegend(metric, query), ShouldEqual, "legend backend mobile {{broken}}") }) + + Convey("build full serie name", func() { + metric := map[p.LabelName]p.LabelValue{ + p.LabelName(p.MetricNameLabel): p.LabelValue("http_request_total"), + p.LabelName("app"): p.LabelValue("backend"), + p.LabelName("device"): p.LabelValue("mobile"), + } + + query := &PrometheusQuery{ + LegendFormat: "", + } + + So(formatLegend(metric, query), ShouldEqual, `http_request_total{app="backend", device="mobile"}`) + }) }) } diff --git a/public/app/core/components/grafana_app.ts b/public/app/core/components/grafana_app.ts index 6630fbe9517..ed3425f670f 100644 --- a/public/app/core/components/grafana_app.ts +++ b/public/app/core/components/grafana_app.ts @@ -107,6 +107,53 @@ export function grafanaAppDirective(playlistSrv, contextSrv) { body.addClass(pageClass); } $("#tooltip, .tooltip").remove(); + + // check for kiosk url param + if (data.params.kiosk) { + appEvents.emit('toggle-kiosk-mode'); + } + }); + + // handle kiosk mode + appEvents.on('toggle-kiosk-mode', () => { + body.toggleClass('page-kiosk-mode'); + }); + + // handle in active view state class + var lastActivity = new Date().getTime(); + var activeUser = true; + var inActiveTimeLimit = 60 * 1000; + + function checkForInActiveUser() { + if (!activeUser) { + return; + } + // only go to activity low mode on dashboard page + if (!body.hasClass('page-dashboard')) { + return; + } + + if ((new Date().getTime() - lastActivity) > inActiveTimeLimit) { + activeUser = false; + body.addClass('user-activity-low'); + } + } + + function userActivityDetected() { + lastActivity = new Date().getTime(); + if (!activeUser) { + activeUser = true; + body.removeClass('user-activity-low'); + } + } + + body.mousemove(userActivityDetected); + body.keydown(userActivityDetected); + setInterval(checkForInActiveUser, 1000); + + appEvents.on('toggle-view-mode', () => { + lastActivity = 0; + checkForInActiveUser(); }); // handle document clicks that should hide things @@ -116,6 +163,17 @@ export function grafanaAppDirective(playlistSrv, contextSrv) { return; } + // for stuff that animates, slides out etc, clicking it needs to + // hide it right away + var clickAutoHide = target.closest('[data-click-hide]'); + if (clickAutoHide.length) { + var clickAutoHideParent = clickAutoHide.parent(); + clickAutoHide.detach(); + setTimeout(function() { + clickAutoHideParent.append(clickAutoHide); + }, 100); + } + if (target.parents('.dash-playlist-actions').length === 0) { playlistSrv.stop(); } diff --git a/public/app/core/components/help/help.html b/public/app/core/components/help/help.html new file mode 100644 index 00000000000..8482e9df7d1 --- /dev/null +++ b/public/app/core/components/help/help.html @@ -0,0 +1,48 @@ + diff --git a/public/app/core/components/help/help.ts b/public/app/core/components/help/help.ts new file mode 100644 index 00000000000..3bc9b57077a --- /dev/null +++ b/public/app/core/components/help/help.ts @@ -0,0 +1,66 @@ +/// + +import coreModule from '../../core_module'; +import appEvents from 'app/core/app_events'; + +export class HelpCtrl { + tabIndex: any; + shortcuts: any; + + /** @ngInject */ + constructor(private $scope, $sce) { + this.tabIndex = 0; + this.shortcuts = { + 'Global': [ + {keys: ['g', 'h'], description: 'Go to Home Dashboard'}, + {keys: ['g', 'p'], description: 'Go to Profile'}, + {keys: ['s', 'o'], description: 'Open search'}, + {keys: ['s', 's'], description: 'Open search with starred filter'}, + {keys: ['s', 't'], description: 'Open search in tags view'}, + {keys: ['esc'], description: 'Exit edit/setting views'}, + ], + 'Dashboard': [ + {keys: ['mod+s'], description: 'Save dashboard'}, + {keys: ['mod+h'], description: 'Hide row controls'}, + {keys: ['d', 'r'], description: 'Refresh all panels'}, + {keys: ['d', 's'], description: 'Dashboard settings'}, + {keys: ['d', 'v'], description: 'Toggle in-active / view mode'}, + {keys: ['d', 'k'], description: 'Toggle kiosk mode (hides top nav)'}, + {keys: ['mod+o'], description: 'Toggle shared graph crosshair'}, + ], + 'Focused Panel': [ + {keys: ['e'], description: 'Toggle panel edit view'}, + {keys: ['v'], description: 'Toggle panel fullscreen view'}, + {keys: ['p', 's'], description: 'Open Panel Share Modal'}, + {keys: ['p', 'r'], description: 'Remove Panel'}, + ], + 'Focused Row': [ + {keys: ['r', 'c'], description: 'Collapse Row'}, + {keys: ['r', 'r'], description: 'Remove Row'}, + ], + 'Time Range': [ + {keys: ['t', 'z'], description: 'Zoom out time range'}, + {keys: ['t', ''], description: 'Move time range back'}, + {keys: ['t', ''], description: 'Move time range forward'}, + ], + }; + } + + dismiss() { + appEvents.emit('hide-modal'); + } +} + +export function helpModal() { + return { + restrict: 'E', + templateUrl: 'public/app/core/components/help/help.html', + controller: HelpCtrl, + bindToController: true, + transclude: true, + controllerAs: 'ctrl', + scope: {}, + }; +} + +coreModule.directive('helpModal', helpModal); diff --git a/public/app/core/components/info_popover.ts b/public/app/core/components/info_popover.ts index d370aab9d6d..6b2a300551e 100644 --- a/public/app/core/components/info_popover.ts +++ b/public/app/core/components/info_popover.ts @@ -44,8 +44,9 @@ export function infoPopover() { } }); - scope.$on('$destroy', function() { + var unbind = scope.$on('$destroy', function() { drop.destroy(); + unbind(); }); }); diff --git a/public/app/core/components/scroll/scroll.ts b/public/app/core/components/scroll/scroll.ts new file mode 100644 index 00000000000..914f3ba2dd9 --- /dev/null +++ b/public/app/core/components/scroll/scroll.ts @@ -0,0 +1,211 @@ +// /// +// +// import _ from 'lodash'; +// +// var objectAssign = require('object-assign'); +// var Emitter = require('tiny-emitter'); +// var Lethargy = require('lethargy').Lethargy; +// var support = require('./support'); +// var clone = require('./clone'); +// var bindAll = require('bindall-standalone'); +// var EVT_ID = 'virtualscroll'; +// +// var keyCodes = { +// LEFT: 37, +// UP: 38, +// RIGHT: 39, +// DOWN: 40 +// }; +// +// function VirtualScroll(this: any, options) { +// _.bindAll(this, '_onWheel', '_onMouseWheel', '_onTouchStart', '_onTouchMove', '_onKeyDown'); +// +// this.el = window; +// if (options && options.el) { +// this.el = options.el; +// delete options.el; +// } +// +// this.options = _.assign({ +// mouseMultiplier: 1, +// touchMultiplier: 2, +// firefoxMultiplier: 15, +// keyStep: 120, +// preventTouch: false, +// unpreventTouchClass: 'vs-touchmove-allowed', +// limitInertia: false +// }, options); +// +// if (this.options.limitInertia) this._lethargy = new Lethargy(); +// +// this._emitter = new Emitter(); +// this._event = { +// y: 0, +// x: 0, +// deltaX: 0, +// deltaY: 0 +// }; +// +// this.touchStartX = null; +// this.touchStartY = null; +// this.bodyTouchAction = null; +// } +// +// VirtualScroll.prototype._notify = function(e) { +// var evt = this._event; +// evt.x += evt.deltaX; +// evt.y += evt.deltaY; +// +// this._emitter.emit(EVT_ID, { +// x: evt.x, +// y: evt.y, +// deltaX: evt.deltaX, +// deltaY: evt.deltaY, +// originalEvent: e +// }); +// }; +// +// VirtualScroll.prototype._onWheel = function(e) { +// var options = this.options; +// if (this._lethargy && this._lethargy.check(e) === false) return; +// +// var evt = this._event; +// +// // In Chrome and in Firefox (at least the new one) +// evt.deltaX = e.wheelDeltaX || e.deltaX * -1; +// evt.deltaY = e.wheelDeltaY || e.deltaY * -1; +// +// // for our purpose deltamode = 1 means user is on a wheel mouse, not touch pad +// // real meaning: https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent#Delta_modes +// if(support.isFirefox && e.deltaMode == 1) { +// evt.deltaX *= options.firefoxMultiplier; +// evt.deltaY *= options.firefoxMultiplier; +// } +// +// evt.deltaX *= options.mouseMultiplier; +// evt.deltaY *= options.mouseMultiplier; +// +// this._notify(e); +// }; +// +// VirtualScroll.prototype._onMouseWheel = function(e) { +// if (this.options.limitInertia && this._lethargy.check(e) === false) return; +// +// var evt = this._event; +// +// // In Safari, IE and in Chrome if 'wheel' isn't defined +// evt.deltaX = (e.wheelDeltaX) ? e.wheelDeltaX : 0; +// evt.deltaY = (e.wheelDeltaY) ? e.wheelDeltaY : e.wheelDelta; +// +// this._notify(e); +// }; +// +// VirtualScroll.prototype._onTouchStart = function(e) { +// var t = (e.targetTouches) ? e.targetTouches[0] : e; +// this.touchStartX = t.pageX; +// this.touchStartY = t.pageY; +// }; +// +// VirtualScroll.prototype._onTouchMove = function(e) { +// var options = this.options; +// if(options.preventTouch +// && !e.target.classList.contains(options.unpreventTouchClass)) { +// e.preventDefault(); +// } +// +// var evt = this._event; +// +// var t = (e.targetTouches) ? e.targetTouches[0] : e; +// +// evt.deltaX = (t.pageX - this.touchStartX) * options.touchMultiplier; +// evt.deltaY = (t.pageY - this.touchStartY) * options.touchMultiplier; +// +// this.touchStartX = t.pageX; +// this.touchStartY = t.pageY; +// +// this._notify(e); +// }; +// +// VirtualScroll.prototype._onKeyDown = function(e) { +// var evt = this._event; +// evt.deltaX = evt.deltaY = 0; +// +// switch(e.keyCode) { +// case keyCodes.LEFT: +// case keyCodes.UP: +// evt.deltaY = this.options.keyStep; +// break; +// +// case keyCodes.RIGHT: +// case keyCodes.DOWN: +// evt.deltaY = - this.options.keyStep; +// break; +// +// default: +// return; +// } +// +// this._notify(e); +// }; +// +// VirtualScroll.prototype._bind = function() { +// if(support.hasWheelEvent) this.el.addEventListener('wheel', this._onWheel); +// if(support.hasMouseWheelEvent) this.el.addEventListener('mousewheel', this._onMouseWheel); +// +// if(support.hasTouch) { +// this.el.addEventListener('touchstart', this._onTouchStart); +// this.el.addEventListener('touchmove', this._onTouchMove); +// } +// +// if(support.hasPointer && support.hasTouchWin) { +// this.bodyTouchAction = document.body.style.msTouchAction; +// document.body.style.msTouchAction = 'none'; +// this.el.addEventListener('MSPointerDown', this._onTouchStart, true); +// this.el.addEventListener('MSPointerMove', this._onTouchMove, true); +// } +// +// if(support.hasKeyDown) document.addEventListener('keydown', this._onKeyDown); +// }; +// +// VirtualScroll.prototype._unbind = function() { +// if(support.hasWheelEvent) this.el.removeEventListener('wheel', this._onWheel); +// if(support.hasMouseWheelEvent) this.el.removeEventListener('mousewheel', this._onMouseWheel); +// +// if(support.hasTouch) { +// this.el.removeEventListener('touchstart', this._onTouchStart); +// this.el.removeEventListener('touchmove', this._onTouchMove); +// } +// +// if(support.hasPointer && support.hasTouchWin) { +// document.body.style.msTouchAction = this.bodyTouchAction; +// this.el.removeEventListener('MSPointerDown', this._onTouchStart, true); +// this.el.removeEventListener('MSPointerMove', this._onTouchMove, true); +// } +// +// if(support.hasKeyDown) document.removeEventListener('keydown', this._onKeyDown); +// }; +// +// VirtualScroll.prototype.on = function(cb, ctx) { +// this._emitter.on(EVT_ID, cb, ctx); +// +// var events = this._emitter.e; +// if (events && events[EVT_ID] && events[EVT_ID].length === 1) this._bind(); +// }; +// +// VirtualScroll.prototype.off = function(cb, ctx) { +// this._emitter.off(EVT_ID, cb, ctx); +// +// var events = this._emitter.e; +// if (!events[EVT_ID] || events[EVT_ID].length <= 0) this._unbind(); +// }; +// +// VirtualScroll.prototype.reset = function() { +// var evt = this._event; +// evt.x = 0; +// evt.y = 0; +// }; +// +// VirtualScroll.prototype.destroy = function() { +// this._emitter.off(); +// this._unbind(); +// }; diff --git a/public/app/core/components/search/search.html b/public/app/core/components/search/search.html index e8e2179ce5c..cc021bb69ed 100644 --- a/public/app/core/components/search/search.html +++ b/public/app/core/components/search/search.html @@ -27,17 +27,13 @@
- diff --git a/public/app/core/components/search/search.ts b/public/app/core/components/search/search.ts index a581afe3fd3..bff6d3149f2 100644 --- a/public/app/core/components/search/search.ts +++ b/public/app/core/components/search/search.ts @@ -29,7 +29,7 @@ export class SearchCtrl { this.isOpen = this.ignoreClose; } - openSearch() { + openSearch(evt, payload) { if (this.isOpen) { this.isOpen = false; return; @@ -43,10 +43,21 @@ export class SearchCtrl { this.currentSearchId = 0; this.ignoreClose = true; + if (payload && payload.starred) { + this.query.starred = true; + } + + if (payload && payload.tagsMode) { + return this.$timeout(() => { + this.ignoreClose = false; + this.giveSearchFocus = this.giveSearchFocus + 1; + this.getTags(); + }, 100); + } + this.$timeout(() => { this.ignoreClose = false; this.giveSearchFocus = this.giveSearchFocus + 1; - this.query.query = ''; this.search(); }, 100); } diff --git a/public/app/core/core.ts b/public/app/core/core.ts index d44cbf4dbfb..567df736a4e 100644 --- a/public/app/core/core.ts +++ b/public/app/core/core.ts @@ -42,6 +42,10 @@ import './filters/filters'; import coreModule from './core_module'; import appEvents from './app_events'; import colors from './utils/colors'; +import {assignModelProperties} from './utils/model_utils'; +import {contextSrv} from './services/context_srv'; +import {KeybindingSrv} from './services/keybindingSrv'; +import {helpModal} from './components/help/help'; export { @@ -62,4 +66,8 @@ export { queryPartEditorDirective, WizardFlow, colors, + assignModelProperties, + contextSrv, + KeybindingSrv, + helpModal, }; diff --git a/public/app/core/directives/dash_class.js b/public/app/core/directives/dash_class.js index 013c267fc0f..7ac13a552ec 100644 --- a/public/app/core/directives/dash_class.js +++ b/public/app/core/directives/dash_class.js @@ -10,8 +10,6 @@ function (_, $, coreModule) { return { link: function($scope, elem) { - var lastHideControlsVal; - $scope.onAppEvent('panel-fullscreen-enter', function() { elem.toggleClass('panel-in-fullscreen', true); }); @@ -20,13 +18,13 @@ function (_, $, coreModule) { elem.toggleClass('panel-in-fullscreen', false); }); + var lastHideControlsVal; $scope.$watch('dashboard.hideControls', function() { if (!$scope.dashboard) { return; } - var hideControls = $scope.dashboard.hideControls || $scope.playlist_active; - + var hideControls = $scope.dashboard.hideControls; if (lastHideControlsVal !== hideControls) { elem.toggleClass('hide-controls', hideControls); lastHideControlsVal = hideControls; diff --git a/public/app/core/directives/plugin_component.ts b/public/app/core/directives/plugin_component.ts index 60685fae74e..83c030cb317 100644 --- a/public/app/core/directives/plugin_component.ts +++ b/public/app/core/directives/plugin_component.ts @@ -58,7 +58,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ var componentInfo: any = { name: 'panel-plugin-' + scope.panel.type, bindings: {dashboard: "=", panel: "=", row: "="}, - attrs: {dashboard: "dashboard", panel: "panel", row: "row"}, + attrs: {dashboard: "ctrl.dashboard", panel: "panel", row: "ctrl.row"}, }; var panelElemName = 'panel-' + scope.panel.type; diff --git a/public/app/core/filters/filters.ts b/public/app/core/filters/filters.ts index 6122a010182..63f2bf28bc3 100644 --- a/public/app/core/filters/filters.ts +++ b/public/app/core/filters/filters.ts @@ -60,8 +60,8 @@ coreModule.filter('noXml', function() { coreModule.filter('interpolateTemplateVars', function (templateSrv) { var filterFunc: any = function(text, scope) { var scopedVars; - if (scope.ctrl && scope.ctrl.panel) { - scopedVars = scope.ctrl.panel.scopedVars; + if (scope.ctrl) { + scopedVars = (scope.ctrl.panel || scope.ctrl.row).scopedVars; } else { scopedVars = scope.row.scopedVars; } diff --git a/public/app/core/routes/dashboard_loaders.js b/public/app/core/routes/dashboard_loaders.js index 49e81c23a7e..728b7e0092f 100644 --- a/public/app/core/routes/dashboard_loaders.js +++ b/public/app/core/routes/dashboard_loaders.js @@ -28,10 +28,17 @@ function (coreModule) { coreModule.default.controller('NewDashboardCtrl', function($scope) { $scope.initDashboard({ - meta: { canStar: false, canShare: false }, + meta: { canStar: false, canShare: false, isNew: true }, dashboard: { title: "New dashboard", - rows: [{ height: '250px', panels:[] }] + rows: [ + { + title: 'Dashboard Row', + height: '250px', + panels:[], + isNew: true, + } + ] }, }, $scope); }); diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts new file mode 100644 index 00000000000..880315ed498 --- /dev/null +++ b/public/app/core/services/keybindingSrv.ts @@ -0,0 +1,218 @@ +/// + +import $ from 'jquery'; +import _ from 'lodash'; + +import coreModule from 'app/core/core_module'; +import appEvents from 'app/core/app_events'; + +import Mousetrap from 'mousetrap'; + +export class KeybindingSrv { + helpModal: boolean; + + /** @ngInject */ + constructor( + private $rootScope, + private $modal, + private $location, + private contextSrv, + private $timeout) { + + // clear out all shortcuts on route change + $rootScope.$on('$routeChangeSuccess', () => { + Mousetrap.reset(); + // rebind global shortcuts + this.setupGlobal(); + }); + + this.setupGlobal(); + } + + setupGlobal() { + this.bind(['?', 'h'], this.showHelpModal); + this.bind("g h", this.goToHome); + this.bind("g a", this.openAlerting); + this.bind("g p", this.goToProfile); + this.bind("s s", this.openSearchStarred); + this.bind('s o', this.openSearch); + this.bind('s t', this.openSearchTags); + this.bind('f', this.openSearch); + } + + openSearchStarred() { + this.$rootScope.appEvent('show-dash-search', {starred: true}); + } + + openSearchTags() { + this.$rootScope.appEvent('show-dash-search', {tagsMode: true}); + } + + openSearch() { + this.$rootScope.appEvent('show-dash-search'); + } + + openAlerting() { + this.$location.url("/alerting"); + } + + goToHome() { + this.$location.url("/"); + } + + goToProfile() { + this.$location.url("/profile"); + } + + showHelpModal() { + appEvents.emit('show-modal', {templateHtml: ''}); + } + + bind(keyArg, fn) { + Mousetrap.bind(keyArg, evt => { + evt.preventDefault(); + evt.stopPropagation(); + evt.returnValue = false; + return this.$rootScope.$apply(fn.bind(this)); + }); + } + + showDashEditView(view) { + var search = _.extend(this.$location.search(), {editview: view}); + this.$location.search(search); + } + + setupDashboardBindings(scope, dashboard) { + // this.bind('b', () => { + // dashboard.toggleEditMode(); + // }); + + this.bind('mod+o', () => { + dashboard.sharedCrosshair = !dashboard.sharedCrosshair; + scope.broadcastRefresh(); + }); + + this.bind('mod+h', () => { + dashboard.hideControls = !dashboard.hideControls; + }); + + this.bind('mod+s', e => { + scope.appEvent('save-dashboard'); + }); + + this.bind('t z', () => { + scope.appEvent('zoom-out'); + }); + + this.bind('t left', () => { + scope.appEvent('shift-time-backward'); + }); + + this.bind('t right', () => { + scope.appEvent('shift-time-forward'); + }); + + this.bind('mod+i', () => { + scope.appEvent('quick-snapshot'); + }); + + // edit panel + this.bind('e', () => { + if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) { + this.$rootScope.appEvent('panel-change-view', { + fullscreen: true, + edit: true, + panelId: dashboard.meta.focusPanelId, + toggle: true + }); + } + }); + + // view panel + this.bind('v', () => { + if (dashboard.meta.focusPanelId) { + this.$rootScope.appEvent('panel-change-view', { + fullscreen: true, + edit: null, + panelId: dashboard.meta.focusPanelId, + toggle: true, + }); + } + }); + + // delete panel + this.bind('p r', () => { + if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) { + var panelInfo = dashboard.getPanelInfoById(dashboard.meta.focusPanelId); + panelInfo.row.removePanel(panelInfo.panel); + dashboard.meta.focusPanelId = 0; + } + }); + + // share panel + this.bind('p s', () => { + if (dashboard.meta.focusPanelId) { + var shareScope = scope.$new(); + var panelInfo = dashboard.getPanelInfoById(dashboard.meta.focusPanelId); + shareScope.panel = panelInfo.panel; + shareScope.dashboard = dashboard; + + appEvents.emit('show-modal', { + src: 'public/app/features/dashboard/partials/shareModal.html', + scope: shareScope + }); + } + }); + + // delete row + this.bind('r r', () => { + if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) { + var panelInfo = dashboard.getPanelInfoById(dashboard.meta.focusPanelId); + dashboard.removeRow(panelInfo.row); + dashboard.meta.focusPanelId = 0; + } + }); + + // collapse row + this.bind('r c', () => { + if (dashboard.meta.focusPanelId) { + var panelInfo = dashboard.getPanelInfoById(dashboard.meta.focusPanelId); + panelInfo.row.toggleCollapse(); + dashboard.meta.focusPanelId = 0; + } + }); + + this.bind('d r', () => { + scope.broadcastRefresh(); + }); + + this.bind('d s', () => { + this.showDashEditView('settings'); + }); + + this.bind('d k', () => { + appEvents.emit('toggle-kiosk-mode'); + }); + + this.bind('d v', () => { + appEvents.emit('toggle-view-mode'); + }); + + this.bind('esc', () => { + var popups = $('.popover.in'); + if (popups.length > 0) { + return; + } + // close modals + var modalData = $(".modal").data(); + if (modalData && modalData.$scope && modalData.$scope.dismiss) { + modalData.$scope.dismiss(); + } + + scope.appEvent('hide-dash-editor'); + scope.exitFullscreen(); + }); + } +} + +coreModule.service('keybindingSrv', KeybindingSrv); diff --git a/public/app/core/services/util_srv.ts b/public/app/core/services/util_srv.ts index 8ca7bf8be72..c043211cefc 100644 --- a/public/app/core/services/util_srv.ts +++ b/public/app/core/services/util_srv.ts @@ -8,6 +8,7 @@ import coreModule from 'app/core/core_module'; import appEvents from 'app/core/app_events'; export class UtilSrv { + modalScope: any; /** @ngInject */ constructor(private $rootScope, private $modal) { @@ -15,12 +16,27 @@ export class UtilSrv { init() { appEvents.on('show-modal', this.showModal.bind(this), this.$rootScope); + appEvents.on('hide-modal', this.hideModal.bind(this), this.$rootScope); + } + + hideModal() { + if (this.modalScope && this.modalScope.dismiss) { + this.modalScope.dismiss(); + } } showModal(options) { + if (this.modalScope && this.modalScope.dismiss) { + this.modalScope.dismiss(); + } + + this.modalScope = options.scope; + if (options.model) { - options.scope = this.$rootScope.$new(); - options.scope.model = options.model; + this.modalScope = this.$rootScope.$new(); + this.modalScope.model = options.model; + } else if (!this.modalScope) { + this.modalScope = this.$rootScope.$new(); } var modal = this.$modal({ @@ -29,7 +45,7 @@ export class UtilSrv { templateHtml: options.templateHtml, persist: false, show: false, - scope: options.scope, + scope: this.modalScope, keyboard: false, backdrop: options.backdrop }); diff --git a/public/app/core/utils/emitter.ts b/public/app/core/utils/emitter.ts index 4cdc19e7b20..2b3f8a90eb4 100644 --- a/public/app/core/utils/emitter.ts +++ b/public/app/core/utils/emitter.ts @@ -23,12 +23,17 @@ export class Emitter { this.emitter.on(name, handler); if (scope) { - scope.$on('$destroy', () => { + var unbind = scope.$on('$destroy', () => { this.emitter.off(name, handler); + unbind(); }); } } + removeAllListeners(evt?) { + this.emitter.removeAllListeners(evt); + } + off(name, handler) { this.emitter.off(name, handler); } diff --git a/public/app/core/utils/model_utils.ts b/public/app/core/utils/model_utils.ts new file mode 100644 index 00000000000..3040094b1e3 --- /dev/null +++ b/public/app/core/utils/model_utils.ts @@ -0,0 +1,10 @@ +export function assignModelProperties(target, source, defaults) { + for (var key in defaults) { + if (!defaults.hasOwnProperty(key)) { + continue; + } + + target[key] = source[key] === undefined ? defaults[key] : source[key]; + } +} + diff --git a/public/app/features/admin/partials/settings.html b/public/app/features/admin/partials/settings.html index 5e070f4d3c0..5371670a9ba 100644 --- a/public/app/features/admin/partials/settings.html +++ b/public/app/features/admin/partials/settings.html @@ -7,7 +7,7 @@
- These system settings are defined in grafana.ini or grafana.custom.ini (or overriden in ENV variables). + These system settings are defined in grafana.ini or custom.ini (or overriden in ENV variables). To change these you currently need to restart grafana.
diff --git a/public/app/features/alerting/alert_def.ts b/public/app/features/alerting/alert_def.ts index 8696e593074..8ee1981f3d8 100644 --- a/public/app/features/alerting/alert_def.ts +++ b/public/app/features/alerting/alert_def.ts @@ -34,13 +34,18 @@ var reducerTypes = [ {text: 'max()', value: 'max'}, {text: 'sum()' , value: 'sum'}, {text: 'count()', value: 'count'}, + {text: 'last()', value: 'last'}, ]; var noDataModes = [ - {text: 'OK', value: 'ok'}, {text: 'Alerting', value: 'alerting'}, {text: 'No Data', value: 'no_data'}, - {text: 'Keep Last', value: 'keep_last'}, + {text: 'Keep Last State', value: 'keep_state'}, +]; + +var executionErrorModes = [ + {text: 'Alerting', value: 'alerting'}, + {text: 'Keep Last State', value: 'keep_state'}, ]; function createReducerPart(model) { @@ -48,7 +53,6 @@ function createReducerPart(model) { return new QueryPart(model, def); } - function getStateDisplayModel(state) { switch (state) { case 'ok': { @@ -113,6 +117,7 @@ export default { conditionTypes: conditionTypes, evalFunctions: evalFunctions, noDataModes: noDataModes, + executionErrorModes: executionErrorModes, reducerTypes: reducerTypes, createReducerPart: createReducerPart, joinEvalMatches: joinEvalMatches, diff --git a/public/app/features/alerting/alert_list_ctrl.ts b/public/app/features/alerting/alert_list_ctrl.ts index b2287759a9b..5e3cce35c6e 100644 --- a/public/app/features/alerting/alert_list_ctrl.ts +++ b/public/app/features/alerting/alert_list_ctrl.ts @@ -15,7 +15,6 @@ export class AlertListCtrl { {text: 'OK', value: 'ok'}, {text: 'Alerting', value: 'alerting'}, {text: 'No Data', value: 'no_data'}, - {text: 'Execution Error', value: 'execution_error'}, ]; filters = { diff --git a/public/app/features/alerting/alert_tab_ctrl.ts b/public/app/features/alerting/alert_tab_ctrl.ts index 61c4d658ed1..c882a6ac3bf 100644 --- a/public/app/features/alerting/alert_tab_ctrl.ts +++ b/public/app/features/alerting/alert_tab_ctrl.ts @@ -19,6 +19,7 @@ export class AlertTabCtrl { conditionModels: any; evalFunctions: any; noDataModes: any; + executionErrorModes: any; addNotificationSegment; notifications; alertNotifications; @@ -42,6 +43,7 @@ export class AlertTabCtrl { this.evalFunctions = alertDef.evalFunctions; this.conditionTypes = alertDef.conditionTypes; this.noDataModes = alertDef.noDataModes; + this.executionErrorModes = alertDef.executionErrorModes; this.appSubUrl = config.appSubUrl; } @@ -52,7 +54,7 @@ export class AlertTabCtrl { var thresholdChangedEventHandler = this.graphThresholdChanged.bind(this); this.panelCtrl.events.on('threshold-changed', thresholdChangedEventHandler); - // set panel alert edit mode + // set panel alert edit mode this.$scope.$on("$destroy", () => { this.panelCtrl.events.off("threshold-changed", thresholdChangedEventHandler); this.panelCtrl.editingThresholds = false; @@ -88,6 +90,7 @@ export class AlertTabCtrl { case "email": return "fa fa-envelope"; case "slack": return "fa fa-slack"; case "webhook": return "fa fa-cubes"; + case "pagerduty": return "fa fa-bullhorn"; } } @@ -140,6 +143,7 @@ export class AlertTabCtrl { } alert.noDataState = alert.noDataState || 'no_data'; + alert.executionErrorState = alert.executionErrorState || 'alerting'; alert.frequency = alert.frequency || '60s'; alert.handler = alert.handler || 1; alert.notifications = alert.notifications || []; diff --git a/public/app/features/alerting/partials/alert_list.html b/public/app/features/alerting/partials/alert_list.html index 685aa1f2db8..35305b9bf03 100644 --- a/public/app/features/alerting/partials/alert_list.html +++ b/public/app/features/alerting/partials/alert_list.html @@ -8,6 +8,11 @@ How to add an alert + + + + Configure notifications +
diff --git a/public/app/features/alerting/partials/alert_tab.html b/public/app/features/alerting/partials/alert_tab.html index 4dd5516945b..5b85ec105ae 100644 --- a/public/app/features/alerting/partials/alert_tab.html +++ b/public/app/features/alerting/partials/alert_tab.html @@ -82,7 +82,7 @@
- If no data points or all values are null + If no data or all values are null SET STATE TO
+ +
+
+
+
+ Mention + + + + Mention a user or a group using @ when notifying in a channel
@@ -86,6 +97,14 @@
+
+

Pagerduty settings

+
+ Integration Key + +
+
+
diff --git a/public/app/features/annotations/editor_ctrl.ts b/public/app/features/annotations/editor_ctrl.ts index 3929d7d9a32..cb76045dbec 100644 --- a/public/app/features/annotations/editor_ctrl.ts +++ b/public/app/features/annotations/editor_ctrl.ts @@ -67,8 +67,8 @@ export class AnnotationsEditorCtrl { this.annotations.push(this.currentAnnotation); this.reset(); this.mode = 'list'; - this.$scope.updateSubmenuVisibility(); this.$scope.broadcastRefresh(); + this.$scope.dashboard.updateSubmenuVisibility(); }; removeAnnotation(annotation) { diff --git a/public/app/features/dashboard/all.js b/public/app/features/dashboard/all.js index 3aa298001a5..8359338fa02 100644 --- a/public/app/features/dashboard/all.js +++ b/public/app/features/dashboard/all.js @@ -5,11 +5,9 @@ define([ './dashnav/dashnav', './submenu/submenu', './saveDashboardAsCtrl', - './rowCtrl', './shareModalCtrl', './shareSnapshotCtrl', './dashboard_srv', - './keybindings', './viewStateSrv', './timeSrv', './unsavedChangesSrv', @@ -22,4 +20,6 @@ define([ './export/export_modal', './dash_list_ctrl', './ad_hoc_filters', + './row/row_ctrl', + './repeat_option/repeat_option', ], function () {}); diff --git a/public/app/features/dashboard/dashboard_ctrl.ts b/public/app/features/dashboard/dashboard_ctrl.ts index 649a04474cc..cf72bb87f61 100644 --- a/public/app/features/dashboard/dashboard_ctrl.ts +++ b/public/app/features/dashboard/dashboard_ctrl.ts @@ -13,7 +13,7 @@ export class DashboardCtrl { constructor( private $scope, private $rootScope, - dashboardKeybindings, + keybindingSrv, timeSrv, variableSrv, alertingSrv, @@ -26,7 +26,6 @@ export class DashboardCtrl { $timeout) { $scope.editor = { index: 0 }; - $scope.panels = config.panels; var resizeEventTimeout; @@ -62,9 +61,9 @@ export class DashboardCtrl { $scope.dashboardMeta = dashboard.meta; $scope.dashboardViewState = dashboardViewStateSrv.create($scope); - dashboardKeybindings.shortcuts($scope); + keybindingSrv.setupDashboardBindings($scope, dashboard); - $scope.updateSubmenuVisibility(); + $scope.dashboard.updateSubmenuVisibility(); $scope.setWindowTitleAndTheme(); $scope.appEvent("dashboard-initialized", $scope.dashboard); @@ -94,10 +93,6 @@ export class DashboardCtrl { dynamicDashboardSrv.process(); }; - $scope.updateSubmenuVisibility = function() { - $scope.submenuEnabled = $scope.dashboard.isSubmenuFeaturesEnabled(); - }; - $scope.setWindowTitleAndTheme = function() { window.document.title = config.window_title_prefix + $scope.dashboard.title; }; @@ -106,22 +101,8 @@ export class DashboardCtrl { $rootScope.$broadcast('refresh'); }; - $scope.addRow = function(dash, row) { - dash.rows.push(row); - }; - $scope.addRowDefault = function() { - $scope.resetRow(); - $scope.row.title = 'New row'; - $scope.addRow($scope.dashboard, $scope.row); - }; - - $scope.resetRow = function() { - $scope.row = { - title: '', - height: '250px', - editable: true, - }; + $scope.dashboard.addEmptyRow(); }; $scope.showJsonEditor = function(evt, options) { @@ -131,31 +112,15 @@ export class DashboardCtrl { $scope.appEvent('show-dash-editor', { src: 'public/app/partials/edit_json.html', scope: editScope }); }; - $scope.onDrop = function(panelId, row, dropTarget) { - var info = $scope.dashboard.getPanelInfoById(panelId); - if (dropTarget) { - var dropInfo = $scope.dashboard.getPanelInfoById(dropTarget.id); - dropInfo.row.panels[dropInfo.index] = info.panel; - info.row.panels[info.index] = dropTarget; - var dragSpan = info.panel.span; - info.panel.span = dropTarget.span; - dropTarget.span = dragSpan; - } else { - info.row.panels.splice(info.index, 1); - info.panel.span = 12 - $scope.dashboard.rowSpan(row); - row.panels.push(info.panel); - } - - $rootScope.$broadcast('render'); - }; - $scope.registerWindowResizeEvent = function() { angular.element(window).bind('resize', function() { $timeout.cancel(resizeEventTimeout); resizeEventTimeout = $timeout(function() { $scope.$broadcast('render'); }, 200); }); + $scope.$on('$destroy', function() { angular.element(window).unbind('resize'); + $scope.dashboard.destroy(); }); }; @@ -165,11 +130,10 @@ export class DashboardCtrl { } init(dashboard) { - this.$scope.resetRow(); - this.$scope.registerWindowResizeEvent(); this.$scope.onAppEvent('show-json-editor', this.$scope.showJsonEditor); this.$scope.onAppEvent('template-variable-value-updated', this.$scope.templateVariableUpdated); this.$scope.setupDashboard(dashboard); + this.$scope.registerWindowResizeEvent(); } } diff --git a/public/app/features/dashboard/dashnav/dashnav.html b/public/app/features/dashboard/dashnav/dashnav.html index a9145973cc2..47af178397a 100644 --- a/public/app/features/dashboard/dashnav/dashnav.html +++ b/public/app/features/dashboard/dashnav/dashnav.html @@ -58,6 +58,11 @@
  • View JSON
  • Make Editable
  • Save As...
  • +
  • Delete dashboard
  • diff --git a/public/app/features/dashboard/dashnav/dashnav.ts b/public/app/features/dashboard/dashnav/dashnav.ts index 929cc66a5b0..33bb4411640 100644 --- a/public/app/features/dashboard/dashnav/dashnav.ts +++ b/public/app/features/dashboard/dashnav/dashnav.ts @@ -33,6 +33,10 @@ export class DashNavCtrl { $location.search(search); }; + $scope.showHelpModal = function() { + $scope.appEvent('show-modal', {templateHtml: ''}); + }; + $scope.starDashboard = function() { if ($scope.dashboardMeta.isStarred) { backendSrv.delete('/api/user/stars/dashboard/' + $scope.dashboard.id).then(function() { diff --git a/public/app/features/dashboard/dynamic_dashboard_srv.ts b/public/app/features/dashboard/dynamic_dashboard_srv.ts index a7189627820..9f8fe7f7ab2 100644 --- a/public/app/features/dashboard/dynamic_dashboard_srv.ts +++ b/public/app/features/dashboard/dynamic_dashboard_srv.ts @@ -5,6 +5,7 @@ import angular from 'angular'; import _ from 'lodash'; import coreModule from 'app/core/core_module'; +import {DashboardRow} from './row/row_model'; export class DynamicDashboardSrv { iteration: number; @@ -45,7 +46,7 @@ export class DynamicDashboardSrv { } } else if (row.repeatRowId && row.repeatIteration !== this.iteration) { // clean up old left overs - this.dashboard.rows.splice(i, 1); + this.dashboard.removeRow(row, true); i = i - 1; continue; } @@ -80,12 +81,14 @@ export class DynamicDashboardSrv { row = this.dashboard.rows[i]; if (row.repeatRowId === sourceRowId && row.repeatIteration !== this.iteration) { copy = row; + copy.copyPropertiesFromRowSource(sourceRow); break; } } if (!copy) { - copy = angular.copy(sourceRow); + var modelCopy = angular.copy(sourceRow.getSaveModel()); + copy = new DashboardRow(modelCopy); this.dashboard.rows.splice(sourceRowIndex + repeatIndex, 0, copy); // set new panel ids diff --git a/public/app/features/dashboard/import/dash_import.html b/public/app/features/dashboard/import/dash_import.html index 245e147dd3d..3e914245627 100644 --- a/public/app/features/dashboard/import/dash_import.html +++ b/public/app/features/dashboard/import/dash_import.html @@ -74,8 +74,8 @@
    - -
    @@ -90,6 +90,15 @@
    +
    +
    + +
    +
    +
    - diff --git a/public/app/features/dashboard/repeat_option/repeat_option.ts b/public/app/features/dashboard/repeat_option/repeat_option.ts new file mode 100644 index 00000000000..380c2861a28 --- /dev/null +++ b/public/app/features/dashboard/repeat_option/repeat_option.ts @@ -0,0 +1,34 @@ +/// + +import {coreModule} from 'app/core/core'; + +var template = ` +
    + +
    +
    + +
    +
    +
    + +
    {{panel.name}}
    +
    +
    +
    + +
    diff --git a/public/app/features/dashboard/row/add_panel.ts b/public/app/features/dashboard/row/add_panel.ts new file mode 100644 index 00000000000..3e2dc9e31d8 --- /dev/null +++ b/public/app/features/dashboard/row/add_panel.ts @@ -0,0 +1,118 @@ +/// + +import _ from 'lodash'; + +import config from 'app/core/config'; +import {coreModule, appEvents} from 'app/core/core'; + +export class AddPanelCtrl { + row: any; + dashboard: any; + rowCtrl: any; + allPanels: any; + panelHits: any; + activeIndex: any; + panelSearch: any; + + /** @ngInject */ + constructor(private $scope, private $timeout, private $rootScope) { + this.row = this.rowCtrl.row; + this.dashboard = this.rowCtrl.dashboard; + this.allPanels = _.orderBy(_.map(config.panels, item => item), 'sort'); + this.panelHits = this.allPanels; + this.activeIndex = 0; + } + + keyDown(evt) { + if (evt.keyCode === 27) { + this.rowCtrl.dropView = 0; + return; + } + + if (evt.keyCode === 40 || evt.keyCode === 39) { + this.moveSelection(1); + } + + if (evt.keyCode === 38 || evt.keyCode === 37) { + this.moveSelection(-1); + } + + if (evt.keyCode === 13) { + var selectedPanel = this.panelHits[this.activeIndex]; + if (selectedPanel) { + this.addPanel(selectedPanel); + } + } + } + + panelSearchBlur() { + // this.$timeout(() => { + // this.rowCtrl.dropView = 0; + // }, 400); + } + + moveSelection(direction) { + var max = this.panelHits.length; + var newIndex = this.activeIndex + direction; + this.activeIndex = ((newIndex %= max) < 0) ? newIndex + max : newIndex; + } + + panelSearchChanged() { + var items = this.allPanels.slice(); + var startsWith = []; + var contains = []; + var searchLower = this.panelSearch.toLowerCase(); + var item; + + while (item = items.shift()) { + var nameLower = item.name.toLowerCase(); + if (nameLower.indexOf(searchLower) === 0) { + startsWith.push(item); + } else if (nameLower.indexOf(searchLower) !== -1) { + contains.push(item); + } + } + + this.panelHits = startsWith.concat(contains); + this.activeIndex = 0; + } + + addPanel(panelPluginInfo) { + var defaultSpan = 12; + var span = 12 - this.row.span; + + var panel = { + id: null, + title: config.new_panel_title, + error: false, + span: span < defaultSpan && span > 0 ? span : defaultSpan, + editable: true, + type: panelPluginInfo.id, + isNew: true, + }; + + this.rowCtrl.closeDropView(); + this.dashboard.addPanel(panel, this.row); + this.$timeout(() => { + this.$rootScope.$broadcast('render'); + //this.$rootScope.appEvent('panel-change-view', { + // fullscreen: true, edit: true, panelId: panel.id + //}); + }); + } +} + +export function addPanelDirective() { + return { + restrict: 'E', + templateUrl: 'public/app/features/dashboard/row/add_panel.html', + controller: AddPanelCtrl, + bindToController: true, + controllerAs: 'ctrl', + scope: { + rowCtrl: "=", + }, + }; +} + +coreModule.directive('dashRowAddPanel', addPanelDirective); diff --git a/public/app/features/dashboard/row/options.html b/public/app/features/dashboard/row/options.html new file mode 100644 index 00000000000..f54c88796e0 --- /dev/null +++ b/public/app/features/dashboard/row/options.html @@ -0,0 +1,34 @@ +
    + + + + +
    +
    +
    + Title + +
    +
    +
    + +
    + +
    +
    + + +
    +
    +
    +
    + Height + +
    +
    + Repeat for + +
    +
    +
    +
    diff --git a/public/app/features/dashboard/row/options.ts b/public/app/features/dashboard/row/options.ts new file mode 100644 index 00000000000..8f19b734d8a --- /dev/null +++ b/public/app/features/dashboard/row/options.ts @@ -0,0 +1,37 @@ +/// + +import _ from 'lodash'; + +import config from 'app/core/config'; +import {coreModule} from 'app/core/core'; +// import VirtualScroll from 'virtual-scroll'; +// console.log(VirtualScroll); + +export class RowOptionsCtrl { + row: any; + dashboard: any; + rowCtrl: any; + fontSizes = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; + + /** @ngInject */ + constructor(private $scope, private $timeout, private $rootScope) { + this.row = this.rowCtrl.row; + this.dashboard = this.rowCtrl.dashboard; + this.row.titleSize = this.row.titleSize || 'h6'; + } +} + +export function rowOptionsDirective() { + return { + restrict: 'E', + templateUrl: 'public/app/features/dashboard/row/options.html', + controller: RowOptionsCtrl, + bindToController: true, + controllerAs: 'ctrl', + scope: { + rowCtrl: "=", + }, + }; +} + +coreModule.directive('dashRowOptions', rowOptionsDirective); diff --git a/public/app/features/dashboard/row/row.html b/public/app/features/dashboard/row/row.html new file mode 100644 index 00000000000..3beddaf7844 --- /dev/null +++ b/public/app/features/dashboard/row/row.html @@ -0,0 +1,73 @@ + + +
    + +
    + +
    + +
    + +
    + + +
    + + +
    + +
    +
    +
    +
    +
    +
    +
    + +
    +
    + diff --git a/public/app/features/dashboard/row/row_ctrl.ts b/public/app/features/dashboard/row/row_ctrl.ts new file mode 100644 index 00000000000..f82f91e04af --- /dev/null +++ b/public/app/features/dashboard/row/row_ctrl.ts @@ -0,0 +1,244 @@ +/// + +import _ from 'lodash'; + +import config from 'app/core/config'; +import {coreModule, appEvents} from 'app/core/core'; + +import './options'; +import './add_panel'; + +export class DashRowCtrl { + dashboard: any; + row: any; + dropView: number; + + /** @ngInject */ + constructor(private $scope, private $rootScope, private $timeout) { + this.row.title = this.row.title || 'Row title'; + + if (this.row.isNew) { + this.dropView = 1; + delete this.row.isNew; + } + } + + onDrop(panelId, dropTarget) { + var dragObject; + + // if string it's a panel type + if (_.isString(panelId)) { + // setup new panel + dragObject = { + row: this.row, + panel: { + title: config.new_panel_title, + type: panelId, + id: this.dashboard.getNextPanelId(), + }, + isNew: true, + }; + } else { + dragObject = this.dashboard.getPanelInfoById(panelId); + } + + if (dropTarget) { + dropTarget = this.dashboard.getPanelInfoById(dropTarget.id); + // if draging new panel onto existing panel split it + if (dragObject.isNew) { + dragObject.panel.span = dropTarget.panel.span = dropTarget.panel.span/2; + // insert after + dropTarget.row.panels.splice(dropTarget.index+1, 0, dragObject.panel); + } else if (this.row === dragObject.row) { + // just move element + this.row.movePanel(dragObject.index, dropTarget.index); + } else { + // split drop target space + dragObject.panel.span = dropTarget.panel.span = dropTarget.panel.span/2; + // insert after + dropTarget.row.panels.splice(dropTarget.index+1, 0, dragObject.panel); + // remove from source row + dragObject.row.removePanel(dragObject.panel, false); + } + } else { + dragObject.panel.span = 12 - this.row.span; + this.row.panels.push(dragObject.panel); + + // if not new remove from source row + if (!dragObject.isNew) { + dragObject.row.removePanel(dragObject.panel, false); + } + } + + this.dropView = 0; + this.row.panelSpanChanged(); + this.$timeout(() => { + this.$rootScope.$broadcast('render'); + }); + } + + setHeight(height) { + this.row.height = height; + this.$scope.$broadcast('render'); + } + + moveRow(direction) { + var rowsList = this.dashboard.rows; + var rowIndex = _.indexOf(rowsList, this.row); + var newIndex = rowIndex + direction; + + if (newIndex >= 0 && newIndex <= (rowsList.length - 1)) { + _.move(rowsList, rowIndex, newIndex); + } + } + + toggleCollapse() { + this.closeDropView(); + this.row.collapse = !this.row.collapse; + } + + onMenuAddPanel() { + this.dropView = 1; + } + + onMenuRowOptions() { + this.dropView = 2; + } + + closeDropView() { + this.dropView = 0; + } + + onMenuDeleteRow() { + this.dashboard.removeRow(this.row); + } +} + +coreModule.directive('dashRow', function($rootScope) { + return { + restrict: 'E', + templateUrl: 'public/app/features/dashboard/row/row.html', + controller: DashRowCtrl, + bindToController: true, + controllerAs: 'ctrl', + scope: { + dashboard: "=", + row: "=", + }, + link: function(scope, element) { + scope.$watchGroup(['ctrl.row.collapse', 'ctrl.row.height'], function() { + element.toggleClass('dash-row--collapse', scope.ctrl.row.collapse); + element.find('.panels-wrapper').css({minHeight: scope.ctrl.row.collapse ? '5px' : scope.ctrl.row.height}); + }); + + $rootScope.onAppEvent('panel-fullscreen-enter', function(evt, info) { + var hasPanel = _.find(scope.ctrl.row.panels, {id: info.panelId}); + if (!hasPanel) { + element.hide(); + } + }, scope); + + $rootScope.onAppEvent('panel-fullscreen-exit', function() { + element.show(); + }, scope); + } + }; +}); + +coreModule.directive('panelWidth', function($rootScope) { + return function(scope, element) { + var fullscreen = false; + + function updateWidth() { + if (!fullscreen) { + element[0].style.width = ((scope.panel.span / 1.2) * 10) + '%'; + } + } + + $rootScope.onAppEvent('panel-fullscreen-enter', function(evt, info) { + fullscreen = true; + + if (scope.panel.id !== info.panelId) { + element.hide(); + } else { + element[0].style.width = '100%'; + } + }, scope); + + $rootScope.onAppEvent('panel-fullscreen-exit', function(evt, info) { + fullscreen = false; + + if (scope.panel.id !== info.panelId) { + element.show(); + } + + updateWidth(); + }, scope); + + scope.$watch('panel.span', updateWidth); + + if (fullscreen) { + element.hide(); + } + }; +}); + + +coreModule.directive('panelDropZone', function($timeout) { + return function(scope, element) { + var row = scope.ctrl.row; + var dashboard = scope.ctrl.dashboard; + var indrag = false; + var textEl = element.find('.panel-drop-zone-text'); + + function showPanel(span, text) { + element.find('.panel-container').css('height', row.height); + element[0].style.width = ((span / 1.2) * 10) + '%'; + textEl.text(text); + element.show(); + } + + function hidePanel() { + element.hide(); + } + + function updateState() { + if (row.panels.length === 0 && indrag === false) { + return showPanel(12, 'Empty Space'); + } + + var dropZoneSpan = 12 - row.span; + if (dropZoneSpan > 0) { + if (indrag) { + return showPanel(dropZoneSpan, 'Drop Here'); + } else { + return showPanel(dropZoneSpan, 'Empty Space'); + } + } + + if (indrag === true) { + var dropZoneSpan = 12 - row.span; + if (dropZoneSpan > 1) { + return showPanel(dropZoneSpan, 'Drop Here'); + } + } + + hidePanel(); + } + + row.events.on('span-changed', updateState, scope); + + scope.$on("ANGULAR_DRAG_START", function() { + indrag = true; + updateState(); + }); + + scope.$on("ANGULAR_DRAG_END", function() { + indrag = false; + updateState(); + }); + + updateState(); + }; +}); + diff --git a/public/app/features/dashboard/row/row_model.ts b/public/app/features/dashboard/row/row_model.ts new file mode 100644 index 00000000000..0729d43bf0a --- /dev/null +++ b/public/app/features/dashboard/row/row_model.ts @@ -0,0 +1,122 @@ +/// + +import _ from 'lodash'; +import {Emitter, contextSrv, appEvents, assignModelProperties} from 'app/core/core'; + +export class DashboardRow { + panels: any; + title: any; + showTitle: any; + titleSize: any; + events: Emitter; + span: number; + height: number; + collapse: boolean; + + defaults = { + title: 'Dashboard Row', + panels: [], + showTitle: false, + titleSize: 'h6', + height: 250, + isNew: false, + repeat: null, + repeatRowId: null, + repeatIteration: null, + collapse: false, + }; + + constructor(private model) { + assignModelProperties(this, model, this.defaults); + this.events = new Emitter(); + this.updateRowSpan(); + } + + getSaveModel() { + assignModelProperties(this.model, this, this.defaults); + return this.model; + } + + updateRowSpan() { + this.span = 0; + for (let panel of this.panels) { + this.span += panel.span; + } + } + + panelSpanChanged(alwaysSendEvent?) { + var oldSpan = this.span; + this.updateRowSpan(); + + if (alwaysSendEvent || oldSpan !== this.span) { + this.events.emit('span-changed'); + } + } + + addPanel(panel) { + var rowSpan = this.span; + var panelCount = this.panels.length; + var space = (12 - rowSpan) - panel.span; + + // try to make room of there is no space left + if (space <= 0) { + if (panelCount === 1) { + this.panels[0].span = 6; + panel.span = 6; + } else if (panelCount === 2) { + this.panels[0].span = 4; + this.panels[1].span = 4; + panel.span = 4; + } else if (panelCount === 3) { + this.panels[0].span = 3; + this.panels[1].span = 3; + this.panels[2].span = 3; + panel.span = 3; + } + } + + this.panels.push(panel); + this.events.emit('panel-added', panel); + this.panelSpanChanged(); + } + + removePanel(panel, ask?) { + if (ask !== false) { + appEvents.emit('confirm-modal', { + title: 'Remove Panel', + text: 'Are you sure you want to remove this panel?', + icon: 'fa-trash', + yesText: 'Remove', + onConfirm: () => { + this.removePanel(panel, false); + } + }); + return; + } + + var index = _.indexOf(this.panels, panel); + this.panels.splice(index, 1); + this.events.emit('panel-removed', panel); + this.panelSpanChanged(); + } + + movePanel(fromIndex, toIndex) { + this.panels.splice(toIndex, 0, this.panels.splice(fromIndex, 1)[0]); + } + + destroy() { + this.events.removeAllListeners(); + } + + copyPropertiesFromRowSource(source) { + this.height = source.height; + this.title = source.title; + this.showTitle = source.showTitle; + this.titleSize = source.titleSize; + } + + toggleCollapse() { + this.collapse = !this.collapse; + } +} + diff --git a/public/app/features/dashboard/rowCtrl.js b/public/app/features/dashboard/rowCtrl.js deleted file mode 100644 index a7b57d8b626..00000000000 --- a/public/app/features/dashboard/rowCtrl.js +++ /dev/null @@ -1,201 +0,0 @@ -define([ - 'angular', - 'lodash', - 'app/core/config' -], -function (angular, _, config) { - 'use strict'; - - var module = angular.module('grafana.controllers'); - - module.controller('RowCtrl', function($scope, $rootScope, $timeout) { - var _d = { - title: "Row", - height: "150px", - collapse: false, - editable: true, - panels: [], - }; - - _.defaults($scope.row,_d); - - $scope.init = function() { - $scope.editor = {index: 0}; - }; - - $scope.togglePanelMenu = function(posX) { - $scope.showPanelMenu = !$scope.showPanelMenu; - $scope.panelMenuPos = posX; - }; - - $scope.toggleRow = function(row) { - row.collapse = row.collapse ? false : true; - }; - - $scope.addPanel = function(panel) { - $scope.dashboard.addPanel(panel, $scope.row); - }; - - $scope.deleteRow = function() { - function delete_row() { - $scope.dashboard.rows = _.without($scope.dashboard.rows, $scope.row); - } - - if (!$scope.row.panels.length) { - delete_row(); - return; - } - - $scope.appEvent('confirm-modal', { - title: 'Delete', - text: 'Are you sure you want to delete this row?', - icon: 'fa-trash', - yesText: 'Delete', - onConfirm: function() { - delete_row(); - } - }); - }; - - $scope.editRow = function() { - $scope.appEvent('show-dash-editor', { - src: 'public/app/partials/roweditor.html', - scope: $scope.$new() - }); - }; - - $scope.moveRow = function(direction) { - var rowsList = $scope.dashboard.rows; - var rowIndex = _.indexOf(rowsList, $scope.row); - var newIndex = rowIndex; - switch(direction) { - case 'up': { - newIndex = rowIndex - 1; - break; - } - case 'down': { - newIndex = rowIndex + 1; - break; - } - case 'top': { - newIndex = 0; - break; - } - case 'bottom': { - newIndex = rowsList.length - 1; - break; - } - default: { - newIndex = rowIndex; - } - } - if (newIndex >= 0 && newIndex <= (rowsList.length - 1)) { - _.move(rowsList, rowIndex, newIndex); - } - }; - - $scope.addPanelDefault = function(type) { - var defaultSpan = 12; - var _as = 12 - $scope.dashboard.rowSpan($scope.row); - - var panel = { - title: config.new_panel_title, - error: false, - span: _as < defaultSpan && _as > 0 ? _as : defaultSpan, - editable: true, - type: type, - isNew: true, - }; - - $scope.addPanel(panel); - - $timeout(function() { - $scope.dashboardViewState.update({fullscreen: true, edit: true, panelId: panel.id }); - }); - }; - - $scope.setHeight = function(height) { - $scope.row.height = height; - $scope.$broadcast('render'); - }; - - $scope.init(); - }); - - module.directive('rowHeight', function() { - return function(scope, element) { - scope.$watchGroup(['row.collapse', 'row.height'], function() { - element.css({ minHeight: scope.row.collapse ? '5px' : scope.row.height }); - }); - - scope.onAppEvent('panel-fullscreen-enter', function(evt, info) { - var hasPanel = _.find(scope.row.panels, {id: info.panelId}); - if (!hasPanel) { - element.hide(); - } - }); - - scope.onAppEvent('panel-fullscreen-exit', function() { - element.show(); - }); - }; - }); - - module.directive('panelWidth', function() { - - return function(scope, element) { - var fullscreen = false; - - function updateWidth() { - if (!fullscreen) { - element[0].style.width = ((scope.panel.span / 1.2) * 10) + '%'; - } - } - - scope.onAppEvent('panel-fullscreen-enter', function(evt, info) { - fullscreen = true; - - if (scope.panel.id !== info.panelId) { - element.hide(); - } else { - element[0].style.width = '100%'; - } - }); - - scope.onAppEvent('panel-fullscreen-exit', function(evt, info) { - fullscreen = false; - - if (scope.panel.id !== info.panelId) { - element.show(); - } - - updateWidth(); - }); - - scope.$watch('panel.span', updateWidth); - - if (fullscreen) { - element.hide(); - } - }; - }); - - module.directive('panelDropZone', function() { - return function(scope, element) { - scope.$on("ANGULAR_DRAG_START", function() { - var dropZoneSpan = 12 - scope.dashboard.rowSpan(scope.row); - - if (dropZoneSpan > 0) { - element.find('.panel-container').css('height', scope.row.height); - element[0].style.width = ((dropZoneSpan / 1.2) * 10) + '%'; - element.show(); - } - }); - - scope.$on("ANGULAR_DRAG_END", function() { - element.hide(); - }); - }; - }); - -}); diff --git a/public/app/features/dashboard/shareModalCtrl.js b/public/app/features/dashboard/shareModalCtrl.js index d0de3dbb4a9..77a6cfb21fc 100644 --- a/public/app/features/dashboard/shareModalCtrl.js +++ b/public/app/features/dashboard/shareModalCtrl.js @@ -29,7 +29,7 @@ function (angular, _, require, config) { $scope.tabs.push({title: 'Snapshot', src: 'shareSnapshot.html'}); } - if (!$scope.dashboard.meta.isSnapshot) { + if (!$scope.dashboard.meta.isSnapshot && !$scope.modeSharePanel) { $scope.tabs.push({title: 'Export', src: 'shareExport.html'}); } diff --git a/public/app/features/dashboard/specs/dashboard_srv_specs.ts b/public/app/features/dashboard/specs/dashboard_srv_specs.ts index b9426cc07fb..520216f18ec 100644 --- a/public/app/features/dashboard/specs/dashboard_srv_specs.ts +++ b/public/app/features/dashboard/specs/dashboard_srv_specs.ts @@ -51,18 +51,13 @@ describe('dashboardSrv', function() { dashboard = _dashboardSrv.create({}); }); - it('row span should sum spans', function() { - var spanLeft = dashboard.rowSpan({ panels: [{ span: 2 }, { span: 3 }] }); - expect(spanLeft).to.be(5); - }); - it('adding default should split span in half', function() { - dashboard.rows = [{ panels: [{ span: 12, id: 7 }] }]; - dashboard.addPanel({span: 4}, dashboard.rows[0]); + dashboard.addEmptyRow(); + dashboard.rows[0].addPanel({span: 12}); + dashboard.rows[0].addPanel({span: 12}); expect(dashboard.rows[0].panels[0].span).to.be(6); expect(dashboard.rows[0].panels[1].span).to.be(6); - expect(dashboard.rows[0].panels[1].id).to.be(8); }); it('duplicate panel should try to add it to same row', function() { diff --git a/public/app/features/dashboard/specs/exporter_specs.ts b/public/app/features/dashboard/specs/exporter_specs.ts index 16430f13916..fd3973206d1 100644 --- a/public/app/features/dashboard/specs/exporter_specs.ts +++ b/public/app/features/dashboard/specs/exporter_specs.ts @@ -3,6 +3,7 @@ import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/co import _ from 'lodash'; import config from 'app/core/config'; import {DashboardExporter} from '../export/exporter'; +import {DashboardModel} from '../model'; describe('given dashboard with repeated panels', function() { var dash, exported; @@ -77,6 +78,7 @@ describe('given dashboard with repeated panels', function() { info: {version: "1.1.0"} }; + dash = new DashboardModel(dash, {}); var exporter = new DashboardExporter(datasourceSrvStub); exporter.makeExportable(dash).then(clean => { exported = clean; diff --git a/public/app/features/dashboard/specs/row_model_specs.ts b/public/app/features/dashboard/specs/row_model_specs.ts new file mode 100644 index 00000000000..d7f573b485a --- /dev/null +++ b/public/app/features/dashboard/specs/row_model_specs.ts @@ -0,0 +1,10 @@ +import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common'; + +import _ from 'lodash'; +import {DashboardRow} from '../row/row_model'; + +describe('DashboardRow', function() { + +}); + + diff --git a/public/app/features/dashboard/submenu/submenu.html b/public/app/features/dashboard/submenu/submenu.html index 04bbba2f59d..4b8cd3e1e2b 100644 --- a/public/app/features/dashboard/submenu/submenu.html +++ b/public/app/features/dashboard/submenu/submenu.html @@ -1,4 +1,5 @@