Merge branch 'master' into redux-poc2

This commit is contained in:
Torkel Ödegaard 2018-09-05 12:11:11 +02:00
commit 0aea60bf17
419 changed files with 8058 additions and 5648 deletions

View File

@ -1,17 +1,28 @@
# 5.3.0 (unreleased)
### New Major Features
* **Alerting**: Notification reminders [#7330](https://github.com/grafana/grafana/issues/7330), thx [@jbaublitz](https://github.com/jbaublitz)
* **Dashboard**: TV & Kiosk mode changes, new cycle view mode button in dashboard toolbar [#13025](https://github.com/grafana/grafana/pull/13025)
* **OAuth**: Gitlab OAuth with support for filter by groups [#5623](https://github.com/grafana/grafana/issues/5623), thx [@BenoitKnecht](https://github.com/BenoitKnecht)
* **Dataproxy**: Pass configured/auth headers to a Datasource [#10971](https://github.com/grafana/grafana/issues/10971), thx [@mrsiano](https://github.com/mrsiano)
* **Cleanup**: Make temp file time to live configurable [#11607](https://github.com/grafana/grafana/issues/11607), thx [@xapon](https://github.com/xapon)
* **Postgres**: Graphical query builder [#10095](https://github.com/grafana/grafana/issues/10095), thx [svenklemm](https://github.com/svenklemm)
### New Features
* **LDAP**: Define Grafana Admin permission in ldap group mappings [#2469](https://github.com/grafana/grafana/issues/2496), PR [#12622](https://github.com/grafana/grafana/issues/12622)
* **Cloudwatch**: CloudWatch GetMetricData support [#11487](https://github.com/grafana/grafana/issues/11487), thx [@mtanda](https://github.com/mtanda)
* **Configuration**: Allow auto-assigning users to specific organization (other than Main. Org) [#1823](https://github.com/grafana/grafana/issues/1823) [#12801](https://github.com/grafana/grafana/issues/12801), thx [@gzzo](https://github.com/gzzo) and [@ofosos](https://github.com/ofosos)
* **Profile**: List teams that the user is member of in current/active organization [#12476](https://github.com/grafana/grafana/issues/12476)
* **LDAP**: Client certificates support [#12805](https://github.com/grafana/grafana/issues/12805), thx [@nyxi](https://github.com/nyxi)
* **Profile**: List teams that the user is member of in current/active organization [#12476](https://github.com/grafana/grafana/issues/12476)
* **Configuration**: Allow auto-assigning users to specific organization (other than Main. Org) [#1823](https://github.com/grafana/grafana/issues/1823) [#12801](https://github.com/grafana/grafana/issues/12801), thx [@gzzo](https://github.com/gzzo) and [@ofosos](https://github.com/ofosos)
* **Dataproxy**: Pass configured/auth headers to a Datasource [#10971](https://github.com/grafana/grafana/issues/10971), thx [@mrsiano](https://github.com/mrsiano)
* **Cloudwatch**: CloudWatch GetMetricData support [#11487](https://github.com/grafana/grafana/issues/11487), thx [@mtanda](https://github.com/mtanda)
* **Postgres**: TimescaleDB support, e.g. use `time_bucket` for grouping by time when option enabled [#12680](https://github.com/grafana/grafana/pull/12680), thx [svenklemm](https://github.com/svenklemm)
* **Cleanup**: Make temp file time to live configurable [#11607](https://github.com/grafana/grafana/issues/11607), thx [@xapon](https://github.com/xapon)
### Minor
* **Units**: Adds bitcoin axes unit. [#13125](https://github.com/grafana/grafana/pull/13125)
* **GrafanaCli**: Fixed issue with grafana-cli install plugin resulting in corrupt http response from source error. Fixes [#13079](https://github.com/grafana/grafana/issues/13079)
* **Logging**: Reopen log files after receiving a SIGHUP signal [#13112](https://github.com/grafana/grafana/pull/13112), thx [@filewalkwithme](https://github.com/filewalkwithme)
* **Api**: Delete nonexistent datasource should return 404 [#12313](https://github.com/grafana/grafana/issues/12313), thx [@AustinWinstanley](https://github.com/AustinWinstanley)
* **Dashboard**: Fix selecting current dashboard from search should not reload dashboard [#12248](https://github.com/grafana/grafana/issues/12248)
* **Dashboard**: Use uid when linking to dashboards internally in a dashboard [#10705](https://github.com/grafana/grafana/issues/10705)
@ -41,7 +52,6 @@
* **Cloudwatch**: Add new Redshift metrics and dimensions [#12063](https://github.com/grafana/grafana/pulls/12063), thx [@A21z](https://github.com/A21z)
* **Table**: Adjust header contrast for the light theme [#12668](https://github.com/grafana/grafana/issues/12668)
* **Table**: Fix link color when using light theme and thresholds in use [#12766](https://github.com/grafana/grafana/issues/12766)
om/grafana/grafana/issues/12668)
* **Table**: Fix for useless horizontal scrollbar for table panel [#9964](https://github.com/grafana/grafana/issues/9964)
* **Table**: Make table sorting stable when null values exist [#12362](https://github.com/grafana/grafana/pull/12362), thx [@bz2](https://github.com/bz2)
* **Elasticsearch**: For alerting/backend, support having index name to the right of pattern in index pattern [#12731](https://github.com/grafana/grafana/issues/12731)
@ -57,10 +67,13 @@ om/grafana/grafana/issues/12668)
* **InfluxDB**: Support timeFilter in query templating for InfluxDB [#12598](https://github.com/grafana/grafana/pull/12598), thx [kichristensen](https://github.com/kichristensen)
* **Provisioning**: Should allow one default datasource per organisation [#12229](https://github.com/grafana/grafana/issues/12229)
* **Heatmap**: Fix broken tooltip and crosshair on Firefox [#12486](https://github.com/grafana/grafana/issues/12486)
* **Login**: Show loading animation while waiting for authentication response on login [#12865](https://github.com/grafana/grafana/issues/12865)
### Breaking changes
* Postgres datasource no longer automatically adds time column alias when using the $__timeGroup alias. However, there's code in place which should make this change backward compatible and shouldn't create any issues.
* Kiosk mode now also hides submenu (variables)
* ?inactive url parameter no longer supported, replaced with kiosk=tv url parameter
### New experimental features
@ -72,6 +85,12 @@ These are new features that's still being worked on and are in an experimental p
* **Frontend**: Convert all Frontend Karma tests to Jest tests [#12224](https://github.com/grafana/grafana/issues/12224)
# 5.2.3 (2018-08-29)
### Important fix for LDAP & OAuth login vulnerability
See [security announcement](https://community.grafana.com/t/grafana-5-2-3-and-4-6-4-security-update/10050) for details.
# 5.2.2 (2018-07-25)
### Minor
@ -440,6 +459,12 @@ The following properties have been deprecated and will be removed in a future re
- `uri` property in `GET /api/search` -> Use new `url` or `uid` property instead
- `meta.slug` property in `GET /api/dashboards/uid/:uid` and `GET /api/dashboards/db/:slug` -> Use new `meta.url` or `dashboard.uid` property instead
# 4.6.4 (2018-08-29)
### Important fix for LDAP & OAuth login vulnerability
See [security announcement](https://community.grafana.com/t/grafana-5-2-3-and-4-6-4-security-update/10050) for details.
# 4.6.3 (2017-12-14)
## Fixes

8
Gopkg.lock generated
View File

@ -427,12 +427,6 @@
revision = "1744e2970ca51c86172c8190fadad617561ed6e7"
version = "v1.0.0"
[[projects]]
branch = "master"
name = "github.com/shurcooL/sanitized_anchor_name"
packages = ["."]
revision = "86672fcb3f950f35f2e675df2240550f2a50762f"
[[projects]]
name = "github.com/smartystreets/assertions"
packages = [
@ -679,6 +673,6 @@
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "cb8e7fd81f23ec987fc4d5dd9d31ae0f1164bc2f30cbea2fe86e0d97dd945beb"
inputs-digest = "81a37e747b875cf870c1b9486fa3147e704dea7db8ba86f7cb942d3ddc01d3e3"
solver-name = "gps-cdcl"
solver-version = 1

View File

@ -538,3 +538,8 @@ container_name =
[external_image_storage.local]
# does not require any configuration
[rendering]
# Options to configure external image rendering server like https://github.com/grafana/grafana-image-renderer
server_url =
callback_url =

View File

@ -460,3 +460,8 @@ log_queries =
[external_image_storage.local]
# does not require any configuration
[rendering]
# Options to configure external image rendering server like https://github.com/grafana/grafana-image-renderer
;server_url =
;callback_url =

View File

@ -155,7 +155,7 @@ Since not all datasources have the same configuration settings we only have the
| tlsSkipVerify | boolean | *All* | Controls whether a client verifies the server's certificate chain and host name. |
| graphiteVersion | string | Graphite | Graphite version |
| timeInterval | string | Elastic, InfluxDB & Prometheus | Lowest interval/step value that should be used for this data source |
| esVersion | string | Elastic | Elasticsearch version as an number (2/5/56) |
| esVersion | number | Elastic | Elasticsearch version as a number (2/5/56) |
| timeField | string | Elastic | Which field that should be used as timestamp |
| interval | string | Elastic | Index date time format |
| authType | string | Cloudwatch | Auth provider. keys/credentials/arn |
@ -165,6 +165,8 @@ Since not all datasources have the same configuration settings we only have the
| tsdbVersion | string | OpenTSDB | Version |
| tsdbResolution | string | OpenTSDB | Resolution |
| sslmode | string | PostgreSQL | SSLmode. 'disable', 'require', 'verify-ca' or 'verify-full' |
| postgresVersion | number | PostgreSQL | Postgres version as a number (903/904/905/906/1000) meaning v9.3, v9.4, ..., v10 |
| timescaledb | boolean | PostgreSQL | Enable usage of TimescaleDB extension |
#### Secure Json Data

View File

@ -16,12 +16,11 @@ weight = 2
When an alert changes state, it sends out notifications. Each alert rule can have
multiple notifications. In order to add a notification to an alert rule you first need
to add and configure a `notification` channel (can be email, PagerDuty or other integration). This is done from the Notification Channels page.
to add and configure a `notification` channel (can be email, PagerDuty or other integration).
This is done from the Notification Channels page.
## Notification Channel Setup
{{< imgbox max-width="30%" img="/img/docs/v50/alerts_notifications_menu.png" caption="Alerting Notification Channels" >}}
On the Notification Channels page hit the `New Channel` button to go the page where you
can configure and setup a new Notification Channel.
@ -30,7 +29,31 @@ sure it's setup correctly.
### Send on all alerts
When checked, this option will nofity for all alert rules - existing and new.
When checked, this option will notify for all alert rules - existing and new.
### Send reminders
> Only available in Grafana v5.3 and above.
{{< docs-imagebox max-width="600px" img="/img/docs/v53/alerting_notification_reminders.png" class="docs-image--right" caption="Alerting notification reminders setup" >}}
When this option is checked additional notifications (reminders) will be sent for triggered alerts. You can specify how often reminders
should be sent using number of seconds (s), minutes (m) or hours (h), for example `30s`, `3m`, `5m` or `1h` etc.
**Important:** Alert reminders are sent after rules are evaluated. Therefore a reminder can never be sent more frequently than a configured [alert rule evaluation interval](/alerting/rules/#name-evaluation-interval).
These examples show how often and when reminders are sent for a triggered alert.
Alert rule evaluation interval | Send reminders every | Reminder sent every (after last alert notification)
---------- | ----------- | -----------
`30s` | `15s` | ~30 seconds
`1m` | `5m` | ~5 minutes
`5m` | `15m` | ~15 minutes
`6m` | `20m` | ~24 minutes
`1h` | `15m` | ~1 hour
`1h` | `2h` | ~2 hours
<div class="clearfix"></div>
## Supported Notification Types
@ -132,23 +155,23 @@ Once these two properties are set, you can send the alerts to Kafka for further
### All supported notifiers
Name | Type |Support images
-----|------------ | ------
Slack | `slack` | yes
Pagerduty | `pagerduty` | yes
Email | `email` | yes
Webhook | `webhook` | link
Kafka | `kafka` | no
Hipchat | `hipchat` | yes
VictorOps | `victorops` | yes
Sensu | `sensu` | yes
OpsGenie | `opsgenie` | yes
Threema | `threema` | yes
Pushover | `pushover` | no
Telegram | `telegram` | no
Line | `line` | no
Prometheus Alertmanager | `prometheus-alertmanager` | no
Microsoft Teams | `teams` | yes
Name | Type |Support images | Support reminders
-----|------------ | ------ | ------ |
Slack | `slack` | yes | yes
Pagerduty | `pagerduty` | yes | yes
Email | `email` | yes | yes
Webhook | `webhook` | link | yes
Kafka | `kafka` | no | yes
Hipchat | `hipchat` | yes | yes
VictorOps | `victorops` | yes | yes
Sensu | `sensu` | yes | yes
OpsGenie | `opsgenie` | yes | yes
Threema | `threema` | yes | yes
Pushover | `pushover` | no | yes
Telegram | `telegram` | no | yes
Line | `line` | no | yes
Microsoft Teams | `teams` | yes | yes
Prometheus Alertmanager | `prometheus-alertmanager` | no | no

View File

@ -88,6 +88,11 @@ So as you can see from the above scenario Grafana will not send out notification
to fire if the rule already is in state `Alerting`. To improve support for queries that return multiple series
we plan to track state **per series** in a future release.
> Starting with Grafana v5.3 you can configure reminders to be sent for triggered alerts. This will send additional notifications
> when an alert continues to fire. If other series (like server2 in the example above) also cause the alert rule to fire they will
> be included in the reminder notification. Depending on what notification channel you're using you may be able to take advantage
> of this feature for identifying new/existing series causing alert to fire. [Read more about notification reminders here](/alerting/notifications/#send-reminders).
### No Data / Null values
Below your conditions you can configure how the rule evaluation engine should handle queries that return no data or only null values.

View File

@ -31,7 +31,9 @@ Name | Description
*User* | Database user's login/username
*Password* | Database user's password
*SSL Mode* | This option determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server.
*TimescaleDB* | With this option enabled Grafana will use TimescaleDB features, e.g. use ```time_bucket``` for grouping by time (only available in Grafana 5.3+).
*Version* | This option determines which functions are available in the query builder (only available in Grafana 5.3+).
*TimescaleDB* | TimescaleDB is a time-series database built as a PostgreSQL extension. If enabled, Grafana will use `time_bucket` in the `$__timeGroup` macro and display TimescaleDB specific aggregate functions in the query builder (only available in Grafana 5.3+).
### Database User Permissions (Important!)
@ -292,5 +294,6 @@ datasources:
password: "Password!"
jsonData:
sslmode: "disable" # disable/require/verify-ca/verify-full
postgresVersion: 903 # 903=9.3, 904=9.4, 905=9.5, 906=9.6, 1000=10
timescaledb: false
```

View File

@ -13,7 +13,35 @@ weight = 1
# Getting started
This guide will help you get started and acquainted with Grafana. It assumes you have a working Grafana server up and running and have added at least one [Data Source](/features/datasources/).
This guide will help you get started and acquainted with Grafana. It assumes you have a working Grafana server up and running. If not please read the [installation guide](/installation/).
## Logging in for the first time
To run Grafana open your browser and go to http://localhost:3000/. 3000 is the default http port that Grafana listens to if you haven't [configured a different port](/installation/configuration/#http-port).
There you will see the login page. Default username is admin and default password is admin. When you log in for the first time you will be asked to change your password. We strongly encourage you to
follow Grafanas best practices and change the default administrator password. You can later go to user preferences and change your user name.
## How to add a data source
{{< docs-imagebox img="/img/docs/v52/sidemenu-datasource.png" max-width="250px" class="docs-image--right docs-image--no-shadow">}}
Before you create your first dashboard you need to add your data source.
First move your cursor to the cog on the side menu which will show you the configuration menu. If the side menu is not visible click the Grafana icon in the upper left corner. The first item on the configuration menu is data sources, click on that and you'll be taken to the data sources page where you can add and edit data sources. You can also simply click the cog.
Click Add data source and you will come to the settings page of your new data source.
{{< docs-imagebox img="/img/docs/v52/add-datasource.png" max-width="700px" class="docs-image--no-shadow">}}
First, give the data source a Name and then select which Type of data source you'll want to create, see [Supported data sources](/features/datasources/#supported-data-sources/) for more information and how to configure your data source.
{{< docs-imagebox img="/img/docs/v52/datasource-settings.png" max-width="700px" class="docs-image--no-shadow">}}
After you have configuered your data source you are ready to save and test.
## Beginner guides

View File

@ -50,6 +50,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```http
HTTP/1.1 200
Content-Type: application/json
[
{
"id": 1,
@ -86,6 +87,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```http
HTTP/1.1 200
Content-Type: application/json
{
"id": 1,
"dashboardId": 1,
@ -146,6 +148,7 @@ JSON Body Schema:
```http
HTTP/1.1 200
Content-Type: application/json
{
"alertId": 1,
"state": "Paused",
@ -177,6 +180,7 @@ JSON Body Schema:
```http
HTTP/1.1 200
Content-Type: application/json
{
"state": "Paused",
"message": "alert paused",
@ -204,14 +208,21 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
HTTP/1.1 200
Content-Type: application/json
{
"id": 1,
"name": "Team A",
"type": "email",
"isDefault": true,
"created": "2017-01-01 12:45",
"updated": "2017-01-01 12:45"
}
[
{
"id": 1,
"name": "Team A",
"type": "email",
"isDefault": false,
"sendReminder": false,
"settings": {
"addresses": "carl@grafana.com;dev@grafana.com"
},
"created": "2018-04-23T14:44:09+02:00",
"updated": "2018-08-20T15:47:49+02:00"
}
]
```
## Create alert notification
@ -232,6 +243,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
"name": "new alert notification", //Required
"type": "email", //Required
"isDefault": false,
"sendReminder": false,
"settings": {
"addresses": "carl@grafana.com;dev@grafana.com"
}
@ -243,14 +255,18 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```http
HTTP/1.1 200
Content-Type: application/json
{
"id": 1,
"name": "new alert notification",
"type": "email",
"isDefault": false,
"settings": { addresses: "carl@grafana.com;dev@grafana.com"} }
"created": "2017-01-01 12:34",
"updated": "2017-01-01 12:34"
"sendReminder": false,
"settings": {
"addresses": "carl@grafana.com;dev@grafana.com"
},
"created": "2018-04-23T14:44:09+02:00",
"updated": "2018-08-20T15:47:49+02:00"
}
```
@ -271,6 +287,8 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
"name": "new alert notification", //Required
"type": "email", //Required
"isDefault": false,
"sendReminder": true,
"frequency": "15m",
"settings": {
"addresses: "carl@grafana.com;dev@grafana.com"
}
@ -282,12 +300,17 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```http
HTTP/1.1 200
Content-Type: application/json
{
"id": 1,
"name": "new alert notification",
"type": "email",
"isDefault": false,
"settings": { addresses: "carl@grafana.com;dev@grafana.com"} }
"sendReminder": true,
"frequency": "15m",
"settings": {
"addresses": "carl@grafana.com;dev@grafana.com"
},
"created": "2017-01-01 12:34",
"updated": "2017-01-01 12:34"
}
@ -311,6 +334,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```http
HTTP/1.1 200
Content-Type: application/json
{
"message": "Notification deleted"
}

View File

@ -266,7 +266,8 @@ The number of days the keep me logged in / remember me cookie lasts.
### secret_key
Used for signing keep me logged in / remember me cookies.
Used for signing some datasource settings like secrets and passwords. Cannot be changed without requiring an update
to datasource settings to re-encode them.
### disable_gravatar

View File

@ -166,3 +166,8 @@ To configure Grafana add a configuration file named `custom.ini` to the
Start Grafana by executing `./bin/grafana-server web`. The `grafana-server`
binary needs the working directory to be the root install directory (where the
binary and the `public` folder is located).
## Logging in for the first time
To run Grafana open your browser and go to http://localhost:3000/. 3000 is the default http port that Grafana listens to if you haven't [configured a different port](/installation/configuration/#http-port).
Then follow the instructions [here](/guides/getting_started/).

View File

@ -217,3 +217,8 @@ chown -R root:root /etc/grafana && \
chown -R grafana:grafana /var/lib/grafana && \
chown -R grafana:grafana /usr/share/grafana
```
## Logging in for the first time
To run Grafana open your browser and go to http://localhost:3000/. 3000 is the default http port that Grafana listens to if you haven't [configured a different port](/installation/configuration/#http-port).
Then follow the instructions [here](/guides/getting_started/).

View File

@ -92,3 +92,7 @@ Start Grafana by executing `./bin/grafana-server web`. The `grafana-server`
binary needs the working directory to be the root install directory (where the
binary and the `public` folder is located).
## Logging in for the first time
To run Grafana open your browser and go to http://localhost:3000/. 3000 is the default http port that Grafana listens to if you haven't [configured a different port](/installation/configuration/#http-port).
Then follow the instructions [here](/guides/getting_started/).

View File

@ -193,3 +193,7 @@ Start Grafana by executing `./bin/grafana-server web`. The `grafana-server`
binary needs the working directory to be the root install directory (where the
binary and the `public` folder is located).
## Logging in for the first time
To run Grafana open your browser and go to http://localhost:3000/. 3000 is the default http port that Grafana listens to if you haven't [configured a different port](/installation/configuration/#http-port).
Then follow the instructions [here](/guides/getting_started/).

View File

@ -109,3 +109,11 @@ positioning system when you load them in v5. Dashboards saved in v5 will not wor
external panel plugins might need to be updated to work properly.
For more details on the new panel positioning system, [click here]({{< relref "reference/dashboard.md#panel-size-position" >}})
## Upgrading to v5.2
One of the database migrations included in this release will update all annotation timestamps from second to millisecond precision. If you have a large amount of annotations the database migration may take a long time to complete which may cause problems if you use systemd to run Grafana.
We've got one report where using systemd, PostgreSQL and a large amount of annotations (table size 1645mb) took 8-20 minutes for the database migration to complete. However, the grafana-server process was killed after 90 seconds by systemd. Any database migration queries in progress when systemd kills the grafana-server process continues to execute in database until finished.
If you're using systemd and have a large amount of annotations consider temporary adjusting the systemd `TimeoutStartSec` setting to something high like `30m` before upgrading.

View File

@ -38,6 +38,11 @@ service using that tool.
Read more about the [configuration options]({{< relref "configuration.md" >}}).
## Logging in for the first time
To run Grafana open your browser and go to the port you configured above, e.g. http://localhost:8080/.
Then follow the instructions [here](/guides/getting_started/).
## Building on Windows
The Grafana backend includes Sqlite3 which requires GCC to compile. So

View File

@ -141,3 +141,8 @@ Please contribute to the Grafana project and submit a pull request! Build new fe
**Problem**: On Windows, getting errors about a tool not being installed even though you just installed that tool.
**Solution**: It is usually because it got added to the path and you have to restart your command prompt to use it.
## Logging in for the first time
To run Grafana open your browser and go to the default port http://localhost:3000 or the port you have configured.
Then follow the instructions [here](/guides/getting_started/).

View File

@ -27,7 +27,7 @@ Grafana will now persist all long term data in the database. How to configure th
## User sessions
The second thing to consider is how to deal with user sessions and how to configure your load balancer infront of Grafana.
Grafana support two says of storing session data locally on disk or in a database/cache-server.
Grafana supports two ways of storing session data: locally on disk or in a database/cache-server.
If you want to store sessions on disk you can use `sticky sessions` in your load balanacer. If you prefer to store session data in a database/cache-server
you can use any stateless routing strategy in your load balancer (ex round robin or least connections).

View File

@ -1,4 +1,4 @@
{
"stable": "5.2.0",
"testing": "5.2.0"
"stable": "5.2.3",
"testing": "5.2.3"
}

View File

@ -101,7 +101,7 @@
"build": "grunt build",
"test": "grunt test",
"test:coverage": "grunt test --coverage=true",
"lint": "tslint -c tslint.json --project tsconfig.json --type-check",
"lint": "tslint -c tslint.json --project tsconfig.json",
"jest": "jest --notify --watch",
"api-tests": "jest --notify --watch --config=tests/api/jest.js",
"precommit": "lint-staged && grunt precommit"

View File

@ -192,14 +192,7 @@ func GetAlertNotifications(c *m.ReqContext) Response {
result := make([]*dtos.AlertNotification, 0)
for _, notification := range query.Result {
result = append(result, &dtos.AlertNotification{
Id: notification.Id,
Name: notification.Name,
Type: notification.Type,
IsDefault: notification.IsDefault,
Created: notification.Created,
Updated: notification.Updated,
})
result = append(result, dtos.NewAlertNotification(notification))
}
return JSON(200, result)
@ -215,7 +208,7 @@ func GetAlertNotificationByID(c *m.ReqContext) Response {
return Error(500, "Failed to get alert notifications", err)
}
return JSON(200, query.Result)
return JSON(200, dtos.NewAlertNotification(query.Result))
}
func CreateAlertNotification(c *m.ReqContext, cmd m.CreateAlertNotificationCommand) Response {
@ -225,7 +218,7 @@ func CreateAlertNotification(c *m.ReqContext, cmd m.CreateAlertNotificationComma
return Error(500, "Failed to create alert notification", err)
}
return JSON(200, cmd.Result)
return JSON(200, dtos.NewAlertNotification(cmd.Result))
}
func UpdateAlertNotification(c *m.ReqContext, cmd m.UpdateAlertNotificationCommand) Response {
@ -235,7 +228,7 @@ func UpdateAlertNotification(c *m.ReqContext, cmd m.UpdateAlertNotificationComma
return Error(500, "Failed to update alert notification", err)
}
return JSON(200, cmd.Result)
return JSON(200, dtos.NewAlertNotification(cmd.Result))
}
func DeleteAlertNotification(c *m.ReqContext) Response {

View File

@ -1,35 +1,76 @@
package dtos
import (
"fmt"
"time"
"github.com/grafana/grafana/pkg/components/null"
"github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/models"
)
type AlertRule struct {
Id int64 `json:"id"`
DashboardId int64 `json:"dashboardId"`
PanelId int64 `json:"panelId"`
Name string `json:"name"`
Message string `json:"message"`
State m.AlertStateType `json:"state"`
NewStateDate time.Time `json:"newStateDate"`
EvalDate time.Time `json:"evalDate"`
EvalData *simplejson.Json `json:"evalData"`
ExecutionError string `json:"executionError"`
Url string `json:"url"`
CanEdit bool `json:"canEdit"`
Id int64 `json:"id"`
DashboardId int64 `json:"dashboardId"`
PanelId int64 `json:"panelId"`
Name string `json:"name"`
Message string `json:"message"`
State models.AlertStateType `json:"state"`
NewStateDate time.Time `json:"newStateDate"`
EvalDate time.Time `json:"evalDate"`
EvalData *simplejson.Json `json:"evalData"`
ExecutionError string `json:"executionError"`
Url string `json:"url"`
CanEdit bool `json:"canEdit"`
}
func formatShort(interval time.Duration) string {
var result string
hours := interval / time.Hour
if hours > 0 {
result += fmt.Sprintf("%dh", hours)
}
remaining := interval - (hours * time.Hour)
mins := remaining / time.Minute
if mins > 0 {
result += fmt.Sprintf("%dm", mins)
}
remaining = remaining - (mins * time.Minute)
seconds := remaining / time.Second
if seconds > 0 {
result += fmt.Sprintf("%ds", seconds)
}
return result
}
func NewAlertNotification(notification *models.AlertNotification) *AlertNotification {
return &AlertNotification{
Id: notification.Id,
Name: notification.Name,
Type: notification.Type,
IsDefault: notification.IsDefault,
Created: notification.Created,
Updated: notification.Updated,
Frequency: formatShort(notification.Frequency),
SendReminder: notification.SendReminder,
Settings: notification.Settings,
}
}
type AlertNotification struct {
Id int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
IsDefault bool `json:"isDefault"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Id int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
IsDefault bool `json:"isDefault"`
SendReminder bool `json:"sendReminder"`
Frequency string `json:"frequency"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Settings *simplejson.Json `json:"settings"`
}
type AlertTestCommand struct {
@ -39,7 +80,7 @@ type AlertTestCommand struct {
type AlertTestResult struct {
Firing bool `json:"firing"`
State m.AlertStateType `json:"state"`
State models.AlertStateType `json:"state"`
ConditionEvals string `json:"conditionEvals"`
TimeMs string `json:"timeMs"`
Error string `json:"error,omitempty"`
@ -59,9 +100,11 @@ type EvalMatch struct {
}
type NotificationTestCommand struct {
Name string `json:"name"`
Type string `json:"type"`
Settings *simplejson.Json `json:"settings"`
Name string `json:"name"`
Type string `json:"type"`
SendReminder bool `json:"sendReminder"`
Frequency string `json:"frequency"`
Settings *simplejson.Json `json:"settings"`
}
type PauseAlertCommand struct {

View File

@ -0,0 +1,35 @@
package dtos
import (
"testing"
"time"
)
func TestFormatShort(t *testing.T) {
tcs := []struct {
interval time.Duration
expected string
}{
{interval: time.Hour, expected: "1h"},
{interval: time.Hour + time.Minute, expected: "1h1m"},
{interval: (time.Hour * 10) + time.Minute, expected: "10h1m"},
{interval: (time.Hour * 10) + (time.Minute * 10) + time.Second, expected: "10h10m1s"},
{interval: time.Minute * 10, expected: "10m"},
}
for _, tc := range tcs {
got := formatShort(tc.interval)
if got != tc.expected {
t.Errorf("expected %s got %s interval: %v", tc.expected, got, tc.interval)
}
parsed, err := time.ParseDuration(tc.expected)
if err != nil {
t.Fatalf("could not parse expected duration")
}
if parsed != tc.interval {
t.Errorf("expectes the parsed duration to equal the interval. Got %v expected: %v", parsed, tc.interval)
}
}
}

View File

@ -152,7 +152,7 @@ func downloadFile(pluginName, filePath, url string) (err error) {
return err
}
r, err := zip.NewReader(bytes.NewReader(body), resp.ContentLength)
r, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
return err
}

View File

@ -42,6 +42,8 @@ func returnOsDefault(currentOs string) string {
return "/usr/local/var/lib/grafana/plugins"
case "freebsd":
return "/var/db/grafana/plugins"
case "openbsd":
return "/var/grafana/plugins"
default: //"linux"
return "/var/lib/grafana/plugins"
}

View File

@ -96,13 +96,17 @@ func main() {
func listenToSystemSignals(server *GrafanaServerImpl) {
signalChan := make(chan os.Signal, 1)
ignoreChan := make(chan os.Signal, 1)
sighupChan := make(chan os.Signal, 1)
signal.Notify(ignoreChan, syscall.SIGHUP)
signal.Notify(sighupChan, syscall.SIGHUP)
signal.Notify(signalChan, os.Interrupt, os.Kill, syscall.SIGTERM)
select {
case sig := <-signalChan:
server.Shutdown(fmt.Sprintf("System signal: %s", sig))
for {
select {
case _ = <-sighupChan:
log.Reload()
case sig := <-signalChan:
server.Shutdown(fmt.Sprintf("System signal: %s", sig))
}
}
}

View File

@ -236,3 +236,20 @@ func (w *FileLogWriter) Close() {
func (w *FileLogWriter) Flush() {
w.mw.fd.Sync()
}
// Reload file logger
func (w *FileLogWriter) Reload() {
// block Logger's io.Writer
w.mw.Lock()
defer w.mw.Unlock()
// Close
fd := w.mw.fd
fd.Close()
// Open again
err := w.StartLogger()
if err != nil {
fmt.Fprintf(os.Stderr, "Reload StartLogger: %s\n", err)
}
}

View File

@ -3,3 +3,7 @@ package log
type DisposableHandler interface {
Close()
}
type ReloadableHandler interface {
Reload()
}

View File

@ -21,10 +21,12 @@ import (
var Root log15.Logger
var loggersToClose []DisposableHandler
var loggersToReload []ReloadableHandler
var filters map[string]log15.Lvl
func init() {
loggersToClose = make([]DisposableHandler, 0)
loggersToReload = make([]ReloadableHandler, 0)
Root = log15.Root()
Root.SetHandler(log15.DiscardHandler())
}
@ -115,6 +117,12 @@ func Close() {
loggersToClose = make([]DisposableHandler, 0)
}
func Reload() {
for _, logger := range loggersToReload {
logger.Reload()
}
}
func GetLogLevelFor(name string) Lvl {
if level, ok := filters[name]; ok {
switch level {
@ -230,6 +238,7 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) {
fileHandler.Init()
loggersToClose = append(loggersToClose, fileHandler)
loggersToReload = append(loggersToReload, fileHandler)
handler = fileHandler
case "syslog":
sysLogHandler := NewSyslog(sec, format)

View File

@ -1,38 +1,50 @@
package models
import (
"errors"
"time"
"github.com/grafana/grafana/pkg/components/simplejson"
)
var (
ErrNotificationFrequencyNotFound = errors.New("Notification frequency not specified")
ErrJournalingNotFound = errors.New("alert notification journaling not found")
)
type AlertNotification struct {
Id int64 `json:"id"`
OrgId int64 `json:"-"`
Name string `json:"name"`
Type string `json:"type"`
IsDefault bool `json:"isDefault"`
Settings *simplejson.Json `json:"settings"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Id int64 `json:"id"`
OrgId int64 `json:"-"`
Name string `json:"name"`
Type string `json:"type"`
SendReminder bool `json:"sendReminder"`
Frequency time.Duration `json:"frequency"`
IsDefault bool `json:"isDefault"`
Settings *simplejson.Json `json:"settings"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
type CreateAlertNotificationCommand struct {
Name string `json:"name" binding:"Required"`
Type string `json:"type" binding:"Required"`
IsDefault bool `json:"isDefault"`
Settings *simplejson.Json `json:"settings"`
Name string `json:"name" binding:"Required"`
Type string `json:"type" binding:"Required"`
SendReminder bool `json:"sendReminder"`
Frequency string `json:"frequency"`
IsDefault bool `json:"isDefault"`
Settings *simplejson.Json `json:"settings"`
OrgId int64 `json:"-"`
Result *AlertNotification
}
type UpdateAlertNotificationCommand struct {
Id int64 `json:"id" binding:"Required"`
Name string `json:"name" binding:"Required"`
Type string `json:"type" binding:"Required"`
IsDefault bool `json:"isDefault"`
Settings *simplejson.Json `json:"settings" binding:"Required"`
Id int64 `json:"id" binding:"Required"`
Name string `json:"name" binding:"Required"`
Type string `json:"type" binding:"Required"`
SendReminder bool `json:"sendReminder"`
Frequency string `json:"frequency"`
IsDefault bool `json:"isDefault"`
Settings *simplejson.Json `json:"settings" binding:"Required"`
OrgId int64 `json:"-"`
Result *AlertNotification
@ -63,3 +75,34 @@ type GetAllAlertNotificationsQuery struct {
Result []*AlertNotification
}
type AlertNotificationJournal struct {
Id int64
OrgId int64
AlertId int64
NotifierId int64
SentAt int64
Success bool
}
type RecordNotificationJournalCommand struct {
OrgId int64
AlertId int64
NotifierId int64
SentAt int64
Success bool
}
type GetLatestNotificationQuery struct {
OrgId int64
AlertId int64
NotifierId int64
Result *AlertNotificationJournal
}
type CleanNotificationJournalCommand struct {
OrgId int64
AlertId int64
NotifierId int64
}

View File

@ -1,6 +1,9 @@
package alerting
import "time"
import (
"context"
"time"
)
type EvalHandler interface {
Eval(evalContext *EvalContext)
@ -15,10 +18,14 @@ type Notifier interface {
Notify(evalContext *EvalContext) error
GetType() string
NeedsImage() bool
ShouldNotify(evalContext *EvalContext) bool
// ShouldNotify checks this evaluation should send an alert notification
ShouldNotify(ctx context.Context, evalContext *EvalContext) bool
GetNotifierId() int64
GetIsDefault() bool
GetSendReminder() bool
GetFrequency() time.Duration
}
type NotifierSlice []Notifier

View File

@ -1,10 +1,10 @@
package alerting
import (
"context"
"errors"
"fmt"
"golang.org/x/sync/errgroup"
"time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/imguploader"
@ -58,17 +58,47 @@ func (n *notificationService) SendIfNeeded(context *EvalContext) error {
return n.sendNotifications(context, notifiers)
}
func (n *notificationService) sendNotifications(context *EvalContext, notifiers []Notifier) error {
g, _ := errgroup.WithContext(context.Ctx)
func (n *notificationService) sendNotifications(evalContext *EvalContext, notifiers []Notifier) error {
for _, notifier := range notifiers {
not := notifier //avoid updating scope variable in go routine
n.log.Debug("Sending notification", "type", not.GetType(), "id", not.GetNotifierId(), "isDefault", not.GetIsDefault())
metrics.M_Alerting_Notification_Sent.WithLabelValues(not.GetType()).Inc()
g.Go(func() error { return not.Notify(context) })
not := notifier
err := bus.InTransaction(evalContext.Ctx, func(ctx context.Context) error {
n.log.Debug("trying to send notification", "id", not.GetNotifierId())
// Verify that we can send the notification again
// but this time within the same transaction.
if !evalContext.IsTestRun && !not.ShouldNotify(context.Background(), evalContext) {
return nil
}
n.log.Debug("Sending notification", "type", not.GetType(), "id", not.GetNotifierId(), "isDefault", not.GetIsDefault())
metrics.M_Alerting_Notification_Sent.WithLabelValues(not.GetType()).Inc()
//send notification
success := not.Notify(evalContext) == nil
if evalContext.IsTestRun {
return nil
}
//write result to db.
cmd := &m.RecordNotificationJournalCommand{
OrgId: evalContext.Rule.OrgId,
AlertId: evalContext.Rule.Id,
NotifierId: not.GetNotifierId(),
SentAt: time.Now().Unix(),
Success: success,
}
return bus.DispatchCtx(ctx, cmd)
})
if err != nil {
n.log.Error("failed to send notification", "id", not.GetNotifierId())
}
}
return g.Wait()
return nil
}
func (n *notificationService) uploadImage(context *EvalContext) (err error) {
@ -110,7 +140,7 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) {
return nil
}
func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []int64, context *EvalContext) (NotifierSlice, error) {
func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []int64, evalContext *EvalContext) (NotifierSlice, error) {
query := &m.GetAlertNotificationsToSendQuery{OrgId: orgId, Ids: notificationIds}
if err := bus.Dispatch(query); err != nil {
@ -123,7 +153,8 @@ func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []
if err != nil {
return nil, err
}
if not.ShouldNotify(context) {
if not.ShouldNotify(evalContext.Ctx, evalContext) {
result = append(result, not)
}
}

View File

@ -1,6 +1,7 @@
package notifiers
import (
"context"
"time"
"github.com/grafana/grafana/pkg/bus"
@ -33,7 +34,7 @@ func NewAlertmanagerNotifier(model *m.AlertNotification) (alerting.Notifier, err
}
return &AlertmanagerNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
NotifierBase: NewNotifierBase(model),
Url: url,
log: log.New("alerting.notifier.prometheus-alertmanager"),
}, nil
@ -45,7 +46,7 @@ type AlertmanagerNotifier struct {
log log.Logger
}
func (this *AlertmanagerNotifier) ShouldNotify(evalContext *alerting.EvalContext) bool {
func (this *AlertmanagerNotifier) ShouldNotify(ctx context.Context, evalContext *alerting.EvalContext) bool {
this.log.Debug("Should notify", "ruleId", evalContext.Rule.Id, "state", evalContext.Rule.State, "previousState", evalContext.PrevAlertState)
// Do not notify when we become OK for the first time.

View File

@ -1,50 +1,94 @@
package notifiers
import (
"github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models"
"context"
"time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting"
)
type NotifierBase struct {
Name string
Type string
Id int64
IsDeault bool
UploadImage bool
Name string
Type string
Id int64
IsDeault bool
UploadImage bool
SendReminder bool
Frequency time.Duration
log log.Logger
}
func NewNotifierBase(id int64, isDefault bool, name, notifierType string, model *simplejson.Json) NotifierBase {
func NewNotifierBase(model *models.AlertNotification) NotifierBase {
uploadImage := true
value, exist := model.CheckGet("uploadImage")
value, exist := model.Settings.CheckGet("uploadImage")
if exist {
uploadImage = value.MustBool()
}
return NotifierBase{
Id: id,
Name: name,
IsDeault: isDefault,
Type: notifierType,
UploadImage: uploadImage,
Id: model.Id,
Name: model.Name,
IsDeault: model.IsDefault,
Type: model.Type,
UploadImage: uploadImage,
SendReminder: model.SendReminder,
Frequency: model.Frequency,
log: log.New("alerting.notifier." + model.Name),
}
}
func defaultShouldNotify(context *alerting.EvalContext) bool {
func defaultShouldNotify(context *alerting.EvalContext, sendReminder bool, frequency time.Duration, lastNotify time.Time) bool {
// Only notify on state change.
if context.PrevAlertState == context.Rule.State {
if context.PrevAlertState == context.Rule.State && !sendReminder {
return false
}
// Do not notify if interval has not elapsed
if sendReminder && !lastNotify.IsZero() && lastNotify.Add(frequency).After(time.Now()) {
return false
}
// Do not notify if alert state if OK or pending even on repeated notify
if sendReminder && (context.Rule.State == models.AlertStateOK || context.Rule.State == models.AlertStatePending) {
return false
}
// Do not notify when we become OK for the first time.
if (context.PrevAlertState == m.AlertStatePending) && (context.Rule.State == m.AlertStateOK) {
if (context.PrevAlertState == models.AlertStatePending) && (context.Rule.State == models.AlertStateOK) {
return false
}
return true
}
func (n *NotifierBase) ShouldNotify(context *alerting.EvalContext) bool {
return defaultShouldNotify(context)
// ShouldNotify checks this evaluation should send an alert notification
func (n *NotifierBase) ShouldNotify(ctx context.Context, c *alerting.EvalContext) bool {
cmd := &models.GetLatestNotificationQuery{
OrgId: c.Rule.OrgId,
AlertId: c.Rule.Id,
NotifierId: n.Id,
}
err := bus.DispatchCtx(ctx, cmd)
if err == models.ErrJournalingNotFound {
return true
}
if err != nil {
n.log.Error("Could not determine last time alert notifier fired", "Alert name", c.Rule.Name, "Error", err)
return false
}
if !cmd.Result.Success {
return true
}
return defaultShouldNotify(c, n.SendReminder, n.Frequency, time.Unix(cmd.Result.SentAt, 0))
}
func (n *NotifierBase) GetType() string {
@ -62,3 +106,11 @@ func (n *NotifierBase) GetNotifierId() int64 {
func (n *NotifierBase) GetIsDefault() bool {
return n.IsDeault
}
func (n *NotifierBase) GetSendReminder() bool {
return n.SendReminder
}
func (n *NotifierBase) GetFrequency() time.Duration {
return n.Frequency
}

View File

@ -2,7 +2,11 @@ package notifiers
import (
"context"
"errors"
"testing"
"time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models"
@ -10,47 +14,129 @@ import (
. "github.com/smartystreets/goconvey/convey"
)
func TestBaseNotifier(t *testing.T) {
Convey("Base notifier tests", t, func() {
Convey("default constructor for notifiers", func() {
bJson := simplejson.New()
func TestShouldSendAlertNotification(t *testing.T) {
tcs := []struct {
name string
prevState m.AlertStateType
newState m.AlertStateType
expected bool
sendReminder bool
}{
{
name: "pending -> ok should not trigger an notification",
newState: m.AlertStatePending,
prevState: m.AlertStateOK,
expected: false,
},
{
name: "ok -> alerting should trigger an notification",
newState: m.AlertStateOK,
prevState: m.AlertStateAlerting,
expected: true,
},
{
name: "ok -> pending should not trigger an notification",
newState: m.AlertStateOK,
prevState: m.AlertStatePending,
expected: false,
},
{
name: "ok -> ok should not trigger an notification",
newState: m.AlertStateOK,
prevState: m.AlertStateOK,
expected: false,
sendReminder: false,
},
{
name: "ok -> alerting should not trigger an notification",
newState: m.AlertStateOK,
prevState: m.AlertStateAlerting,
expected: true,
sendReminder: true,
},
{
name: "ok -> ok with reminder should not trigger an notification",
newState: m.AlertStateOK,
prevState: m.AlertStateOK,
expected: false,
sendReminder: true,
},
}
Convey("can parse false value", func() {
bJson.Set("uploadImage", false)
base := NewNotifierBase(1, false, "name", "email", bJson)
So(base.UploadImage, ShouldBeFalse)
})
Convey("can parse true value", func() {
bJson.Set("uploadImage", true)
base := NewNotifierBase(1, false, "name", "email", bJson)
So(base.UploadImage, ShouldBeTrue)
})
Convey("default value should be true for backwards compatibility", func() {
base := NewNotifierBase(1, false, "name", "email", bJson)
So(base.UploadImage, ShouldBeTrue)
})
for _, tc := range tcs {
evalContext := alerting.NewEvalContext(context.TODO(), &alerting.Rule{
State: tc.newState,
})
Convey("should notify", func() {
Convey("pending -> ok", func() {
context := alerting.NewEvalContext(context.TODO(), &alerting.Rule{
State: m.AlertStatePending,
})
context.Rule.State = m.AlertStateOK
So(defaultShouldNotify(context), ShouldBeFalse)
evalContext.Rule.State = tc.prevState
if defaultShouldNotify(evalContext, true, 0, time.Now()) != tc.expected {
t.Errorf("failed %s. expected %+v to return %v", tc.name, tc, tc.expected)
}
}
}
func TestShouldNotifyWhenNoJournalingIsFound(t *testing.T) {
Convey("base notifier", t, func() {
bus.ClearBusHandlers()
notifier := NewNotifierBase(&m.AlertNotification{
Id: 1,
Name: "name",
Type: "email",
Settings: simplejson.New(),
})
evalContext := alerting.NewEvalContext(context.TODO(), &alerting.Rule{})
Convey("should notify if no journaling is found", func() {
bus.AddHandlerCtx("", func(ctx context.Context, q *m.GetLatestNotificationQuery) error {
return m.ErrJournalingNotFound
})
Convey("ok -> alerting", func() {
context := alerting.NewEvalContext(context.TODO(), &alerting.Rule{
State: m.AlertStateOK,
})
context.Rule.State = m.AlertStateAlerting
So(defaultShouldNotify(context), ShouldBeTrue)
if !notifier.ShouldNotify(context.Background(), evalContext) {
t.Errorf("should send notifications when ErrJournalingNotFound is returned")
}
})
Convey("should not notify query returns error", func() {
bus.AddHandlerCtx("", func(ctx context.Context, q *m.GetLatestNotificationQuery) error {
return errors.New("some kind of error unknown error")
})
if notifier.ShouldNotify(context.Background(), evalContext) {
t.Errorf("should not send notifications when query returns error")
}
})
})
}
func TestBaseNotifier(t *testing.T) {
Convey("default constructor for notifiers", t, func() {
bJson := simplejson.New()
model := &m.AlertNotification{
Id: 1,
Name: "name",
Type: "email",
Settings: bJson,
}
Convey("can parse false value", func() {
bJson.Set("uploadImage", false)
base := NewNotifierBase(model)
So(base.UploadImage, ShouldBeFalse)
})
Convey("can parse true value", func() {
bJson.Set("uploadImage", true)
base := NewNotifierBase(model)
So(base.UploadImage, ShouldBeTrue)
})
Convey("default value should be true for backwards compatibility", func() {
base := NewNotifierBase(model)
So(base.UploadImage, ShouldBeTrue)
})
})
}

View File

@ -32,7 +32,7 @@ func NewDingDingNotifier(model *m.AlertNotification) (alerting.Notifier, error)
}
return &DingDingNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
NotifierBase: NewNotifierBase(model),
Url: url,
log: log.New("alerting.notifier.dingding"),
}, nil

View File

@ -39,7 +39,7 @@ func NewDiscordNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
}
return &DiscordNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
NotifierBase: NewNotifierBase(model),
WebhookURL: url,
log: log.New("alerting.notifier.discord"),
}, nil

View File

@ -52,7 +52,7 @@ func NewEmailNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
})
return &EmailNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
NotifierBase: NewNotifierBase(model),
Addresses: addresses,
log: log.New("alerting.notifier.email"),
}, nil

View File

@ -59,7 +59,7 @@ func NewHipChatNotifier(model *models.AlertNotification) (alerting.Notifier, err
roomId := model.Settings.Get("roomid").MustString()
return &HipChatNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
NotifierBase: NewNotifierBase(model),
Url: url,
ApiKey: apikey,
RoomId: roomId,

View File

@ -43,7 +43,7 @@ func NewKafkaNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
}
return &KafkaNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
NotifierBase: NewNotifierBase(model),
Endpoint: endpoint,
Topic: topic,
log: log.New("alerting.notifier.kafka"),

View File

@ -39,7 +39,7 @@ func NewLINENotifier(model *m.AlertNotification) (alerting.Notifier, error) {
}
return &LineNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
NotifierBase: NewNotifierBase(model),
Token: token,
log: log.New("alerting.notifier.line"),
}, nil

View File

@ -56,7 +56,7 @@ func NewOpsGenieNotifier(model *m.AlertNotification) (alerting.Notifier, error)
}
return &OpsGenieNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
NotifierBase: NewNotifierBase(model),
ApiKey: apiKey,
ApiUrl: apiUrl,
AutoClose: autoClose,

View File

@ -51,7 +51,7 @@ func NewPagerdutyNotifier(model *m.AlertNotification) (alerting.Notifier, error)
}
return &PagerdutyNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
NotifierBase: NewNotifierBase(model),
Key: key,
AutoResolve: autoResolve,
log: log.New("alerting.notifier.pagerduty"),

View File

@ -99,7 +99,7 @@ func NewPushoverNotifier(model *m.AlertNotification) (alerting.Notifier, error)
return nil, alerting.ValidationError{Reason: "API token not given"}
}
return &PushoverNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
NotifierBase: NewNotifierBase(model),
UserKey: userKey,
ApiToken: apiToken,
Priority: priority,

View File

@ -51,7 +51,7 @@ func NewSensuNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
}
return &SensuNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
NotifierBase: NewNotifierBase(model),
Url: url,
User: model.Settings.Get("username").MustString(),
Source: model.Settings.Get("source").MustString(),

View File

@ -78,7 +78,7 @@ func NewSlackNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
uploadImage := model.Settings.Get("uploadImage").MustBool(true)
return &SlackNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
NotifierBase: NewNotifierBase(model),
Url: url,
Recipient: recipient,
Mention: mention,

View File

@ -33,7 +33,7 @@ func NewTeamsNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
}
return &TeamsNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
NotifierBase: NewNotifierBase(model),
Url: url,
log: log.New("alerting.notifier.teams"),
}, nil

View File

@ -78,7 +78,7 @@ func NewTelegramNotifier(model *m.AlertNotification) (alerting.Notifier, error)
}
return &TelegramNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
NotifierBase: NewNotifierBase(model),
BotToken: botToken,
ChatID: chatId,
UploadImage: uploadImage,

View File

@ -106,7 +106,7 @@ func NewThreemaNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
}
return &ThreemaNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
NotifierBase: NewNotifierBase(model),
GatewayID: gatewayID,
RecipientID: recipientID,
APISecret: apiSecret,

View File

@ -51,7 +51,7 @@ func NewVictoropsNotifier(model *models.AlertNotification) (alerting.Notifier, e
}
return &VictoropsNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
NotifierBase: NewNotifierBase(model),
URL: url,
AutoResolve: autoResolve,
log: log.New("alerting.notifier.victorops"),

View File

@ -47,7 +47,7 @@ func NewWebHookNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
}
return &WebhookNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
NotifierBase: NewNotifierBase(model),
Url: url,
User: model.Settings.Get("username").MustString(),
Password: model.Settings.Get("password").MustString(),

View File

@ -88,6 +88,18 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error {
}
}
if evalContext.Rule.State == m.AlertStateOK && evalContext.PrevAlertState != m.AlertStateOK {
for _, notifierId := range evalContext.Rule.Notifications {
cmd := &m.CleanNotificationJournalCommand{
AlertId: evalContext.Rule.Id,
NotifierId: notifierId,
OrgId: evalContext.Rule.OrgId,
}
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
handler.log.Error("Failed to clean up old notification records", "notifier", notifierId, "alert", evalContext.Rule.Id, "Error", err)
}
}
}
handler.notifier.SendIfNeeded(evalContext)
return nil

View File

@ -2,6 +2,7 @@ package rendering
import (
"context"
"fmt"
"io"
"net"
"net/http"
@ -20,14 +21,13 @@ var netTransport = &http.Transport{
TLSHandshakeTimeout: 5 * time.Second,
}
var netClient = &http.Client{
Transport: netTransport,
}
func (rs *RenderingService) renderViaHttp(ctx context.Context, opts Opts) (*RenderResult, error) {
filePath := rs.getFilePathForNewImage()
var netClient = &http.Client{
Timeout: opts.Timeout,
Transport: netTransport,
}
rendererUrl, err := url.Parse(rs.Cfg.RendererUrl)
if err != nil {
return nil, err
@ -35,10 +35,10 @@ func (rs *RenderingService) renderViaHttp(ctx context.Context, opts Opts) (*Rend
queryParams := rendererUrl.Query()
queryParams.Add("url", rs.getURL(opts.Path))
queryParams.Add("renderKey", rs.getRenderKey(opts.UserId, opts.OrgId, opts.OrgRole))
queryParams.Add("renderKey", rs.getRenderKey(opts.OrgId, opts.UserId, opts.OrgRole))
queryParams.Add("width", strconv.Itoa(opts.Width))
queryParams.Add("height", strconv.Itoa(opts.Height))
queryParams.Add("domain", rs.getLocalDomain())
queryParams.Add("domain", rs.domain)
queryParams.Add("timezone", isoTimeOffsetToPosixTz(opts.Timezone))
queryParams.Add("encoding", opts.Encoding)
queryParams.Add("timeout", strconv.Itoa(int(opts.Timeout.Seconds())))
@ -49,20 +49,48 @@ func (rs *RenderingService) renderViaHttp(ctx context.Context, opts Opts) (*Rend
return nil, err
}
reqContext, cancel := context.WithTimeout(ctx, opts.Timeout+time.Second*2)
defer cancel()
req = req.WithContext(reqContext)
// make request to renderer server
resp, err := netClient.Do(req)
if err != nil {
return nil, err
rs.log.Error("Failed to send request to remote rendering service.", "error", err)
return nil, fmt.Errorf("Failed to send request to remote rendering service. %s", err)
}
// save response to file
defer resp.Body.Close()
// check for timeout first
if reqContext.Err() == context.DeadlineExceeded {
rs.log.Info("Rendering timed out")
return nil, ErrTimeout
}
// if we didnt get a 200 response, something went wrong.
if resp.StatusCode != http.StatusOK {
rs.log.Error("Remote rendering request failed", "error", resp.Status)
return nil, fmt.Errorf("Remote rendering request failed. %d: %s", resp.StatusCode, resp.Status)
}
out, err := os.Create(filePath)
if err != nil {
return nil, err
}
defer out.Close()
io.Copy(out, resp.Body)
_, err = io.Copy(out, resp.Body)
if err != nil {
// check that we didnt timeout while receiving the response.
if reqContext.Err() == context.DeadlineExceeded {
rs.log.Info("Rendering timed out")
return nil, ErrTimeout
}
rs.log.Error("Remote rendering request failed", "error", err)
return nil, fmt.Errorf("Remote rendering request failed. %s", err)
}
return &RenderResult{FilePath: filePath}, err
}

View File

@ -49,7 +49,7 @@ func (rs *RenderingService) renderViaPhantomJS(ctx context.Context, opts Opts) (
fmt.Sprintf("width=%v", opts.Width),
fmt.Sprintf("height=%v", opts.Height),
fmt.Sprintf("png=%v", pngPath),
fmt.Sprintf("domain=%v", rs.getLocalDomain()),
fmt.Sprintf("domain=%v", rs.domain),
fmt.Sprintf("timeout=%v", opts.Timeout.Seconds()),
fmt.Sprintf("renderKey=%v", renderKey),
}

View File

@ -77,10 +77,10 @@ func (rs *RenderingService) renderViaPlugin(ctx context.Context, opts Opts) (*Re
Height: int32(opts.Height),
FilePath: pngPath,
Timeout: int32(opts.Timeout.Seconds()),
RenderKey: rs.getRenderKey(opts.UserId, opts.OrgId, opts.OrgRole),
RenderKey: rs.getRenderKey(opts.OrgId, opts.UserId, opts.OrgRole),
Encoding: opts.Encoding,
Timezone: isoTimeOffsetToPosixTz(opts.Timezone),
Domain: rs.getLocalDomain(),
Domain: rs.domain,
})
if err != nil {

View File

@ -3,6 +3,8 @@ package rendering
import (
"context"
"fmt"
"net/url"
"os"
"path/filepath"
plugin "github.com/hashicorp/go-plugin"
@ -27,12 +29,31 @@ type RenderingService struct {
grpcPlugin pluginModel.RendererPlugin
pluginInfo *plugins.RendererPlugin
renderAction renderFunc
domain string
Cfg *setting.Cfg `inject:""`
}
func (rs *RenderingService) Init() error {
rs.log = log.New("rendering")
// ensure ImagesDir exists
err := os.MkdirAll(rs.Cfg.ImagesDir, 0700)
if err != nil {
return err
}
// set value used for domain attribute of renderKey cookie
if rs.Cfg.RendererUrl != "" {
// RendererCallbackUrl has already been passed, it wont generate an error.
u, _ := url.Parse(rs.Cfg.RendererCallbackUrl)
rs.domain = u.Hostname()
} else if setting.HttpAddr != setting.DEFAULT_HTTP_ADDR {
rs.domain = setting.HttpAddr
} else {
rs.domain = "localhost"
}
return nil
}
@ -82,16 +103,17 @@ func (rs *RenderingService) getFilePathForNewImage() string {
}
func (rs *RenderingService) getURL(path string) string {
// &render=1 signals to the legacy redirect layer to
return fmt.Sprintf("%s://%s:%s/%s&render=1", setting.Protocol, rs.getLocalDomain(), setting.HttpPort, path)
}
if rs.Cfg.RendererUrl != "" {
// The backend rendering service can potentially be remote.
// So we need to use the root_url to ensure the rendering service
// can reach this Grafana instance.
// &render=1 signals to the legacy redirect layer to
return fmt.Sprintf("%s%s&render=1", rs.Cfg.RendererCallbackUrl, path)
func (rs *RenderingService) getLocalDomain() string {
if setting.HttpAddr != setting.DEFAULT_HTTP_ADDR {
return setting.HttpAddr
}
return "localhost"
// &render=1 signals to the legacy redirect layer to
return fmt.Sprintf("%s://%s:%s/%s&render=1", setting.Protocol, rs.domain, setting.HttpPort, path)
}
func (rs *RenderingService) getRenderKey(orgId, userId int64, orgRole models.RoleType) string {

View File

@ -2,6 +2,7 @@ package sqlstore
import (
"bytes"
"context"
"fmt"
"strings"
"time"
@ -17,6 +18,9 @@ func init() {
bus.AddHandler("sql", DeleteAlertNotification)
bus.AddHandler("sql", GetAlertNotificationsToSend)
bus.AddHandler("sql", GetAllAlertNotifications)
bus.AddHandlerCtx("sql", RecordNotificationJournal)
bus.AddHandlerCtx("sql", GetLatestNotification)
bus.AddHandlerCtx("sql", CleanNotificationJournal)
}
func DeleteAlertNotification(cmd *m.DeleteAlertNotificationCommand) error {
@ -53,7 +57,9 @@ func GetAlertNotificationsToSend(query *m.GetAlertNotificationsToSendQuery) erro
alert_notification.created,
alert_notification.updated,
alert_notification.settings,
alert_notification.is_default
alert_notification.is_default,
alert_notification.send_reminder,
alert_notification.frequency
FROM alert_notification
`)
@ -91,7 +97,9 @@ func getAlertNotificationInternal(query *m.GetAlertNotificationsQuery, sess *DBS
alert_notification.created,
alert_notification.updated,
alert_notification.settings,
alert_notification.is_default
alert_notification.is_default,
alert_notification.send_reminder,
alert_notification.frequency
FROM alert_notification
`)
@ -137,17 +145,31 @@ func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error
return fmt.Errorf("Alert notification name %s already exists", cmd.Name)
}
alertNotification := &m.AlertNotification{
OrgId: cmd.OrgId,
Name: cmd.Name,
Type: cmd.Type,
Settings: cmd.Settings,
Created: time.Now(),
Updated: time.Now(),
IsDefault: cmd.IsDefault,
var frequency time.Duration
if cmd.SendReminder {
if cmd.Frequency == "" {
return m.ErrNotificationFrequencyNotFound
}
frequency, err = time.ParseDuration(cmd.Frequency)
if err != nil {
return err
}
}
if _, err = sess.Insert(alertNotification); err != nil {
alertNotification := &m.AlertNotification{
OrgId: cmd.OrgId,
Name: cmd.Name,
Type: cmd.Type,
Settings: cmd.Settings,
SendReminder: cmd.SendReminder,
Frequency: frequency,
Created: time.Now(),
Updated: time.Now(),
IsDefault: cmd.IsDefault,
}
if _, err = sess.MustCols("send_reminder").Insert(alertNotification); err != nil {
return err
}
@ -179,16 +201,77 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
current.Name = cmd.Name
current.Type = cmd.Type
current.IsDefault = cmd.IsDefault
current.SendReminder = cmd.SendReminder
sess.UseBool("is_default")
if current.SendReminder {
if cmd.Frequency == "" {
return m.ErrNotificationFrequencyNotFound
}
frequency, err := time.ParseDuration(cmd.Frequency)
if err != nil {
return err
}
current.Frequency = frequency
}
sess.UseBool("is_default", "send_reminder")
if affected, err := sess.ID(cmd.Id).Update(current); err != nil {
return err
} else if affected == 0 {
return fmt.Errorf("Could not find alert notification")
return fmt.Errorf("Could not update alert notification")
}
cmd.Result = &current
return nil
})
}
func RecordNotificationJournal(ctx context.Context, cmd *m.RecordNotificationJournalCommand) error {
return inTransactionCtx(ctx, func(sess *DBSession) error {
journalEntry := &m.AlertNotificationJournal{
OrgId: cmd.OrgId,
AlertId: cmd.AlertId,
NotifierId: cmd.NotifierId,
SentAt: cmd.SentAt,
Success: cmd.Success,
}
if _, err := sess.Insert(journalEntry); err != nil {
return err
}
return nil
})
}
func GetLatestNotification(ctx context.Context, cmd *m.GetLatestNotificationQuery) error {
return inTransactionCtx(ctx, func(sess *DBSession) error {
nj := &m.AlertNotificationJournal{}
_, err := sess.Desc("alert_notification_journal.sent_at").
Limit(1).
Where("alert_notification_journal.org_id = ? AND alert_notification_journal.alert_id = ? AND alert_notification_journal.notifier_id = ?", cmd.OrgId, cmd.AlertId, cmd.NotifierId).Get(nj)
if err != nil {
return err
}
if nj.AlertId == 0 && nj.Id == 0 && nj.NotifierId == 0 && nj.OrgId == 0 {
return m.ErrJournalingNotFound
}
cmd.Result = nj
return nil
})
}
func CleanNotificationJournal(ctx context.Context, cmd *m.CleanNotificationJournalCommand) error {
return inTransactionCtx(ctx, func(sess *DBSession) error {
sql := "DELETE FROM alert_notification_journal WHERE alert_notification_journal.org_id = ? AND alert_notification_journal.alert_id = ? AND alert_notification_journal.notifier_id = ?"
_, err := sess.Exec(sql, cmd.OrgId, cmd.AlertId, cmd.NotifierId)
return err
})
}

View File

@ -1,7 +1,9 @@
package sqlstore
import (
"context"
"testing"
"time"
"github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models"
@ -11,7 +13,48 @@ import (
func TestAlertNotificationSQLAccess(t *testing.T) {
Convey("Testing Alert notification sql access", t, func() {
InitTestDB(t)
var err error
Convey("Alert notification journal", func() {
var alertId int64 = 5
var orgId int64 = 5
var notifierId int64 = 5
Convey("Getting last journal should raise error if no one exists", func() {
query := &m.GetLatestNotificationQuery{AlertId: alertId, OrgId: orgId, NotifierId: notifierId}
err := GetLatestNotification(context.Background(), query)
So(err, ShouldEqual, m.ErrJournalingNotFound)
Convey("shoulbe be able to record two journaling events", func() {
createCmd := &m.RecordNotificationJournalCommand{AlertId: alertId, NotifierId: notifierId, OrgId: orgId, Success: true, SentAt: 1}
err := RecordNotificationJournal(context.Background(), createCmd)
So(err, ShouldBeNil)
createCmd.SentAt += 1000 //increase epoch
err = RecordNotificationJournal(context.Background(), createCmd)
So(err, ShouldBeNil)
Convey("get last journaling event", func() {
err := GetLatestNotification(context.Background(), query)
So(err, ShouldBeNil)
So(query.Result.SentAt, ShouldEqual, 1001)
Convey("be able to clear all journaling for an notifier", func() {
cmd := &m.CleanNotificationJournalCommand{AlertId: alertId, NotifierId: notifierId, OrgId: orgId}
err := CleanNotificationJournal(context.Background(), cmd)
So(err, ShouldBeNil)
Convey("querying for last junaling should raise error", func() {
query := &m.GetLatestNotificationQuery{AlertId: alertId, OrgId: orgId, NotifierId: notifierId}
err := GetLatestNotification(context.Background(), query)
So(err, ShouldEqual, m.ErrJournalingNotFound)
})
})
})
})
})
})
Convey("Alert notifications should be empty", func() {
cmd := &m.GetAlertNotificationsQuery{
@ -24,19 +67,75 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
So(cmd.Result, ShouldBeNil)
})
Convey("Can save Alert Notification", func() {
Convey("Cannot save alert notifier with send reminder = true", func() {
cmd := &m.CreateAlertNotificationCommand{
Name: "ops",
Type: "email",
OrgId: 1,
Settings: simplejson.New(),
Name: "ops",
Type: "email",
OrgId: 1,
SendReminder: true,
Settings: simplejson.New(),
}
err = CreateAlertNotificationCommand(cmd)
Convey("and missing frequency", func() {
err := CreateAlertNotificationCommand(cmd)
So(err, ShouldEqual, m.ErrNotificationFrequencyNotFound)
})
Convey("invalid frequency", func() {
cmd.Frequency = "invalid duration"
err := CreateAlertNotificationCommand(cmd)
So(err.Error(), ShouldEqual, "time: invalid duration invalid duration")
})
})
Convey("Cannot update alert notifier with send reminder = false", func() {
cmd := &m.CreateAlertNotificationCommand{
Name: "ops update",
Type: "email",
OrgId: 1,
SendReminder: false,
Settings: simplejson.New(),
}
err := CreateAlertNotificationCommand(cmd)
So(err, ShouldBeNil)
updateCmd := &m.UpdateAlertNotificationCommand{
Id: cmd.Result.Id,
SendReminder: true,
}
Convey("and missing frequency", func() {
err := UpdateAlertNotification(updateCmd)
So(err, ShouldEqual, m.ErrNotificationFrequencyNotFound)
})
Convey("invalid frequency", func() {
updateCmd.Frequency = "invalid duration"
err := UpdateAlertNotification(updateCmd)
So(err, ShouldNotBeNil)
So(err.Error(), ShouldEqual, "time: invalid duration invalid duration")
})
})
Convey("Can save Alert Notification", func() {
cmd := &m.CreateAlertNotificationCommand{
Name: "ops",
Type: "email",
OrgId: 1,
SendReminder: true,
Frequency: "10s",
Settings: simplejson.New(),
}
err := CreateAlertNotificationCommand(cmd)
So(err, ShouldBeNil)
So(cmd.Result.Id, ShouldNotEqual, 0)
So(cmd.Result.OrgId, ShouldNotEqual, 0)
So(cmd.Result.Type, ShouldEqual, "email")
So(cmd.Result.Frequency, ShouldEqual, 10*time.Second)
Convey("Cannot save Alert Notification with the same name", func() {
err = CreateAlertNotificationCommand(cmd)
@ -45,25 +144,42 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
Convey("Can update alert notification", func() {
newCmd := &m.UpdateAlertNotificationCommand{
Name: "NewName",
Type: "webhook",
OrgId: cmd.Result.OrgId,
Settings: simplejson.New(),
Id: cmd.Result.Id,
Name: "NewName",
Type: "webhook",
OrgId: cmd.Result.OrgId,
SendReminder: true,
Frequency: "60s",
Settings: simplejson.New(),
Id: cmd.Result.Id,
}
err := UpdateAlertNotification(newCmd)
So(err, ShouldBeNil)
So(newCmd.Result.Name, ShouldEqual, "NewName")
So(newCmd.Result.Frequency, ShouldEqual, 60*time.Second)
})
Convey("Can update alert notification to disable sending of reminders", func() {
newCmd := &m.UpdateAlertNotificationCommand{
Name: "NewName",
Type: "webhook",
OrgId: cmd.Result.OrgId,
SendReminder: false,
Settings: simplejson.New(),
Id: cmd.Result.Id,
}
err := UpdateAlertNotification(newCmd)
So(err, ShouldBeNil)
So(newCmd.Result.SendReminder, ShouldBeFalse)
})
})
Convey("Can search using an array of ids", func() {
cmd1 := m.CreateAlertNotificationCommand{Name: "nagios", Type: "webhook", OrgId: 1, Settings: simplejson.New()}
cmd2 := m.CreateAlertNotificationCommand{Name: "slack", Type: "webhook", OrgId: 1, Settings: simplejson.New()}
cmd3 := m.CreateAlertNotificationCommand{Name: "ops2", Type: "email", OrgId: 1, Settings: simplejson.New()}
cmd4 := m.CreateAlertNotificationCommand{IsDefault: true, Name: "default", Type: "email", OrgId: 1, Settings: simplejson.New()}
cmd1 := m.CreateAlertNotificationCommand{Name: "nagios", Type: "webhook", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
cmd2 := m.CreateAlertNotificationCommand{Name: "slack", Type: "webhook", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
cmd3 := m.CreateAlertNotificationCommand{Name: "ops2", Type: "email", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
cmd4 := m.CreateAlertNotificationCommand{IsDefault: true, Name: "default", Type: "email", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
otherOrg := m.CreateAlertNotificationCommand{Name: "default", Type: "email", OrgId: 2, Settings: simplejson.New()}
otherOrg := m.CreateAlertNotificationCommand{Name: "default", Type: "email", OrgId: 2, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
So(CreateAlertNotificationCommand(&cmd1), ShouldBeNil)
So(CreateAlertNotificationCommand(&cmd2), ShouldBeNil)

View File

@ -65,6 +65,13 @@ func addAlertMigrations(mg *Migrator) {
mg.AddMigration("Add column is_default", NewAddColumnMigration(alert_notification, &Column{
Name: "is_default", Type: DB_Bool, Nullable: false, Default: "0",
}))
mg.AddMigration("Add column frequency", NewAddColumnMigration(alert_notification, &Column{
Name: "frequency", Type: DB_BigInt, Nullable: true,
}))
mg.AddMigration("Add column send_reminder", NewAddColumnMigration(alert_notification, &Column{
Name: "send_reminder", Type: DB_Bool, Nullable: true, Default: "0",
}))
mg.AddMigration("add index alert_notification org_id & name", NewAddIndexMigration(alert_notification, alert_notification.Indices[0]))
mg.AddMigration("Update alert table charset", NewTableCharsetMigration("alert", []*Column{
@ -82,4 +89,22 @@ func addAlertMigrations(mg *Migrator) {
{Name: "type", Type: DB_NVarchar, Length: 255, Nullable: false},
{Name: "settings", Type: DB_Text, Nullable: false},
}))
notification_journal := Table{
Name: "alert_notification_journal",
Columns: []*Column{
{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "org_id", Type: DB_BigInt, Nullable: false},
{Name: "alert_id", Type: DB_BigInt, Nullable: false},
{Name: "notifier_id", Type: DB_BigInt, Nullable: false},
{Name: "sent_at", Type: DB_BigInt, Nullable: false},
{Name: "success", Type: DB_Bool, Nullable: false},
},
Indices: []*Index{
{Cols: []string{"org_id", "alert_id", "notifier_id"}, Type: IndexType},
},
}
mg.AddMigration("create notification_journal table v1", NewAddTableMigration(notification_journal))
mg.AddMigration("add index notification_journal org_id & alert_id & notifier_id", NewAddIndexMigration(notification_journal, notification_journal.Indices[0]))
}

View File

@ -197,6 +197,7 @@ type Cfg struct {
ImagesDir string
PhantomDir string
RendererUrl string
RendererCallbackUrl string
DisableBruteForceLoginProtection bool
TempDataLifetime time.Duration
@ -641,6 +642,18 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
// Rendering
renderSec := iniFile.Section("rendering")
cfg.RendererUrl = renderSec.Key("server_url").String()
cfg.RendererCallbackUrl = renderSec.Key("callback_url").String()
if cfg.RendererCallbackUrl == "" {
cfg.RendererCallbackUrl = AppUrl
} else {
if cfg.RendererCallbackUrl[len(cfg.RendererCallbackUrl)-1] != '/' {
cfg.RendererCallbackUrl += "/"
}
_, err := url.Parse(cfg.RendererCallbackUrl)
if err != nil {
log.Fatal(4, "Invalid callback_url(%s): %s", cfg.RendererCallbackUrl, err)
}
}
cfg.ImagesDir = filepath.Join(DataPath, "png")
cfg.PhantomDir = filepath.Join(HomePath, "tools/phantomjs")
cfg.TempDataLifetime = iniFile.Section("paths").Key("temp_data_lifetime").MustDuration(time.Second * 3600 * 24)

View File

@ -20,6 +20,7 @@ func TestLoadingSettings(t *testing.T) {
So(err, ShouldBeNil)
So(AdminUser, ShouldEqual, "admin")
So(cfg.RendererCallbackUrl, ShouldEqual, "http://localhost:3000/")
})
Convey("Should be able to override via environment variables", func() {
@ -178,5 +179,15 @@ func TestLoadingSettings(t *testing.T) {
So(InstanceName, ShouldEqual, hostname)
})
Convey("Reading callback_url should add trailing slash", func() {
cfg := NewCfg()
cfg.Load(&CommandLineArgs{
HomePath: "../../",
Args: []string{"cfg:rendering.callback_url=http://myserver/renderer"},
})
So(cfg.RendererCallbackUrl, ShouldEqual, "http://myserver/renderer/")
})
})
}

View File

@ -21,7 +21,7 @@ import _ from 'lodash';
import moment from 'moment';
// add move to lodash for backward compatabiltiy
_.move = function(array, fromIndex, toIndex) {
_.move = (array, fromIndex, toIndex) => {
array.splice(toIndex, 0, array.splice(fromIndex, 1)[0]);
return array;
};
@ -76,9 +76,9 @@ export class GrafanaApp {
$provide.decorator('$http', [
'$delegate',
'$templateCache',
function($delegate, $templateCache) {
($delegate, $templateCache) => {
const get = $delegate.get;
$delegate.get = function(url, config) {
$delegate.get = (url, config) => {
if (url.match(/\.html$/)) {
// some template's already exist in the cache
if (!$templateCache.get(url)) {
@ -105,9 +105,9 @@ export class GrafanaApp {
'react',
];
const module_types = ['controllers', 'directives', 'factories', 'services', 'filters', 'routes'];
const moduleTypes = ['controllers', 'directives', 'factories', 'services', 'filters', 'routes'];
_.each(module_types, type => {
_.each(moduleTypes, type => {
const moduleName = 'grafana.' + type;
this.useModule(angular.module(moduleName, []));
});
@ -135,7 +135,7 @@ export class GrafanaApp {
this.preBootModules = null;
});
})
.catch(function(err) {
.catch(err => {
console.log('Application boot failed:', err);
});
}

View File

@ -173,6 +173,12 @@ export class Explore extends React.Component<any, ExploreState> {
datasource.init();
}
// Keep queries but reset edit state
const nextQueries = this.state.queries.map(q => ({
...q,
edited: false,
}));
this.setState(
{
datasource,
@ -182,6 +188,7 @@ export class Explore extends React.Component<any, ExploreState> {
supportsLogs,
supportsTable,
datasourceLoading: false,
queries: nextQueries,
},
() => datasourceError === null && this.onSubmit()
);

View File

@ -1,10 +1,10 @@
// Based on underscore.js debounce()
export default function debounce(func, wait) {
let timeout;
return function() {
return function(this: any) {
const context = this;
const args = arguments;
const later = function() {
const later = () => {
timeout = null;
func.apply(context, args);
};

View File

@ -1,6 +1,6 @@
// Node.closest() polyfill
if ('Element' in window && !Element.prototype.closest) {
Element.prototype.closest = function(s) {
Element.prototype.closest = function(this: any, s) {
const matches = (this.document || this.ownerDocument).querySelectorAll(s);
let el = this;
let i;
@ -9,7 +9,8 @@ if ('Element' in window && !Element.prototype.closest) {
i = matches.length;
// eslint-disable-next-line
while (--i >= 0 && matches.item(i) !== el) {}
} while (i < 0 && (el = el.parentElement));
el = el.parentElement;
} while (i < 0 && el);
return el;
};
}

View File

@ -6,6 +6,7 @@ import { NavStore } from 'app/stores/NavStore/NavStore';
import { TeamsStore, Team } from 'app/stores/TeamsStore/TeamsStore';
import { BackendSrv } from 'app/core/services/backend_srv';
import DeleteButton from 'app/core/components/DeleteButton/DeleteButton';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
interface Props {
nav: typeof NavStore.Type;
@ -61,48 +62,81 @@ export class TeamList extends React.Component<Props, any> {
);
}
renderTeamList(teams) {
return (
<div className="page-container page-body">
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<label className="gf-form--has-input-icon gf-form--grow">
<input
type="text"
className="gf-form-input"
placeholder="Search teams"
value={teams.search}
onChange={this.onSearchQueryChange}
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
</div>
<div className="page-action-bar__spacer" />
<a className="btn btn-success" href="org/teams/new">
<i className="fa fa-plus" /> New team
</a>
</div>
<div className="admin-list-table">
<table className="filter-table filter-table--hover form-inline">
<thead>
<tr>
<th />
<th>Name</th>
<th>Email</th>
<th>Members</th>
<th style={{ width: '1%' }} />
</tr>
</thead>
<tbody>{teams.filteredTeams.map(team => this.renderTeamMember(team))}</tbody>
</table>
</div>
</div>
);
}
renderEmptyList() {
return (
<div className="page-container page-body">
<EmptyListCTA
model={{
title: "You haven't created any teams yet.",
buttonIcon: 'fa fa-plus',
buttonLink: 'org/teams/new',
buttonTitle: ' New team',
proTip: 'Assign folder and dashboard permissions to teams instead of users to ease administration.',
proTipLink: '',
proTipLinkTitle: '',
proTipTarget: '_blank',
}}
/>
</div>
);
}
render() {
const { nav, teams } = this.props;
let view;
if (teams.filteredTeams.length > 0) {
view = this.renderTeamList(teams);
} else {
view = this.renderEmptyList();
}
return (
<div>
<PageHeader model={nav as any} />
<div className="page-container page-body">
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<label className="gf-form--has-input-icon gf-form--grow">
<input
type="text"
className="gf-form-input"
placeholder="Search teams"
value={teams.search}
onChange={this.onSearchQueryChange}
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
</div>
<div className="page-action-bar__spacer" />
<a className="btn btn-success" href="org/teams/new">
<i className="fa fa-plus" /> New team
</a>
</div>
<div className="admin-list-table">
<table className="filter-table filter-table--hover form-inline">
<thead>
<tr>
<th />
<th>Name</th>
<th>Email</th>
<th>Members</th>
<th style={{ width: '1%' }} />
</tr>
</thead>
<tbody>{teams.filteredTeams.map(team => this.renderTeamMember(team))}</tbody>
</table>
</div>
</div>
{view}
</div>
);
}

View File

@ -2,16 +2,16 @@ import { react2AngularDirective } from 'app/core/utils/react2angular';
import { PasswordStrength } from './components/PasswordStrength';
import PageHeader from './components/PageHeader/PageHeader';
import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
import LoginBackground from './components/Login/LoginBackground';
import { SearchResult } from './components/search/SearchResult';
import { TagFilter } from './components/TagFilter/TagFilter';
import { SideMenu } from './components/sidemenu/SideMenu';
import DashboardPermissions from './components/Permissions/DashboardPermissions';
export function registerAngularDirectives() {
react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
react2AngularDirective('sidemenu', SideMenu, []);
react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']);
react2AngularDirective('emptyListCta', EmptyListCTA, ['model']);
react2AngularDirective('loginBackground', LoginBackground, []);
react2AngularDirective('searchResult', SearchResult, []);
react2AngularDirective('tagFilter', TagFilter, [
'tags',

View File

@ -1,4 +1,4 @@
import { Emitter } from './utils/emitter';
var appEvents = new Emitter();
const appEvents = new Emitter();
export default appEvents;

File diff suppressed because it is too large Load Diff

View File

@ -84,7 +84,7 @@ function link(scope, elem, attrs) {
// disable depreacation warning
codeEditor.$blockScrolling = Infinity;
// Padding hacks
(<any>codeEditor.renderer).setScrollMargin(15, 15);
(codeEditor.renderer as any).setScrollMargin(15, 15);
codeEditor.renderer.setPadding(10);
setThemeMode();
@ -97,11 +97,11 @@ function link(scope, elem, attrs) {
textarea.addClass('gf-form-input');
if (scope.codeEditorFocus) {
setTimeout(function() {
setTimeout(() => {
textarea.focus();
var domEl = textarea[0];
const domEl = textarea[0];
if (domEl.setSelectionRange) {
var pos = textarea.val().length * 2;
const pos = textarea.val().length * 2;
domEl.setSelectionRange(pos, pos);
}
}, 100);
@ -119,7 +119,7 @@ function link(scope, elem, attrs) {
scope.$watch('content', (newValue, oldValue) => {
const editorValue = codeEditor.getValue();
if (newValue !== editorValue && newValue !== oldValue) {
scope.$$postDigest(function() {
scope.$$postDigest(() => {
setEditorContent(newValue);
});
}
@ -152,7 +152,7 @@ function link(scope, elem, attrs) {
if (scope.getCompleter()) {
// make copy of array as ace seems to share completers array between instances
const anyEditor = <any>codeEditor;
const anyEditor = codeEditor as any;
anyEditor.completers = anyEditor.completers.slice();
anyEditor.completers.push(scope.getCompleter());
}

View File

@ -13,7 +13,7 @@ export function spectrumPicker() {
scope: true,
replace: true,
template: '<color-picker color="ngModel.$viewValue" onChange="onColorChange"></color-picker>',
link: function(scope, element, attrs, ngModel) {
link: (scope, element, attrs, ngModel) => {
scope.ngModel = ngModel;
scope.onColorChange = color => {
ngModel.$setViewValue(color);

View File

@ -1,6 +1,6 @@
import coreModule from 'app/core/core_module';
var template = `
const template = `
<select class="gf-form-input" ng-model="ctrl.model" ng-options="f.value as f.text for f in ctrl.options"></select>
`;

View File

@ -1,8 +1,8 @@
import _ from 'lodash';
import coreModule from '../../core_module';
function typeaheadMatcher(item) {
var str = this.query;
function typeaheadMatcher(this: any, item) {
let str = this.query;
if (str === '') {
return true;
}
@ -36,7 +36,7 @@ export class FormDropdownCtrl {
startOpen: any;
debounce: number;
/** @ngInject **/
/** @ngInject */
constructor(private $scope, $element, private $sce, private templateSrv, private $q) {
this.inputElement = $element.find('input').first();
this.linkElement = $element.find('a').first();

View File

@ -31,7 +31,7 @@ export function gfPageDirective() {
header: '?gfPageHeader',
body: 'gfPageBody',
},
link: function(scope, elem, attrs) {
link: (scope, elem, attrs) => {
console.log(scope);
},
};

View File

@ -30,7 +30,7 @@ export class GrafanaCtrl {
setBackendSrv(backendSrv);
createStore({ backendSrv, datasourceSrv });
$scope.init = function() {
$scope.init = () => {
$scope.contextSrv = contextSrv;
$scope.appSubUrl = config.appSubUrl;
$scope._ = _;
@ -45,14 +45,14 @@ export class GrafanaCtrl {
$rootScope.colors = colors;
$scope.initDashboard = function(dashboardData, viewScope) {
$scope.initDashboard = (dashboardData, viewScope) => {
$scope.appEvent('dashboard-fetch-end', dashboardData);
$controller('DashboardCtrl', { $scope: viewScope }).init(dashboardData);
};
$rootScope.onAppEvent = function(name, callback, localScope) {
var unbind = $rootScope.$on(name, callback);
var callerScope = this;
const unbind = $rootScope.$on(name, callback);
let callerScope = this;
if (callerScope.$id === 1 && !localScope) {
console.log('warning rootScope onAppEvent called without localscope');
}
@ -62,7 +62,7 @@ export class GrafanaCtrl {
callerScope.$on('$destroy', unbind);
};
$rootScope.appEvent = function(name, payload) {
$rootScope.appEvent = (name, payload) => {
$rootScope.$emit(name, payload);
appEvents.emit(name, payload);
};
@ -71,17 +71,43 @@ export class GrafanaCtrl {
}
}
function setViewModeBodyClass(body, mode, sidemenuOpen: boolean) {
body.removeClass('view-mode--tv');
body.removeClass('view-mode--kiosk');
body.removeClass('view-mode--inactive');
switch (mode) {
case 'tv': {
body.removeClass('sidemenu-open');
body.addClass('view-mode--tv');
break;
}
// 1 & true for legacy states
case 1:
case true: {
body.removeClass('sidemenu-open');
body.addClass('view-mode--kiosk');
break;
}
default: {
body.toggleClass('sidemenu-open', sidemenuOpen);
}
}
}
/** @ngInject */
export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScope, $location) {
return {
restrict: 'E',
controller: GrafanaCtrl,
link: (scope, elem) => {
var sidemenuOpen;
var body = $('body');
let sidemenuOpen;
const body = $('body');
// see https://github.com/zenorocha/clipboard.js/issues/155
$.fn.modal.Constructor.prototype.enforceFocus = function() {};
$.fn.modal.Constructor.prototype.enforceFocus = () => {};
$('.preloader').remove();
sidemenuOpen = scope.contextSrv.sidemenu;
body.toggleClass('sidemenu-open', sidemenuOpen);
@ -99,9 +125,12 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
body.toggleClass('sidemenu-hidden');
});
scope.$watch(() => playlistSrv.isPlaying, function(newValue) {
elem.toggleClass('playlist-active', newValue === true);
});
scope.$watch(
() => playlistSrv.isPlaying,
newValue => {
elem.toggleClass('view-mode--playlist', newValue === true);
}
);
// check if we are in server side render
if (document.cookie.indexOf('renderKey') !== -1) {
@ -110,8 +139,8 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
// tooltip removal fix
// manage page classes
var pageClass;
scope.$on('$routeChangeSuccess', function(evt, data) {
let pageClass;
scope.$on('$routeChangeSuccess', (evt, data) => {
if (pageClass) {
body.removeClass(pageClass);
}
@ -129,17 +158,7 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
$('#tooltip, .tooltip').remove();
// check for kiosk url param
if (data.params.kiosk) {
appEvents.emit('toggle-kiosk-mode');
}
// check for 'inactive' url param for clean looks like kiosk, but with title
if (data.params.inactive) {
body.addClass('user-activity-low');
// for some reason, with this class it looks cleanest
body.addClass('sidemenu-open');
}
setViewModeBodyClass(body, data.params.kiosk, sidemenuOpen);
// close all drops
for (const drop of Drop.drops) {
@ -148,15 +167,37 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
});
// handle kiosk mode
appEvents.on('toggle-kiosk-mode', () => {
body.toggleClass('page-kiosk-mode');
appEvents.on('toggle-kiosk-mode', options => {
const search = $location.search();
if (options && options.exit) {
search.kiosk = 1;
}
switch (search.kiosk) {
case 'tv': {
search.kiosk = 1;
appEvents.emit('alert-success', ['Press ESC to exit Kiosk mode']);
break;
}
case 1:
case true: {
delete search.kiosk;
break;
}
default: {
search.kiosk = 'tv';
}
}
$location.search(search);
setViewModeBodyClass(body, search.kiosk, sidemenuOpen);
});
// handle in active view state class
var lastActivity = new Date().getTime();
var activeUser = true;
var inActiveTimeLimit = 60 * 1000;
var sidemenuHidden = false;
let lastActivity = new Date().getTime();
let activeUser = true;
const inActiveTimeLimit = 60 * 5000;
function checkForInActiveUser() {
if (!activeUser) {
@ -169,15 +210,8 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
if (new Date().getTime() - lastActivity > inActiveTimeLimit) {
activeUser = false;
body.addClass('user-activity-low');
// hide sidemenu
if (sidemenuOpen) {
sidemenuHidden = true;
body.removeClass('sidemenu-open');
$timeout(function() {
$rootScope.$broadcast('render');
}, 100);
}
body.addClass('view-mode--inactive');
body.removeClass('sidemenu-open');
}
}
@ -185,17 +219,8 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
lastActivity = new Date().getTime();
if (!activeUser) {
activeUser = true;
body.removeClass('user-activity-low');
// restore sidemenu
if (sidemenuHidden) {
sidemenuHidden = false;
body.addClass('sidemenu-open');
appEvents.emit('toggle-inactive-mode');
$timeout(function() {
$rootScope.$broadcast('render');
}, 100);
}
body.removeClass('view-mode--inactive');
body.toggleClass('sidemenu-open', sidemenuOpen);
}
}
@ -216,19 +241,19 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
});
// handle document clicks that should hide things
body.click(function(evt) {
var target = $(evt.target);
body.click(evt => {
const target = $(evt.target);
if (target.parents().length === 0) {
return;
}
// for stuff that animates, slides out etc, clicking it needs to
// hide it right away
var clickAutoHide = target.closest('[data-click-hide]');
const clickAutoHide = target.closest('[data-click-hide]');
if (clickAutoHide.length) {
var clickAutoHideParent = clickAutoHide.parent();
const clickAutoHideParent = clickAutoHide.parent();
clickAutoHide.detach();
setTimeout(function() {
setTimeout(() => {
clickAutoHideParent.append(clickAutoHide);
}, 100);
}
@ -240,14 +265,14 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
// hide search
if (body.find('.search-container').length > 0) {
if (target.parents('.search-results-container, .search-field-wrapper').length === 0) {
scope.$apply(function() {
scope.$apply(() => {
scope.appEvent('hide-dash-search');
});
}
}
// hide popovers
var popover = elem.find('.popover');
const popover = elem.find('.popover');
if (popover.length > 0 && target.parents('.graph-legend').length === 0) {
popover.hide();
}

View File

@ -7,7 +7,7 @@ export function infoPopover() {
restrict: 'E',
template: '<i class="fa fa-info-circle"></i>',
transclude: true,
link: function(scope, elem, attrs, ctrl, transclude) {
link: (scope, elem, attrs, ctrl, transclude) => {
const offset = attrs.offset || '0 -10px';
const position = attrs.position || 'right middle';
let classes = 'drop-help drop-hide-out-of-bounds';
@ -23,7 +23,7 @@ export function infoPopover() {
elem.addClass('gf-form-help-icon--' + attrs.mode);
}
transclude(function(clone, newScope) {
transclude((clone, newScope) => {
const content = document.createElement('div');
content.className = 'markdown-html';
@ -54,7 +54,7 @@ export function infoPopover() {
scope.$applyAsync(() => {
const drop = new Drop(dropOptions);
const unbind = scope.$on('$destroy', function() {
const unbind = scope.$on('$destroy', () => {
drop.destroy();
unbind();
});

View File

@ -12,7 +12,7 @@ function escapeString(str: string): string {
* Determines if a value is an object
*/
export function isObject(value: any): boolean {
var type = typeof value;
const type = typeof value;
return !!value && type === 'object';
}
@ -21,7 +21,7 @@ export function isObject(value: any): boolean {
* From http://stackoverflow.com/a/332429
*
*/
export function getObjectName(object: Object): string {
export function getObjectName(object: object): string {
if (object === undefined) {
return '';
}
@ -44,7 +44,7 @@ export function getObjectName(object: Object): string {
/*
* Gets type of an object. Returns "null" for null objects
*/
export function getType(object: Object): string {
export function getType(object: object): string {
if (object === null) {
return 'null';
}
@ -54,8 +54,8 @@ export function getType(object: Object): string {
/*
* Generates inline preview for a JavaScript object based on a value
*/
export function getValuePreview(object: Object, value: string): string {
var type = getType(object);
export function getValuePreview(object: object, value: string): string {
const type = getType(object);
if (type === 'null' || type === 'undefined') {
return type;
@ -79,15 +79,15 @@ export function getValuePreview(object: Object, value: string): string {
/*
* Generates inline preview for a JavaScript object
*/
export function getPreview(object: string): string {
let value = '';
if (isObject(object)) {
value = getObjectName(object);
if (Array.isArray(object)) {
value += '[' + object.length + ']';
let value = '';
export function getPreview(obj: object): string {
if (isObject(obj)) {
value = getObjectName(obj);
if (Array.isArray(obj)) {
value += '[' + obj.length + ']';
}
} else {
value = getValuePreview(object, object);
value = getValuePreview(obj, obj.toString());
}
return value;
}

View File

@ -14,10 +14,10 @@ const MAX_ANIMATED_TOGGLE_ITEMS = 10;
const requestAnimationFrame =
window.requestAnimationFrame ||
function(cb: () => void) {
((cb: () => void) => {
cb();
return 0;
};
});
export interface JsonExplorerConfig {
animateOpen?: boolean;
@ -279,7 +279,7 @@ export class JsonExplorer {
const objectWrapperSpan = createElement('span');
// get constructor name and append it to wrapper span
var constructorName = createElement('span', 'constructor-name', this.constructorName);
const constructorName = createElement('span', 'constructor-name', this.constructorName);
objectWrapperSpan.appendChild(constructorName);
// if it's an array append the array specific elements like brackets and length

View File

@ -10,8 +10,8 @@ coreModule.directive('jsonTree', [
startExpanded: '@',
rootName: '@',
},
link: function(scope, elem) {
var jsonExp = new JsonExplorer(scope.object, 3, {
link: (scope, elem) => {
const jsonExp = new JsonExplorer(scope.object, 3, {
animateOpen: true,
});

View File

@ -1,7 +1,7 @@
import store from 'app/core/store';
import coreModule from 'app/core/core_module';
var template = `
const template = `
<div class="layout-selector">
<button ng-click="ctrl.listView()" ng-class="{active: ctrl.mode === 'list'}">
<i class="fa fa-list"></i>
@ -15,7 +15,7 @@ var template = `
export class LayoutSelectorCtrl {
mode: string;
/** @ngInject **/
/** @ngInject */
constructor(private $rootScope) {
this.mode = store.get('grafana.list.layout.mode') || 'grid';
}
@ -33,7 +33,7 @@ export class LayoutSelectorCtrl {
}
}
/** @ngInject **/
/** @ngInject */
export function layoutSelector() {
return {
restrict: 'E',
@ -45,14 +45,14 @@ export function layoutSelector() {
};
}
/** @ngInject **/
/** @ngInject */
export function layoutMode($rootScope) {
return {
restrict: 'A',
scope: {},
link: function(scope, elem) {
var layout = store.get('grafana.list.layout.mode') || 'grid';
var className = 'card-list-layout-' + layout;
link: (scope, elem) => {
const layout = store.get('grafana.list.layout.mode') || 'grid';
let className = 'card-list-layout-' + layout;
elem.addClass(className);
$rootScope.onAppEvent(

View File

@ -14,7 +14,7 @@ class Query {
}
export class ManageDashboardsCtrl {
public sections: any[];
sections: any[];
query: Query;
navModel: any;

View File

@ -30,7 +30,7 @@ export function navbarDirective() {
scope: {
model: '=',
},
link: function(scope, elem) {},
link: (scope, elem) => {},
};
}

View File

@ -74,7 +74,7 @@ export class QueryPart {
return;
}
var text = this.def.type + '(';
let text = this.def.type + '(';
text += this.params.join(', ');
text += ')';
this.text = text;
@ -82,9 +82,9 @@ export class QueryPart {
}
export function functionRenderer(part, innerExpr) {
var str = part.def.type + '(';
var parameters = _.map(part.params, (value, index) => {
var paramType = part.def.params[index];
const str = part.def.type + '(';
const parameters = _.map(part.params, (value, index) => {
const paramType = part.def.params[index];
if (paramType.type === 'time') {
if (value === 'auto') {
value = '$__interval';

View File

@ -2,7 +2,7 @@ import _ from 'lodash';
import $ from 'jquery';
import coreModule from 'app/core/core_module';
var template = `
const template = `
<div class="dropdown cascade-open">
<a ng-click="showActionsMenu()" class="query-part-name pointer dropdown-toggle" data-toggle="dropdown">{{part.def.type}}</a>
<span>(</span><span class="query-part-parameters"></span><span>)</span>
@ -15,7 +15,7 @@ var template = `
/** @ngInject */
export function queryPartEditorDirective($compile, templateSrv) {
var paramTemplate = '<input type="text" class="hide input-mini tight-form-func-param"></input>';
const paramTemplate = '<input type="text" class="hide input-mini tight-form-func-param"></input>';
return {
restrict: 'E',
@ -26,17 +26,17 @@ export function queryPartEditorDirective($compile, templateSrv) {
debounce: '@',
},
link: function postLink($scope, elem) {
var part = $scope.part;
var partDef = part.def;
var $paramsContainer = elem.find('.query-part-parameters');
var debounceLookup = $scope.debounce;
const part = $scope.part;
const partDef = part.def;
const $paramsContainer = elem.find('.query-part-parameters');
const debounceLookup = $scope.debounce;
$scope.partActions = [];
function clickFuncParam(paramIndex) {
function clickFuncParam(this: any, paramIndex) {
/*jshint validthis:true */
var $link = $(this);
var $input = $link.next();
const $link = $(this);
const $input = $link.next();
$input.val(part.params[paramIndex]);
$input.css('width', $link.width() + 16 + 'px');
@ -46,18 +46,18 @@ export function queryPartEditorDirective($compile, templateSrv) {
$input.focus();
$input.select();
var typeahead = $input.data('typeahead');
const typeahead = $input.data('typeahead');
if (typeahead) {
$input.val('');
typeahead.lookup();
}
}
function inputBlur(paramIndex) {
function inputBlur(this: any, paramIndex) {
/*jshint validthis:true */
var $input = $(this);
var $link = $input.prev();
var newValue = $input.val();
const $input = $(this);
const $link = $input.prev();
const newValue = $input.val();
if (newValue !== '' || part.def.params[paramIndex].optional) {
$link.html(templateSrv.highlightVariablesAsHtml(newValue));
@ -72,14 +72,14 @@ export function queryPartEditorDirective($compile, templateSrv) {
$link.show();
}
function inputKeyPress(paramIndex, e) {
function inputKeyPress(this: any, paramIndex, e) {
/*jshint validthis:true */
if (e.which === 13) {
inputBlur.call(this, paramIndex);
}
}
function inputKeyDown() {
function inputKeyDown(this: any) {
/*jshint validthis:true */
this.style.width = (3 + this.value.length) * 8 + 'px';
}
@ -89,20 +89,20 @@ export function queryPartEditorDirective($compile, templateSrv) {
return;
}
var typeaheadSource = function(query, callback) {
const typeaheadSource = (query, callback) => {
if (param.options) {
var options = param.options;
let options = param.options;
if (param.type === 'int') {
options = _.map(options, function(val) {
options = _.map(options, val => {
return val.toString();
});
}
return options;
}
$scope.$apply(function() {
$scope.handleEvent({ $event: { name: 'get-param-options' } }).then(function(result) {
var dynamicOptions = _.map(result, function(op) {
$scope.$apply(() => {
$scope.handleEvent({ $event: { name: 'get-param-options' } }).then(result => {
const dynamicOptions = _.map(result, op => {
return op.value;
});
callback(dynamicOptions);
@ -116,18 +116,18 @@ export function queryPartEditorDirective($compile, templateSrv) {
source: typeaheadSource,
minLength: 0,
items: 1000,
updater: function(value) {
setTimeout(function() {
updater: value => {
setTimeout(() => {
inputBlur.call($input[0], paramIndex);
}, 0);
return value;
},
});
var typeahead = $input.data('typeahead');
const typeahead = $input.data('typeahead');
typeahead.lookup = function() {
this.query = this.$element.val() || '';
var items = this.source(this.query, $.proxy(this.process, this));
const items = this.source(this.query, $.proxy(this.process, this));
return items ? this.process(items) : items;
};
@ -136,18 +136,18 @@ export function queryPartEditorDirective($compile, templateSrv) {
}
}
$scope.showActionsMenu = function() {
$scope.showActionsMenu = () => {
$scope.handleEvent({ $event: { name: 'get-part-actions' } }).then(res => {
$scope.partActions = res;
});
};
$scope.triggerPartAction = function(action) {
$scope.triggerPartAction = action => {
$scope.handleEvent({ $event: { name: 'action', action: action } });
};
function addElementsAndCompile() {
_.each(partDef.params, function(param, index) {
_.each(partDef.params, (param, index) => {
if (param.optional && part.params.length <= index) {
return;
}
@ -156,9 +156,9 @@ export function queryPartEditorDirective($compile, templateSrv) {
$('<span>, </span>').appendTo($paramsContainer);
}
var paramValue = templateSrv.highlightVariablesAsHtml(part.params[index]);
var $paramLink = $('<a class="graphite-func-param-link pointer">' + paramValue + '</a>');
var $input = $(paramTemplate);
const paramValue = templateSrv.highlightVariablesAsHtml(part.params[index]);
const $paramLink = $('<a class="graphite-func-param-link pointer">' + paramValue + '</a>');
const $input = $(paramTemplate);
$paramLink.appendTo($paramsContainer);
$input.appendTo($paramsContainer);

View File

@ -4,7 +4,7 @@ import appEvents from 'app/core/app_events';
export function pageScrollbar() {
return {
restrict: 'A',
link: function(scope, elem, attrs) {
link: (scope, elem, attrs) => {
let lastPos = 0;
appEvents.on(

View File

@ -1,7 +1,6 @@
import $ from 'jquery';
import baron from 'baron';
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
const scrollBarHTML = `
<div class="baron__track">
@ -15,7 +14,7 @@ const scrollerClass = 'baron__scroller';
export function geminiScrollbar() {
return {
restrict: 'A',
link: function(scope, elem, attrs) {
link: (scope, elem, attrs) => {
let scrollRoot = elem.parent();
const scroller = elem;
@ -39,43 +38,6 @@ export function geminiScrollbar() {
const scrollbar = baron(scrollParams);
let lastPos = 0;
appEvents.on(
'dash-scroll',
evt => {
if (evt.restore) {
elem[0].scrollTop = lastPos;
return;
}
lastPos = elem[0].scrollTop;
if (evt.animate) {
elem.animate({ scrollTop: evt.pos }, 500);
} else {
elem[0].scrollTop = evt.pos;
}
},
scope
);
// force updating dashboard width
appEvents.on('toggle-sidemenu', forceUpdate, scope);
appEvents.on('toggle-sidemenu-hidden', forceUpdate, scope);
appEvents.on('toggle-view-mode', forceUpdate, scope);
appEvents.on('toggle-kiosk-mode', forceUpdate, scope);
appEvents.on('toggle-inactive-mode', forceUpdate, scope);
function forceUpdate() {
scrollbar.scroll();
}
scope.$on('$routeChangeSuccess', () => {
lastPos = 0;
elem[0].scrollTop = 0;
});
scope.$on('$destroy', () => {
scrollbar.dispose();
});

View File

@ -130,8 +130,8 @@ export class SearchCtrl {
}
const max = flattenedResult.length;
let newIndex = this.selectedIndex + direction;
this.selectedIndex = (newIndex %= max) < 0 ? newIndex + max : newIndex;
const newIndex = (this.selectedIndex + direction) % max;
this.selectedIndex = newIndex < 0 ? newIndex + max : newIndex;
const selectedItem = flattenedResult[this.selectedIndex];
if (selectedItem.dashboardIndex === undefined && this.results[selectedItem.folderIndex].id === 0) {
@ -159,7 +159,7 @@ export class SearchCtrl {
searchDashboards() {
this.currentSearchId = this.currentSearchId + 1;
var localSearchId = this.currentSearchId;
const localSearchId = this.currentSearchId;
return this.searchSrv.search(this.query).then(results => {
if (localSearchId < this.currentSearchId) {
@ -172,7 +172,7 @@ export class SearchCtrl {
}
queryHasNoFilters() {
var query = this.query;
const query = this.query;
return query.query === '' && query.starred === false && query.tag.length === 0;
}

View File

@ -0,0 +1,96 @@
import React from 'react';
import { shallow } from 'enzyme';
import BottomNavLinks from './BottomNavLinks';
import appEvents from '../../app_events';
jest.mock('../../app_events', () => ({
emit: jest.fn(),
}));
const setup = (propOverrides?: object) => {
const props = Object.assign(
{
link: {},
user: {
isGrafanaAdmin: false,
isSignedIn: false,
orgCount: 2,
orgRole: '',
orgId: 1,
orgName: 'Grafana',
timezone: 'UTC',
helpFlags1: 1,
lightTheme: false,
hasEditPermissionInFolders: false,
},
},
propOverrides
);
return shallow(<BottomNavLinks {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
it('should render organisation switcher', () => {
const wrapper = setup({
link: {
showOrgSwitcher: true,
},
});
expect(wrapper).toMatchSnapshot();
});
it('should render subtitle', () => {
const wrapper = setup({
link: {
subTitle: 'subtitle',
},
});
expect(wrapper).toMatchSnapshot();
});
it('should render children', () => {
const wrapper = setup({
link: {
children: [
{
id: '1',
},
{
id: '2',
},
{
id: '3',
},
{
id: '4',
hideFromMenu: true,
},
],
},
});
expect(wrapper).toMatchSnapshot();
});
});
describe('Functions', () => {
describe('item clicked', () => {
const wrapper = setup();
const mockEvent = { preventDefault: jest.fn() };
it('should emit show modal event if url matches shortcut', () => {
const child = { url: '/shortcuts' };
const instance = wrapper.instance() as BottomNavLinks;
instance.itemClicked(mockEvent, child);
expect(appEvents.emit).toHaveBeenCalledWith('show-modal', { templateHtml: '<help-modal></help-modal>' });
});
});
});

View File

@ -0,0 +1,78 @@
import React, { PureComponent } from 'react';
import appEvents from '../../app_events';
import { User } from '../../services/context_srv';
export interface Props {
link: any;
user: User;
}
class BottomNavLinks extends PureComponent<Props> {
itemClicked = (event, child) => {
if (child.url === '/shortcuts') {
event.preventDefault();
appEvents.emit('show-modal', {
templateHtml: '<help-modal></help-modal>',
});
}
};
switchOrg = () => {
appEvents.emit('show-modal', {
templateHtml: '<org-switcher dismiss="dismiss()"></org-switcher>',
});
};
render() {
const { link, user } = this.props;
return (
<div className="sidemenu-item dropdown dropup">
<a href={link.url} className="sidemenu-link" target={link.target}>
<span className="icon-circle sidemenu-icon">
{link.icon && <i className={link.icon} />}
{link.img && <img src={link.img} />}
</span>
</a>
<ul className="dropdown-menu dropdown-menu--sidemenu" role="menu">
{link.subTitle && (
<li className="sidemenu-subtitle">
<span className="sidemenu-item-text">{link.subTitle}</span>
</li>
)}
{link.showOrgSwitcher && (
<li className="sidemenu-org-switcher">
<a onClick={this.switchOrg}>
<div>
<div className="sidemenu-org-switcher__org-name">{user.orgName}</div>
<div className="sidemenu-org-switcher__org-current">Current Org:</div>
</div>
<div className="sidemenu-org-switcher__switch">
<i className="fa fa-fw fa-random" />Switch
</div>
</a>
</li>
)}
{link.children &&
link.children.map((child, index) => {
if (!child.hideFromMenu) {
return (
<li className={child.divider} key={`${child.text}-${index}`}>
<a href={child.url} target={child.target} onClick={event => this.itemClicked(event, child)}>
{child.icon && <i className={child.icon} />}
{child.text}
</a>
</li>
);
}
return null;
})}
<li className="side-menu-header">
<span className="sidemenu-item-text">{link.text}</span>
</li>
</ul>
</div>
);
}
}
export default BottomNavLinks;

View File

@ -0,0 +1,44 @@
import React from 'react';
import { shallow } from 'enzyme';
import BottomSection from './BottomSection';
jest.mock('../../config', () => ({
bootData: {
navTree: [
{
id: 'profile',
hideFromMenu: true,
},
{
hideFromMenu: true,
},
{
hideFromMenu: false,
},
{
hideFromMenu: true,
},
],
},
user: {
orgCount: 5,
orgName: 'Grafana',
},
}));
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
sidemenu: true,
isSignedIn: false,
isGrafanaAdmin: false,
hasEditPermissionFolders: false,
},
}));
describe('Render', () => {
it('should render component', () => {
const wrapper = shallow(<BottomSection />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -0,0 +1,29 @@
import React from 'react';
import _ from 'lodash';
import SignIn from './SignIn';
import BottomNavLinks from './BottomNavLinks';
import { contextSrv } from 'app/core/services/context_srv';
import config from '../../config';
export default function BottomSection() {
const navTree = _.cloneDeep(config.bootData.navTree);
const bottomNav = _.filter(navTree, item => item.hideFromMenu);
const isSignedIn = contextSrv.isSignedIn;
const user = contextSrv.user;
if (user && user.orgCount > 1) {
const profileNode = _.find(bottomNav, { id: 'profile' });
if (profileNode) {
profileNode.showOrgSwitcher = true;
}
}
return (
<div className="sidemenu__bottom">
{!isSignedIn && <SignIn />}
{bottomNav.map((link, index) => {
return <BottomNavLinks link={link} user={user} key={`${link.url}-${index}`} />;
})}
</div>
);
}

View File

@ -0,0 +1,35 @@
import React from 'react';
import { shallow } from 'enzyme';
import DropDownChild from './DropDownChild';
const setup = (propOverrides?: object) => {
const props = Object.assign(
{
child: {
divider: true,
},
},
propOverrides
);
return shallow(<DropDownChild {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
it('should render icon if exists', () => {
const wrapper = setup({
child: {
divider: false,
icon: 'icon-test',
},
});
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -0,0 +1,21 @@
import React, { SFC } from 'react';
export interface Props {
child: any;
}
const DropDownChild: SFC<Props> = props => {
const { child } = props;
const listItemClassName = child.divider ? 'divider' : '';
return (
<li className={listItemClassName}>
<a href={child.url}>
{child.icon && <i className={child.icon} />}
{child.text}
</a>
</li>
);
};
export default DropDownChild;

View File

@ -0,0 +1,70 @@
import React from 'react';
import { shallow } from 'enzyme';
import { SideMenu } from './SideMenu';
import appEvents from '../../app_events';
import { contextSrv } from 'app/core/services/context_srv';
jest.mock('../../app_events', () => ({
emit: jest.fn(),
}));
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
sidemenu: true,
user: {},
isSignedIn: false,
isGrafanaAdmin: false,
isEditor: false,
hasEditPermissionFolders: false,
toggleSideMenu: jest.fn(),
},
}));
const setup = (propOverrides?: object) => {
const props = Object.assign(
{
loginUrl: '',
user: {},
mainLinks: [],
bottomeLinks: [],
isSignedIn: false,
},
propOverrides
);
return shallow(<SideMenu {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
});
describe('Functions', () => {
describe('toggle side menu', () => {
const wrapper = setup();
const instance = wrapper.instance() as SideMenu;
instance.toggleSideMenu();
it('should call contextSrv.toggleSideMenu', () => {
expect(contextSrv.toggleSideMenu).toHaveBeenCalled();
});
it('should emit toggle sidemenu event', () => {
expect(appEvents.emit).toHaveBeenCalledWith('toggle-sidemenu');
});
});
describe('toggle side menu on mobile', () => {
const wrapper = setup();
const instance = wrapper.instance() as SideMenu;
instance.toggleSideMenuSmallBreakpoint();
it('should emit toggle sidemenu event', () => {
expect(appEvents.emit).toHaveBeenCalledWith('toggle-sidemenu-mobile');
});
});
});

View File

@ -0,0 +1,32 @@
import React, { PureComponent } from 'react';
import appEvents from '../../app_events';
import { contextSrv } from 'app/core/services/context_srv';
import TopSection from './TopSection';
import BottomSection from './BottomSection';
export class SideMenu extends PureComponent {
toggleSideMenu = () => {
contextSrv.toggleSideMenu();
appEvents.emit('toggle-sidemenu');
};
toggleSideMenuSmallBreakpoint = () => {
appEvents.emit('toggle-sidemenu-mobile');
};
render() {
return [
<div className="sidemenu__logo" onClick={this.toggleSideMenu} key="logo">
<img src="public/img/grafana_icon.svg" alt="graphana_logo" />
</div>,
<div className="sidemenu__logo_small_breakpoint" onClick={this.toggleSideMenuSmallBreakpoint} key="hamburger">
<i className="fa fa-bars" />
<span className="sidemenu__close">
<i className="fa fa-times" />&nbsp;Close
</span>
</div>,
<TopSection key="topsection" />,
<BottomSection key="bottomsection" />,
];
}
}

View File

@ -0,0 +1,35 @@
import React from 'react';
import { shallow } from 'enzyme';
import SideMenuDropDown from './SideMenuDropDown';
const setup = (propOverrides?: object) => {
const props = Object.assign(
{
link: {
text: 'link',
},
},
propOverrides
);
return shallow(<SideMenuDropDown {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
it('should render children', () => {
const wrapper = setup({
link: {
text: 'link',
children: [{ id: 1 }, { id: 2 }, { id: 3 }],
},
});
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -0,0 +1,23 @@
import React, { SFC } from 'react';
import DropDownChild from './DropDownChild';
interface Props {
link: any;
}
const SideMenuDropDown: SFC<Props> = props => {
const { link } = props;
return (
<ul className="dropdown-menu dropdown-menu--sidemenu" role="menu">
<li className="side-menu-header">
<span className="sidemenu-item-text">{link.text}</span>
</li>
{link.children &&
link.children.map((child, index) => {
return <DropDownChild child={child} key={`${child.url}-${index}`} />;
})}
</ul>
);
};
export default SideMenuDropDown;

View File

@ -0,0 +1,11 @@
import React from 'react';
import { shallow } from 'enzyme';
import SignIn from './SignIn';
describe('Render', () => {
it('should render component', () => {
const wrapper = shallow(<SignIn />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -0,0 +1,23 @@
import React, { SFC } from 'react';
const SignIn: SFC<any> = () => {
const loginUrl = `login?redirect=${encodeURIComponent(window.location.pathname)}`;
return (
<div className="sidemenu-item">
<a href={loginUrl} className="sidemenu-link" target="_self">
<span className="icon-circle sidemenu-icon">
<i className="fa fa-fw fa-sign-in" />
</span>
</a>
<a href={loginUrl} target="_self">
<ul className="dropdown-menu dropdown-menu--sidemenu" role="menu">
<li className="side-menu-header">
<span className="sidemenu-item-text">Sign In</span>
</li>
</ul>
</a>
</div>
);
};
export default SignIn;

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