Merge branch 'master' into develop

This commit is contained in:
Torkel Ödegaard
2017-10-09 16:01:54 +02:00
176 changed files with 5940 additions and 2717 deletions

View File

@@ -1,13 +1,6 @@
# http://editorconfig.org
root = true
[*.go]
indent_style = tab
indent_size = 4
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*]
indent_style = space
indent_size = 2
@@ -15,5 +8,12 @@ charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.go]
indent_style = tab
indent_size = 4
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

8
.gitignore vendored
View File

@@ -41,9 +41,17 @@ profile.cov
.notouch
/pkg/cmd/grafana-cli/grafana-cli
/pkg/cmd/grafana-server/grafana-server
/pkg/cmd/grafana-server/debug
/examples/*/dist
/packaging/**/*.rpm
/packaging/**/*.deb
# Ignore OSX indexing
.DS_Store
/vendor/**/*.py
/vendor/**/*.xml
/vendor/**/*.yml
/vendor/**/*_test.go
/vendor/**/.editorconfig
/vendor/**/appengine*

View File

@@ -19,12 +19,19 @@
* **Prometheus**: Add support for instant queries [#5765](https://github.com/grafana/grafana/issues/5765), thx [@mtanda](https://github.com/mtanda)
* **Cloudwatch**: Add support for alerting using the cloudwatch datasource [#8050](https://github.com/grafana/grafana/pull/8050), thx [@mtanda](https://github.com/mtanda)
* **Pagerduty**: Include triggering series in pagerduty notification [#8479](https://github.com/grafana/grafana/issues/8479), thx [@rickymoorhouse](https://github.com/rickymoorhouse)
* **Timezone**: Time ranges like Today & Yesterday now work correctly when timezone setting is set to UTC [#8916](https://github.com/grafana/grafana/issues/8916), thx [@ctide](https://github.com/ctide)
* **Prometheus**: Align $__interval with the step parameters. [#9226](https://github.com/grafana/grafana/pull/9226), thx [@alin-amana](https://github.com/alin-amana)
* **Prometheus**: Autocomplete for label name and label value [#9208](https://github.com/grafana/grafana/pull/9208), thx [@mtanda](https://github.com/mtanda)
## Minor
* **SMTP**: Make it possible to set specific EHLO for smtp client. [#9319](https://github.com/grafana/grafana/issues/9319)
* **Dataproxy**: Allow grafan to renegotiate tls connection [#9250](https://github.com/grafana/grafana/issues/9250)
* **HTTP**: set net.Dialer.DualStack to true for all http clients [#9367](https://github.com/grafana/grafana/pull/9367)
* **Alerting**: Add diff and percent diff as series reducers [#9386](https://github.com/grafana/grafana/pull/9386), thx [@shanhuhai5739](https://github.com/shanhuhai5739)
* **Slack**: Allow images to be uploaded to slack when Token is precent [#7175](https://github.com/grafana/grafana/issues/7175), thx [@xginn8](https://github.com/xginn8)
* **Opsgenie**: Use their latest API instead of old version [#9399](https://github.com/grafana/grafana/pull/9399), thx [@cglrkn](https://github.com/cglrkn)
* **Table**: Add support for displaying the timestamp with milliseconds [#9429](https://github.com/grafana/grafana/pull/9429), thx [@s1061123](https://github.com/s1061123)
* **Hipchat**: Add metrics, message and image to hipchat notifications [#9110](https://github.com/grafana/grafana/issues/9110), thx [@eloo](https://github.com/eloo)
## Tech
* **Go**: Grafana is now built using golang 1.9

View File

@@ -45,7 +45,7 @@ yarn install --pure-lockfile
npm run build
```
To rebuild frontend assets (typesript, sass etc) as you change them start the watcher via.
To rebuild frontend assets (typescript, sass etc) as you change them start the watcher via.
```bash
npm run watch

View File

@@ -17,7 +17,7 @@ But it will give you an idea of our current vision and plan.
### Long term
- Backend plugins to support more Auth options, Alerting data sources & notifications
- Universial time series transformations for any data source (meta queries)
- Universal time series transformations for any data source (meta queries)
- Reporting
- Web socket & live data streams
- Migrate to Angular2 or react

View File

@@ -7,7 +7,7 @@ clone_folder: c:\gopath\src\github.com\grafana\grafana
environment:
nodejs_version: "6"
GOPATH: c:\gopath
GOVERSION: 1.9
GOVERSION: 1.9.1
install:
- rmdir c:\go /s /q

View File

@@ -9,7 +9,7 @@ machine:
GOPATH: "/home/ubuntu/.go_workspace"
ORG_PATH: "github.com/grafana"
REPO_PATH: "${ORG_PATH}/grafana"
GODIST: "go1.9.linux-amd64.tar.gz"
GODIST: "go1.9.1.linux-amd64.tar.gz"
post:
- mkdir -p ~/download
- mkdir -p ~/docker

View File

@@ -479,6 +479,7 @@ provider =
bucket_url =
bucket =
region =
path =
access_key =
secret_key =

View File

@@ -424,6 +424,7 @@
[external_image_storage.s3]
;bucket =
;region =
;path =
;access_key =
;secret_key =

View File

@@ -41,7 +41,7 @@ then there are two flags that can be used to set homepath and the config file pa
If you have not lost the admin password then it is better to set in the Grafana UI. If you need to set the password in a script then the [Grafana API](http://docs.grafana.org/http_api/user/#change-password) can be used. Here is an example with curl using basic auth:
```
```bash
curl -X PUT -H "Content-Type: application/json" -d '{
"oldPassword": "admin",
"newPassword": "newpass",

View File

@@ -48,12 +48,15 @@ external image destination if available or fallback to attaching the image in th
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.
in the slack messages you have to configure either the [external image destination](#external-image-store) in Grafana,
or a bot integration via Slack Apps. Follow Slack's guide to set up a bot integration and use the token provided
https://api.slack.com/bot-users, which starts with "xoxb".
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
Token | If provided, Grafana will upload the generated image via Slack's file.upload API method, not the external image destination.
### PagerDuty

View File

@@ -50,11 +50,12 @@ Create a file at `~/.aws/credentials`. That is the `HOME` path for user running
Example content:
```bash
[default]
aws_access_key_id = asdsadasdasdasd
aws_secret_access_key = dasdasdsadasdasdasdsa
region = us-west-2
```
## Metric Query Editor
@@ -117,7 +118,9 @@ Filters syntax:
Example `ec2_instance_attribute()` query
```javascript
ec2_instance_attribute(us-east-1, InstanceId, { "tag:Environment": [ "production" ] })
```
### Selecting Attributes
@@ -156,7 +159,9 @@ Tags can be selected by prepending the tag name with `Tags.`
Example `ec2_instance_attribute()` query
```javascript
ec2_instance_attribute(us-east-1, Tags.Name, { "tag:Team": [ "sysops" ] })
```
## Cost

View File

@@ -38,8 +38,10 @@ Proxy access means that the Grafana backend will proxy all requests from the bro
If you select direct access you must update your Elasticsearch configuration to allow other domains to access
Elasticsearch from the browser. You do this by specifying these to options in your **elasticsearch.yml** config file.
```bash
http.cors.enabled: true
http.cors.allow-origin: "*"
```
### Index settings

View File

@@ -56,44 +56,46 @@ options.
{{< docs-imagebox img="/img/docs/v43/graph_axes_grid_options.png" max-width= "900px" >}}
The Axes tab controls the display of axes, grids and legend. The ``Left Y`` and ``Right Y`` can be customized using:
The Axes tab controls the display of axes, grids and legend. The **Left Y** and **Right Y** can be customized using:
- ``Unit`` - The display unit for the Y value
- ``Grid Max`` - The maximum Y value. (default auto)
- ``Grid Min`` - The minimum Y value. (default auto)
- ``Label`` - The Y axis label (default "")
- **Unit** - The display unit for the Y value
- **Scale** -
- **Y-Min** - The minimum Y value. (default auto)
- **Y-Max** - The maximum Y value. (default auto)
- **Label** - The Y axis label (default "")
Axes can also be hidden by unchecking the appropriate box from `Show Axis`.
Axes can also be hidden by unchecking the appropriate box from **Show**.
### X-Axis Mode
There are three options:
- The default option is `Time` and means the x-axis represents time and that the data is grouped by time (for example, by hour or by minute).
- The default option is **Time** and means the x-axis represents time and that the data is grouped by time (for example, by hour or by minute).
- The `Series` option means that the data is grouped by series and not by time. The y-axis still represents the value.
- The **Series** option means that the data is grouped by series and not by time. The y-axis still represents the value.
{{< docs-imagebox img="/img/docs/v45/graph-x-axis-mode-series.png" max-width="700px">}}
- The `Histogram` option converts the graph into a histogram. A Histogram is a kind of bar chart that groups numbers into ranges, often called buckets or bins. Taller bars show that more data falls in that range. Histograms and buckets are described in more detail [here](http://docs.grafana.org/features/panels/heatmap/#histograms-and-buckets).
- The **Histogram** option converts the graph into a histogram. A Histogram is a kind of bar chart that groups numbers into ranges, often called buckets or bins. Taller bars show that more data falls in that range. Histograms and buckets are described in more detail [here](http://docs.grafana.org/features/panels/heatmap/#histograms-and-buckets).
<img src="/img/docs/v43/heatmap_histogram.png" class="no-shadow">
### Legend
The legend hand be hidden by checking the ``Show`` checkbox. If it's shown, it can be
displayed as a table of values by checking the ``Table`` checkbox. Series with no
values can be hidden from the legend using the ``Hide empty`` checkbox.
The legend hand be hidden by checking the **Show** checkbox. If it's shown, it can be
displayed as a table of values by checking the **Table** checkbox. Series with no
values can be hidden from the legend using the **Hide empty** checkbox.
### Legend Values
Additional values can be shown along-side the legend names:
- ``Total`` - Sum of all values returned from metric query
- ``Current`` - Last value returned from the metric query
- ``Min`` - Minimum of all values returned from metric query
- ``Max`` - Maximum of all values returned from the metric query
- ``Avg`` - Average of all values returned from metric query
- ``Decimals`` - Controls how many decimals are displayed for legend values (and graph hover tooltips)
- **Total** - Sum of all values returned from metric query
- **Current** - Last value returned from the metric query
- **Min** - Minimum of all values returned from metric query
- **Max** - Maximum of all values returned from the metric query
- **Avg** - Average of all values returned from metric query
- **Decimals** - Controls how many decimals are displayed for legend values (and graph hover tooltips)
The legend values are calculated client side by Grafana and depend on what type of
aggregation or point consolidation your metric query is using. All the above legend values cannot
@@ -115,23 +117,23 @@ the graph crosses a particular threshold.
### Chart Options
- ``Bar`` - Display values as a bar chart
- ``Lines`` - Display values as a line graph
- ``Points`` - Display points for values
- **Bar** - Display values as a bar chart
- **Lines** - Display values as a line graph
- **Points** - Display points for values
### Line Options
- ``Line Fill`` - Amount of color fill for a series. 0 is none.
- ``Line Width`` - The width of the line for a series.
- ``Null point mode`` - How null values are displayed
- ``Staircase line`` - Draws adjacent points as staircase
- **Line Fill** - Amount of color fill for a series. 0 is none.
- **Line Width** - The width of the line for a series.
- **Null point mode** - How null values are displayed
- **Staircase line** - Draws adjacent points as staircase
### Multiple Series
If there are multiple series, they can be displayed as a group.
- ``Stack`` - Each series is stacked on top of another
- ``Percent`` - Each series is drawn as a percentage of the total of all series
- **Stack** - Each series is stacked on top of another
- **Percent** - Each series is drawn as a percentage of the total of all series
If you have stack enabled, you can select what the mouse hover feature should show.
@@ -140,12 +142,12 @@ If you have stack enabled, you can select what the mouse hover feature should sh
### Rendering
- ``Flot`` - Render the graphs in the browser using Flot (default)
- ``Graphite PNG`` - Render the graph on the server using graphite's render API.
- **Flot** - Render the graphs in the browser using Flot (default)
- **Graphite PNG** - Render the graph on the server using graphite's render API.
### Tooltip
- ``All series`` - Show all series on the same tooltip and a x crosshairs to help follow all series
- **All series** - Show all series on the same tooltip and a x crosshairs to help follow all series
### Series Specific Overrides
@@ -158,12 +160,6 @@ There is an option under Series overrides to draw lines as dashes. Set Dashes to
## Time Range
<<<<<<< HEAD
The time range tab allows you to override the dashboard time range and specify a panel specific time. Either through a relative from now time option or through a timeshift.
{{< docs-imagebox img="/img/docs/v45/graph-time-range.png" max-width= "900px" >}}
||||||| merged common ancestors
![](/img/docs/v2/graph_time_range.png)
=======
<img src="/img/docs/v45/graph-time-range.png" class="no-shadow">
>>>>>>> 0a65100eaf64cd57b38110001bf614630821610c

View File

@@ -22,20 +22,20 @@ The singlestat panel has a normal query editor to allow you define your exact me
{{< docs-imagebox img="/img/docs/v45/singlestat-value-options.png" class="docs-image--no-shadow" max-width="900px" >}}
1. `Stats`: The Stats field let you set the function (min, max, average, current, total, first, delta, range) that your entire query is reduced into a single value with. This reduces the entire query into a single summary value that is displayed.
* `min` - The smallest value in the series
* `max` - The largest value in the series
* `avg` - The average of all the non-null values in the series
* `current` - The last value in the series. If the series ends on null the previous value will be used.
* `total` - The sum of all the non-null values in the series
* `first` - The first value in the series
* `delta` - The total incremental increase (of a counter) in the series. An attempt is made to account for counter resets, but this will only be accurate for single instance metrics. Used to show total counter increase in time series.
* `diff` - The difference betwen 'current' (last value) and 'first'.
* `range` - The difference between 'min' and 'max'. Useful the show the range of change for a gauge.
2. `Prefix/Postfix`: The Prefix/Postfix fields let you define a custom label to appear *before/after* the value. The `$__name` variable can be used here to use the series name or alias from the metric query.
3. `Units`: Units are appended to the the Singlestat within the panel, and will respect the color and threshold settings for the value.
4. `Decimals`: The Decimal field allows you to override the automatic decimal precision, and set it explicitly.
5. `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.
1. **Stats**: The Stats field let you set the function (min, max, average, current, total, first, delta, range) that your entire query is reduced into a single value with. This reduces the entire query into a single summary value that is displayed.
* **min** - The smallest value in the series
* **max** - The largest value in the series
* **avg** - The average of all the non-null values in the series
* **current** - The last value in the series. If the series ends on null the previous value will be used.
* **total** - The sum of all the non-null values in the series
* **first** - The first value in the series
* **delta** - The total incremental increase (of a counter) in the series. An attempt is made to account for counter resets, but this will only be accurate for single instance metrics. Used to show total counter increase in time series.
* **diff** - The difference betwen 'current' (last value) and 'first'.
* **range** - The difference between 'min' and 'max'. Useful the show the range of change for a gauge.
2. **Prefix/Postfix**: The Prefix/Postfix fields let you define a custom label to appear *before/after* the value. The `$__name` variable can be used here to use the series name or alias from the metric query.
3. **Units**: Units are appended to the the Singlestat within the panel, and will respect the color and threshold settings for the value.
4. **Decimals**: The Decimal field allows you to override the automatic decimal precision, and set it explicitly.
5. **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.
### Coloring
@@ -43,11 +43,11 @@ The coloring options of the Singlestat Panel config allow you to dynamically cha
{{< docs-imagebox img="/img/docs/v45/singlestat-color-options.png" max-width="500px" class="docs-image--right docs-image--no-shadow">}}
1. `Background`: This checkbox applies the configured thresholds and colors to the entirety of the Singlestat Panel background.
2. `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.
3. `Colors`: Select a color and opacity
4. `Value`: This checkbox applies the configured thresholds and colors to the summary stat.
5. `Invert order`: This link toggles the threshold color order.</br>For example: Green, Orange, Red (<img class="no-shadow" src="/img/docs(v1/gyr.png">) will become Red, Orange, Green (<img class="no-shadow" src="/img/docs/v1/ryg.png">).
1. **Background**: This checkbox applies the configured thresholds and colors to the entirety of the Singlestat Panel background.
2. **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.
3. **Colors**: Select a color and opacity
4. **Value**: This checkbox applies the configured thresholds and colors to the summary stat.
5. **Invert order**: This link toggles the threshold color order.</br>For example: Green, Orange, Red (<img class="no-shadow" src="/img/docs(v1/gyr.png">) will become Red, Orange, Green (<img class="no-shadow" src="/img/docs/v1/ryg.png">).
### Spark Lines
@@ -55,62 +55,35 @@ Sparklines are a great way of seeing the historical data related to the summary
{{< docs-imagebox img="/img/docs/v45/singlestat-spark-options.png" max-width="500px" class="docs-image--right docs-image--no-shadow">}}
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. `Full Height`: Check if you want the sparklines to take up the full panel height, or uncheck if they should be below the main Singlestat value.
3. `Line Color`: This color selection applies to the color of the sparkline itself.
4. `Fill Color`: This color selection applies to the area below the sparkline.
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. **Full Height**: Check if you want the sparklines to take up the full panel height, or uncheck if they should be below the main Singlestat value.
3. **Line Color**: This color selection applies to the color of the sparkline itself.
4. **Fill Color**: This color selection applies to the area below the sparkline.
<div class="clearfix"></div>
> ***Pro-tip:*** Reduce the opacity on fill colors for nice looking panels.
<<<<<<< HEAD
### Gauge
Gauges gives a clear picture of how high a value is in it's context. It's a great way to see if a value is close to the thresholds. The gauge uses the colors set in the color options.
{{< docs-imagebox img="/img/docs/v45/singlestat-gauge-options.png" max-width="500px" class="docs-image--right docs-image--no-shadow">}}
1. `Show`: The show checkbox will toggle wether the gauge is shown in the panel. When unselected, only the Singlestat value will appear.
2. `Min/Max`: This sets the start and end point for the gauge.
3. `Threshold Labels`: Check if you want to show the threshold labels. Thresholds are set in the color options.
4. `Threshold Markers`: Check if you want to have a second meter showing the thresholds.
1. **Show**: The show checkbox will toggle wether the gauge is shown in the panel. When unselected, only the Singlestat value will appear.
2. **Min/Max**: This sets the start and end point for the gauge.
3. **Threshold Labels**: Check if you want to show the threshold labels. Thresholds are set in the color options.
4. **Threshold Markers**: Check if you want to have a second meter showing the thresholds.
<div class="clearfix"></div>
||||||| merged common ancestors
=======
### Gauge
Gauges gives a clear picture of how high a value is in it's context. It's a great way to see if a value is close to the thresholds. The gauge uses the colors set in the color options.
{{< docs-imagebox img="/img/docs/v45/singlestat-gauge-options.png" max-width="500px" class="docs-image--right docs-image--no-shadow">}}
1. `Show`: The show checkbox will toggle wether the gauge is shown in the panel. When unselected, only the Singlestat value will appear.
2. `Min/Max`: This sets the start and end point for the gauge.
3. `Threshold Labels`: Check if you want to show the threshold labels. Thresholds are set in the color options.
4. `Threshold Markers`: Check if you want to have a second meter showing the thresholds.
>>>>>>> 0a65100eaf64cd57b38110001bf614630821610c
### Value to text mapping
<<<<<<< HEAD
{{< docs-imagebox img="/img/docs/v45/singlestat-value-mapping.png" class="docs-image--right docs-image--no-shadow">}}
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.
||||||| merged common ancestors
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.
=======
{{< docs-imagebox img="/img/docs/v45/singlestat-value-mapping.png" class="docs-image--right docs-image--no-shadow">}}
>>>>>>> 0a65100eaf64cd57b38110001bf614630821610c
<<<<<<< HEAD
<div class="clearfix"></div>
||||||| merged common ancestors
<img class="no-shadow" src="/img/docs/v1/Singlestat-ValueMapping.png">
=======
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.
>>>>>>> 0a65100eaf64cd57b38110001bf614630821610c
## Troubleshooting

View File

@@ -25,8 +25,8 @@ The table panel has many ways to manipulate your data for optimal presentation.
{{< docs-imagebox img="/img/docs/v45/table_options.png" class="docs-image--no-shadow" max-width= "500px" >}}
1. `Data`: Control how your query is transformed into a table.
2. `Paging`: Table display options.
1. **Data**: Control how your query is transformed into a table.
2. **Paging**: Table display options.
## Data to Table
@@ -43,20 +43,20 @@ you want in the table. Only applicable for some transforms.
{{< docs-imagebox img="/img/docs/v45/table_ts_to_rows.png" >}}
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.
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
{{< docs-imagebox img="/img/docs/v45/table_ts_to_columns.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.
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
{{< docs-imagebox img="/img/docs/v45/table_ts_to_aggregations.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.
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
@@ -70,7 +70,7 @@ mode then any queries you have in the metrics tab will be ignored.
{{< docs-imagebox img="/img/docs/v45/table_json_data.png" max-width="500px" >}}
If you have an Elasticsearch **Raw Document** query or an Elasticsearch query without a `date histogram` use this
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.
@@ -80,9 +80,9 @@ transform mode and pick the columns using the **Columns** section.
{{< docs-imagebox img="/img/docs/v45/table_paging.png" class="docs-image--no-shadow docs-image--right" max-width="350px" >}}
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.
3. `Font Size`: The `font size` field allows you to increase or decrease the size for the panel, relative to the default font size.
1. **Rows Per Page**: The table display fields allow you to control how many rows per page there should be. For example, if your table had 95 records with a rows per page value of 10, your table would be split across 10 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.
3. **Font Size**: The font size field allows you to increase or decrease the size for the panel, relative to the default font size.
## Column Styles
@@ -91,9 +91,9 @@ The column styles allow you control how dates and numbers are formatted.
{{< docs-imagebox img="/img/docs/v45/table_column_styles.png" class="docs-image--no-shadow" >}}
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. `Column Header`: Title for the column, when using a Regex the title can include replacement strings like `$1`.
3. `Add column style rule`: Add new column rule.
4. `Thresholds` and `Coloring`: Specify color mode and thresholds limits.
5. `Type`: The three supported types of types are `Number`, `String` and `Date`. `Unit` and `Decimals`: Specify unit and decimal precision for numbers.`Format`: Specify date format for dates.
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. **Column Header**: Title for the column, when using a Regex the title can include replacement strings like `$1`.
3. **Add column style rule**: Add new column rule.
4. **Thresholds and Coloring**: Specify color mode and thresholds limits.
5. **Type**: The three supported types of types are **Number**, **String** and **Date**. **Unit** and **Decimals**: Specify unit and decimal precision for numbers. **Format**: Specify date format for dates.

View File

@@ -18,6 +18,6 @@ The text panel lets you make information and description panels etc. for your da
{{< docs-imagebox img="/img/docs/v45/text-options.png" max-width="600px" class="docs-image--no-shadow">}}
1. `Mode`: Here you can choose between markdown, HTML or text.
2. `Content`: Here you write your content.
1. **Mode**: Here you can choose between markdown, HTML or text.
2. **Content**: Here you write your content.

View File

@@ -23,12 +23,15 @@ Only works with Basic Authentication (username and password). See [introduction]
**Example Request**:
```bash
GET /api/admin/settings
Accept: application/json
Content-Type: application/json
```
**Example Response**:
```bash
HTTP/1.1 200
Content-Type: application/json
@@ -166,7 +169,8 @@ Only works with Basic Authentication (username and password). See [introduction]
"key_file":"",
"password":"************",
"skip_verify":"false",
"user":""},
"user":""
},
"users":{
"allow_org_create":"true",
"allow_sign_up":"false",
@@ -174,7 +178,7 @@ Only works with Basic Authentication (username and password). See [introduction]
"auto_assign_org_role":"Viewer"
}
}
```
## Grafana Stats
`GET /api/admin/stats`
@@ -183,12 +187,15 @@ Only works with Basic Authentication (username and password). See [introduction]
**Example Request**:
```bash
GET /api/admin/stats
Accept: application/json
Content-Type: application/json
```
**Example Response**:
```json
HTTP/1.1 200
Content-Type: application/json
@@ -203,6 +210,7 @@ Only works with Basic Authentication (username and password). See [introduction]
"starred_db_count":2,
"grafana_admin_count":2
}
```
## Global Users
@@ -211,6 +219,7 @@ Only works with Basic Authentication (username and password). See [introduction]
Create new user. Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation.
**Example Request**:
```json
POST /api/admin/users HTTP/1.1
Accept: application/json
@@ -222,13 +231,16 @@ Create new user. Only works with Basic Authentication (username and password). S
"login":"user",
"password":"userpassword"
}
```
**Example Response**:
```json
HTTP/1.1 200
Content-Type: application/json
{"id":5,"message":"User created"}
```
## Password for User
@@ -239,18 +251,22 @@ Change password for a specific user.
**Example Request**:
```json
PUT /api/admin/users/2/password HTTP/1.1
Accept: application/json
Content-Type: application/json
{"password":"userpassword"}
```
**Example Response**:
```json
HTTP/1.1 200
Content-Type: application/json
{"message": "User password updated"}
```
## Permissions
@@ -260,18 +276,22 @@ Only works with Basic Authentication (username and password). See [introduction]
**Example Request**:
```json
PUT /api/admin/users/2/permissions HTTP/1.1
Accept: application/json
Content-Type: application/json
{"isGrafanaAdmin": true}
```
**Example Response**:
```json
HTTP/1.1 200
Content-Type: application/json
{message: "User permissions updated"}
```
## Delete global User
@@ -281,16 +301,20 @@ Only works with Basic Authentication (username and password). See [introduction]
**Example Request**:
```json
DELETE /api/admin/users/2 HTTP/1.1
Accept: application/json
Content-Type: application/json
```
**Example Response**:
```json
HTTP/1.1 200
Content-Type: application/json
{message: "User deleted"}
```
## Pause all alerts
@@ -300,6 +324,7 @@ Only works with Basic Authentication (username and password). See [introduction]
**Example Request**:
```json
POST /api/admin/pause-all-alerts HTTP/1.1
Accept: application/json
Content-Type: application/json
@@ -307,6 +332,7 @@ Only works with Basic Authentication (username and password). See [introduction]
{
"paused": true
}
```
JSON Body schema:
@@ -314,7 +340,9 @@ JSON Body schema:
**Example Response**:
```json
HTTP/1.1 200
Content-Type: application/json
{state: "new state", message: "alerts pause/un paused", "alertsAffected": 100}
```

View File

@@ -23,11 +23,12 @@ This API can also be used to create, update and delete alert notifications.
**Example Request**:
```http
GET /api/alerts HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
Querystring Parameters:
These parameters are used as querystring parameters. For example:
@@ -41,6 +42,7 @@ This API can also be used to create, update and delete alert notifications.
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
[
@@ -63,6 +65,7 @@ This API can also be used to create, update and delete alert notifications.
```http
HTTP/1.1 200
Content-Type: application/json
{
```
@@ -70,13 +73,16 @@ This API can also be used to create, update and delete alert notifications.
`POST /api/alerts/:id/pause`
**Example Request**:
```http
POST /api/alerts/1/pause HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
The :id query parameter is the id of the alert to be paused or unpaused.
JSON Body Schema:
@@ -90,6 +96,7 @@ This API can also be used to create, update and delete alert notifications.
Content-Type: application/json
{
```
## Get alert notifications
`GET /api/alert-notifications`
@@ -97,6 +104,7 @@ This API can also be used to create, update and delete alert notifications.
**Example Request**:
```http
GET /api/alert-notifications HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
@@ -105,6 +113,7 @@ This API can also be used to create, update and delete alert notifications.
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
@@ -114,6 +123,7 @@ JSON Body Schema:
`POST /api/alert-notifications`
**Example Request**:
```http
POST /api/alert-notifications HTTP/1.1
@@ -121,6 +131,7 @@ JSON Body Schema:
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
@@ -128,15 +139,17 @@ JSON Body Schema:
HTTP/1.1 200
Content-Type: application/json
{
```
## Update alert notification
`PUT /api/alert-notifications/1`
**Example Request**:
```http
PUT /api/alert-notifications/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
@@ -148,6 +161,7 @@ JSON Body Schema:
HTTP/1.1 200
Content-Type: application/json
{
```
## Delete alert notification
@@ -155,6 +169,7 @@ JSON Body Schema:
**Example Request**:
```http
DELETE /api/alert-notifications/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
@@ -168,10 +183,11 @@ JSON Body Schema:
Content-Type: application/json
{
```
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{
@@ -183,6 +199,7 @@ JSON Body Schema:
"created": "2017-01-01 12:34",
"updated": "2017-01-01 12:34"
}
```
## Update alert notification
@@ -190,6 +207,7 @@ JSON Body Schema:
**Example Request**:
```http
PUT /api/alert-notifications/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
@@ -204,10 +222,11 @@ JSON Body Schema:
"addresses: "carl@grafana.com;dev@grafana.com"
}
}
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{
@@ -219,6 +238,7 @@ JSON Body Schema:
"created": "2017-01-01 12:34",
"updated": "2017-01-01 12:34"
}
```
## Delete alert notification
@@ -226,15 +246,19 @@ JSON Body Schema:
**Example Request**:
```http
DELETE /api/alert-notifications/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{
"message": "Notification deleted"
}
```

View File

@@ -0,0 +1,189 @@
+++
title = "Annotations HTTP API "
description = "Grafana Annotations HTTP API"
keywords = ["grafana", "http", "documentation", "api", "annotation", "annotations", "comment"]
aliases = ["/http_api/annotations/"]
type = "docs"
[menu.docs]
name = "Annotations"
identifier = "annotationshttp"
parent = "http_api"
+++
# Annotations resources / actions
This is the API documentation for the new Grafana Annotations feature released in Grafana 4.6. Annotations are saved in the Grafana database (sqlite, mysql or postgres). Annotations can be global annotations that can be shown on any dashboard by configuring an annotation data source - they are filtered by tags. Or they can be tied to a panel on a dashboard and are then only shown on that panel.
## Find Annotations
`GET /api/annotations?from=1506676478816&to=1507281278816&tags=tag1&tags=tag2&limit=100`
**Example Request**:
```http
GET /api/annotations?from=1506676478816&to=1507281278816&tags=tag1&tags=tag2&limit=100 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Basic YWRtaW46YWRtaW4=
```
Query Parameters:
- `from`: epoch datetime in milliseconds. Optional.
- `to`: epoch datetime in milliseconds. Optional.
- `limit`: number. Optional - default is 10. Max limit for results returned.
- `alertId`: number. Optional. Find annotations for a specified alert.
- `dashboardId`: number. Optional. Find annotations that are scoped to a specific dashboard
- `panelId`: number. Optional. Find annotations that are scoped to a specific panel
- `tags`: string. Optional. Use this to filter global annotations. Global annotations are annotations from an annotation data source that are not connected specifically to a dashboard or panel. To do an "AND" filtering with multiple tags, specify the tags parameter multiple times e.g. `tags=tag1&tags=tag2`.
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
[
```
## Create Annotation
Creates an annotation in the Grafana database. The `dashboardId` and `panelId` fields are optional. If they are not specified then a global annotation is created and can be queried in any dashboard that adds the Grafana annotations data source.
`POST /api/annotations`
**Example Request**:
```json
POST /api/annotations HTTP/1.1
Accept: application/json
Content-Type: application/json
{
"dashboardId":468,
"panelId":1,
"time":1507037197339,
"isRegion":true,
"timeEnd":1507180805056,
"tags":["tag1","tag2"],
"text":"Annotation Description"
}
```
**Example Response**:
```json
HTTP/1.1 200
Content-Type: application/json
{"message":"Annotation added"}
```
## Update Annotation
`PUT /api/annotations/:id`
**Example Request**:
```json
PUT /api/annotations/1141 HTTP/1.1
Accept: application/json
Content-Type: application/json
{
"time":1507037197339,
"isRegion":true,
"timeEnd":1507180805056,
"text":"Annotation Description",
"tags":["tag3","tag4","tag5"]
}
```
## Delete Annotation By Id
`DELETE /api/annotation/:id`
Deletes the annotation that matches the specified id.
**Example Request**:
```http
DELETE /api/annotation/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
```
## Delete Annotation By RegionId
`DELETE /api/annotation/region/:id`
Deletes the annotation that matches the specified region id. A region is an annotation that covers a timerange and has a start and end time. In the Grafana database, this is a stored as two annotations connected by a region id.
**Example Request**:
```http
DELETE /api/annotation/region/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
```
Deletes the annotation that matches the specified id.
**Example Request**:
```http
DELETE /api/annotation/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{"message":"Annotation deleted"}
```
## Delete Annotation By RegionId
`DELETE /api/annotation/region/:id`
Deletes the annotation that matches the specified region id. A region is an annotation that covers a timerange and has a start and end time. In the Grafana database, this is a stored as two annotations connected by a region id.
**Example Request**:
```http
DELETE /api/annotation/region/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{"message":"Annotation region deleted"}
```

View File

@@ -21,7 +21,7 @@ If basic auth is enabled (it is enabled by default) you can authenticate your HT
standard basic auth. Basic auth will also authenticate LDAP users.
curl example:
```
```bash
?curl http://admin:admin@localhost:3000/api/org
{"id":1,"name":"Main Org."}
```
@@ -36,9 +36,11 @@ You use the token in all requests in the `Authorization` header, like this:
**Example**:
```http
GET http://your.grafana.com/api/dashboards/db/mydash HTTP/1.1
Accept: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
The `Authorization` header value should be `Bearer <your api key>`.
@@ -50,13 +52,16 @@ The `Authorization` header value should be `Bearer <your api key>`.
**Example Request**:
```http
GET /api/auth/keys HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
@@ -72,6 +77,7 @@ The `Authorization` header value should be `Bearer <your api key>`.
POST /api/auth/keys HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
@@ -79,6 +85,7 @@ The `Authorization` header value should be `Bearer <your api key>`.
- **name** The key name
- **role** Sets the access level/Grafana Role for the key. Can be one of the following values: `Viewer`, `Editor`, `Read Only Editor` or `Admin`.
**Example Response**:
```http
@@ -88,6 +95,7 @@ The `Authorization` header value should be `Bearer <your api key>`.
```
## Delete API Key
`DELETE /api/auth/keys/:id`
**Example Request**:
@@ -96,10 +104,12 @@ JSON Body schema:
DELETE /api/auth/keys/3 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
```
@@ -107,14 +117,17 @@ JSON Body schema:
**Example Request**:
```http
DELETE /api/auth/keys/3 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{"message":"API key deleted"}
```

View File

@@ -158,13 +158,16 @@ Will return the home dashboard.
`GET /api/search/`
Query parameters:
- **query** Search Query
- **tag** Tag to use
- **starred** Flag indicating if only starred Dashboards should be returned
- **tagcloud** - Flag indicating if a tagcloud should be returned
**Example Request**:
```http
GET /api/search?query=MyDashboard&starred=true&tag=prod HTTP/1.1
Accept: application/json
Content-Type: application/json
@@ -205,6 +208,7 @@ Will return the home dashboard.
"version":5
}
}
```
## Tags for Dashboard
@@ -215,13 +219,16 @@ Get all tags of dashboards
**Example Request**:
```http
GET /api/dashboards/tags HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
@@ -235,6 +242,7 @@ Get all tags of dashboards
"count":4
}
]
```
## Search Dashboards
@@ -249,13 +257,16 @@ Query parameters:
**Example Request**:
```http
GET /api/search?query=MyDashboard&starred=true&tag=prod HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
@@ -269,3 +280,4 @@ Query parameters:
"isStarred":false
}
]
```

View File

@@ -18,13 +18,16 @@ parent = "http_api"
**Example Request**:
```http
GET /api/datasources HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
@@ -46,6 +49,7 @@ parent = "http_api"
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
```
@@ -53,13 +57,16 @@ parent = "http_api"
## Get a single data source by Name
`GET /api/datasources/name/:name`
**Example Request**:
```http
GET /api/datasources/name/test_datasource HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
@@ -79,6 +86,7 @@ parent = "http_api"
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
@@ -86,13 +94,16 @@ parent = "http_api"
HTTP/1.1 200
Content-Type: application/json
```
## Create data source
`POST /api/datasources`
**Example Graphite Request**:
```http
POST /api/datasources HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
@@ -112,6 +123,7 @@ parent = "http_api"
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
```
@@ -119,19 +131,23 @@ parent = "http_api"
## Update an existing data source
`PUT /api/datasources/:datasourceId`
**Example Request**:
```http
PUT /api/datasources/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
```
## Delete an existing data source by id
@@ -139,6 +155,7 @@ parent = "http_api"
`DELETE /api/datasources/:datasourceId`
**Example Request**:
```http
DELETE /api/datasources/1 HTTP/1.1
Accept: application/json
@@ -151,9 +168,11 @@ parent = "http_api"
```http
HTTP/1.1 200
Content-Type: application/json
```
```
## Delete an existing data source by name
`DELETE /api/datasources/name/:datasourceName`
**Example Request**:
@@ -177,10 +196,12 @@ parent = "http_api"
`GET /api/datasources/proxy/:datasourceId/*`
Proxies all calls to the actual datasource.
HTTP/1.1 200
Content-Type: application/json
{"id":1,"message":"Datasource added", "name": "test_datasource"}
```
## Update an existing data source
@@ -188,6 +209,7 @@ parent = "http_api"
**Example Request**:
```http
PUT /api/datasources/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
@@ -209,13 +231,16 @@ parent = "http_api"
"isDefault":false,
"jsonData":null
}
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{"message":"Datasource updated", "id": 1, "name": "test_datasource"}
```
## Delete an existing data source by id
@@ -223,17 +248,21 @@ parent = "http_api"
**Example Request**:
```http
DELETE /api/datasources/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{"message":"Data source deleted"}
```
## Delete an existing data source by name
@@ -241,17 +270,21 @@ parent = "http_api"
**Example Request**:
```http
DELETE /api/datasources/name/test_datasource HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{"message":"Data source deleted"}
```
## Data source proxy calls

View File

@@ -18,13 +18,16 @@ parent = "http_api"
**Example Request**:
```http
GET /api/org HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
@@ -32,6 +35,7 @@ parent = "http_api"
## Get Organisation by Id
`GET /api/orgs/:orgId`
**Example Request**:
@@ -39,13 +43,16 @@ parent = "http_api"
GET /api/orgs/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
```
## Get Organisation by Name
`GET /api/orgs/name/:orgName`
@@ -61,20 +68,23 @@ parent = "http_api"
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
```
## Create Organisation
`POST /api/orgs`
**Example Request**:
```http
POST /api/orgs HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
@@ -90,6 +100,7 @@ parent = "http_api"
## Update current Organisation
`PUT /api/org`
**Example Request**:
```http
@@ -97,6 +108,7 @@ parent = "http_api"
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
@@ -105,10 +117,11 @@ parent = "http_api"
HTTP/1.1 200
Content-Type: application/json
```
## Get all users within the actual organisation
`GET /api/org/users`
**Example Request**:
@@ -116,7 +129,7 @@ parent = "http_api"
GET /api/org/users HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
@@ -125,6 +138,7 @@ parent = "http_api"
HTTP/1.1 200
Content-Type: application/json
```
## Add a new user to the actual organisation
@@ -133,15 +147,16 @@ parent = "http_api"
Adds a global user to the actual organisation.
**Example Request**:
```http
POST /api/org/users HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
@@ -149,13 +164,16 @@ parent = "http_api"
```
## Updates the given user
`PATCH /api/org/users/:userId`
**Example Request**:
```http
PATCH /api/org/users/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
@@ -168,6 +186,7 @@ parent = "http_api"
```
## Delete user in actual organisation
`DELETE /api/org/users/:userId`
@@ -177,6 +196,7 @@ Adds a global user to the actual organisation.
DELETE /api/org/users/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
@@ -186,14 +206,16 @@ Adds a global user to the actual organisation.
Content-Type: application/json
```
# Organisations
## Search all Organisations
`GET /api/orgs`
**Example Request**:
```http
GET /api/orgs HTTP/1.1
Accept: application/json
Content-Type: application/json
@@ -201,6 +223,7 @@ Adds a global user to the actual organisation.
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
@@ -209,15 +232,16 @@ Adds a global user to the actual organisation.
## Update Organisation
`PUT /api/orgs/:orgId`
Update Organisation, fields *Adress 1*, *Adress 2*, *City* are not implemented yet.
**Example Request**:
```http
PUT /api/orgs/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
@@ -225,18 +249,21 @@ Adds a global user to the actual organisation.
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
```
## Get Users in Organisation
`GET /api/orgs/:orgId/users`
**Example Request**:
```http
GET /api/orgs/1/users HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
@@ -246,13 +273,16 @@ Adds a global user to the actual organisation.
HTTP/1.1 200
Content-Type: application/json
[
```
## Add User in Organisation
`POST /api/orgs/:orgId/users`
**Example Request**:
```http
POST /api/orgs/1/users HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
@@ -262,6 +292,7 @@ Adds a global user to the actual organisation.
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
```
@@ -271,6 +302,7 @@ Update Organisation, fields *Adress 1*, *Adress 2*, *City* are not implemented y
`PATCH /api/orgs/:orgId/users/:userId`
**Example Request**:
```http
PATCH /api/orgs/1/users/2 HTTP/1.1
Accept: application/json
@@ -279,14 +311,16 @@ Update Organisation, fields *Adress 1*, *Adress 2*, *City* are not implemented y
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
```
## Delete User in Organisation
`DELETE /api/orgs/:orgId/users/:userId`
**Example Request**:
@@ -294,13 +328,16 @@ Update Organisation, fields *Adress 1*, *Adress 2*, *City* are not implemented y
```http
DELETE /api/orgs/1/users/2 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
```
Content-Type: application/json
[
@@ -312,6 +349,7 @@ Update Organisation, fields *Adress 1*, *Adress 2*, *City* are not implemented y
"role":"Admin"
}
]
```
## Add User in Organisation
@@ -319,6 +357,7 @@ Update Organisation, fields *Adress 1*, *Adress 2*, *City* are not implemented y
**Example Request**:
```http
POST /api/orgs/1/users HTTP/1.1
Accept: application/json
Content-Type: application/json
@@ -328,13 +367,16 @@ Update Organisation, fields *Adress 1*, *Adress 2*, *City* are not implemented y
"loginOrEmail":"user",
"role":"Viewer"
}
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{"message":"User added to organization"}
```
## Update Users in Organisation
@@ -342,6 +384,7 @@ Update Organisation, fields *Adress 1*, *Adress 2*, *City* are not implemented y
**Example Request**:
```http
PATCH /api/orgs/1/users/2 HTTP/1.1
Accept: application/json
Content-Type: application/json
@@ -350,13 +393,16 @@ Update Organisation, fields *Adress 1*, *Adress 2*, *City* are not implemented y
{
"role":"Admin"
}
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{"message":"Organization user updated"}
```
## Delete User in Organisation
@@ -364,14 +410,18 @@ Update Organisation, fields *Adress 1*, *Adress 2*, *City* are not implemented y
**Example Request**:
```http
DELETE /api/orgs/1/users/2 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{"message":"User removed from organization"}
```

View File

@@ -18,13 +18,16 @@ parent = "http_api"
**Example Request**:
```http
GET /api/frontend/settings HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
@@ -55,6 +58,7 @@ parent = "http_api"
},
"defaultDatasource": "Grafana"
}
```
# Login API
@@ -64,14 +68,18 @@ parent = "http_api"
**Example Request**:
```http
GET /api/login/ping HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{"message": "Logged in"}
```

View File

@@ -26,17 +26,21 @@ system default value.
**Example Request**:
```http
GET /api/user/preferences HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
```
## Update Current User Prefs
`PUT /api/user/preferences`
@@ -44,6 +48,7 @@ system default value.
**Example Request**:
```http
PUT /api/user/preferences HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
@@ -54,13 +59,16 @@ system default value.
```http
HTTP/1.1 200
Content-Type: text/plain; charset=utf-8
```
## Get Current Org Prefs
`GET /api/org/preferences`
**Example Request**:
```http
GET /api/org/preferences HTTP/1.1
Accept: application/json
@@ -68,17 +76,21 @@ system default value.
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
```
## Update Current Org Prefs
`PUT /api/org/preferences`
**Example Request**:
```http
PUT /api/org/preferences HTTP/1.1
Accept: application/json
Content-Type: application/json
@@ -86,6 +98,7 @@ system default value.
```
**Example Response**:
```http
HTTP/1.1 200
@@ -96,10 +109,13 @@ system default value.
"homeDashboardId":0,
"timezone":"utc"
}
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: text/plain; charset=utf-8
{"message":"Preferences updated"}
```

View File

@@ -17,6 +17,7 @@ parent = "http_api"
**Example Request**:
```http
POST /api/snapshots HTTP/1.1
Accept: application/json
Content-Type: application/json
@@ -51,6 +52,7 @@ parent = "http_api"
},
"expires": 3600
}
```
JSON Body schema:
@@ -63,6 +65,7 @@ JSON Body schema:
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{
@@ -71,6 +74,7 @@ JSON Body schema:
"key":"YYYYYYY",
"url":"myurl/dashboard/snapshot/YYYYYYY"
}
```
Keys:
@@ -83,13 +87,16 @@ Keys:
**Example Request**:
```http
GET /api/snapshots/YYYYYYY HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
@@ -131,6 +138,7 @@ Keys:
"version":5
}
}
```
## Delete Snapshot by Id
@@ -138,14 +146,18 @@ Keys:
**Example Request**:
```http
GET /api/snapshots/YYYYYYY HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{"message":"Snapshot deleted. It might take an hour before it's cleared from a CDN cache."}
```

View File

@@ -17,15 +17,18 @@ parent = "http_api"
**Example Request**:
```http
GET /api/users HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Basic YWRtaW46YWRtaW4=
```
Default value for the `perpage` parameter is `1000` and for the `page` parameter is `1`. Requires basic authentication and that the authenticated user is a Grafana Admin.
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
@@ -45,6 +48,7 @@ Default value for the `perpage` parameter is `1000` and for the `page` parameter
```
Default value for the `perpage` parameter is `1000` and for the `page` parameter is `1`. The `totalCount` field in the response can be used for pagination of the user list E.g. if `totalCount` is equal to 100 users and the `perpage` parameter is set to 10 then there are 10 pages of users. The `query` parameter is optional and it will return results where the query value is contained in one of the `name`, `login` or `email` fields. Query values with spaces need to be url encoded e.g. `query=Jane%20Doe`.
Requires basic authentication and that the authenticated user is a Grafana Admin.
**Example Response**:
@@ -52,10 +56,12 @@ Default value for the `perpage` parameter is `1000` and for the `page` parameter
```http
HTTP/1.1 200
Content-Type: application/json
{
```
## Get single user by Id
`GET /api/users/:id`
**Example Request**:
@@ -63,6 +69,7 @@ Requires basic authentication and that the authenticated user is a Grafana Admin
GET /api/users/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Basic YWRtaW46YWRtaW4=
```
Requires basic authentication and that the authenticated user is a Grafana Admin.
@@ -86,6 +93,7 @@ Requires basic authentication and that the authenticated user is a Grafana Admin
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Request using the username as option**:
```http
@@ -93,15 +101,17 @@ Requires basic authentication and that the authenticated user is a Grafana Admin
Accept: application/json
Content-Type: application/json
Authorization: Basic YWRtaW46YWRtaW4=
```
Requires basic authentication and that the authenticated user is a Grafana Admin.
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
```
## User Update
@@ -113,6 +123,7 @@ Requires basic authentication and that the authenticated user is a Grafana Admin
PUT /api/users/2 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Basic YWRtaW46YWRtaW4=
```
@@ -120,22 +131,27 @@ Requires basic authentication and that the authenticated user is a Grafana Admin
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
```
## Get Organisations for user
`GET /api/users/:id/orgs`
**Example Request**:
```http
GET /api/users/1/orgs HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Basic YWRtaW46YWRtaW4=
```
Requires basic authentication and that the authenticated user is a Grafana Admin.
**Example Response**:
```http
@@ -147,7 +163,7 @@ Requires basic authentication and that the authenticated user is a Grafana Admin
## User
## Actual User
`GET /api/user`
**Example Request**:
@@ -155,6 +171,7 @@ Requires basic authentication and that the authenticated user is a Grafana Admin
```http
GET /api/user HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
@@ -166,16 +183,18 @@ Requires basic authentication and that the authenticated user is a Grafana Admin
```
## Change Password
`PUT /api/user/password`
Changes the password for the user
**Example Request**:
```http
PUT /api/user/password HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
@@ -183,15 +202,18 @@ Requires basic authentication and that the authenticated user is a Grafana Admin
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
```
## Switch user context for a specified user
`POST /api/users/:userId/using/:organizationId`
Switch user context to the given organization. Requires basic authentication and that the authenticated user is a Grafana Admin.
**Example Request**:
```http
POST /api/users/7/using/2 HTTP/1.1
@@ -202,6 +224,7 @@ Requires basic authentication and that the authenticated user is a Grafana Admin
```http
HTTP/1.1 200
Content-Type: application/json
```
@@ -211,13 +234,16 @@ Requires basic authentication and that the authenticated user is a Grafana Admin
Switch user context to the given organization.
**Example Request**:
```http
POST /api/user/using/2 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
@@ -229,6 +255,7 @@ Requires basic authentication and that the authenticated user is a Grafana Admin
`GET /api/user/orgs`
Return a list of all organisations of the current user.
**Example Request**:
@@ -238,6 +265,7 @@ Changes the password for the user
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
@@ -248,13 +276,16 @@ Changes the password for the user
## Star a dashboard
`POST /api/user/stars/dashboard/:dashboardId`
Stars the given Dashboard for the actual user.
**Example Request**:
```http
POST /api/user/stars/dashboard/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
@@ -264,15 +295,19 @@ Switch user context to the given organization. Requires basic authentication and
HTTP/1.1 200
Content-Type: application/json
```
## Unstar a dashboard
`DELETE /api/user/stars/dashboard/:dashboardId`
Deletes the starring of the given Dashboard for the actual user.
**Example Request**:
```http
DELETE /api/user/stars/dashboard/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
@@ -282,17 +317,21 @@ Switch user context to the given organization.
```http
HTTP/1.1 200
Content-Type: application/json
```
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{"message":"Active organization changed"}
```
## Organisations of the actual User
@@ -302,13 +341,16 @@ Return a list of all organisations of the current user.
**Example Request**:
```http
GET /api/user/orgs HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
@@ -319,6 +361,7 @@ Return a list of all organisations of the current user.
"role":"Admin"
}
]
```
## Star a dashboard
@@ -328,17 +371,21 @@ Stars the given Dashboard for the actual user.
**Example Request**:
```http
POST /api/user/stars/dashboard/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{"message":"Dashboard starred!"}
```
## Unstar a dashboard
@@ -348,14 +395,18 @@ Deletes the starring of the given Dashboard for the actual user.
**Example Request**:
```http
DELETE /api/user/stars/dashboard/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
{"message":"Dashboard unstarred"}
```

View File

@@ -15,7 +15,7 @@ weight = 1
It should be straight forward to get Grafana up and running behind a reverse proxy. But here are some things that you might run into.
Links and redirects will not be rendered correctly unless you set the server.domain setting.
```
```bash
[server]
domain = foo.bar
```
@@ -28,14 +28,14 @@ Here are some example configurations for running Grafana behind a reverse proxy.
### Grafana configuration (ex http://foo.bar.com)
```
```bash
[server]
domain = foo.bar
```
### Nginx configuration
```
```bash
server {
listen 80;
root /usr/share/nginx/www;
@@ -50,14 +50,14 @@ server {
### Examples with **sub path** (ex http://foo.bar.com/grafana)
#### Grafana configuration with sub path
```
```bash
[server]
domain = foo.bar
root_url = %(protocol)s://%(domain)s:/grafana
```
#### Nginx configuration with sub path
```
```bash
server {
listen 80;
root /usr/share/nginx/www;

View File

@@ -37,11 +37,14 @@ A common problem is forgetting to uncomment a line in the `custom.ini` (or `graf
All options in the configuration file (listed below) can be overridden
using environment variables using the syntax:
```bash
GF_<SectionName>_<KeyName>
```
Where the section name is the text within the brackets. Everything
should be upper case, `.` should be replaced by `_`. For example, given these configuration settings:
```bash
# default section
instance_name = ${HOSTNAME}
@@ -50,13 +53,15 @@ should be upper case, `.` should be replaced by `_`. For example, given these co
[auth.google]
client_secret = 0ldS3cretKey
```
Then you can override them using:
```bash
export GF_DEFAULT_INSTANCE_NAME=my-instance
export GF_SECURITY_ADMIN_USER=true
export GF_AUTH_GOOGLE_CLIENT_SECRET=newS3cretKey
```
<hr />
@@ -93,11 +98,15 @@ The IP address to bind to. If empty will bind to all interfaces
The port to bind to, defaults to `3000`. To use port 80 you need to
either give the Grafana binary permission for example:
```bash
$ sudo setcap 'cap_net_bind_service=+ep' /usr/sbin/grafana-server
```
Or redirect port 80 to the Grafana port using:
```bash
$ sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 3000
```
Another way is put a webserver like Nginx or Apache in front of Grafana and have them proxy requests to Grafana.
@@ -312,7 +321,9 @@ You need to create a GitHub OAuth application (you find this under the GitHub
settings page). When you create the application you will need to specify
a callback URL. Specify this as callback:
```bash
http://<my_grafana_server_name_or_ip>:<grafana_server_port>/login/github
```
This callback URL must match the full HTTP address that you use in your
browser to access Grafana, but with the prefix path of `/login/github`.
@@ -320,6 +331,7 @@ When the GitHub OAuth application is created you will get a Client ID and a
Client Secret. Specify these in the Grafana configuration file. For
example:
```bash
[auth.github]
enabled = true
allow_sign_up = true
@@ -331,6 +343,7 @@ example:
api_url = https://api.github.com/user
team_ids =
allowed_organizations =
```
Restart the Grafana back-end. You should now see a GitHub login button
on the login page. You can now login or sign up with your GitHub
@@ -348,6 +361,7 @@ GitHub. If the authenticated user isn't a member of at least one of the
teams they will not be able to register or authenticate with your
Grafana instance. For example:
```bash
[auth.github]
enabled = true
client_id = YOUR_GITHUB_APP_CLIENT_ID
@@ -357,6 +371,7 @@ Grafana instance. For example:
auth_url = https://github.com/login/oauth/authorize
token_url = https://github.com/login/oauth/access_token
allow_sign_up = true
```
### allowed_organizations
@@ -365,6 +380,7 @@ organizations on GitHub. If the authenticated user isn't a member of at least
one of the organizations they will not be able to register or authenticate with
your Grafana instance. For example
```bash
[auth.github]
enabled = true
client_id = YOUR_GITHUB_APP_CLIENT_ID
@@ -375,6 +391,7 @@ your Grafana instance. For example
allow_sign_up = true
# space-delimited organization names
allowed_organizations = github google
```
<hr>
@@ -385,13 +402,16 @@ Developer Console](https://console.developers.google.com/project). When
you create the project you will need to specify a callback URL. Specify
this as callback:
```bash
http://<my_grafana_server_name_or_ip>:<grafana_server_port>/login/google
```
This callback URL must match the full HTTP address that you use in your
browser to access Grafana, but with the prefix path of `/login/google`.
When the Google project is created you will get a Client ID and a Client
Secret. Specify these in the Grafana configuration file. For example:
```bash
[auth.google]
enabled = true
client_id = YOUR_GOOGLE_APP_CLIENT_ID
@@ -401,6 +421,7 @@ Secret. Specify these in the Grafana configuration file. For example:
token_url = https://accounts.google.com/o/oauth2/token
allowed_domains = mycompany.com mycompany.org
allow_sign_up = true
```
Restart the Grafana back-end. You should now see a Google login button
on the login page. You can now login or sign up with your Google
@@ -418,6 +439,7 @@ This option could be used if have your own oauth service.
This callback URL must match the full HTTP address that you use in your
browser to access Grafana, but with the prefix path of `/login/generic_oauth`.
```bash
[auth.generic_oauth]
enabled = true
client_id = YOUR_APP_CLIENT_ID
@@ -428,9 +450,44 @@ browser to access Grafana, but with the prefix path of `/login/generic_oauth`.
api_url =
allowed_domains = mycompany.com mycompany.org
allow_sign_up = true
```
Set api_url to the resource that returns [OpenID UserInfo](https://connect2id.com/products/server/docs/api/userinfo) compatible information.
### Set up oauth2 with Okta
First set up Grafana as an OpenId client "webapplication" in Okta. Then set the Base URIs to `https://<grafana domain>/` and set the Login redirect URIs to `https://<grafana domain>/login/generic_oauth`.
Finaly set up the generic oauth module like this:
```bash
[auth.generic_oauth]
name = Okta
enabled = true
scopes = openid profile email
client_id = <okta application Client ID>
client_secret = <okta application Client Secret>
auth_url = https://<okta domain>/oauth2/v1/authorize
token_url = https://<okta domain>/oauth2/v1/token
api_url = https://<okta domain>/oauth2/v1/userinfo
```
### Set up oauth2 with Bitbucket
```bash
[auth.generic_oauth]
name = BitBucket
enabled = true
allow_sign_up = true
client_id = <client id>
client_secret = <secret>
scopes = account email
auth_url = https://bitbucket.org/site/oauth2/authorize
token_url = https://bitbucket.org/site/oauth2/access_token
api_url = https://api.bitbucket.org/2.0/user
team_ids =
allowed_organizations =
```
<hr>
## [auth.basic]
@@ -503,21 +560,25 @@ session table manually.
Mysql Example:
```bash
CREATE TABLE `session` (
`key` CHAR(16) NOT NULL,
`data` BLOB,
`expiry` INT(11) UNSIGNED NOT NULL,
PRIMARY KEY (`key`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
```
Postgres Example:
```bash
CREATE TABLE session (
key CHAR(16) NOT NULL,
data BYTEA,
expiry INTEGER NOT NULL,
PRIMARY KEY (key)
);
```
Postgres valid `sslmode` are `disable`, `require` (default), `verify-ca`, and `verify-full`.
@@ -651,11 +712,16 @@ These options control how images should be made public so they can be shared on
You can choose between (s3, webdav, gcs). If left empty Grafana will ignore the upload action.
## [external_image_storage.s3]
### bucket
Bucket name for S3. e.g. grafana.snapshot
### region
Region name for S3. e.g. 'us-east-1', 'cn-north-1', etc
### path
Optional extra path inside bucket, useful to apply expiration policies
### bucket_url
(for backward compatibility, only works when no bucket or region are configured)
Bucket URL for S3. AWS region can be specified within URL or defaults to 'us-east-1', e.g.

View File

@@ -45,13 +45,17 @@ sudo dpkg -i grafana_4.5.2-beta1_amd64.deb
Add the following line to your `/etc/apt/sources.list` file.
```bash
deb https://packagecloud.io/grafana/stable/debian/ jessie main
```
Use the above line even if you are on Ubuntu or another Debian version.
There is also a testing repository if you want beta or release
candidates.
```bash
deb https://packagecloud.io/grafana/testing/debian/ jessie main
```
Then add the [Package Cloud](https://packagecloud.io/grafana) key. This
allows you to install signed packages.

View File

@@ -14,7 +14,9 @@ weight = 4
Grafana is very easy to install and run using the offical docker container.
```bash
$ docker run -d -p 3000:3000 grafana/grafana
```
All Grafana configuration settings can be defined using environment
variables, this is especially useful when using the above container.
@@ -26,10 +28,12 @@ folder `/var/lib/grafana` and configuration files is in `/etc/grafana/`
folder. You can map these volumes to host folders when you start the
container:
```bash
$ docker run -d -p 3000:3000 \
-v /var/lib/grafana:/var/lib/grafana \
-e "GF_SECURITY_ADMIN_PASSWORD=secret" \
grafana/grafana
```
In the above example I map the data folder and sets a configuration option via
an `ENV` instruction.

View File

@@ -92,7 +92,7 @@ org_role = "Viewer"
By default the configuration expects you to specify a bind DN and bind password. This should be a read only user that can perform LDAP searches.
When the user DN is found a second bind is performed with the user provided username & password (in the normal Grafana login form).
```
```bash
bind_dn = "cn=admin,dc=grafana,dc=org"
bind_password = "grafana"
```
@@ -102,7 +102,7 @@ bind_password = "grafana"
If you can provide a single bind expression that matches all possible users, you can skip the second bind and bind against the user DN directly.
This allows you to not specify a bind_password in the configuration file.
```
```bash
bind_dn = "cn=%s,o=users,dc=grafana,dc=org"
```

View File

@@ -15,7 +15,7 @@ Installation can be done using [homebrew](http://brew.sh/)
Install latest stable:
```
```bash
brew update
brew install grafana
```
@@ -24,7 +24,7 @@ To start grafana look at the command printed after the homebrew install complete
To upgrade use the reinstall command
```
```bash
brew update
brew reinstall grafana
```
@@ -34,13 +34,13 @@ brew reinstall grafana
You can also install the latest unstable grafana from git:
```
```bash
brew install --HEAD grafana/grafana/grafana
```
To upgrade grafana if you've installed from HEAD:
```
```bash
brew reinstall --HEAD grafana/grafana/grafana
```
@@ -48,13 +48,13 @@ brew reinstall --HEAD grafana/grafana/grafana
To start Grafana using homebrew services first make sure homebrew/services is installed.
```
```bash
brew tap homebrew/services
```
Then start Grafana using:
```
```bash
brew services start grafana
```

View File

@@ -26,41 +26,54 @@ installation.
You can install Grafana using Yum directly.
```bash
$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.5.2-1.x86_64.rpm
```
Or install manually using `rpm`.
#### On CentOS / Fedora / Redhat:
```bash
$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.5.2-1.x86_64.rpm
$ sudo yum install initscripts fontconfig
$ sudo rpm -Uvh grafana-4.5.2-1.x86_64.rpm
```
#### On OpenSuse:
```bash
$ sudo rpm -i --nodeps grafana-4.5.2-1.x86_64.rpm
```
## Install via YUM Repository
Add the following to a new file at `/etc/yum.repos.d/grafana.repo`
```bash
[grafana]
name=grafana
baseurl=https://packagecloud.io/grafana/stable/el/6/$basearch
repo_gpgcheck=1
enabled=1
gpgcheck=1
gpgkey=https://packagecloud.io/gpg.key https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana
gpgkey=https://packagecloud.io/gpg.key
https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana
sslverify=1
sslcacert=/etc/pki/tls/certs/ca-bundle.crt
```
There is also a testing repository if you want beta or release candidates.
```bash
baseurl=https://packagecloud.io/grafana/testing/el/6/$basearch
```
Then install Grafana via the `yum` command.
```bash
$ sudo yum install grafana
```
### RPM GPG Key
@@ -81,7 +94,9 @@ key](https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana).
You can start Grafana by running:
```bash
$ sudo service grafana-server start
```
This will start the `grafana-server` process as the `grafana` user,
which is created during package installation. The default HTTP port is
@@ -89,17 +104,23 @@ which is created during package installation. The default HTTP port is
To configure the Grafana server to start at boot time:
```bash
$ sudo /sbin/chkconfig --add grafana-server
```
## Start the server (via systemd)
```bash
$ systemctl daemon-reload
$ systemctl start grafana-server
$ systemctl status grafana-server
```
### Enable the systemd service to start at boot
```bash
sudo systemctl enable grafana-server.service
```
## Environment file
@@ -138,7 +159,7 @@ for example in alert notifications.
If the image is missing text make sure you have font packages installed.
```
```bash
yum install fontconfig
yum install freetype*
yum install urw-fonts

View File

@@ -29,7 +29,7 @@ installed grafana to custom location using a binary tar/zip it is usally in `<gr
#### mysql
```
```bash
backup:
> mysqldump -u root -p[root_password] [grafana] > grafana_backup.sql
@@ -39,7 +39,7 @@ restore:
#### postgres
```
```bash
backup:
> pg_dump grafana > grafana_backup
@@ -54,7 +54,7 @@ and execute the same `dpkg -i` command but with the new package. It will upgrade
If you used our APT repository:
```
```bash
sudo apt-get update
sudo apt-get install grafana
```
@@ -73,14 +73,14 @@ and execute the same `yum install` or `rpm -i` command but with the new package.
If you used our YUM repository:
```
```bash
sudo yum update grafana
```
### Docker
This just an example, details depend on how you configured your grafana container.
```
```bash
docker pull grafana
docker stop my-grafana-container
docker rm my-grafana-container

View File

@@ -23,7 +23,7 @@ The most important fields are the first three, especially the id. The convention
Examples:
```
```bash
raintank-worldping-app
grafana-simple-json-datasource
grafana-piechart-panel
@@ -66,7 +66,7 @@ The README.md file is rendered both on Grafana.net and in the plugins section in
Here is a typical directory structure for a plugin.
```
```bash
johnnyb-awesome-datasource
|-- dist
|-- spec

View File

@@ -45,7 +45,7 @@ The javascript object that communicates with the database and transforms data to
The Datasource should contain the following functions:
```
```javascript
query(options) //used by panels to get data
testDatasource() //used by datasource configuration page to make sure the connection is working
annotationQuery(options) // used by dashboards to get annotations

View File

@@ -30,37 +30,37 @@ On Linux systems the grafana-cli will assume that the grafana plugin directory i
### Grafana-cli Commands
List available plugins
```
```bash
grafana-cli plugins list-remote
```
Install the latest version of a plugin
```
```bash
grafana-cli plugins install <plugin-id>
```
Install a specific version of a plugin
```
```bash
grafana-cli plugins install <plugin-id> <version>
```
List installed plugins
```
```bash
grafana-cli plugins ls
```
Update all installed plugins
```
```bash
grafana-cli plugins update-all
```
Update one plugin
```
```bash
grafana-cli plugins update <plugin-id>
```
Remove one plugin
```
```bash
grafana-cli plugins remove <plugin-id>
```
@@ -73,7 +73,7 @@ The Download URL from Grafana.com API is in this form:
`https://grafana.com/api/plugins/<plugin id>/versions/<version number>/download`
You can specify a local URL by using the `--pluginUrl` option.
```
```bash
grafana-cli --pluginUrl https://nexus.company.com/grafana/plugins/<plugin-id>-<plugin-version>.zip plugins install <plugin-id>
```
@@ -84,7 +84,7 @@ To manually install a Plugin via the Grafana.com API:
{{< imgbox img="/img/docs/installation-tab.png" caption="Installation Tab" >}}
2. Use the Grafana API to find the plugin using this url `https://grafana.com/api/plugins/<plugin id from step 1>`. For example: https://grafana.com/api/plugins/jdbranham-diagram-panel should return:
```
```bash
{
"id": 145,
"typeId": 3,
@@ -97,7 +97,7 @@ To manually install a Plugin via the Grafana.com API:
```
3. Find the download link:
```
```bash
{
"rel": "download",
"href": "/plugins/jdbranham-diagram-panel/versions/1.4.0/download"

View File

@@ -13,27 +13,27 @@ dev environment. Grafana ships with its own required backend server; also comple
## Dependencies
- [Go 1.9](https://golang.org/dl/)
- [Go 1.9.1](https://golang.org/dl/)
- [NodeJS LTS](https://nodejs.org/download/)
- [Git](https://git-scm.com/downloads)
## Get Code
Create a directory for the project and set your path accordingly (or use the [default Go workspace directory](https://golang.org/doc/code.html#GOPATH)). Then download and install Grafana into your $GOPATH directory:
```
```bash
export GOPATH=`pwd`
go get github.com/grafana/grafana
```
On Windows use setx instead of export and then restart your command prompt:
```
```bash
setx GOPATH %cd%
```
You may see an error such as: `package github.com/grafana/grafana: no buildable Go source files`. This is just a warning, and you can proceed with the directions.
## Building the backend
```
```bash
cd $GOPATH/src/github.com/grafana/grafana
go run build.go setup
go run build.go build # (or 'go build ./pkg/cmd/grafana-server')
@@ -45,7 +45,7 @@ to install GCC. We recommend [TDM-GCC](http://tdm-gcc.tdragon.net/download).
[node-gyp](https://github.com/nodejs/node-gyp#installation) is the Node.js native addon build tool and it requires extra dependencies to be installed on Windows. In a command prompt which is run as administrator, run:
```
```bash
npm --add-python-to-path='true' --debug install --global windows-build-tools
```
@@ -53,7 +53,7 @@ npm --add-python-to-path='true' --debug install --global windows-build-tools
For this you need nodejs (v.6+).
```
```bash
npm install -g yarn
yarn install --pure-lockfile
npm run build
@@ -62,7 +62,7 @@ npm run build
## Running Grafana Locally
You can run a local instance of Grafana by running:
```
```bash
./bin/grafana-server
```
If you built the binary with `go run build.go build`, run `./bin/grafana-server`
@@ -76,7 +76,7 @@ Open grafana in your browser (default [http://localhost:3000](http://localhost:3
To add features, customize your config, etc, you'll need to rebuild the backend when you change the source code. We use a tool named `bra` that
does this.
```
```bash
go get github.com/Unknwon/bra
bra run
@@ -88,7 +88,7 @@ You'll also need to run `npm run watch` to watch for changes to the front-end (t
This step builds linux packages and requires that fpm is installed. Install fpm via `gem install fpm`.
```
```bash
go run build.go build package
```

View File

@@ -24,7 +24,7 @@ When a user creates a new dashboard, a new dashboard JSON object is initialized
> Note: In the following JSON, id is shown as null which is the default value assigned to it until a dashboard is saved. Once a dashboard is saved, an integer value is assigned to the `id` field.
```
```json
{
"id": null,
"title": "New dashboard",

View File

@@ -43,7 +43,7 @@ You also get a link to service side rendered PNG of the panel. Useful if you wan
Example of a link to a server-side rendered PNG:
```
```bash
http://play.grafana.org/render/dashboard-solo/db/grafana-play-home?orgId=1&panelId=4&from=1499272191563&to=1499279391563&width=1000&height=500&tz=UTC%2B02%3A00&timeout=5000
```

View File

@@ -22,24 +22,24 @@ Some parts of the API are only available through basic authentication and these
The task is to create a new organization and then add a Token that can be used by other users. In the examples below which use basic auth, the user is `admin` and the password is `admin`.
1. [Create the org](http://docs.grafana.org/http_api/org/#create-organisation). Here is an example using curl:
```
```bash
curl -X POST -H "Content-Type: application/json" -d '{"name":"apiorg"}' http://admin:admin@localhost:3000/api/orgs
```
This should return a response: `{"message":"Organization created","orgId":6}`. Use the orgId for the next steps.
2. Optional step. If the org was created previously and/or step 3 fails then first [add your Admin user to the org](http://docs.grafana.org/http_api/org/#add-user-in-organisation):
```
```bash
curl -X POST -H "Content-Type: application/json" -d '{"loginOrEmail":"admin", "role": "Admin"}' http://admin:admin@localhost:3000/api/orgs/<org id of new org>/users
```
3. [Switch the org context for the Admin user to the new org](http://docs.grafana.org/http_api/user/#switch-user-context):
```
```bash
curl -X POST http://admin:admin@localhost:3000/api/user/using/<id of new org>
```
4. [Create the API token](http://docs.grafana.org/http_api/auth/#create-api-key):
```
```bash
curl -X POST -H "Content-Type: application/json" -d '{"name":"apikeycurl", "role": "Admin"}' http://admin:admin@localhost:3000/api/auth/keys
```
@@ -53,7 +53,7 @@ Using the Token that was created in the previous step, you can create a dashboar
1. [Add a dashboard](http://docs.grafana.org/http_api/dashboard/#create-update-dashboard) using the key (or bearer token as it is also called):
```
```bash
curl -X POST --insecure -H "Authorization: Bearer eyJrIjoiR0ZXZmt1UFc0OEpIOGN5RWdUalBJTllUTk83VlhtVGwiLCJuIjoiYXBpa2V5Y3VybCIsImlkIjo2fQ==" -H "Content-Type: application/json" -d '{
"dashboard": {
"id": null,

View File

@@ -0,0 +1,243 @@
+++
title = "Grafana Authproxy"
type = "docs"
keywords = ["grafana", "tutorials", "authproxy"]
[menu.docs]
parent = "tutorials"
weight = 10
+++
# Grafana Authproxy
AuthProxy allows you to offload the authentication of users to a web server (there are many reasons why youd want to run a web server in front of a production version of Grafana, especially if its exposed to the Internet).
Popular web servers have a very extensive list of pluggable authentication modules, and any of them can be used with the AuthProxy feature.
The Grafana AuthProxy feature is very simple in design, but it is this simplicity that makes it so powerful.
## Interacting with Grafanas AuthProxy via curl
The AuthProxy feature can be configured through the Grafana configuration file with the following options:
```js
[auth.proxy]
enabled = true
header_name = X-WEBAUTH-USER
header_property = username
auto_sign_up = true
```
* **enabled**: this is to toggle the feature on or off
* **header_name**: this is the HTTP header name that passes the username or email address of the authenticated user to Grafana. Grafana will trust what ever username is contained in this header and automatically log the user in.
* **header_property**: this tells Grafana whether the value in the header_name is a username or an email address. (In Grafana you can log in using your account username or account email)
* **auto_sign_up**: If set to true, Grafana will automatically create user accounts in the Grafana DB if one does not exist. If set to false, users who do not exist in the GrafanaDB wont be able to log in, even though their username and password are valid.
With a fresh install of Grafana, using the above configuration for the authProxy feature, we can send a simple API call to list all users. The only user that will be present is the default “Admin” user that is added the first time Grafana starts up. As you can see all we need to do to authenticate the request is to provide the “X-WEBAUTH-USER” header.
```bash
curl -H "X-WEBAUTH-USER: admin" http://localhost:3000/api/users
[
{
"id":1,
"name":"",
"login":"admin",
"email":"admin@localhost",
"isAdmin":true
}
]
```
We can then send a second request to the `/api/user` method which will return the details of the logged in user. We will use this request to show how Grafana automatically adds the new user we specify to the system. Here we create a new user called “anthony”.
```bash
curl -H "X-WEBAUTH-USER: anthony" http://localhost:3000/api/user
{
"email":"anthony",
"name":"",
"login":"anthony",
"theme":"",
"orgId":1,
"isGrafanaAdmin":false
}
```
## Making Apaches auth work together with Grafanas AuthProxy
Ill demonstrate how to use Apache for authenticating users. In this example we use BasicAuth with Apaches text file based authentication handler, i.e. htpasswd files. However, any available Apache authentication capabilities could be used.
### Apache BasicAuth
In this example we use Apache as a reverseProxy in front of Grafana. Apache handles the Authentication of users before forwarding requests to the Grafana backend service.
#### Apache configuration
```bash
<VirtualHost *:80>
ServerAdmin webmaster@authproxy
ServerName authproxy
ErrorLog "logs/authproxy-error_log"
CustomLog "logs/authproxy-access_log" common
<Proxy *>
AuthType Basic
AuthName GrafanaAuthProxy
AuthBasicProvider file
AuthUserFile /etc/apache2/grafana_htpasswd
Require valid-user
RewriteEngine On
RewriteRule .* - [E=PROXY_USER:%{LA-U:REMOTE_USER},NS]
RequestHeader set X-WEBAUTH-USER "%{PROXY_USER}e"
</Proxy>
RequestHeader unset Authorization
ProxyRequests Off
ProxyPass / http://localhost:3000/
ProxyPassReverse / http://localhost:3000/
</VirtualHost>
```
* The first 4 lines of the virtualhost configuration are standard, so we wont go into detail on what they do.
* We use a **\<proxy>** configuration block for applying our authentication rules to every proxied request. These rules include requiring basic authentication where user:password credentials are stored in the **/etc/apache2/grafana_htpasswd** file. This file can be created with the `htpasswd` command.
* The next part of the configuration is the tricky part. We use Apaches rewrite engine to create our **X-WEBAUTH-USER header**, populated with the authenticated user.
* **RewriteRule .* - [E=PROXY_USER:%{LA-U:REMOTE_USER}, NS]**: This line is a little bit of magic. What it does, is for every request use the rewriteEngines look-ahead (LA-U) feature to determine what the REMOTE_USER variable would be set to after processing the request. Then assign the result to the variable PROXY_USER. This is neccessary as the REMOTE_USER variable is not available to the RequestHeader function.
* **RequestHeader set X-WEBAUTH-USER “%{PROXY_USER}e”**: With the authenticated username now stored in the PROXY_USER variable, we create a new HTTP request header that will be sent to our backend Grafana containing the username.
* The **RequestHeader unset Authorization** removes the Authorization header from the HTTP request before it is forwarded to Grafana. This ensures that Grafana does not try to authenticate the user using these credentials (BasicAuth is a supported authentication handler in Grafana).
* The last 3 lines are then just standard reverse proxy configuration to direct all authenticated requests to our Grafana server running on port 3000.
#### Grafana configuration
```bash
############# Users ################
[users]
# disable user signup / registration
allow_sign_up = false
# Set to true to automatically assign new users to the default organization (id 1)
auto_assign_org = true
# Default role new users will be automatically assigned (if auto_assign_org above is set to true)
auto_assign_org_role = Editor
############ Auth Proxy ########
[auth.proxy]
enabled = true
# the Header name that contains the authenticated user.
header_name = X-WEBAUTH-USER
# does the user authenticate against the proxy using a 'username' or an 'email'
header_property = username
# automatically add the user to the system if they don't already exist.
auto_sign_up = true
```
#### Full walk through using Docker.
##### Grafana Container
For this example, we use the offical Grafana docker image available at [Docker Hub](https://hub.docker.com/r/grafana/grafana/)
* Create a file `grafana.ini` with the following contents
```bash
[users]
allow_sign_up = false
auto_assign_org = true
auto_assign_org_role = Editor
[auth.proxy]
enabled = true
header_name = X-WEBAUTH-USER
header_property = username
auto_sign_up = true
```
* Launch the Grafana container, using our custom grafana.ini to replace `/etc/grafana/grafana.ini`. We dont expose any ports for this container as it will only be connected to by our Apache container.
```bash
docker run -i -v $(pwd)/grafana.ini:/etc/grafana/grafana.ini --name grafana grafana/grafana
```
### Apache Container
For this example we use the offical Apache docker image available at [Docker Hub](https://hub.docker.com/_/httpd/)
* Create a file `httpd.conf` with the following contents
```bash
ServerRoot "/usr/local/apache2"
Listen 80
LoadModule authn_file_module modules/mod_authn_file.so
LoadModule authn_core_module modules/mod_authn_core.so
LoadModule authz_host_module modules/mod_authz_host.so
LoadModule authz_user_module modules/mod_authz_user.so
LoadModule authz_core_module modules/mod_authz_core.so
LoadModule auth_basic_module modules/mod_auth_basic.so
LoadModule log_config_module modules/mod_log_config.so
LoadModule env_module modules/mod_env.so
LoadModule headers_module modules/mod_headers.so
LoadModule unixd_module modules/mod_unixd.so
LoadModule rewrite_module modules/mod_rewrite.so
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_http_module modules/mod_proxy_http.so
<IfModule unixd_module>
User daemon
Group daemon
</IfModule>
ServerAdmin you@example.com
<Directory />
AllowOverride none
Require all denied
</Directory>
DocumentRoot "/usr/local/apache2/htdocs"
ErrorLog /proc/self/fd/2
LogLevel error
<IfModule log_config_module>
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined
LogFormat "%h %l %u %t \"%r\" %>s %b" common
<IfModule logio_module>
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\" %I %O" combinedio
</IfModule>
CustomLog /proc/self/fd/1 common
</IfModule>
<Proxy *>
AuthType Basic
AuthName GrafanaAuthProxy
AuthBasicProvider file
AuthUserFile /tmp/htpasswd
Require valid-user
RewriteEngine On
RewriteRule .* - [E=PROXY_USER:%{LA-U:REMOTE_USER},NS]
RequestHeader set X-WEBAUTH-USER "%{PROXY_USER}e"
</Proxy>
RequestHeader unset Authorization
ProxyRequests Off
ProxyPass / http://grafana:3000/
ProxyPassReverse / http://grafana:3000/
```
* Create a htpasswd file. We create a new user **anthony** with the password **password**
```bash
htpasswd -bc htpasswd anthony password
```
* Launch the httpd container using our custom httpd.conf and our htpasswd file. The container will listen on port 80, and we create a link to the **grafana** container so that this container can resolve the hostname **grafana** to the grafana containers ip address.
```bash
docker run -i -p 80:80 --link grafana:grafana -v $(pwd)/httpd.conf:/usr/local/apache2/conf/httpd.conf -v $(pwd)/htpasswd:/tmp/htpasswd httpd:2.4
```
### Use grafana.
With our Grafana and Apache containers running, you can now connect to http://localhost/ and log in using the username/password we created in the htpasswd file.

View File

@@ -39,9 +39,9 @@ read the official [Getting Started With Hubot](https://hubot.github.com/docs/) g
## Install Hubot-Grafana script
In your Hubot project repo install the Grafana plugin using `npm`:
```bash
npm install hubot-grafana --save
```
Edit the file external-scripts.json, and add hubot-grafana to the list of plugins.
```json
@@ -56,6 +56,7 @@ Edit the file external-scripts.json, and add hubot-grafana to the list of plugin
The `hubot-grafana` plugin requires a number of environment variables to be set in order to work properly.
```bash
export HUBOT_GRAFANA_HOST=http://play.grafana.org
export HUBOT_GRAFANA_API_KEY=abcd01234deadbeef01234
export HUBOT_GRAFANA_S3_BUCKET=mybucket
@@ -63,6 +64,7 @@ The `hubot-grafana` plugin requires a number of environment variables to be set
export HUBOT_GRAFANA_S3_SECRET_ACCESS_KEY=aBcD01234dEaDbEef01234
export HUBOT_GRAFANA_S3_PREFIX=graphs
export HUBOT_GRAFANA_S3_REGION=us-standard
```
### Grafana server side rendering
@@ -112,7 +114,9 @@ can create hubot command aliases with the hubot script `hubot-alias`.
Install it:
```bash
npm i --save hubot-alias
```
Now add `hubot-alias` to the list of plugins in `external-scripts.json` and restart hubot.

View File

@@ -15,7 +15,9 @@
"@types/node": "^8.0.31",
"@types/react": "^16.0.5",
"@types/react-dom": "^15.5.4",
"angular-mocks": "^1.6.6",
"autoprefixer": "^6.4.0",
"awesome-typescript-loader": "^3.2.3",
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-preset-es2015": "^6.24.1",
@@ -50,6 +52,7 @@
"grunt-webpack": "^3.0.2",
"html-loader": "^0.5.1",
"html-webpack-plugin": "^2.30.1",
"husky": "^0.14.3",
"jshint-stylish": "~2.2.1",
"json-loader": "^0.5.7",
"karma": "1.7.0",
@@ -61,17 +64,19 @@
"karma-sinon": "^1.0.5",
"karma-sourcemap-loader": "^0.3.7",
"karma-webpack": "^2.0.4",
"lint-staged": "^4.2.3",
"load-grunt-tasks": "3.5.2",
"mocha": "3.5.0",
"mocha": "^4.0.1",
"ng-annotate-loader": "^0.6.1",
"ng-annotate-webpack-plugin": "^0.2.1-pre",
"ngtemplate-loader": "^2.0.1",
"npm": "^5.4.2",
"phantomjs-prebuilt": "^2.1.15",
"postcss-browser-reporter": "^0.5.0",
"postcss-loader": "^2.0.6",
"postcss-reporter": "^5.0.0",
"prettier": "1.7.3",
"react-test-renderer": "^16.0.0",
"rxjs": "^5.4.3",
"sass-lint": "^1.10.2",
"sass-loader": "^6.0.6",
"sinon": "1.17.6",
@@ -85,34 +90,32 @@
"webpack-bundle-analyzer": "^2.9.0",
"webpack-cleanup-plugin": "^0.5.1",
"webpack-merge": "^4.1.0",
"zone.js": "^0.7.2",
"awesome-typescript-loader": "^3.2.3",
"angular-mocks": "^1.6.6",
"karma-sinon": "^1.0.5",
"npm": "^5.4.2"
"zone.js": "^0.7.2"
},
"scripts": {
"dev": "./node_modules/.bin/webpack --progress --colors --config scripts/webpack/webpack.dev.js",
"watch": "./node_modules/.bin/webpack --progress --colors --watch --config scripts/webpack/webpack.dev.js",
"build": "./node_modules/grunt-cli/bin/grunt build",
"test": "./node_modules/grunt-cli/bin/grunt test",
"build": "./node_modules/.bin/grunt build",
"test": "./node_modules/.bin/grunt test",
"lint": "./node_modules/.bin/tslint -c tslint.json --project tsconfig.json --type-check",
"watch-test": "./node_modules/grunt-cli/bin/grunt karma:dev"
},
"license": "Apache-2.0",
"dependencies": {
"babel-polyfill": "^6.26.0",
"jquery": "^3.2.1",
"angular": "^1.6.6",
"angular-bindonce": "^0.3.1",
"angular-mocks": "^1.6.6",
"angular-native-dragdrop": "^1.2.2",
"angular-route": "^1.6.6",
"angular-sanitize": "^1.6.6",
"babel-polyfill": "^6.26.0",
"brace": "^0.10.0",
"clipboard": "^1.7.1",
"eventemitter3": "^2.0.2",
"gridstack": "https://github.com/grafana/gridstack.js#grafana",
"gemini-scrollbar": "https://github.com/grafana/gemini-scrollbar#grafana",
"file-saver": "^1.3.3",
"jquery": "^3.2.1",
"lodash": "^4.17.4",
"moment": "^2.18.1",
"mousetrap": "^1.6.0",
@@ -120,7 +123,9 @@
"react": "^16.0.0",
"react-dom": "^16.0.0",
"remarkable": "^1.7.1",
"rxjs": "^5.4.3",
"tether": "^1.4.0",
"tether-drop": "https://github.com/torkelo/drop"
"tether-drop": "https://github.com/torkelo/drop",
"tinycolor2": "^1.4.1"
}
}

View File

@@ -2,6 +2,7 @@ package api
import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/services/annotations"
)
@@ -11,13 +12,12 @@ func GetAnnotations(c *middleware.Context) Response {
query := &annotations.ItemQuery{
From: c.QueryInt64("from") / 1000,
To: c.QueryInt64("to") / 1000,
Type: annotations.ItemType(c.Query("type")),
OrgId: c.OrgId,
AlertId: c.QueryInt64("alertId"),
DashboardId: c.QueryInt64("dashboardId"),
PanelId: c.QueryInt64("panelId"),
Limit: c.QueryInt64("limit"),
NewState: c.QueryStrings("newState"),
Tags: c.QueryStrings("tags"),
}
repo := annotations.GetRepository()
@@ -27,25 +27,14 @@ func GetAnnotations(c *middleware.Context) Response {
return ApiError(500, "Failed to get annotations", err)
}
result := make([]dtos.Annotation, 0)
for _, item := range items {
result = append(result, dtos.Annotation{
AlertId: item.AlertId,
Time: item.Epoch * 1000,
Data: item.Data,
NewState: item.NewState,
PrevState: item.PrevState,
Text: item.Text,
Metric: item.Metric,
Title: item.Title,
PanelId: item.PanelId,
RegionId: item.RegionId,
Type: string(item.Type),
})
if item.Email != "" {
item.AvatarUrl = dtos.GetGravatarUrl(item.Email)
}
item.Time = item.Time * 1000
}
return Json(200, result)
return Json(200, items)
}
func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response {
@@ -53,14 +42,13 @@ func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response
item := annotations.Item{
OrgId: c.OrgId,
UserId: c.UserId,
DashboardId: cmd.DashboardId,
PanelId: cmd.PanelId,
Epoch: cmd.Time / 1000,
Title: cmd.Title,
Text: cmd.Text,
CategoryId: cmd.CategoryId,
NewState: cmd.FillColor,
Type: annotations.EventType,
Data: cmd.Data,
Tags: cmd.Tags,
}
if err := repo.Save(&item); err != nil {
@@ -71,12 +59,16 @@ func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response
if cmd.IsRegion {
item.RegionId = item.Id
if item.Data == nil {
item.Data = simplejson.New()
}
if err := repo.Update(&item); err != nil {
return ApiError(500, "Failed set regionId on annotation", err)
}
item.Id = 0
item.Epoch = cmd.TimeEnd
item.Epoch = cmd.TimeEnd / 1000
if err := repo.Save(&item); err != nil {
return ApiError(500, "Failed save annotation for region end time", err)
@@ -86,6 +78,41 @@ func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response
return ApiSuccess("Annotation added")
}
func UpdateAnnotation(c *middleware.Context, cmd dtos.UpdateAnnotationsCmd) Response {
annotationId := c.ParamsInt64(":annotationId")
repo := annotations.GetRepository()
item := annotations.Item{
OrgId: c.OrgId,
UserId: c.UserId,
Id: annotationId,
Epoch: cmd.Time / 1000,
Text: cmd.Text,
Tags: cmd.Tags,
}
if err := repo.Update(&item); err != nil {
return ApiError(500, "Failed to update annotation", err)
}
if cmd.IsRegion {
itemRight := item
itemRight.RegionId = item.Id
itemRight.Epoch = cmd.TimeEnd / 1000
// We don't know id of region right event, so set it to 0 and find then using query like
// ... WHERE region_id = <item.RegionId> AND id != <item.RegionId> ...
itemRight.Id = 0
if err := repo.Update(&itemRight); err != nil {
return ApiError(500, "Failed to update annotation for region end time", err)
}
}
return ApiSuccess("Annotation updated")
}
func DeleteAnnotations(c *middleware.Context, cmd dtos.DeleteAnnotationsCmd) Response {
repo := annotations.GetRepository()
@@ -101,3 +128,33 @@ func DeleteAnnotations(c *middleware.Context, cmd dtos.DeleteAnnotationsCmd) Res
return ApiSuccess("Annotations deleted")
}
func DeleteAnnotationById(c *middleware.Context) Response {
repo := annotations.GetRepository()
annotationId := c.ParamsInt64(":annotationId")
err := repo.Delete(&annotations.DeleteParams{
Id: annotationId,
})
if err != nil {
return ApiError(500, "Failed to delete annotation", err)
}
return ApiSuccess("Annotation deleted")
}
func DeleteAnnotationRegion(c *middleware.Context) Response {
repo := annotations.GetRepository()
regionId := c.ParamsInt64(":regionId")
err := repo.Delete(&annotations.DeleteParams{
RegionId: regionId,
})
if err != nil {
return ApiError(500, "Failed to delete annotation region", err)
}
return ApiSuccess("Annotation region deleted")
}

View File

@@ -301,6 +301,9 @@ func (hs *HttpServer) registerRoutes() {
apiRoute.Group("/annotations", func(annotationsRoute RouteRegister) {
annotationsRoute.Post("/", bind(dtos.PostAnnotationsCmd{}), wrap(PostAnnotation))
annotationsRoute.Delete("/:annotationId", wrap(DeleteAnnotationById))
annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), wrap(UpdateAnnotation))
annotationsRoute.Delete("/region/:regionId", wrap(DeleteAnnotationRegion))
}, reqEditorRole)
// error test

View File

@@ -2,31 +2,22 @@ package dtos
import "github.com/grafana/grafana/pkg/components/simplejson"
type Annotation struct {
AlertId int64 `json:"alertId"`
DashboardId int64 `json:"dashboardId"`
PanelId int64 `json:"panelId"`
NewState string `json:"newState"`
PrevState string `json:"prevState"`
Time int64 `json:"time"`
Title string `json:"title"`
Text string `json:"text"`
Metric string `json:"metric"`
RegionId int64 `json:"regionId"`
Type string `json:"type"`
Data *simplejson.Json `json:"data"`
}
type PostAnnotationsCmd struct {
DashboardId int64 `json:"dashboardId"`
PanelId int64 `json:"panelId"`
CategoryId int64 `json:"categoryId"`
Time int64 `json:"time"`
Title string `json:"title"`
Text string `json:"text"`
Tags []string `json:"tags"`
Data *simplejson.Json `json:"data"`
IsRegion bool `json:"isRegion"`
TimeEnd int64 `json:"timeEnd"`
}
FillColor string `json:"fillColor"`
type UpdateAnnotationsCmd struct {
Id int64 `json:"id"`
Time int64 `json:"time"`
Text string `json:"text"`
Tags []string `json:"tags"`
IsRegion bool `json:"isRegion"`
TimeEnd int64 `json:"timeEnd"`
}
@@ -35,4 +26,6 @@ type DeleteAnnotationsCmd struct {
AlertId int64 `json:"alertId"`
DashboardId int64 `json:"dashboardId"`
PanelId int64 `json:"panelId"`
AnnotationId int64 `json:"annotationId"`
RegionId int64 `json:"regionId"`
}

View File

@@ -158,7 +158,9 @@ func GetPluginMarkdown(c *middleware.Context) Response {
return ApiError(500, "Could not get markdown file", err)
} else {
return Respond(200, content)
resp := Respond(200, content)
resp.Header("Content-Type", "text/plain; charset=utf-8")
return resp
}
}

View File

@@ -30,9 +30,15 @@ func NewImageUploader() (ImageUploader, error) {
bucket := s3sec.Key("bucket").MustString("")
region := s3sec.Key("region").MustString("")
path := s3sec.Key("path").MustString("")
bucketUrl := s3sec.Key("bucket_url").MustString("")
accessKey := s3sec.Key("access_key").MustString("")
secretKey := s3sec.Key("secret_key").MustString("")
if path != "" && path[len(path)-1:] != "/" {
path += "/"
}
if bucket == "" || region == "" {
info, err := getRegionAndBucketFromUrl(bucketUrl)
if err != nil {
@@ -42,7 +48,7 @@ func NewImageUploader() (ImageUploader, error) {
region = info.region
}
return NewS3Uploader(region, bucket, "public-read", accessKey, secretKey), nil
return NewS3Uploader(region, bucket, path, "public-read", accessKey, secretKey), nil
case "webdav":
webdavSec, err := setting.Cfg.GetSection("external_image_storage.webdav")
if err != nil {

View File

@@ -19,16 +19,18 @@ import (
type S3Uploader struct {
region string
bucket string
path string
acl string
secretKey string
accessKey string
log log.Logger
}
func NewS3Uploader(region, bucket, acl, accessKey, secretKey string) *S3Uploader {
func NewS3Uploader(region, bucket, path, acl, accessKey, secretKey string) *S3Uploader {
return &S3Uploader{
region: region,
bucket: bucket,
path: path,
acl: acl,
accessKey: accessKey,
secretKey: secretKey,
@@ -56,7 +58,7 @@ func (u *S3Uploader) Upload(ctx context.Context, imageDiskPath string) (string,
}
s3_endpoint, _ := endpoints.DefaultResolver().EndpointFor("s3", u.region)
key := util.GetRandomString(20) + ".png"
key := u.path + util.GetRandomString(20) + ".png"
image_url := s3_endpoint.URL + "/" + u.bucket + "/" + key
log.Debug("Uploading image to s3", "url = ", image_url)

60
pkg/models/tags.go Normal file
View File

@@ -0,0 +1,60 @@
package models
import (
"strings"
)
type Tag struct {
Id int64
Key string
Value string
}
func ParseTagPairs(tagPairs []string) (tags []*Tag) {
if tagPairs == nil {
return []*Tag{}
}
for _, tagPair := range tagPairs {
var tag Tag
if strings.Contains(tagPair, ":") {
keyValue := strings.Split(tagPair, ":")
tag.Key = strings.Trim(keyValue[0], " ")
tag.Value = strings.Trim(keyValue[1], " ")
} else {
tag.Key = strings.Trim(tagPair, " ")
}
if tag.Key == "" || ContainsTag(tags, &tag) {
continue
}
tags = append(tags, &tag)
}
return tags
}
func ContainsTag(existingTags []*Tag, tag *Tag) bool {
for _, t := range existingTags {
if t.Key == tag.Key && t.Value == tag.Value {
return true
}
}
return false
}
func JoinTagPairs(tags []*Tag) []string {
tagPairs := []string{}
for _, tag := range tags {
if tag.Value != "" {
tagPairs = append(tagPairs, tag.Key+":"+tag.Value)
} else {
tagPairs = append(tagPairs, tag.Key)
}
}
return tagPairs
}

95
pkg/models/tags_test.go Normal file
View File

@@ -0,0 +1,95 @@
package models
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
)
func TestParsingTags(t *testing.T) {
Convey("Testing parsing a tag pairs into tags", t, func() {
Convey("Can parse one empty tag", func() {
tags := ParseTagPairs([]string{""})
So(len(tags), ShouldEqual, 0)
})
Convey("Can parse valid tags", func() {
tags := ParseTagPairs([]string{"outage", "type:outage", "error"})
So(len(tags), ShouldEqual, 3)
So(tags[0].Key, ShouldEqual, "outage")
So(tags[0].Value, ShouldEqual, "")
So(tags[1].Key, ShouldEqual, "type")
So(tags[1].Value, ShouldEqual, "outage")
So(tags[2].Key, ShouldEqual, "error")
So(tags[2].Value, ShouldEqual, "")
})
Convey("Can parse tags with spaces", func() {
tags := ParseTagPairs([]string{" outage ", " type : outage ", "error "})
So(len(tags), ShouldEqual, 3)
So(tags[0].Key, ShouldEqual, "outage")
So(tags[0].Value, ShouldEqual, "")
So(tags[1].Key, ShouldEqual, "type")
So(tags[1].Value, ShouldEqual, "outage")
So(tags[2].Key, ShouldEqual, "error")
So(tags[2].Value, ShouldEqual, "")
})
Convey("Can parse empty tags", func() {
tags := ParseTagPairs([]string{" outage ", "", "", ":", "type : outage ", "error ", "", ""})
So(len(tags), ShouldEqual, 3)
So(tags[0].Key, ShouldEqual, "outage")
So(tags[0].Value, ShouldEqual, "")
So(tags[1].Key, ShouldEqual, "type")
So(tags[1].Value, ShouldEqual, "outage")
So(tags[2].Key, ShouldEqual, "error")
So(tags[2].Value, ShouldEqual, "")
})
Convey("Can parse tags with extra colons", func() {
tags := ParseTagPairs([]string{" outage", "type : outage:outage2 :outage3 ", "error :"})
So(len(tags), ShouldEqual, 3)
So(tags[0].Key, ShouldEqual, "outage")
So(tags[0].Value, ShouldEqual, "")
So(tags[1].Key, ShouldEqual, "type")
So(tags[1].Value, ShouldEqual, "outage")
So(tags[2].Key, ShouldEqual, "error")
So(tags[2].Value, ShouldEqual, "")
})
Convey("Can parse tags that contains key and values with spaces", func() {
tags := ParseTagPairs([]string{" outage 1", "type 1: outage 1 ", "has error "})
So(len(tags), ShouldEqual, 3)
So(tags[0].Key, ShouldEqual, "outage 1")
So(tags[0].Value, ShouldEqual, "")
So(tags[1].Key, ShouldEqual, "type 1")
So(tags[1].Value, ShouldEqual, "outage 1")
So(tags[2].Key, ShouldEqual, "has error")
So(tags[2].Value, ShouldEqual, "")
})
Convey("Can filter out duplicate tags", func() {
tags := ParseTagPairs([]string{"test", "test", "key:val1", "key:val2"})
So(len(tags), ShouldEqual, 3)
So(tags[0].Key, ShouldEqual, "test")
So(tags[0].Value, ShouldEqual, "")
So(tags[1].Key, ShouldEqual, "key")
So(tags[1].Value, ShouldEqual, "val1")
So(tags[2].Key, ShouldEqual, "key")
So(tags[2].Value, ShouldEqual, "val2")
})
Convey("Can join tag pairs", func() {
tagPairs := []*Tag{
{Key: "key1", Value: "val1"},
{Key: "key2", Value: ""},
{Key: "key3"},
}
tags := JoinTagPairs(tagPairs)
So(len(tags), ShouldEqual, 3)
So(tags[0], ShouldEqual, "key1:val1")
So(tags[1], ShouldEqual, "key2")
So(tags[2], ShouldEqual, "key3")
})
})
}

View File

@@ -84,15 +84,17 @@ func (this *HipChatNotifier) Notify(evalContext *alerting.EvalContext) error {
return err
}
message := evalContext.GetNotificationTitle() + " in state " + evalContext.GetStateModel().Text + "<br><a href=" + ruleUrl + ">Check Dashboard</a>"
fields := make([]map[string]interface{}, 0)
message += "<br>"
attributes := make([]map[string]interface{}, 0)
for index, evt := range evalContext.EvalMatches {
message += evt.Metric + " :: " + strconv.FormatFloat(evt.Value.Float64, 'f', -1, 64) + "<br>"
fields = append(fields, map[string]interface{}{
"title": evt.Metric,
"value": evt.Value,
"short": true,
metricName := evt.Metric
if len(metricName) > 50 {
metricName = metricName[:50]
}
attributes = append(attributes, map[string]interface{}{
"label": metricName,
"value": map[string]interface{}{
"label": strconv.FormatFloat(evt.Value.Float64, 'f', -1, 64),
},
})
if index > maxFieldCount {
break
@@ -100,16 +102,23 @@ func (this *HipChatNotifier) Notify(evalContext *alerting.EvalContext) error {
}
if evalContext.Error != nil {
fields = append(fields, map[string]interface{}{
"title": "Error message",
"value": evalContext.Error.Error(),
"short": false,
attributes = append(attributes, map[string]interface{}{
"label": "Error message",
"value": map[string]interface{}{
"label": evalContext.Error.Error(),
},
})
}
message := ""
if evalContext.Rule.State != models.AlertStateOK { //dont add message when going back to alert state ok.
message += " " + evalContext.Rule.Message
}
if message == "" {
message = evalContext.GetNotificationTitle() + " in state " + evalContext.GetStateModel().Text
}
//HipChat has a set list of colors
var color string
switch evalContext.Rule.State {
@@ -123,15 +132,24 @@ func (this *HipChatNotifier) Notify(evalContext *alerting.EvalContext) error {
// Add a card with link to the dashboard
card := map[string]interface{}{
"style": "link",
"style": "application",
"url": ruleUrl,
"id": "1",
"title": evalContext.GetNotificationTitle(),
"description": evalContext.GetNotificationTitle() + " in state " + evalContext.GetStateModel().Text,
"description": message,
"icon": map[string]interface{}{
"url": "https://grafana.com/assets/img/fav32.png",
},
"date": evalContext.EndTime.Unix(),
"attributes": attributes,
}
if evalContext.ImagePublicUrl != "" {
card["thumbnail"] = map[string]interface{}{
"url": evalContext.ImagePublicUrl,
"url@2x": evalContext.ImagePublicUrl,
"width": 1193,
"height": 564,
}
}
body := map[string]interface{}{
@@ -144,6 +162,7 @@ func (this *HipChatNotifier) Notify(evalContext *alerting.EvalContext) error {
hipUrl := fmt.Sprintf("%s/v2/room/%s/notification?auth_token=%s", this.Url, this.RoomId, this.ApiKey)
data, _ := json.Marshal(&body)
this.log.Info("Request payload", "json", string(data))
cmd := &models.SendWebhookSync{Url: hipUrl, Body: string(data)}
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {

View File

@@ -37,8 +37,7 @@ func init() {
}
var (
opsgenieCreateAlertURL string = "https://api.opsgenie.com/v1/json/alert"
opsgenieCloseAlertURL string = "https://api.opsgenie.com/v1/json/alert/close"
opsgenieAlertURL string = "https://api.opsgenie.com/v2/alerts"
)
func NewOpsGenieNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
@@ -87,7 +86,6 @@ func (this *OpsGenieNotifier) createAlert(evalContext *alerting.EvalContext) err
}
bodyJSON := simplejson.New()
bodyJSON.Set("apiKey", this.ApiKey)
bodyJSON.Set("message", evalContext.Rule.Name)
bodyJSON.Set("source", "Grafana")
bodyJSON.Set("alias", "alertId-"+strconv.FormatInt(evalContext.Rule.Id, 10))
@@ -103,9 +101,13 @@ func (this *OpsGenieNotifier) createAlert(evalContext *alerting.EvalContext) err
body, _ := bodyJSON.MarshalJSON()
cmd := &m.SendWebhookSync{
Url: opsgenieCreateAlertURL,
Url: opsgenieAlertURL,
Body: string(body),
HttpMethod: "POST",
HttpHeader: map[string]string{
"Content-Type": "application/json",
"Authorization": fmt.Sprintf("GenieKey %s", this.ApiKey),
},
}
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
@@ -119,14 +121,17 @@ func (this *OpsGenieNotifier) closeAlert(evalContext *alerting.EvalContext) erro
this.log.Info("Closing OpsGenie alert", "ruleId", evalContext.Rule.Id, "notification", this.Name)
bodyJSON := simplejson.New()
bodyJSON.Set("apiKey", this.ApiKey)
bodyJSON.Set("alias", "alertId-"+strconv.FormatInt(evalContext.Rule.Id, 10))
bodyJSON.Set("source", "Grafana")
body, _ := bodyJSON.MarshalJSON()
cmd := &m.SendWebhookSync{
Url: opsgenieCloseAlertURL,
Url: fmt.Sprintf("%s/alertId-%d/close?identifierType=alias", opsgenieAlertURL, evalContext.Rule.Id),
Body: string(body),
HttpMethod: "POST",
HttpHeader: map[string]string{
"Content-Type": "application/json",
"Authorization": fmt.Sprintf("GenieKey %s", this.ApiKey),
},
}
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {

View File

@@ -1,7 +1,11 @@
package notifiers
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"os"
"time"
"github.com/grafana/grafana/pkg/bus"
@@ -15,7 +19,7 @@ func init() {
alerting.RegisterNotifier(&alerting.NotifierPlugin{
Type: "slack",
Name: "Slack",
Description: "Sends notifications using Grafana server configured STMP settings",
Description: "Sends notifications to Slack via Slack Webhooks",
Factory: NewSlackNotifier,
OptionsTemplate: `
<h3 class="page-heading">Slack settings</h3>
@@ -45,6 +49,17 @@ func init() {
Mention a user or a group using @ when notifying in a channel
</info-popover>
</div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-6">Token</span>
<input type="text"
class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.token"
data-placement="right">
</input>
<info-popover mode="right-absolute">
Provide a bot token to use the Slack file.upload API (starts with "xoxb")
</info-popover>
</div>
`,
})
@@ -58,12 +73,16 @@ func NewSlackNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
recipient := model.Settings.Get("recipient").MustString()
mention := model.Settings.Get("mention").MustString()
token := model.Settings.Get("token").MustString()
uploadImage := model.Settings.Get("uploadImage").MustBool(true)
return &SlackNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
Url: url,
Recipient: recipient,
Mention: mention,
Token: token,
Upload: uploadImage,
log: log.New("alerting.notifier.slack"),
}, nil
}
@@ -73,6 +92,8 @@ type SlackNotifier struct {
Url string
Recipient string
Mention string
Token string
Upload bool
log log.Logger
}
@@ -110,6 +131,11 @@ func (this *SlackNotifier) Notify(evalContext *alerting.EvalContext) error {
if evalContext.Rule.State != m.AlertStateOK { //dont add message when going back to alert state ok.
message += " " + evalContext.Rule.Message
}
image_url := ""
// default to file.upload API method if a token is provided
if this.Token == "" {
image_url = evalContext.ImagePublicUrl
}
body := map[string]interface{}{
"attachments": []map[string]interface{}{
@@ -120,7 +146,7 @@ func (this *SlackNotifier) Notify(evalContext *alerting.EvalContext) error {
"title_link": ruleUrl,
"text": message,
"fields": fields,
"image_url": evalContext.ImagePublicUrl,
"image_url": image_url,
"footer": "Grafana v" + setting.BuildVersion,
"footer_icon": "https://grafana.com/assets/img/fav32.png",
"ts": time.Now().Unix(),
@@ -133,14 +159,75 @@ func (this *SlackNotifier) Notify(evalContext *alerting.EvalContext) error {
if this.Recipient != "" {
body["channel"] = this.Recipient
}
data, _ := json.Marshal(&body)
cmd := &m.SendWebhookSync{Url: this.Url, Body: string(data)}
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
this.log.Error("Failed to send slack notification", "error", err, "webhook", this.Name)
return err
}
if this.Token != "" && this.UploadImage {
err = SlackFileUpload(evalContext, this.log, "https://slack.com/api/files.upload", this.Recipient, this.Token)
if err != nil {
return err
}
}
return nil
}
func SlackFileUpload(evalContext *alerting.EvalContext, log log.Logger, url string, recipient string, token string) error {
if evalContext.ImageOnDiskPath == "" {
evalContext.ImageOnDiskPath = "public/img/mixed_styles.png"
}
log.Info("Uploading to slack via file.upload API")
headers, uploadBody, err := GenerateSlackBody(evalContext.ImageOnDiskPath, token, recipient)
if err != nil {
return err
}
cmd := &m.SendWebhookSync{Url: url, Body: uploadBody.String(), HttpHeader: headers, HttpMethod: "POST"}
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
log.Error("Failed to upload slack image", "error", err, "webhook", "file.upload")
return err
}
if err != nil {
return err
}
return nil
}
func GenerateSlackBody(file string, token string, recipient string) (map[string]string, bytes.Buffer, error) {
// Slack requires all POSTs to files.upload to present
// an "application/x-www-form-urlencoded" encoded querystring
// See https://api.slack.com/methods/files.upload
var b bytes.Buffer
w := multipart.NewWriter(&b)
// Add the generated image file
f, err := os.Open(file)
if err != nil {
return nil, b, err
}
defer f.Close()
fw, err := w.CreateFormFile("file", file)
if err != nil {
return nil, b, err
}
_, err = io.Copy(fw, f)
if err != nil {
return nil, b, err
}
// Add the authorization token
err = w.WriteField("token", token)
if err != nil {
return nil, b, err
}
// Add the channel(s) to POST to
err = w.WriteField("channels", recipient)
if err != nil {
return nil, b, err
}
w.Close()
headers := map[string]string{
"Content-Type": w.FormDataContentType(),
"Authorization": "auth_token=\"" + token + "\"",
}
return headers, b, nil
}

View File

@@ -48,14 +48,16 @@ func TestSlackNotifier(t *testing.T) {
So(slackNotifier.Url, ShouldEqual, "http://google.com")
So(slackNotifier.Recipient, ShouldEqual, "")
So(slackNotifier.Mention, ShouldEqual, "")
So(slackNotifier.Token, ShouldEqual, "")
})
Convey("from settings with Recipient and Mention", func() {
Convey("from settings with Recipient, Mention, and Token", func() {
json := `
{
"url": "http://google.com",
"recipient": "#ds-opentsdb",
"mention": "@carl"
"mention": "@carl",
"token": "xoxb-XXXXXXXX-XXXXXXXX-XXXXXXXXXX"
}`
settingsJSON, _ := simplejson.NewJson([]byte(json))
@@ -74,6 +76,7 @@ func TestSlackNotifier(t *testing.T) {
So(slackNotifier.Url, ShouldEqual, "http://google.com")
So(slackNotifier.Recipient, ShouldEqual, "#ds-opentsdb")
So(slackNotifier.Mention, ShouldEqual, "@carl")
So(slackNotifier.Token, ShouldEqual, "xoxb-XXXXXXXX-XXXXXXXX-XXXXXXXXXX")
})
})

View File

@@ -73,10 +73,8 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error {
OrgId: evalContext.Rule.OrgId,
DashboardId: evalContext.Rule.DashboardId,
PanelId: evalContext.Rule.PanelId,
Type: annotations.AlertType,
AlertId: evalContext.Rule.Id,
Title: evalContext.Rule.Name,
Text: evalContext.GetStateModel().Text,
Text: "",
NewState: string(evalContext.Rule.State),
PrevState: string(evalContext.PrevAlertState),
Epoch: time.Now().Unix(),

View File

@@ -5,7 +5,7 @@ import "github.com/grafana/grafana/pkg/components/simplejson"
type Repository interface {
Save(item *Item) error
Update(item *Item) error
Find(query *ItemQuery) ([]*Item, error)
Find(query *ItemQuery) ([]*ItemDTO, error)
Delete(params *DeleteParams) error
}
@@ -13,11 +13,10 @@ type ItemQuery struct {
OrgId int64 `json:"orgId"`
From int64 `json:"from"`
To int64 `json:"to"`
Type ItemType `json:"type"`
AlertId int64 `json:"alertId"`
DashboardId int64 `json:"dashboardId"`
PanelId int64 `json:"panelId"`
NewState []string `json:"newState"`
Tags []string `json:"tags"`
Limit int64 `json:"limit"`
}
@@ -28,12 +27,15 @@ type PostParams struct {
Epoch int64 `json:"epoch"`
Title string `json:"title"`
Text string `json:"text"`
Icon string `json:"icon"`
}
type DeleteParams struct {
Id int64 `json:"id"`
AlertId int64 `json:"alertId"`
DashboardId int64 `json:"dashboardId"`
PanelId int64 `json:"panelId"`
RegionId int64 `json:"regionId"`
}
var repositoryInstance Repository
@@ -46,29 +48,41 @@ func SetRepository(rep Repository) {
repositoryInstance = rep
}
type ItemType string
const (
AlertType ItemType = "alert"
EventType ItemType = "event"
)
type Item struct {
Id int64 `json:"id"`
OrgId int64 `json:"orgId"`
UserId int64 `json:"userId"`
DashboardId int64 `json:"dashboardId"`
PanelId int64 `json:"panelId"`
CategoryId int64 `json:"categoryId"`
RegionId int64 `json:"regionId"`
Type ItemType `json:"type"`
Title string `json:"title"`
Text string `json:"text"`
Metric string `json:"metric"`
AlertId int64 `json:"alertId"`
UserId int64 `json:"userId"`
PrevState string `json:"prevState"`
NewState string `json:"newState"`
Epoch int64 `json:"epoch"`
Tags []string `json:"tags"`
Data *simplejson.Json `json:"data"`
// needed until we remove it from db
Type string
Title string
}
type ItemDTO struct {
Id int64 `json:"id"`
AlertId int64 `json:"alertId"`
AlertName string `json:"alertName"`
DashboardId int64 `json:"dashboardId"`
PanelId int64 `json:"panelId"`
UserId int64 `json:"userId"`
NewState string `json:"newState"`
PrevState string `json:"prevState"`
Time int64 `json:"time"`
Text string `json:"text"`
RegionId int64 `json:"regionId"`
Tags []string `json:"tags"`
Login string `json:"login"`
Email string `json:"email"`
AvatarUrl string `json:"avatarUrl"`
Data *simplejson.Json `json:"data"`
}

View File

@@ -2,9 +2,11 @@ package sqlstore
import (
"bytes"
"errors"
"fmt"
"strings"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/annotations"
)
@@ -13,19 +15,94 @@ type SqlAnnotationRepo struct {
func (r *SqlAnnotationRepo) Save(item *annotations.Item) error {
return inTransaction(func(sess *DBSession) error {
tags := models.ParseTagPairs(item.Tags)
item.Tags = models.JoinTagPairs(tags)
if _, err := sess.Table("annotation").Insert(item); err != nil {
return err
}
if item.Tags != nil {
if tags, err := r.ensureTagsExist(sess, tags); err != nil {
return err
} else {
for _, tag := range tags {
if _, err := sess.Exec("INSERT INTO annotation_tag (annotation_id, tag_id) VALUES(?,?)", item.Id, tag.Id); err != nil {
return err
}
}
}
}
return nil
})
}
// Will insert if needed any new key/value pars and return ids
func (r *SqlAnnotationRepo) ensureTagsExist(sess *DBSession, tags []*models.Tag) ([]*models.Tag, error) {
for _, tag := range tags {
var existingTag models.Tag
// check if it exists
if exists, err := sess.Table("tag").Where("key=? AND value=?", tag.Key, tag.Value).Get(&existingTag); err != nil {
return nil, err
} else if exists {
tag.Id = existingTag.Id
} else {
if _, err := sess.Table("tag").Insert(tag); err != nil {
return nil, err
}
}
}
return tags, nil
}
func (r *SqlAnnotationRepo) Update(item *annotations.Item) error {
return inTransaction(func(sess *DBSession) error {
var (
isExist bool
err error
)
existing := new(annotations.Item)
if _, err := sess.Table("annotation").Id(item.Id).Update(item); err != nil {
if item.Id == 0 && item.RegionId != 0 {
// Update region end time
isExist, err = sess.Table("annotation").Where("region_id=? AND id!=? AND org_id=?", item.RegionId, item.RegionId, item.OrgId).Get(existing)
} else {
isExist, err = sess.Table("annotation").Where("id=? AND org_id=?", item.Id, item.OrgId).Get(existing)
}
if err != nil {
return err
}
if !isExist {
return errors.New("Annotation not found")
}
existing.Epoch = item.Epoch
existing.Text = item.Text
if item.RegionId != 0 {
existing.RegionId = item.RegionId
}
if item.Tags != nil {
if tags, err := r.ensureTagsExist(sess, models.ParseTagPairs(item.Tags)); err != nil {
return err
} else {
if _, err := sess.Exec("DELETE FROM annotation_tag WHERE annotation_id = ?", existing.Id); err != nil {
return err
}
for _, tag := range tags {
if _, err := sess.Exec("INSERT INTO annotation_tag (annotation_id, tag_id) VALUES(?,?)", existing.Id, tag.Id); err != nil {
return err
}
}
}
}
existing.Tags = item.Tags
if _, err := sess.Table("annotation").Id(existing.Id).Cols("epoch", "text", "region_id", "tags").Update(existing); err != nil {
return err
}
@@ -33,51 +110,79 @@ func (r *SqlAnnotationRepo) Update(item *annotations.Item) error {
})
}
func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.Item, error) {
func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.ItemDTO, error) {
var sql bytes.Buffer
params := make([]interface{}, 0)
sql.WriteString(`SELECT *
from annotation
sql.WriteString(`
SELECT
annotation.id,
annotation.epoch as time,
annotation.dashboard_id,
annotation.panel_id,
annotation.new_state,
annotation.prev_state,
annotation.alert_id,
annotation.region_id,
annotation.text,
annotation.tags,
annotation.data,
usr.email,
usr.login,
alert.name as alert_name
FROM annotation
LEFT OUTER JOIN ` + dialect.Quote("user") + ` as usr on usr.id = annotation.user_id
LEFT OUTER JOIN alert on alert.id = annotation.alert_id
`)
sql.WriteString(`WHERE org_id = ?`)
sql.WriteString(`WHERE annotation.org_id = ?`)
params = append(params, query.OrgId)
if query.AlertId != 0 {
sql.WriteString(` AND alert_id = ?`)
params = append(params, query.AlertId)
}
if query.AlertId != 0 {
sql.WriteString(` AND alert_id = ?`)
sql.WriteString(` AND annotation.alert_id = ?`)
params = append(params, query.AlertId)
}
if query.DashboardId != 0 {
sql.WriteString(` AND dashboard_id = ?`)
sql.WriteString(` AND annotation.dashboard_id = ?`)
params = append(params, query.DashboardId)
}
if query.PanelId != 0 {
sql.WriteString(` AND panel_id = ?`)
sql.WriteString(` AND annotation.panel_id = ?`)
params = append(params, query.PanelId)
}
if query.From > 0 && query.To > 0 {
sql.WriteString(` AND epoch BETWEEN ? AND ?`)
sql.WriteString(` AND annotation.epoch BETWEEN ? AND ?`)
params = append(params, query.From, query.To)
}
if query.Type != "" {
sql.WriteString(` AND type = ?`)
params = append(params, string(query.Type))
if len(query.Tags) > 0 {
keyValueFilters := []string{}
tags := models.ParseTagPairs(query.Tags)
for _, tag := range tags {
if tag.Value == "" {
keyValueFilters = append(keyValueFilters, "(tag.key = ?)")
params = append(params, tag.Key)
} else {
keyValueFilters = append(keyValueFilters, "(tag.key = ? AND tag.value = ?)")
params = append(params, tag.Key, tag.Value)
}
}
if len(query.NewState) > 0 {
sql.WriteString(` AND new_state IN (?` + strings.Repeat(",?", len(query.NewState)-1) + ")")
for _, v := range query.NewState {
params = append(params, v)
if len(tags) > 0 {
tagsSubQuery := fmt.Sprintf(`
SELECT SUM(1) FROM annotation_tag at
INNER JOIN tag on tag.id = at.tag_id
WHERE at.annotation_id = annotation.id
AND (
%s
)
`, strings.Join(keyValueFilters, " OR "))
sql.WriteString(fmt.Sprintf(" AND (%s) = %d ", tagsSubQuery, len(tags)))
}
}
@@ -87,7 +192,7 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I
sql.WriteString(fmt.Sprintf(" ORDER BY epoch DESC LIMIT %v", query.Limit))
items := make([]*annotations.Item, 0)
items := make([]*annotations.ItemDTO, 0)
if err := x.Sql(sql.String(), params...).Find(&items); err != nil {
return nil, err
}
@@ -97,11 +202,31 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I
func (r *SqlAnnotationRepo) Delete(params *annotations.DeleteParams) error {
return inTransaction(func(sess *DBSession) error {
var (
sql string
annoTagSql string
queryParams []interface{}
)
sql := "DELETE FROM annotation WHERE dashboard_id = ? AND panel_id = ?"
if params.RegionId != 0 {
annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE region_id = ?)"
sql = "DELETE FROM annotation WHERE region_id = ?"
queryParams = []interface{}{params.RegionId}
} else if params.Id != 0 {
annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE id = ?)"
sql = "DELETE FROM annotation WHERE id = ?"
queryParams = []interface{}{params.Id}
} else {
annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE dashboard_id = ? AND panel_id = ?)"
sql = "DELETE FROM annotation WHERE dashboard_id = ? AND panel_id = ?"
queryParams = []interface{}{params.DashboardId, params.PanelId}
}
_, err := sess.Exec(sql, params.DashboardId, params.PanelId)
if err != nil {
if _, err := sess.Exec(annoTagSql, queryParams...); err != nil {
return err
}
if _, err := sess.Exec(sql, queryParams...); err != nil {
return err
}

View File

@@ -0,0 +1,208 @@
package sqlstore
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/annotations"
)
func TestSavingTags(t *testing.T) {
Convey("Testing annotation saving/loading", t, func() {
InitTestDB(t)
repo := SqlAnnotationRepo{}
Convey("Can save tags", func() {
tagPairs := []*models.Tag{
{Key: "outage"},
{Key: "type", Value: "outage"},
{Key: "server", Value: "server-1"},
{Key: "error"},
}
tags, err := repo.ensureTagsExist(newSession(), tagPairs)
So(err, ShouldBeNil)
So(len(tags), ShouldEqual, 4)
})
})
}
func TestAnnotations(t *testing.T) {
Convey("Testing annotation saving/loading", t, func() {
InitTestDB(t)
repo := SqlAnnotationRepo{}
Convey("Can save annotation", func() {
err := repo.Save(&annotations.Item{
OrgId: 1,
UserId: 1,
DashboardId: 1,
Text: "hello",
Epoch: 10,
Tags: []string{"outage", "error", "type:outage", "server:server-1"},
})
So(err, ShouldBeNil)
Convey("Can query for annotation", func() {
items, err := repo.Find(&annotations.ItemQuery{
OrgId: 1,
DashboardId: 1,
From: 0,
To: 15,
})
So(err, ShouldBeNil)
So(items, ShouldHaveLength, 1)
Convey("Can read tags", func() {
So(items[0].Tags, ShouldResemble, []string{"outage", "error", "type:outage", "server:server-1"})
})
})
Convey("Should not find any when item is outside time range", func() {
items, err := repo.Find(&annotations.ItemQuery{
OrgId: 1,
DashboardId: 1,
From: 12,
To: 15,
})
So(err, ShouldBeNil)
So(items, ShouldHaveLength, 0)
})
Convey("Should not find one when tag filter does not match", func() {
items, err := repo.Find(&annotations.ItemQuery{
OrgId: 1,
DashboardId: 1,
From: 1,
To: 15,
Tags: []string{"asd"},
})
So(err, ShouldBeNil)
So(items, ShouldHaveLength, 0)
})
Convey("Should find one when all tag filters does match", func() {
items, err := repo.Find(&annotations.ItemQuery{
OrgId: 1,
DashboardId: 1,
From: 1,
To: 15,
Tags: []string{"outage", "error"},
})
So(err, ShouldBeNil)
So(items, ShouldHaveLength, 1)
})
Convey("Should find one when all key value tag filters does match", func() {
items, err := repo.Find(&annotations.ItemQuery{
OrgId: 1,
DashboardId: 1,
From: 1,
To: 15,
Tags: []string{"type:outage", "server:server-1"},
})
So(err, ShouldBeNil)
So(items, ShouldHaveLength, 1)
})
Convey("Can update annotation and remove all tags", func() {
query := &annotations.ItemQuery{
OrgId: 1,
DashboardId: 1,
From: 0,
To: 15,
}
items, err := repo.Find(query)
So(err, ShouldBeNil)
annotationId := items[0].Id
err = repo.Update(&annotations.Item{
Id: annotationId,
OrgId: 1,
Text: "something new",
Tags: []string{},
})
So(err, ShouldBeNil)
items, err = repo.Find(query)
So(err, ShouldBeNil)
Convey("Can read tags", func() {
So(items[0].Id, ShouldEqual, annotationId)
So(len(items[0].Tags), ShouldEqual, 0)
So(items[0].Text, ShouldEqual, "something new")
})
})
Convey("Can update annotation with new tags", func() {
query := &annotations.ItemQuery{
OrgId: 1,
DashboardId: 1,
From: 0,
To: 15,
}
items, err := repo.Find(query)
So(err, ShouldBeNil)
annotationId := items[0].Id
err = repo.Update(&annotations.Item{
Id: annotationId,
OrgId: 1,
Text: "something new",
Tags: []string{"newtag1", "newtag2"},
})
So(err, ShouldBeNil)
items, err = repo.Find(query)
So(err, ShouldBeNil)
Convey("Can read tags", func() {
So(items[0].Id, ShouldEqual, annotationId)
So(items[0].Tags, ShouldResemble, []string{"newtag1", "newtag2"})
So(items[0].Text, ShouldEqual, "something new")
})
})
Convey("Can delete annotation", func() {
query := &annotations.ItemQuery{
OrgId: 1,
DashboardId: 1,
From: 0,
To: 15,
}
items, err := repo.Find(query)
So(err, ShouldBeNil)
annotationId := items[0].Id
err = repo.Delete(&annotations.DeleteParams{Id: annotationId})
items, err = repo.Find(query)
So(err, ShouldBeNil)
Convey("Should be deleted", func() {
So(len(items), ShouldEqual, 0)
})
})
})
})
}

View File

@@ -378,6 +378,7 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error {
"DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?",
"DELETE FROM dashboard_version WHERE dashboard_id = ?",
"DELETE FROM dashboard WHERE folder_id = ?",
"DELETE FROM annotation WHERE dashboard_id = ?",
}
for _, sql := range deletes {

View File

@@ -57,4 +57,37 @@ func addAnnotationMig(mg *Migrator) {
mg.AddMigration("Add column region_id to annotation table", NewAddColumnMigration(table, &Column{
Name: "region_id", Type: DB_BigInt, Nullable: true, Default: "0",
}))
categoryIdIndex := &Index{Cols: []string{"org_id", "category_id"}, Type: IndexType}
mg.AddMigration("Drop category_id index", NewDropIndexMigration(table, categoryIdIndex))
mg.AddMigration("Add column tags to annotation table", NewAddColumnMigration(table, &Column{
Name: "tags", Type: DB_NVarchar, Nullable: true, Length: 500,
}))
///
/// Annotation tag
///
annotationTagTable := Table{
Name: "annotation_tag",
Columns: []*Column{
{Name: "annotation_id", Type: DB_BigInt, Nullable: false},
{Name: "tag_id", Type: DB_BigInt, Nullable: false},
},
Indices: []*Index{
{Cols: []string{"annotation_id", "tag_id"}, Type: UniqueIndex},
},
}
mg.AddMigration("Create annotation_tag table v2", NewAddTableMigration(annotationTagTable))
mg.AddMigration("Add unique index annotation_tag.annotation_id_tag_id", NewAddIndexMigration(annotationTagTable, annotationTagTable.Indices[0]))
//
// clear alert text
//
updateTextFieldSql := "UPDATE annotation SET TEXT = '' WHERE alert_id > 0"
mg.AddMigration("Update alert annotations and set TEXT to empty", new(RawSqlMigration).
Sqlite(updateTextFieldSql).
Postgres(updateTextFieldSql).
Mysql(updateTextFieldSql))
}

View File

@@ -28,6 +28,7 @@ func AddMigrations(mg *Migrator) {
addDashboardVersionMigration(mg)
addUserGroupMigrations(mg)
addDashboardAclMigrations(mg)
addTagMigration(mg)
}
func addMigrationLogMigrations(mg *Migrator) {

View File

@@ -0,0 +1,24 @@
package migrations
import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
func addTagMigration(mg *Migrator) {
tagTable := Table{
Name: "tag",
Columns: []*Column{
{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "key", Type: DB_NVarchar, Length: 100, Nullable: false},
{Name: "value", Type: DB_NVarchar, Length: 100, Nullable: false},
},
Indices: []*Index{
{Cols: []string{"key", "value"}, Type: UniqueIndex},
},
}
// create table
mg.AddMigration("create tag table", NewAddTableMigration(tagTable))
// create indices
mg.AddMigration("add index tag.key_value", NewAddIndexMigration(tagTable, tagTable.Indices[0]))
}

View File

@@ -104,7 +104,7 @@ func (db *Postgres) SqlType(c *Column) string {
func (db *Postgres) TableCheckSql(tableName string) (string, []interface{}) {
args := []interface{}{"grafana", tableName}
sql := "SELECT `TABLE_NAME` from `INFORMATION_SCHEMA`.`TABLES` WHERE `TABLE_SCHEMA`=? and `TABLE_NAME`=?"
sql := "SELECT table_name FROM information_schema.tables WHERE table_schema=? and table_name=?"
return sql, args
}

View File

@@ -1,69 +0,0 @@
///<reference path="../../headers/common.d.ts" />
import coreModule from 'app/core/core_module';
var template = `
<div class="graph-legend-popover">
<div ng-show="ctrl.series" class="p-b-1">
<label>Y Axis:</label>
<button ng-click="ctrl.toggleAxis(yaxis);" class="btn btn-small"
ng-class="{'btn-success': ctrl.series.yaxis === 1,
'btn-inverse': ctrl.series.yaxis === 2}">
Left
</button>
<button ng-click="ctrl.toggleAxis(yaxis);"
class="btn btn-small"
ng-class="{'btn-success': ctrl.series.yaxis === 2,
'btn-inverse': ctrl.series.yaxis === 1}">
Right
</button>
</div>
<p class="m-b-0">
<i ng-repeat="color in ctrl.colors" class="pointer fa fa-circle"
ng-style="{color:color}"
ng-click="ctrl.colorSelected(color);">&nbsp;</i>
</p>
</div>
`;
export class ColorPickerCtrl {
colors: any;
autoClose: boolean;
series: any;
showAxisControls: boolean;
/** @ngInject */
constructor(private $scope, $rootScope) {
this.colors = $rootScope.colors;
this.autoClose = $scope.autoClose;
this.series = $scope.series;
}
toggleAxis(yaxis) {
this.$scope.toggleAxis();
if (this.$scope.autoClose) {
this.$scope.dismiss();
}
}
colorSelected(color) {
this.$scope.colorSelected(color);
if (this.$scope.autoClose) {
this.$scope.dismiss();
}
}
}
export function colorPicker() {
return {
restrict: 'E',
controller: ColorPickerCtrl,
bindToController: true,
controllerAs: 'ctrl',
template: template,
};
}
coreModule.directive('gfColorPicker', colorPicker);

View File

@@ -0,0 +1,45 @@
import React from 'react';
import coreModule from 'app/core/core_module';
import { sortedColors } from 'app/core/utils/colors';
export interface IProps {
color: string;
onColorSelect: (c: string) => void;
}
export class GfColorPalette extends React.Component<IProps, any> {
paletteColors: string[];
constructor(props) {
super(props);
this.paletteColors = sortedColors;
this.onColorSelect = this.onColorSelect.bind(this);
}
onColorSelect(color) {
return () => {
this.props.onColorSelect(color);
};
}
render() {
const colorPaletteItems = this.paletteColors.map((paletteColor) => {
const cssClass = paletteColor.toLowerCase() === this.props.color.toLowerCase() ? 'fa-circle-o' : 'fa-circle';
return (
<i key={paletteColor} className={"pointer fa " + cssClass}
style={{'color': paletteColor}}
onClick={this.onColorSelect(paletteColor)}>&nbsp;
</i>
);
});
return (
<div className="graph-legend-popover">
<p className="m-b-0">{colorPaletteItems}</p>
</div>
);
}
}
coreModule.directive('gfColorPalette', function (reactDirective) {
return reactDirective(GfColorPalette, ['color', 'onColorSelect']);
});

View File

@@ -0,0 +1,84 @@
import React from 'react';
import ReactDOM from 'react-dom';
import $ from 'jquery';
import Drop from 'tether-drop';
import coreModule from 'app/core/core_module';
import { ColorPickerPopover } from './ColorPickerPopover';
export interface IProps {
color: string;
onChange: (c: string) => void;
}
export class ColorPicker extends React.Component<IProps, any> {
pickerElem: any;
colorPickerDrop: any;
constructor(props) {
super(props);
this.openColorPicker = this.openColorPicker.bind(this);
this.closeColorPicker = this.closeColorPicker.bind(this);
this.setPickerElem = this.setPickerElem.bind(this);
this.onColorSelect = this.onColorSelect.bind(this);
}
setPickerElem(elem) {
this.pickerElem = $(elem);
}
openColorPicker() {
const dropContent = (
<ColorPickerPopover color={this.props.color} onColorSelect={this.onColorSelect} />
);
let dropContentElem = document.createElement('div');
ReactDOM.render(dropContent, dropContentElem);
let drop = new Drop({
target: this.pickerElem[0],
content: dropContentElem,
position: 'top center',
classes: 'drop-popover drop-popover--form',
openOn: 'hover',
hoverCloseDelay: 200,
tetherOptions: {
constraints: [{ to: 'scrollParent', attachment: "none both" }]
}
});
drop.on('close', this.closeColorPicker);
this.colorPickerDrop = drop;
this.colorPickerDrop.open();
}
closeColorPicker() {
setTimeout(() => {
if (this.colorPickerDrop && this.colorPickerDrop.tether) {
this.colorPickerDrop.destroy();
}
}, 100);
}
onColorSelect(color) {
this.props.onChange(color);
}
render() {
return (
<div className="sp-replacer sp-light" onClick={this.openColorPicker} ref={this.setPickerElem}>
<div className="sp-preview">
<div className="sp-preview-inner" style={{backgroundColor: this.props.color}}>
</div>
</div>
</div>
);
}
}
coreModule.directive('colorPicker', function (reactDirective) {
return reactDirective(ColorPicker, [
'color',
['onChange', { watchDepth: 'reference', wrapApply: true }]
]);
});

View File

@@ -0,0 +1,121 @@
import React from 'react';
import $ from 'jquery';
import tinycolor from 'tinycolor2';
import coreModule from 'app/core/core_module';
import { GfColorPalette } from './ColorPalette';
import { GfSpectrumPicker } from './SpectrumPicker';
const DEFAULT_COLOR = '#000000';
export interface IProps {
color: string;
onColorSelect: (c: string) => void;
}
export class ColorPickerPopover extends React.Component<IProps, any> {
pickerNavElem: any;
constructor(props) {
super(props);
this.state = {
tab: 'palette',
color: this.props.color || DEFAULT_COLOR,
colorString: this.props.color || DEFAULT_COLOR
};
}
setPickerNavElem(elem) {
this.pickerNavElem = $(elem);
}
setColor(color) {
let newColor = tinycolor(color);
if (newColor.isValid()) {
this.setState({
color: newColor.toString(),
colorString: newColor.toString()
});
this.props.onColorSelect(color);
}
}
sampleColorSelected(color) {
this.setColor(color);
}
spectrumColorSelected(color) {
let rgbColor = color.toRgbString();
this.setColor(rgbColor);
}
onColorStringChange(e) {
let colorString = e.target.value;
this.setState({
colorString: colorString
});
let newColor = tinycolor(colorString);
if (newColor.isValid()) {
// Update only color state
this.setState({
color: newColor.toString(),
});
this.props.onColorSelect(newColor);
}
}
onColorStringBlur(e) {
let colorString = e.target.value;
this.setColor(colorString);
}
componentDidMount() {
this.pickerNavElem.find('li:first').addClass('active');
this.pickerNavElem.on('show', (e) => {
// use href attr (#name => name)
let tab = e.target.hash.slice(1);
this.setState({
tab: tab
});
});
}
render() {
const paletteTab = (
<div id="palette">
<GfColorPalette color={this.state.color} onColorSelect={this.sampleColorSelected.bind(this)} />
</div>
);
const spectrumTab = (
<div id="spectrum">
<GfSpectrumPicker color={this.state.color} onColorSelect={this.spectrumColorSelected.bind(this)} options={{}} />
</div>
);
const currentTab = this.state.tab === 'palette' ? paletteTab : spectrumTab;
return (
<div className="gf-color-picker">
<ul className="nav nav-tabs" id="colorpickernav" ref={this.setPickerNavElem.bind(this)}>
<li className="gf-tabs-item-colorpicker">
<a href="#palette" data-toggle="tab">Colors</a>
</li>
<li className="gf-tabs-item-colorpicker">
<a href="#spectrum" data-toggle="tab">Custom</a>
</li>
</ul>
<div className="gf-color-picker__body">
{currentTab}
</div>
<div>
<input className="gf-form-input gf-form-input--small" value={this.state.colorString}
onChange={this.onColorStringChange.bind(this)} onBlur={this.onColorStringBlur.bind(this)}>
</input>
</div>
</div>
);
}
}
coreModule.directive('gfColorPickerPopover', function (reactDirective) {
return reactDirective(ColorPickerPopover, ['color', 'onColorSelect']);
});

View File

@@ -0,0 +1,55 @@
import React from 'react';
import coreModule from 'app/core/core_module';
import {ColorPickerPopover} from './ColorPickerPopover';
export interface IProps {
series: any;
onColorChange: (color: string) => void;
onToggleAxis: () => void;
}
export class SeriesColorPicker extends React.Component<IProps, any> {
constructor(props) {
super(props);
this.onColorChange = this.onColorChange.bind(this);
this.onToggleAxis = this.onToggleAxis.bind(this);
}
onColorChange(color) {
this.props.onColorChange(color);
}
onToggleAxis() {
this.props.onToggleAxis();
}
renderAxisSelection() {
const leftButtonClass = this.props.series.yaxis === 1 ? 'btn-success' : 'btn-inverse';
const rightButtonClass = this.props.series.yaxis === 2 ? 'btn-success' : 'btn-inverse';
return (
<div className="p-b-1">
<label className="small p-r-1">Y Axis:</label>
<button onClick={this.onToggleAxis} className={'btn btn-small ' + leftButtonClass}>
Left
</button>
<button onClick={this.onToggleAxis} className={'btn btn-small ' + rightButtonClass}>
Right
</button>
</div>
);
}
render() {
return (
<div className="graph-legend-popover">
{this.props.series && this.renderAxisSelection()}
<ColorPickerPopover color={this.props.series.color} onColorSelect={this.onColorChange} />
</div>
);
}
}
coreModule.directive('seriesColorPicker', function(reactDirective) {
return reactDirective(SeriesColorPicker, ['series', 'onColorChange', 'onToggleAxis']);
});

View File

@@ -0,0 +1,76 @@
import React from 'react';
import coreModule from 'app/core/core_module';
import _ from 'lodash';
import $ from 'jquery';
import 'vendor/spectrum';
export interface IProps {
color: string;
options: object;
onColorSelect: (c: string) => void;
}
export class GfSpectrumPicker extends React.Component<IProps, any> {
elem: any;
isMoving: boolean;
constructor(props) {
super(props);
this.onSpectrumMove = this.onSpectrumMove.bind(this);
this.setComponentElem = this.setComponentElem.bind(this);
}
setComponentElem(elem) {
this.elem = $(elem);
}
onSpectrumMove(color) {
this.isMoving = true;
this.props.onColorSelect(color);
}
componentDidMount() {
let spectrumOptions = _.assignIn({
flat: true,
showAlpha: true,
showButtons: false,
color: this.props.color,
appendTo: this.elem,
move: this.onSpectrumMove,
}, this.props.options);
this.elem.spectrum(spectrumOptions);
this.elem.spectrum('show');
this.elem.spectrum('set', this.props.color);
}
componentWillUpdate(nextProps) {
// If user move pointer over spectrum field this produce 'move' event and component
// may update props.color. We don't want to update spectrum color in this case, so we can use
// isMoving flag for tracking moving state. Flag should be cleared in componentDidUpdate() which
// is called after updating occurs (when user finished moving).
if (!this.isMoving) {
this.elem.spectrum('set', nextProps.color);
}
}
componentDidUpdate() {
if (this.isMoving) {
this.isMoving = false;
}
}
componentWillUnmount() {
this.elem.spectrum('destroy');
}
render() {
return (
<div className="spectrum-container" ref={this.setComponentElem}></div>
);
}
}
coreModule.directive('gfSpectrumPicker', function (reactDirective) {
return reactDirective(GfSpectrumPicker, ['color', 'options', 'onColorSelect']);
});

View File

@@ -4,9 +4,6 @@ import coreModule from 'app/core/core_module';
var template = `
<select class="gf-form-input" ng-model="ctrl.model" ng-options="f.value as f.text for f in ctrl.options"></select>
<info-popover mode="right-absolute">
Not finding dashboard you want? Star it first, then it should appear in this select box.
</info-popover>
`;
export class DashboardSelectorCtrl {

View File

@@ -7,6 +7,7 @@ import $ from 'jquery';
import coreModule from 'app/core/core_module';
import {profiler} from 'app/core/profiler';
import appEvents from 'app/core/app_events';
import Drop from 'tether-drop';
export class GrafanaCtrl {
@@ -105,6 +106,11 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
if (data.params.kiosk) {
appEvents.emit('toggle-kiosk-mode');
}
// close all drops
for (let drop of Drop.drops) {
drop.destroy();
}
});
// handle kiosk mode

View File

@@ -27,6 +27,8 @@ export function infoPopover() {
transclude(function(clone, newScope) {
var content = document.createElement("div");
content.className = 'markdown-html';
_.each(clone, (node) => {
content.appendChild(node);
});

View File

@@ -1,77 +1,5 @@
/** Created by: Alex Wendland (me@alexwendland.com), 2014-08-06
*
* angular-json-tree
*
* Directive for creating a tree-view out of a JS Object. Only loads
* sub-nodes on demand in order to improve performance of rendering large
* objects.
*
* Attributes:
* - object (Object, 2-way): JS object to build the tree from
* - start-expanded (Boolean, 1-way, ?=true): should the tree default to expanded
*
* Usage:
* // In the controller
* scope.someObject = {
* test: 'hello',
* array: [1,1,2,3,5,8]
* };
* // In the html
* <json-tree object="someObject"></json-tree>
*
* Dependencies:
* - utils (json-tree.js)
* - ajsRecursiveDirectiveHelper (json-tree.js)
*
* Test: json-tree-test.js
*/
import angular from 'angular';
import coreModule from 'app/core/core_module';
var utils = {
/* See link for possible type values to check against.
* http://stackoverflow.com/questions/4622952/json-object-containing-array
*
* Value Class Type
* -------------------------------------
* "foo" String string
* new String("foo") String object
* 1.2 Number number
* new Number(1.2) Number object
* true Boolean boolean
* new Boolean(true) Boolean object
* new Date() Date object
* new Error() Error object
* [1,2,3] Array object
* new Array(1, 2, 3) Array object
* new Function("") Function function
* /abc/g RegExp object (function in Nitro/V8)
* new RegExp("meow") RegExp object (function in Nitro/V8)
* {} Object object
* new Object() Object object
*/
is: function is(obj, clazz) {
return Object.prototype.toString.call(obj).slice(8, -1) === clazz;
},
// See above for possible values
whatClass: function whatClass(obj) {
return Object.prototype.toString.call(obj).slice(8, -1);
},
// Iterate over an objects keyset
forKeys: function forKeys(obj, f) {
for (var key in obj) {
if (obj.hasOwnProperty(key) && typeof obj[key] !== 'function') {
if (f(key, obj[key])) {
break;
}
}
}
}
};
import {JsonExplorer} from '../json_explorer/json_explorer';
coreModule.directive('jsonTree', [function jsonTreeDirective() {
return{
@@ -81,121 +9,14 @@ coreModule.directive('jsonTree', [function jsonTreeDirective() {
startExpanded: '@',
rootName: '@',
},
template: '<json-node key="rootName" value="object" start-expanded="startExpanded"></json-node>'
};
}]);
link: function(scope, elem) {
coreModule.directive('jsonNode', ['ajsRecursiveDirectiveHelper', function jsonNodeDirective(ajsRecursiveDirectiveHelper) {
return {
restrict: 'E',
scope: {
key: '=',
value: '=',
startExpanded: '@'
},
compile: function jsonNodeDirectiveCompile(elem) {
return ajsRecursiveDirectiveHelper.compile(elem, this);
},
template: ' <span class="json-tree-key" ng-click="toggleExpanded()">{{key}}</span>' +
' <span class="json-tree-leaf-value" ng-if="!isExpandable">{{value}}</span>' +
' <span class="json-tree-branch-preview" ng-if="isExpandable" ng-show="!isExpanded" ng-click="toggleExpanded()">' +
' {{preview}}</span>' +
' <ul class="json-tree-branch-value" ng-if="isExpandable && shouldRender" ng-show="isExpanded">' +
' <li ng-repeat="(subkey,subval) in value">' +
' <json-node key="subkey" value="subval"></json-node>' +
' </li>' +
' </ul>',
pre: function jsonNodeDirectiveLink(scope, elem, attrs) {
// Set value's type as Class for CSS styling
elem.addClass(utils.whatClass(scope.value).toLowerCase());
// If the value is an Array or Object, use expandable view type
if (utils.is(scope.value, 'Object') || utils.is(scope.value, 'Array')) {
scope.isExpandable = true;
// Add expandable class for CSS usage
elem.addClass('expandable');
// Setup preview text
var isArray = utils.is(scope.value, 'Array');
scope.preview = isArray ? '[ ' : '{ ';
utils.forKeys(scope.value, function jsonNodeDirectiveLinkForKeys(key, value) {
if (value === null) { scope.value[key] = 'null'; }
if (isArray) {
scope.preview += value + ', ';
} else {
scope.preview += key + ': ' + value + ', ';
}
});
scope.preview = scope.preview.substring(0, scope.preview.length - (scope.preview.length > 2 ? 2 : 0)) + (isArray ? ' ]' : ' }');
// If directive initially has isExpanded set, also set shouldRender to true
if (scope.startExpanded) {
scope.shouldRender = true;
elem.addClass('expanded');
}
// Setup isExpanded state handling
scope.isExpanded = scope.startExpanded;
scope.toggleExpanded = function jsonNodeDirectiveToggleExpanded() {
scope.isExpanded = !scope.isExpanded;
if (scope.isExpanded) {
elem.addClass('expanded');
} else {
elem.removeClass('expanded');
}
// For delaying subnode render until requested
scope.shouldRender = true;
};
} else {
scope.isExpandable = false;
// Add expandable class for CSS usage
elem.addClass('not-expandable');
}
}
};
}]);
/** Added by: Alex Wendland (me@alexwendland.com), 2014-08-09
* Source: http://stackoverflow.com/questions/14430655/recursion-in-angular-directives
*
* Used to allow for recursion within directives
*/
coreModule.factory('ajsRecursiveDirectiveHelper', ['$compile', function RecursiveDirectiveHelper($compile) {
return {
/**
* Manually compiles the element, fixing the recursion loop.
* @param element
* @param [link] A post-link function, or an object with function(s) registered via pre and post properties.
* @returns An object containing the linking functions.
*/
compile: function RecursiveDirectiveHelperCompile(element, link) {
// Normalize the link parameter
if (angular.isFunction(link)) {
link = {
post: link
};
}
// Break the recursion loop by removing the contents
var contents = element.contents().remove();
var compiledContents;
return {
pre: (link && link.pre) ? link.pre : null,
/**
* Compiles and re-adds the contents
*/
post: function RecursiveDirectiveHelperCompilePost(scope, element) {
// Compile the contents
if (!compiledContents) {
compiledContents = $compile(contents);
}
// Re-add the compiled contents to the element
compiledContents(scope, function (clone) {
element.append(clone);
var jsonExp = new JsonExplorer(scope.object, 3, {
animateOpen: true
});
// Call the post-linking function, if any
if (link && link.post) {
link.post.apply(null, arguments);
}
}
};
const html = jsonExp.render(true);
elem.html(html);
}
};
}]);

View File

@@ -5,7 +5,6 @@ import "./directives/dropdown_typeahead";
import "./directives/metric_segment";
import "./directives/misc";
import "./directives/ng_model_on_blur";
import "./directives/spectrum_picker";
import "./directives/tags";
import "./directives/value_select_dropdown";
import "./directives/rebuild_on_change";
@@ -16,12 +15,13 @@ import './partials';
import './components/jsontree/jsontree';
import './components/code_editor/code_editor';
import './utils/outline';
import './components/colorpicker/ColorPicker';
import './components/colorpicker/SeriesColorPicker';
import {grafanaAppDirective} from './components/grafana_app';
import {sideMenuDirective} from './components/sidemenu/sidemenu';
import {searchDirective} from './components/search/search';
import {infoPopover} from './components/info_popover';
import {colorPicker} from './components/colorpicker';
import {navbarDirective} from './components/navbar/navbar';
import {arrayJoin} from './directives/array_join';
import {liveSrv} from './live/live_srv';
@@ -58,7 +58,6 @@ export {
sideMenuDirective,
navbarDirective,
searchDirective,
colorPicker,
liveSrv,
layoutSelector,
switchDirective,

View File

@@ -79,7 +79,9 @@ function (_, $, coreModule) {
$scope.$apply(function() {
$scope.getOptions({ $query: query }).then(function(altSegments) {
$scope.altSegments = altSegments;
options = _.map($scope.altSegments, function(alt) { return alt.value; });
options = _.map($scope.altSegments, function(alt) {
return _.escape(alt.value);
});
// add custom values
if (segment.custom !== 'false') {

View File

@@ -1,41 +0,0 @@
define([
'angular',
'../core_module',
'vendor/spectrum',
],
function (angular, coreModule) {
'use strict';
coreModule.default.directive('spectrumPicker', function() {
return {
restrict: 'E',
require: 'ngModel',
scope: false,
replace: true,
template: "<span><input class='input-small' /></span>",
link: function(scope, element, attrs, ngModel) {
var input = element.find('input');
var options = angular.extend({
showAlpha: true,
showButtons: false,
color: ngModel.$viewValue,
change: function(color) {
scope.$apply(function() {
ngModel.$setViewValue(color.toRgbString());
});
}
}, scope.$eval(attrs.options));
ngModel.$render = function() {
input.spectrum('set', ngModel.$viewValue || '');
};
input.spectrum(options);
scope.$on('$destroy', function() {
input.spectrum('destroy');
});
}
};
});
});

View File

@@ -88,6 +88,7 @@ function (angular, $, coreModule) {
typeahead: {
source: angular.isFunction(scope.$parent[attrs.typeaheadSource]) ? scope.$parent[attrs.typeaheadSource] : null
},
widthClass: attrs.widthClass,
itemValue: getItemProperty(scope, attrs.itemvalue),
itemText : getItemProperty(scope, attrs.itemtext),
tagClass : angular.isFunction(scope.$parent[attrs.tagclass]) ?

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
import config from 'app/core/config';

View File

@@ -90,7 +90,7 @@ export class NavModelSrv {
menu.push({
title: 'Annotations',
icon: 'fa fa-fw fa-bolt',
icon: 'fa fa-fw fa-comment',
clickHandler: () => dashNavCtrl.openEditView('annotations')
});

View File

@@ -1,6 +1,15 @@
import _ from 'lodash';
import tinycolor from 'tinycolor2';
export const PALETTE_ROWS = 4;
export const PALETTE_COLUMNS = 14;
export const DEFAULT_ANNOTATION_COLOR = 'rgba(0, 211, 255, 1)';
export const OK_COLOR = "rgba(11, 237, 50, 1)";
export const ALERTING_COLOR = "rgba(237, 46, 24, 1)";
export const NO_DATA_COLOR = "rgba(150, 150, 150, 1)";
export const REGION_FILL_ALPHA = 0.09;
export default [
let colors = [
"#7EB26D","#EAB839","#6ED0E0","#EF843C","#E24D42","#1F78C1","#BA43A9","#705DA0",
"#508642","#CCA300","#447EBC","#C15C17","#890F02","#0A437C","#6D1F62","#584477",
"#B7DBAB","#F4D598","#70DBED","#F9BA8F","#F29191","#82B5D8","#E5A8E2","#AEA2E0",
@@ -10,3 +19,26 @@ export default [
"#E0F9D7","#FCEACA","#CFFAFF","#F9E2D2","#FCE2DE","#BADFF4","#F9D9F9","#DEDAF7"
];
export function sortColorsByHue(hexColors) {
let hslColors = _.map(hexColors, hexToHsl);
let sortedHSLColors = _.sortBy(hslColors, ['h']);
sortedHSLColors = _.chunk(sortedHSLColors, PALETTE_ROWS);
sortedHSLColors = _.map(sortedHSLColors, chunk => {
return _.sortBy(chunk, 'l');
});
sortedHSLColors = _.flattenDeep(_.zip(...sortedHSLColors));
return _.map(sortedHSLColors, hslToHex);
}
export function hexToHsl(color) {
return tinycolor(color).toHsl();
}
export function hslToHex(color) {
return tinycolor(color).toHexString();
}
export let sortedColors = sortColorsByHue(colors);
export default colors;

View File

@@ -5,7 +5,7 @@ import moment from 'moment';
var units = ['y', 'M', 'w', 'd', 'h', 'm', 's'];
export function parse(text, roundUp?) {
export function parse(text, roundUp?, timezone?) {
if (!text) { return undefined; }
if (moment.isMoment(text)) { return text; }
if (_.isDate(text)) { return moment(text); }
@@ -16,7 +16,11 @@ export function parse(text, roundUp?) {
var parseString;
if (text.substring(0, 3) === 'now') {
if (timezone === 'utc') {
time = moment.utc();
} else {
time = moment();
}
mathString = text.substring('now'.length);
} else {
index = text.indexOf('||');

View File

@@ -1,14 +1,12 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
import moment from 'moment';
declare var window: any;
const DEFAULT_DATETIME_FORMAT: String = 'YYYY-MM-DDTHH:mm:ssZ';
const DEFAULT_DATETIME_FORMAT = 'YYYY-MM-DDTHH:mm:ssZ';
export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
var text = excel ? 'sep=;\n' : '' + 'Series;Time;Value\n';
var text = (excel ? 'sep=;\n' : '') + 'Series;Time;Value\n';
_.each(seriesList, function(series) {
_.each(series.datapoints, function(dp) {
text += series.alias + ';' + moment(dp[1]).format(dateTimeFormat) + ';' + dp[0] + '\n';
@@ -18,7 +16,7 @@ export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATET
}
export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
var text = excel ? 'sep=;\n' : '' + 'Time;';
var text = (excel ? 'sep=;\n' : '') + 'Time;';
// add header
_.each(seriesList, function(series) {
text += series.alias + ';';

View File

@@ -402,6 +402,10 @@ function($, _, moment) {
kbn.valueFormats.currencyRUB = kbn.formatBuilders.currency('₽');
kbn.valueFormats.currencyUAH = kbn.formatBuilders.currency('₴');
kbn.valueFormats.currencyBRL = kbn.formatBuilders.currency('R$');
kbn.valueFormats.currencyDKK = kbn.formatBuilders.currency('kr');
kbn.valueFormats.currencyISK = kbn.formatBuilders.currency('kr');
kbn.valueFormats.currencyNOK = kbn.formatBuilders.currency('kr');
kbn.valueFormats.currencySEK = kbn.formatBuilders.currency('kr');
// Data (Binary)
kbn.valueFormats.bits = kbn.formatBuilders.binarySIPrefix('b');
@@ -756,6 +760,10 @@ function($, _, moment) {
{text: 'Rubles (₽)', value: 'currencyRUB'},
{text: 'Hryvnias (₴)', value: 'currencyUAH'},
{text: 'Real (R$)', value: 'currencyBRL'},
{text: 'Danish Krone (kr)', value: 'currencyDKK'},
{text: 'Icelandic Krone (kr)', value: 'currencyISK'},
{text: 'Norwegian Krone (kr)', value: 'currencyNOK'},
{text: 'Swedish Krone (kr)', value: 'currencySEK'},
]
},
{

View File

@@ -41,7 +41,7 @@
<form name="passwordForm" class="gf-form-group">
<div class="gf-form" >
<editor-checkbox text="Grafana Admin" model="permissions.isGrafanaAdmin" style="line-height: 1.5rem;"></editor-checkbox>
<gf-form-switch class="gf-form" label="Grafana Admin" checked="permissions.isGrafanaAdmin" switch-class="max-width-6"></gf-form-switch>
</div>
<div class="gf-form-button-row">

View File

@@ -128,7 +128,6 @@ function joinEvalMatches(matches, separator: string) {
}
function getAlertAnnotationInfo(ah) {
// backward compatability, can be removed in grafana 5.x
// old way stored evalMatches in data property directly,
// new way stores it in evalMatches property on new data object

View File

@@ -1,12 +1,10 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
import $ from 'jquery';
import coreModule from 'app/core/core_module';
import alertDef from '../alerting/alert_def';
/** @ngInject **/
export function annotationTooltipDirective($sanitize, dashboardSrv, $compile) {
export function annotationTooltipDirective($sanitize, dashboardSrv, contextSrv, popoverSrv, $compile) {
function sanitizeString(str) {
try {
@@ -21,6 +19,7 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, $compile) {
restrict: 'E',
scope: {
"event": "=",
"onEdit": "&"
},
link: function(scope, element) {
var event = scope.event;
@@ -31,33 +30,46 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, $compile) {
var tooltip = '<div class="graph-annotation">';
var titleStateClass = '';
if (event.source.name === 'panel-alert') {
if (event.alertId) {
var stateModel = alertDef.getStateDisplayModel(event.newState);
titleStateClass = stateModel.stateClass;
title = `<i class="icon-gf ${stateModel.iconClass}"></i> ${stateModel.text}`;
text = alertDef.getAlertAnnotationInfo(event);
if (event.text) {
text = text + '<br />' + event.text;
}
} else if (title) {
text = title + '<br />' + text;
title = '';
}
tooltip += `
<div class="graph-annotation-header">
<span class="graph-annotation-title ${titleStateClass}">${sanitizeString(title)}</span>
<span class="graph-annotation-time">${dashboard.formatDate(event.min)}</span>
</div>
var header = `<div class="graph-annotation__header">`;
if (event.login) {
header += `<div class="graph-annotation__user" bs-tooltip="'Created by ${event.login}'"><img src="${event.avatarUrl}" /></div>`;
}
header += `
<span class="graph-annotation__title ${titleStateClass}">${sanitizeString(title)}</span>
<span class="graph-annotation__time">${dashboard.formatDate(event.min)}</span>
`;
tooltip += '<div class="graph-annotation-body">';
// Show edit icon only for users with at least Editor role
if (event.id && contextSrv.isEditor) {
header += `
<span class="pointer graph-annotation__edit-icon" ng-click="onEdit()">
<i class="fa fa-pencil-square"></i>
</span>
`;
}
header += `</div>`;
tooltip += header;
tooltip += '<div class="graph-annotation__body">';
if (text) {
tooltip += sanitizeString(text).replace(/\n/g, '<br>') + '<br>';
tooltip += '<div>' + sanitizeString(text).replace(/\n/g, '<br>') + '</div>';
}
var tags = event.tags;
if (_.isString(event.tags)) {
tags = event.tags.split(',');
if (tags.length === 1) {
tags = event.tags.split(' ');
}
}
if (tags && tags.length) {
scope.tags = tags;
@@ -65,6 +77,7 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, $compile) {
}
tooltip += "</div>";
tooltip += '</div>';
var $tooltip = $(tooltip);
$tooltip.appendTo(element);

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import './editor_ctrl';
import angular from 'angular';
@@ -11,11 +9,7 @@ export class AnnotationsSrv {
alertStatesPromise: any;
/** @ngInject */
constructor(private $rootScope,
private $q,
private datasourceSrv,
private backendSrv,
private timeSrv) {
constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) {
$rootScope.onAppEvent('refresh', this.clearCache.bind(this), $rootScope);
$rootScope.onAppEvent('dashboard-initialized', this.clearCache.bind(this), $rootScope);
}
@@ -26,66 +20,42 @@ export class AnnotationsSrv {
}
getAnnotations(options) {
return this.$q.all([
this.getGlobalAnnotations(options),
this.getPanelAnnotations(options),
this.getAlertStates(options)
]).then(results => {
return this.$q
.all([this.getGlobalAnnotations(options), this.getAlertStates(options)])
.then(results => {
// combine the annotations and flatten results
var annotations = _.flattenDeep([results[0], results[1]]);
var annotations = _.flattenDeep(results[0]);
// filter out annotations that do not belong to requesting panel
annotations = _.filter(annotations, item => {
// shownIn === 1 requires annotation matching panel id
if (item.source.showIn === 1) {
if (item.panelId && options.panel.id === item.panelId) {
return true;
}
return false;
// if event has panel id and query is of type dashboard then panel and requesting panel id must match
if (item.panelId && item.source.type === 'dashboard') {
return item.panelId === options.panel.id;
}
return true;
});
annotations = dedupAnnotations(annotations);
annotations = makeRegions(annotations, options);
// look for alert state for this panel
var alertState = _.find(results[2], {panelId: options.panel.id});
var alertState = _.find(results[1], {panelId: options.panel.id});
return {
annotations: annotations,
alertState: alertState,
};
}).catch(err => {
})
.catch(err => {
if (!err.message && err.data && err.data.message) {
err.message = err.data.message;
}
this.$rootScope.appEvent('alert-error', ['Annotation Query Failed', (err.message || err)]);
console.log('AnnotationSrv.query error', err);
this.$rootScope.appEvent('alert-error', ['Annotation Query Failed', err.message || err]);
return [];
});
}
getPanelAnnotations(options) {
var panel = options.panel;
var dashboard = options.dashboard;
if (dashboard.id && panel && panel.alert) {
return this.backendSrv.get('/api/annotations', {
from: options.range.from.valueOf(),
to: options.range.to.valueOf(),
limit: 100,
panelId: panel.id,
dashboardId: dashboard.id,
}).then(results => {
// this built in annotation source name `panel-alert` is used in annotation tooltip
// to know that this annotation is from panel alert
return this.translateQueryResult({iconColor: '#AA0000', name: 'panel-alert'}, results);
});
}
return this.$q.when([]);
}
getAlertStates(options) {
if (!options.dashboard.id) {
return this.$q.when([]);
@@ -104,32 +74,42 @@ export class AnnotationsSrv {
return this.alertStatesPromise;
}
this.alertStatesPromise = this.backendSrv.get('/api/alerts/states-for-dashboard', {dashboardId: options.dashboard.id});
this.alertStatesPromise = this.backendSrv.get('/api/alerts/states-for-dashboard', {
dashboardId: options.dashboard.id,
});
return this.alertStatesPromise;
}
getGlobalAnnotations(options) {
var dashboard = options.dashboard;
if (dashboard.annotations.list.length === 0) {
return this.$q.when([]);
}
if (this.globalAnnotationsPromise) {
return this.globalAnnotationsPromise;
}
var annotations = _.filter(dashboard.annotations.list, {enable: true});
var range = this.timeSrv.timeRange();
var promises = [];
for (let annotation of dashboard.annotations.list) {
if (!annotation.enable) {
continue;
}
this.globalAnnotationsPromise = this.$q.all(_.map(annotations, annotation => {
if (annotation.snapshotData) {
return this.translateQueryResult(annotation, annotation.snapshotData);
}
return this.datasourceSrv.get(annotation.datasource).then(datasource => {
promises.push(
this.datasourceSrv
.get(annotation.datasource)
.then(datasource => {
// issue query against data source
return datasource.annotationQuery({range: range, rangeRaw: range.raw, annotation: annotation});
return datasource.annotationQuery({
range: range,
rangeRaw: range.raw,
annotation: annotation,
dashboard: dashboard,
});
})
.then(results => {
// store response in annotation object if this is a snapshot call
@@ -138,9 +118,11 @@ export class AnnotationsSrv {
}
// translate result
return this.translateQueryResult(annotation, results);
});
}));
}),
);
}
this.globalAnnotationsPromise = this.$q.all(promises);
return this.globalAnnotationsPromise;
}
@@ -149,6 +131,21 @@ export class AnnotationsSrv {
return this.backendSrv.post('/api/annotations', annotation);
}
updateAnnotationEvent(annotation) {
this.globalAnnotationsPromise = null;
return this.backendSrv.put(`/api/annotations/${annotation.id}`, annotation);
}
deleteAnnotationEvent(annotation) {
this.globalAnnotationsPromise = null;
let deleteUrl = `/api/annotations/${annotation.id}`;
if (annotation.isRegion) {
deleteUrl = `/api/annotations/region/${annotation.regionId}`;
}
return this.backendSrv.delete(deleteUrl);
}
translateQueryResult(annotation, results) {
// if annotation has snapshotData
// make clone and remove it
@@ -159,13 +156,88 @@ export class AnnotationsSrv {
for (var item of results) {
item.source = annotation;
item.min = item.time;
item.max = item.time;
item.scope = 1;
item.eventType = annotation.name;
}
return results;
}
}
/**
* This function converts annotation events into set
* of single events and regions (event consist of two)
* @param annotations
* @param options
*/
function makeRegions(annotations, options) {
let [regionEvents, singleEvents] = _.partition(annotations, 'regionId');
let regions = getRegions(regionEvents, options.range);
annotations = _.concat(regions, singleEvents);
return annotations;
}
function getRegions(events, range) {
let region_events = _.filter(events, event => {
return event.regionId;
});
let regions = _.groupBy(region_events, 'regionId');
regions = _.compact(
_.map(regions, region_events => {
let region_obj = _.head(region_events);
if (region_events && region_events.length > 1) {
region_obj.timeEnd = region_events[1].time;
region_obj.isRegion = true;
return region_obj;
} else {
if (region_events && region_events.length) {
// Don't change proper region object
if (!region_obj.time || !region_obj.timeEnd) {
// This is cut region
if (isStartOfRegion(region_obj)) {
region_obj.timeEnd = range.to.valueOf() - 1;
} else {
// Start time = null
region_obj.timeEnd = region_obj.time;
region_obj.time = range.from.valueOf() + 1;
}
region_obj.isRegion = true;
}
return region_obj;
}
}
}),
);
return regions;
}
function isStartOfRegion(event): boolean {
return event.id && event.id === event.regionId;
}
function dedupAnnotations(annotations) {
let dedup = [];
// Split events by annotationId property existance
let events = _.partition(annotations, 'id');
let eventsById = _.groupBy(events[0], 'id');
dedup = _.map(eventsById, eventGroup => {
if (eventGroup.length > 1 && !_.every(eventGroup, isPanelAlert)) {
// Get first non-panel alert
return _.find(eventGroup, event => {
return event.eventType !== 'panel-alert';
});
} else {
return _.head(eventGroup);
}
});
dedup = _.concat(dedup, events[1]);
return dedup;
}
function isPanelAlert(event) {
return event.eventType === 'panel-alert';
}
coreModule.service('annotationsSrv', AnnotationsSrv);

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import angular from 'angular';
import _ from 'lodash';
import $ from 'jquery';
@@ -36,11 +34,7 @@ export class AnnotationsEditorCtrl {
this.annotations = $scope.dashboard.annotations.list;
this.reset();
$scope.$watch('mode', newVal => {
if (newVal === 'new') {
this.reset();
}
});
this.onColorChange = this.onColorChange.bind(this);
}
datasourceChanged() {
@@ -71,6 +65,11 @@ export class AnnotationsEditorCtrl {
this.$scope.broadcastRefresh();
}
setupNew() {
this.mode = 'new';
this.reset();
}
add() {
this.annotations.push(this.currentAnnotation);
this.reset();
@@ -85,6 +84,18 @@ export class AnnotationsEditorCtrl {
this.$scope.dashboard.updateSubmenuVisibility();
this.$scope.broadcastRefresh();
}
onColorChange(newColor) {
this.currentAnnotation.iconColor = newColor;
}
annotationEnabledChange() {
this.$scope.broadcastRefresh();
}
annotationHiddenChanged() {
this.$scope.dashboard.updateSubmenuVisibility();
}
}
coreModule.controller('AnnotationsEditorCtrl', AnnotationsEditorCtrl);

View File

@@ -2,9 +2,11 @@
export class AnnotationEvent {
dashboardId: number;
panelId: number;
userId: number;
time: any;
timeEnd: any;
isRegion: boolean;
title: string;
text: string;
type: string;
tags: string;
}

View File

@@ -1,6 +1,5 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
import moment from 'moment';
import {coreModule} from 'app/core/core';
import {MetricsPanelCtrl} from 'app/plugins/sdk';
import {AnnotationEvent} from './event';
@@ -11,11 +10,20 @@ export class EventEditorCtrl {
timeRange: {from: number, to: number};
form: any;
close: any;
timeFormated: string;
/** @ngInject **/
constructor(private annotationsSrv) {
this.event.panelId = this.panelCtrl.panel.id;
this.event.dashboardId = this.panelCtrl.dashboard.id;
// Annotations query returns time as Unix timestamp in milliseconds
this.event.time = tryEpochToMoment(this.event.time);
if (this.event.isRegion) {
this.event.timeEnd = tryEpochToMoment(this.event.timeEnd);
}
this.timeFormated = this.panelCtrl.dashboard.formatDate(this.event.time);
}
save() {
@@ -28,7 +36,7 @@ export class EventEditorCtrl {
saveModel.timeEnd = 0;
if (saveModel.isRegion) {
saveModel.timeEnd = saveModel.timeEnd.valueOf();
saveModel.timeEnd = this.event.timeEnd.valueOf();
if (saveModel.timeEnd < saveModel.time) {
console.log('invalid time');
@@ -36,14 +44,48 @@ export class EventEditorCtrl {
}
}
this.annotationsSrv.saveAnnotationEvent(saveModel).then(() => {
if (saveModel.id) {
this.annotationsSrv.updateAnnotationEvent(saveModel)
.then(() => {
this.panelCtrl.refresh();
this.close();
})
.catch(() => {
this.panelCtrl.refresh();
this.close();
});
} else {
this.annotationsSrv.saveAnnotationEvent(saveModel)
.then(() => {
this.panelCtrl.refresh();
this.close();
})
.catch(() => {
this.panelCtrl.refresh();
this.close();
});
}
}
timeChanged() {
this.panelCtrl.render();
delete() {
return this.annotationsSrv.deleteAnnotationEvent(this.event)
.then(() => {
this.panelCtrl.refresh();
this.close();
})
.catch(() => {
this.panelCtrl.refresh();
this.close();
});
}
}
function tryEpochToMoment(timestamp) {
if (timestamp && _.isNumber(timestamp)) {
let epoch = Number(timestamp);
return moment(epoch);
} else {
return timestamp;
}
}

View File

@@ -1,27 +1,28 @@
import _ from 'lodash';
import moment from 'moment';
import tinycolor from 'tinycolor2';
import {MetricsPanelCtrl} from 'app/plugins/sdk';
import {AnnotationEvent} from './event';
import {OK_COLOR, ALERTING_COLOR, NO_DATA_COLOR, DEFAULT_ANNOTATION_COLOR, REGION_FILL_ALPHA} from 'app/core/utils/colors';
export class EventManager {
event: AnnotationEvent;
editorOpen: boolean;
constructor(private panelCtrl: MetricsPanelCtrl, private elem, private popoverSrv) {
}
constructor(private panelCtrl: MetricsPanelCtrl) {}
editorClosed() {
console.log('editorClosed');
this.event = null;
this.editorOpen = false;
this.panelCtrl.render();
}
updateTime(range) {
let newEvent = true;
editorOpened() {
this.editorOpen = true;
}
if (this.event) {
newEvent = false;
} else {
// init new event
updateTime(range) {
if (!this.event) {
this.event = new AnnotationEvent();
this.event.dashboardId = this.panelCtrl.dashboard.id;
this.event.panelId = this.panelCtrl.panel.id;
@@ -35,25 +36,11 @@ export class EventManager {
this.event.isRegion = true;
}
// newEvent means the editor is not visible
if (!newEvent) {
this.panelCtrl.render();
return;
}
this.popoverSrv.show({
element: this.elem[0],
classNames: 'drop-popover drop-popover--form',
position: 'bottom center',
openOn: null,
template: '<event-editor panel-ctrl="panelCtrl" event="event" close="dismiss()"></event-editor>',
onClose: this.editorClosed.bind(this),
model: {
event: this.event,
panelCtrl: this.panelCtrl,
},
});
editEvent(event, elem?) {
this.event = event;
this.panelCtrl.render();
}
@@ -63,36 +50,60 @@ export class EventManager {
}
var types = {
'$__alerting': {
color: 'rgba(237, 46, 24, 1)',
$__alerting: {
color: ALERTING_COLOR,
position: 'BOTTOM',
markerSize: 5,
},
'$__ok': {
color: 'rgba(11, 237, 50, 1)',
$__ok: {
color: OK_COLOR,
position: 'BOTTOM',
markerSize: 5,
},
'$__no_data': {
color: 'rgba(150, 150, 150, 1)',
$__no_data: {
color: NO_DATA_COLOR,
position: 'BOTTOM',
markerSize: 5,
},
$__editing: {
color: DEFAULT_ANNOTATION_COLOR,
position: 'BOTTOM',
markerSize: 5,
},
};
if (this.event) {
if (this.event.isRegion) {
annotations = [
{
isRegion: true,
min: this.event.time.valueOf(),
timeEnd: this.event.timeEnd.valueOf(),
text: this.event.text,
eventType: '$__editing',
editModel: this.event,
},
];
} else {
annotations = [
{
min: this.event.time.valueOf(),
title: this.event.title,
text: this.event.text,
eventType: '$__alerting',
}
editModel: this.event,
eventType: '$__editing',
},
];
}
} else {
// annotations from query
for (var i = 0; i < annotations.length; i++) {
var item = annotations[i];
// add properties used by jquery flot events
item.min = item.time;
item.max = item.time;
item.eventType = item.source.name;
if (item.newState) {
item.eventType = '$__' + item.newState;
continue;
@@ -108,10 +119,50 @@ export class EventManager {
}
}
let regions = getRegions(annotations);
addRegionMarking(regions, flotOptions);
let eventSectionHeight = 20;
let eventSectionMargin = 7;
flotOptions.grid.eventSectionHeight = eventSectionMargin;
flotOptions.xaxis.eventSectionHeight = eventSectionHeight;
flotOptions.events = {
levels: _.keys(types).length + 1,
data: annotations,
types: types,
manager: this,
};
}
}
function getRegions(events) {
return _.filter(events, 'isRegion');
}
function addRegionMarking(regions, flotOptions) {
let markings = flotOptions.grid.markings;
let defaultColor = DEFAULT_ANNOTATION_COLOR;
let fillColor;
_.each(regions, region => {
if (region.source) {
fillColor = region.source.iconColor || defaultColor;
} else {
fillColor = defaultColor;
}
fillColor = addAlphaToRGB(fillColor, REGION_FILL_ALPHA);
markings.push({xaxis: {from: region.min, to: region.timeEnd}, color: fillColor});
});
}
function addAlphaToRGB(colorString: string, alpha: number): string {
let color = tinycolor(colorString);
if (color.isValid()) {
color.setAlpha(alpha);
return color.toRgbString();
} else {
return colorString;
}
}

View File

@@ -40,10 +40,11 @@
Annotations provide a way to integrate event data into your graphs. They are visualized as vertical lines and icons
on all graph panels. When you hover over an annotation icon you can get title, tags, and text information for the event.
In the <i>Queries</i> tab you can add queries that return annotation events.
<br>
<br>
Checkout the <a class="external-link" target="_blank" href="http://docs.grafana.org/reference/annotations/">Annotations documentation</a> for more information.
</p>
<p>
You can add annotations directly from grafana by holding CTRL or CMD + click on graph (or drag region). These will be stored in Grafana's annotation database.
</p>
Checkout the <a class="external-link" target="_blank" href="http://docs.grafana.org/reference/annotations/">Annotations documentation</a> for more information.
</div>
</div>
@@ -53,13 +54,16 @@
</div>
<table class="grafana-options-table">
<tr ng-repeat="annotation in ctrl.annotations">
<td style="width:90%">
<i class="fa fa-bolt" style="color:{{annotation.iconColor}}"></i> &nbsp;
<td style="width:90%" ng-hide="annotation.builtIn">
<i class="fa fa-comment" style="color:{{annotation.iconColor}}"></i> &nbsp;
{{annotation.name}}
</td>
<td style="width:90%" ng-show="annotation.builtIn">
<i class="fa fa-comment"></i> &nbsp;
<em class="muted">{{annotation.name}} (Built-in)</em>
</td>
<td style="width: 1%"><i ng-click="_.move(ctrl.annotations,$index,$index-1)" ng-hide="$first" class="pointer fa fa-arrow-up"></i></td>
<td style="width: 1%"><i ng-click="_.move(ctrl.annotations,$index,$index+1)" ng-hide="$last" class="pointer fa fa-arrow-down"></i></td>
<td style="width: 1%">
<a ng-click="ctrl.edit(annotation)" class="btn btn-inverse btn-mini">
<i class="fa fa-edit"></i>
@@ -67,7 +71,7 @@
</a>
</td>
<td style="width: 1%">
<a ng-click="ctrl.removeAnnotation(annotation)" class="btn btn-danger btn-mini">
<a ng-click="ctrl.removeAnnotation(annotation)" class="btn btn-danger btn-mini" ng-hide="annotation.builtIn">
<i class="fa fa-remove"></i>
</a>
</td>
@@ -77,43 +81,49 @@
<div class="gf-form" ng-show="ctrl.mode === 'list'">
<div class="gf-form-button-row">
<a type="button" class="btn gf-form-button btn-success" ng-click="ctrl.mode = 'new';"><i class="fa fa-plus" ></i>&nbsp;&nbsp;New</a>
<a type="button" class="btn gf-form-button btn-success" ng-click="ctrl.setupNew()"><i class="fa fa-plus" ></i>&nbsp;&nbsp;New</a>
</div>
</div>
<div class="annotations-basic-settings" ng-if="ctrl.mode === 'edit' || ctrl.mode === 'new'">
<div>
<div class="gf-form-group">
<h5 class="section-heading">Options</h5>
<h5 class="section-heading">General</h5>
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-9">Name</span>
<input type="text" class="gf-form-input width-12" ng-model='ctrl.currentAnnotation.name' placeholder="name"></input>
<span class="gf-form-label width-7">Name</span>
<input type="text" class="gf-form-input width-20" ng-model='ctrl.currentAnnotation.name' placeholder="name"></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-9">Data source</span>
<div class="gf-form-select-wrapper width-12">
<span class="gf-form-label width-7">Data source</span>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="ctrl.currentAnnotation.datasource" ng-options="f.name as f.name for f in ctrl.datasources" ng-change="ctrl.datasourceChanged()"></select>
</div>
</div>
</div>
</div>
<div class="gf-form-group">
<div class="gf-form-inline">
<!-- <div class="gf&#45;form"> -->
<!-- <span class="gf&#45;form&#45;label width&#45;7">Show in</span> -->
<!-- <div class="gf&#45;form&#45;select&#45;wrapper width&#45;12"> -->
<!-- <select class="gf&#45;form&#45;input" ng&#45;model="ctrl.currentAnnotation.showIn" ng&#45;options="f.value as f.text for f in ctrl.showOptions"></select> -->
<!-- </div> -->
<!-- </div> -->
<gf-form-switch class="gf-form"
label="Hide toggle"
label="Enabled"
checked="ctrl.currentAnnotation.enable"
on-change="ctrl.annotationEnabledChange()"
label-class="width-7">
</gf-form-switch>
<gf-form-switch class="gf-form"
label="Hidden"
tooltip="Hides the annotation query toggle from showing at the top of the dashboard"
checked="ctrl.currentAnnotation.hide"
label-class="width-9">
on-change="ctrl.annotationHiddenChanged()"
label-class="width-7">
</gf-form-switch>
</div>
<div class="gf-form">
<label class="gf-form-label width-9">Color</label>
<spectrum-picker class="gf-form-input width-3" ng-model="ctrl.currentAnnotation.iconColor"></spectrum-picker>
<span class="gf-form-label">
<color-picker color="ctrl.currentAnnotation.iconColor" onChange="ctrl.onColorChange"></color-picker>
</span>
</div>
</div>
</div>
</div>
@@ -131,6 +141,5 @@
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,38 +1,35 @@
<h5 class="section-heading text-center">Add annotation</h5>
<div class="graph-annotation">
<div class="graph-annotation__header">
<div class="graph-annotation__user" bs-tooltip="'Created by {{ctrl.login}}'">
</div>
<form name="ctrl.form" class="text-center">
<div class="graph-annotation__title">
<span ng-if="!ctrl.event.id">Add Annotation</span>
<span ng-if="ctrl.event.id">Edit Annotation</span>
</div>
<div class="graph-annotation__time">{{ctrl.timeFormated}}</div>
</div>
<form name="ctrl.form" class="graph-annotation__body text-center">
<div style="display: inline-block">
<div class="gf-form">
<span class="gf-form-label width-7">Title</span>
<input type="text" ng-model="ctrl.event.title" class="gf-form-input max-width-20" required>
</div>
<!-- single event -->
<div ng-if="!ctrl.event.isRegion">
<div class="gf-form">
<span class="gf-form-label width-7">Time</span>
<input type="text" ng-model="ctrl.event.time" class="gf-form-input max-width-20" input-datetime required ng-change="ctrl.timeChanged()">
</div>
</div>
<!-- region event -->
<div ng-if="ctrl.event.isRegion">
<div class="gf-form">
<span class="gf-form-label width-7">Start</span>
<input type="text" ng-model="ctrl.event.time" class="gf-form-input max-width-20" input-datetime required ng-change="ctrl.timeChanged()">
</div>
<div class="gf-form">
<span class="gf-form-label width-7">End</span>
<input type="text" ng-model="ctrl.event.timeEnd" class="gf-form-input max-width-20" input-datetime required ng-change="ctrl.timeChanged()">
</div>
</div>
<div class="gf-form gf-form--v-stretch">
<span class="gf-form-label width-7">Description</span>
<textarea class="gf-form-input width-20" rows="3" ng-model="ctrl.event.text" placeholder="Event description"></textarea>
<textarea class="gf-form-input width-20" rows="2" ng-model="ctrl.event.text" placeholder="Description"></textarea>
</div>
<div class="gf-form">
<span class="gf-form-label width-7">Tags</span>
<bootstrap-tagsinput ng-model="ctrl.event.tags" tagclass="label label-tag" placeholder="add tags">
</bootstrap-tagsinput>
</div>
<div class="gf-form-button-row">
<button type="submit" class="btn gf-form-btn btn-success" ng-click="ctrl.save()">Save</button>
<button type="submit" class="btn btn-success" ng-click="ctrl.save()">Save</button>
<button ng-if="ctrl.event.id" type="submit" class="btn btn-danger" ng-click="ctrl.delete()">Delete</button>
<a class="btn-text" ng-click="ctrl.close();">Cancel</a>
</div>
</div>
</form>
</div>

View File

@@ -0,0 +1,40 @@
import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common';
import '../annotations_srv';
import helpers from 'test/specs/helpers';
describe('AnnotationsSrv', function() {
var ctx = new helpers.ServiceTestContext();
beforeEach(angularMocks.module('grafana.core'));
beforeEach(angularMocks.module('grafana.services'));
beforeEach(() => {
ctx.createService('annotationsSrv');
});
describe('When translating the query result', () => {
const annotationSource = {
datasource: '-- Grafana --',
enable: true,
hide: false,
limit: 200,
name: 'test',
scope: 'global',
tags: [
'test'
],
type: 'event',
};
const time = 1507039543000;
const annotations = [{id: 1, panelId: 1, text: 'text', time: time}];
let translatedAnnotations;
beforeEach(() => {
translatedAnnotations = ctx.service.translateQueryResult(annotationSource, annotations);
});
it('should set defaults', () => {
expect(translatedAnnotations[0].source).to.eql(annotationSource);
});
});
});

View File

@@ -1,5 +1,3 @@
///<reference path="../../../headers/common.d.ts" />
import angular from 'angular';
import * as fileExport from 'app/core/utils/file_export';
import appEvents from 'app/core/app_events';
@@ -8,7 +6,7 @@ export class ExportDataModalCtrl {
private data: any[];
private panel: string;
asRows: Boolean = true;
dateTimeFormat: String = 'YYYY-MM-DDTHH:mm:ssZ';
dateTimeFormat = 'YYYY-MM-DDTHH:mm:ssZ';
excel: false;
export() {

View File

@@ -5,6 +5,7 @@ import moment from 'moment';
import _ from 'lodash';
import $ from 'jquery';
import {DEFAULT_ANNOTATION_COLOR} from 'app/core/utils/colors';
import {Emitter, contextSrv, appEvents} from 'app/core/core';
import {DashboardRow} from './row/row_model';
import sortByKeys from 'app/core/utils/sort_by_keys';
@@ -82,10 +83,35 @@ export class DashboardModel {
this.panels = data.panels || [];
this.rows = [];
this.addBuiltInAnnotationQuery();
this.initMeta(meta);
this.updateSchema(data);
}
addBuiltInAnnotationQuery() {
let found = false;
for (let item of this.annotations.list) {
if (item.builtIn === 1) {
found = true;
break;
}
}
if (found) {
return;
}
this.annotations.list.unshift({
datasource: '-- Grafana --',
name: 'Annotations & Alerts',
type: 'dashboard',
iconColor: DEFAULT_ANNOTATION_COLOR,
enable: true,
hide: true,
builtIn: 1,
});
}
private initMeta(meta) {
meta = meta || {};

Some files were not shown because too many files have changed in this diff Show More