Merge branch 'master' into backend_plugins

* master: (117 commits)
  fix: share snapshot controller was missing ngInject comment, fixes #10511
  Use URLEncoding instead of StdEncoding to be sure state value will be corectly decoded (#10512)
  Optimize metrics and notifications docs
  Optimize cli and provisioning docs
  docs: Guide for IIS reverse proxy
  changelog: adds note about closing #9645
  telegram: Send notifications with an inline image
  telegram: Switch to using multipart form rather than JSON as a body
  telegram: Fix a typo in variable name
  fix: alert list pause/start toggle was not working properly
  fix template variable selector overlap by the panel (#10493)
  dashboard: Close/hide 'Add Panel' before saving a dashboard (#10482)
  fix: removed unused param
  Fix variables values passing when both repeat rows and panels is used (#10488)
  moved angular-mocks out of dependencies
  ux: minor change to alert list page
  ux: minor word change to alert list
  fix: updated snapshot test
  Add eu-west-3 in cloudwatch datasource default's region (#10477)
  fix: Make sure orig files are not added to git again #10289
  ...
This commit is contained in:
bergquist 2018-01-15 10:27:12 +01:00
commit 5499f4910c
458 changed files with 9766 additions and 10236 deletions

View File

@ -7,6 +7,8 @@ indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 120
insert_final_newline = true
[*.go]
indent_style = tab

1
.gitignore vendored
View File

@ -60,3 +60,4 @@ debug.test
/vendor/**/*_test.go
/vendor/**/.editorconfig
/vendor/**/appengine*
*.orig

View File

@ -25,6 +25,8 @@ Dashboard panels and rows are positioned using a gridPos object `{x: 0, y: 0, w:
Config files for provisioning datasources as configuration have changed from `/conf/datasources` to `/conf/provisioning/datasources`.
From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when installed with deb/rpm packages.
The pagerduty notifier now defaults to not auto resolve incidents. More details at [#10222](https://github.com/grafana/grafana/issues/10222)
## New Features
* **Data Source Proxy**: Add support for whitelisting specified cookies that will be passed through to the data source when proxying data source requests [#5457](https://github.com/grafana/grafana/issues/5457), thanks [@robingustafsson](https://github.com/robingustafsson)
* **Postgres/MySQL**: add __timeGroup macro for mysql [#9596](https://github.com/grafana/grafana/pull/9596), thanks [@svenklemm](https://github.com/svenklemm)
@ -36,6 +38,7 @@ From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when
* **Dashboard as cfg**: Load dashboards from file into Grafana on startup/change [#9654](https://github.com/grafana/grafana/issues/9654) [#5269](https://github.com/grafana/grafana/issues/5269)
* **Prometheus**: Grafana can now send alerts to Prometheus Alertmanager while firing [#7481](https://github.com/grafana/grafana/issues/7481), thx [@Thib17](https://github.com/Thib17) and [@mtanda](https://github.com/mtanda)
* **Table**: Support multiple table formated queries in table panel [#9170](https://github.com/grafana/grafana/issues/9170), thx [@davkal](https://github.com/davkal)
## Minor
* **Alert panel**: Adds placeholder text when no alerts are within the time range [#9624](https://github.com/grafana/grafana/issues/9624), thx [@straend](https://github.com/straend)
* **Mysql**: MySQL enable MaxOpenCon and MaxIdleCon regards how constring is configured. [#9784](https://github.com/grafana/grafana/issues/9784), thx [@dfredell](https://github.com/dfredell)
@ -46,6 +49,8 @@ From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when
* **Github**: Use organizations_url provided from github to verify user belongs in org. [#10111](https://github.com/grafana/grafana/issues/10111), thx
[@adiletmaratov](https://github.com/adiletmaratov)
* **Backend**: Fixed bug where Grafana exited before all sub routines where finished [#10131](https://github.com/grafana/grafana/issues/10131)
* **Azure**: Adds support for Azure blob storage as external image stor [#8955](https://github.com/grafana/grafana/issues/8955), thx [@saada](https://github.com/saada)
* **Telegram**: Add support for inline image uploads to telegram notifier plugin [#9967](https://github.com/grafana/grafana/pull/9967), thx [@rburchell](https://github.com/rburchell)
## Tech
* **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645)
@ -55,6 +60,7 @@ From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when
* **Sensu**: Send alert message to sensu output [#9551](https://github.com/grafana/grafana/issues/9551), thx [@cjchand](https://github.com/cjchand)
* **Singlestat**: suppress error when result contains no datapoints [#9636](https://github.com/grafana/grafana/issues/9636), thx [@utkarshcmu](https://github.com/utkarshcmu)
* **Postgres/MySQL**: Control quoting in SQL-queries when using template variables [#9030](https://github.com/grafana/grafana/issues/9030), thanks [@svenklemm](https://github.com/svenklemm)
* **Pagerduty**: Pagerduty dont auto resolve incidents by default anymore. [#10222](https://github.com/grafana/grafana/issues/10222)
# 4.6.3 (2017-12-14)

View File

@ -473,7 +473,7 @@ sampler_param = 1
#################################### External Image Storage ##############
[external_image_storage]
# You can choose between (s3, webdav, gcs)
# You can choose between (s3, webdav, gcs, azure_blob)
provider =
[external_image_storage.s3]
@ -494,3 +494,8 @@ public_url =
key_file =
bucket =
path =
[external_image_storage.azure_blob]
account_name =
account_key =
container_name =

View File

@ -417,7 +417,7 @@ log_queries =
#################################### External image storage ##########################
[external_image_storage]
# Used for uploading images to public servers so they can be included in slack/email messages.
# you can choose between (s3, webdav, gcs)
# you can choose between (s3, webdav, gcs, azure_blob)
;provider =
[external_image_storage.s3]
@ -437,3 +437,8 @@ log_queries =
;key_file =
;bucket =
;path =
[external_image_storage.azure_blob]
;account_name =
;account_key =
;container_name =

View File

@ -0,0 +1,4 @@
FROM jmferrer/apache2-reverse-proxy:latest
COPY ports.conf /etc/apache2/sites-enabled
COPY proxy.conf /etc/apache2/sites-enabled

View File

@ -0,0 +1,9 @@
# This will proxy all requests for http://localhost:10081/grafana/ to
# http://localhost:3000 (Grafana running locally)
#
# Please note that you'll need to change the root_url in the Grafana configuration:
# root_url = %(protocol)s://%(domain)s:/grafana/
apacheproxy:
build: blocks/apache_proxy
network_mode: host

View File

@ -0,0 +1 @@
Listen 10081

View File

@ -0,0 +1,4 @@
<VirtualHost *:10081>
ProxyPass /grafana/ http://localhost:3000/
ProxyPassReverse /grafana/ http://localhost:3000/
</VirtualHost>

View File

@ -0,0 +1,3 @@
FROM nginx:alpine
COPY nginx.conf /etc/nginx/nginx.conf

View File

@ -0,0 +1,9 @@
# This will proxy all requests for http://localhost:10080/grafana/ to
# http://localhost:3000 (Grafana running locally)
#
# Please note that you'll need to change the root_url in the Grafana configuration:
# root_url = %(protocol)s://%(domain)s:/grafana/
nginxproxy:
build: blocks/nginx_proxy
network_mode: host

View File

@ -0,0 +1,19 @@
events { worker_connections 1024; }
http {
sendfile on;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $server_name;
server {
listen 10080;
location /grafana/ {
proxy_pass http://localhost:3000/;
}
}
}

View File

@ -10,17 +10,17 @@ weight = 8
# Grafana CLI
Grafana cli is a small executable that is bundled with grafana server and is suppose to be executed on the same machine as grafana runs.
Grafana cli is a small executable that is bundled with Grafana-server and is supposed to be executed on the same machine Grafana-server is running on.
## Plugins
The CLI helps you install, upgrade and manage your plugins on the same machine it CLI is running.
You can find more information about how to install and manage your plugins at the
[plugin page]({{< relref "plugins/installation.md" >}}).
The CLI allows you to install, upgrade and manage your plugins on the machine it is running on.
You can find more information about how to install and manage your plugins in the
[plugins page]({{< relref "plugins/installation.md" >}}).
## Admin
> This feature is only available in grafana 4.1 and above.
> This feature is only available in Grafana 4.1 and above.
To show all admin commands:
`grafana-cli admin`
@ -39,7 +39,7 @@ then there are two flags that can be used to set homepath and the config file pa
`grafana-cli admin reset-admin-password --homepath "/usr/share/grafana" newpass`
If you have not lost the admin password then it is better to set in the Grafana UI. If you need to set the password in a script then the [Grafana API](http://docs.grafana.org/http_api/user/#change-password) can be used. Here is an example with curl using basic auth:
If you have not lost the admin password then it is better to set in the Grafana UI. If you need to set the password in a script then the [Grafana API](http://docs.grafana.org/http_api/user/#change-password) can be used. Here is an example using curl with basic auth:
```bash
curl -X PUT -H "Content-Type: application/json" -d '{

View File

@ -10,6 +10,6 @@ weight = 8
# Internal metrics
Grafana collects some metrics about it self internally. Currently Grafana supports pushing metrics to graphite and exposing them to be scraped by Prometheus.
Grafana collects some metrics about itself internally. Currently, Grafana supports pushing metrics to Graphite or exposing them to be scraped by Prometheus.
To enabled internal metrics you have to enable it under the [metrics] section in your [grafana.ini](http://docs.grafana.org/installation/configuration/#enabled-6) config file.If you want to push metrics to graphite you have also have to configure the [metrics.graphite](http://docs.grafana.org/installation/configuration/#metrics-graphite) section.
To emit internal metrics you have to enable the option under the [metrics] section in your [grafana.ini](http://docs.grafana.org/installation/configuration/#enabled-6) config file. If you want to push metrics to Graphite, you must also configure the [metrics.graphite](http://docs.grafana.org/installation/configuration/#metrics-graphite) section.

View File

@ -12,7 +12,7 @@ weight = 8
## Config file
Checkout the [configuration](/installation/configuration) page for more information about what you can configure in `grafana.ini`
Checkout the [configuration](/installation/configuration) page for more information on what you can configure in `grafana.ini`
### Config file locations
@ -35,7 +35,7 @@ GF_<SectionName>_<KeyName>
```
Where the section name is the text within the brackets. Everything
should be upper case, `.` should be replaced by `_`. For example, given these configuration settings:
should be upper case and `.` should be replaced by `_`. For example, given these configuration settings:
```bash
# default section
@ -48,7 +48,7 @@ admin_user = admin
client_secret = 0ldS3cretKey
```
Then you can override them using:
Overriding will be done like so:
```bash
export GF_DEFAULT_INSTANCE_NAME=my-instance
@ -60,7 +60,7 @@ export GF_AUTH_GOOGLE_CLIENT_SECRET=newS3cretKey
## Configuration management tools
Currently we do not provide any scripts/manifests for configuring Grafana. Rather then spending time learning and creating scripts/manifests for each tool, we think our time is better spent making Grafana easier to provision. Therefor, we heavily relay on the expertise of he community.
Currently we do not provide any scripts/manifests for configuring Grafana. Rather than spending time learning and creating scripts/manifests for each tool, we think our time is better spent making Grafana easier to provision. Therefore, we heavily relay on the expertise of the community.
Tool | Project
-----|------------
@ -76,8 +76,8 @@ Saltstack | [https://github.com/salt-formulas/salt-formula-grafana](https://gith
It's possible to manage datasources in Grafana by adding one or more yaml config files in the [`provisioning/datasources`](/installation/configuration/#provisioning) directory. Each config file can contain a list of `datasources` that will be added or updated during start up. If the datasource already exists, Grafana will update it to match the configuration file. The config file can also contain a list of datasources that should be deleted. That list is called `delete_datasources`. Grafana will delete datasources listed in `delete_datasources` before inserting/updating those in the `datasource` list.
### Running multiple grafana instances.
If you are running multiple instances of Grafana you might run into problems if they have different versions of the datasource.yaml configuration file. The best way to solve this problem is to add a version number to each datasource in the configuration and increase it when you update the config. Grafana will only update datasources with the same or lower version number than specified in the config. That way old configs cannot overwrite newer configs if they restart at the same time.
### Running multiple Grafana instances.
If you are running multiple instances of Grafana you might run into problems if they have different versions of the `datasource.yaml` configuration file. The best way to solve this problem is to add a version number to each datasource in the configuration and increase it when you update the config. Grafana will only update datasources with the same or lower version number than specified in the config. That way, old configs cannot overwrite newer configs if they restart at the same time.
### Example datasource config file
```yaml
@ -132,12 +132,13 @@ datasources:
#### Json data
Since all datasources dont have the same configuration settings we only have the most common ones as fields. The rest should be stored as a json blob in the `json_data` field. Here are the most common settings that the core datasources use.
Since not all datasources have the same configuration settings we only have the most common ones as fields. The rest should be stored as a json blob in the `json_data` field. Here are the most common settings that the core datasources use.
| Name | Type | Datasource |Description |
| ----| ---- | ---- | --- |
| tlsAuth | boolean | *All* | Enable TLS authentication using client cert configured in secure json data |
| tlsAuthWithCACert | boolean | *All* | Enable TLS authtication using CA cert |
| 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 |
@ -156,7 +157,7 @@ Since all datasources dont have the same configuration settings we only have the
{"authType":"keys","defaultRegion":"us-west-2","timeField":"@timestamp"}
Secure json data is a map of settings that will be encrypted with [secret key](/installation/configuration/#secret-key) from the grafana config. The purpose of this is only to hide content from the users of the application. This should be used for storing TLS Cert and password that Grafana will append to request on the server side. All these settings are optional.
Secure json data is a map of settings that will be encrypted with [secret key](/installation/configuration/#secret-key) from the Grafana config. The purpose of this is only to hide content from the users of the application. This should be used for storing TLS Cert and password that Grafana will append to the request on the server side. All of these settings are optional.
| Name | Type | Datasource | Description |
| ----| ---- | ---- | --- |
@ -168,9 +169,9 @@ Secure json data is a map of settings that will be encrypted with [secret key](/
### Dashboards
It's possible to manage dashboards in Grafana by adding one or more yaml config files in the [`provisioning/dashboards`](/installation/configuration/#provisioning) directory. Each config file can contain a list of `dashboards providers` that will load dashboards into grafana. Currently we only support reading dashboards from file but we will add more providers in the future.
It's possible to manage dashboards in Grafana by adding one or more yaml config files in the [`provisioning/dashboards`](/installation/configuration/#provisioning) directory. Each config file can contain a list of `dashboards providers` that will load dashboards into Grafana. Currently we only support reading dashboards from file but we will add more providers in the future.
The dashboard provider config file looks like this
The dashboard provider config file looks somewhat like this:
```yaml
- name: 'default'
@ -181,4 +182,4 @@ The dashboard provider config file looks like this
folder: /var/lib/grafana/dashboards
```
When grafana starts it will update/insert all dashboards available in the configured folders. If you modify the file the dashboard will also be updated.
When Grafana starts, it will update/insert all dashboards available in the configured folders. If you modify the file, the dashboard will also be updated.

View File

@ -13,7 +13,7 @@ weight = 2
> Alerting is only available in Grafana v4.0 and above.
The alert engine publishes some internal metrics about itself. You can read more about how Grafana published [internal metrics](/installation/configuration/#metrics).
The alert engine publishes some internal metrics about itself. You can read more about how Grafana publishes [internal metrics](/installation/configuration/#metrics).
Description | Type | Metric name
---------- | ----------- | ----------

View File

@ -14,9 +14,9 @@ weight = 2
> Alerting is only available in Grafana v4.0 and above.
When an alert changes state it sends out notifications. Each alert rule can have
multiple notifications. But in order to add a notification to an alert rule you first need
to add and configure a `notification` channel (can be email, Pagerduty or other integration). This is done from the Notification Channels page.
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.
## Notification Channel Setup
@ -25,12 +25,12 @@ to add and configure a `notification` channel (can be email, Pagerduty or other
On the Notification Channels page hit the `New Channel` button to go the page where you
can configure and setup a new Notification Channel.
You specify name and type, and type specific options. You can also test the notification to make
sure it's working and setup correctly.
You specify a name and a type, and type specific options. You can also test the notification to make
sure it's setup correctly.
### Send on all alerts
When checked this option will make this notification used for all alert rules, existing and new.
When checked, this option will nofity for all alert rules - existing and new.
## Supported Notification Types
@ -38,39 +38,39 @@ Grafana ships with the following set of notification types:
### Email
To enable email notification you have to setup [SMTP settings](/installation/configuration/#smtp)
in the Grafana config. Email notification will upload an image of the alert graph to an
external image destination if available or fallback to attaching the image in the email.
To enable email notifications you have to setup [SMTP settings](/installation/configuration/#smtp)
in the Grafana config. Email notifications will upload an image of the alert graph to an
external image destination if available or fallback to attaching the image to the email.
### Slack
{{< imgbox max-width="40%" img="/img/docs/v4/slack_notification.png" caption="Alerting Slack Notification" >}}
To set up slack you need to configure an incoming webhook url at slack. You can follow their guide for how
to do that https://api.slack.com/incoming-webhooks If you want to include screenshots of the firing alerts
in the slack messages you have to configure either the [external image destination](#external-image-store) in Grafana,
To set up slack you need to configure an incoming webhook url at slack. You can follow their guide on how
to do that [here](https://api.slack.com/incoming-webhooks). If you want to include screenshots of the firing alerts
in the Slack messages you have to configure either the [external image destination](#external-image-store) in Grafana,
or a bot integration via Slack Apps. Follow Slack's guide to set up a bot integration and use the token provided
https://api.slack.com/bot-users, which starts with "xoxb".
(https://api.slack.com/bot-users), which starts with "xoxb".
Setting | Description
---------- | -----------
Recipient | allows you to override the slack recipient.
Mention | make it possible to include a mention in the slack notification sent by Grafana. Ex @here or @channel
Recipient | allows you to override the Slack recipient.
Mention | make it possible to include a mention in the Slack notification sent by Grafana. Ex @here or @channel
Token | If provided, Grafana will upload the generated image via Slack's file.upload API method, not the external image destination.
### PagerDuty
To set up PagerDuty, all you have to do is to provide an api key.
To set up PagerDuty, all you have to do is to provide an API key.
Setting | Description
---------- | -----------
Integration Key | Integration key for pagerduty.
Auto resolve incidents | Resolve incidents in pagerduty once the alert goes back to ok
Integration Key | Integration key for PagerDuty.
Auto resolve incidents | Resolve incidents in PagerDuty once the alert goes back to ok
### Webhook
The webhook notification is a simple way to send information about an state change over HTTP to a custom endpoint.
Using this notification you could integrate Grafana into any system you choose, by yourself.
The webhook notification is a simple way to send information about a state change over HTTP to a custom endpoint.
Using this notification you could integrate Grafana into a system of your choosing.
Example json body:
@ -117,14 +117,14 @@ Dingtalk supports the following "message type": `text`, `link` and `markdown`. O
### Kafka
Notifications can be sent to a Kafka topic from Grafana using [Kafka REST Proxy](https://docs.confluent.io/1.0/kafka-rest/docs/index.html).
There are couple of configurations options which need to be set in Grafana UI under Kafka Settings:
Notifications can be sent to a Kafka topic from Grafana using the [Kafka REST Proxy](https://docs.confluent.io/1.0/kafka-rest/docs/index.html).
There are a couple of configuration options which need to be set up in Grafana UI under Kafka Settings:
1. Kafka REST Proxy endpoint.
2. Kafka Topic.
Once these two properties are set, you can send the alerts to Kafka for further processing or throttling them.
Once these two properties are set, you can send the alerts to Kafka for further processing or throttling.
### All supported notifier
@ -150,13 +150,13 @@ Prometheus Alertmanager | `prometheus-alertmanager` | no
# Enable images in notifications {#external-image-store}
Grafana can render the panel associated with the alert rule and include that in the notification. Most Notification Channels require that this image be publicly accessible (Slack and PagerDuty for example). In order to include images in alert notifications, Grafana can upload the image to an image store. It currently supports
Amazon S3 and Webdav for this. So to set that up you need to configure the [external image uploader](/installation/configuration/#external-image-storage) in your grafana-server ini config file.
Amazon S3, Webdav, and Azure Blob Storage for this. So to set that up you need to configure the [external image uploader](/installation/configuration/#external-image-storage) in your grafana-server ini config file.
Currently only the Email Channels attaches images if no external image store is specified. To include images in alert notifications for other channels then you need to set up an external image store.
This is an optional requirement, you can get Slack and email notifications without setting this up.
This is an optional requirement. You can get Slack and email notifications without setting this up.
# Configure the link back to Grafana from alert notifications
All alert notifications contains a link back to the triggered alert in the Grafana instance.
All alert notifications contain a link back to the triggered alert in the Grafana instance.
This url is based on the [domain](/installation/configuration/#domain) setting in Grafana.

View File

@ -55,7 +55,7 @@ of another alert in your conditions, and `Time Of Day`.
Alerting would not be very useful if there was no way to send notifications when rules trigger and change state. You
can setup notifications of different types. We currently have `Slack`, `PagerDuty`, `Email` and `Webhook` with more in the
pipe that will be added during beta period. The notifications can then be added to your alert rules.
If you have configured an external image store in the grafana.ini config file (s3 and webdav options available)
If you have configured an external image store in the grafana.ini config file (s3, webdav, and azure_blob options available)
you can get very rich notifications with an image of the graph and the metric
values all included in the notification.

View File

@ -68,5 +68,38 @@ server {
}
}
```
### IIS URL Rewrite Rule (Windows) with Subpath
IIS requires that the URL Rewrite module is installed.
Given:
- subpath `grafana`
- Grafana installed on `http://localhost:3000`
- server config:
```bash
[server]
domain = localhost:8080
root_url = %(protocol)s://%(domain)s:/grafana
```
Create an Inbound Rule for the parent website (localhost:8080 in this example) with the following settings:
- pattern: `grafana(/)?(.*)`
- check the `Ignore case` checkbox
- rewrite url set to `http://localhost:3000/{R:2}`
- check the `Append query string` checkbox
- check the `Stop processing of subsequent rules` checkbox
The rewrite rule that is generated for the web.config:
```xml
<rewrite>
<rules>
<rule name="Grafana" enabled="true" stopProcessing="true">
<match url="grafana(/)?(.*)" />
<action type="Rewrite" url="http://localhost:3000/{R:2}" logRewrittenUrl="false" />
</rule>
</rules>
</rewrite>
```

View File

@ -496,7 +496,7 @@ name = BitBucket
enabled = true
allow_sign_up = true
client_id = <client id>
client_secret = <secret>
client_secret = <client secret>
scopes = account email
auth_url = https://bitbucket.org/site/oauth2/authorize
token_url = https://bitbucket.org/site/oauth2/access_token
@ -505,6 +505,41 @@ team_ids =
allowed_organizations =
```
### Set up oauth2 with OneLogin
1. Create a new Custom Connector with the following settings:
- Name: Grafana
- Sign On Method: OpenID Connect
- Redirect URI: `https://<grafana domain>/login/generic_oauth`
- Signing Algorithm: RS256
- Login URL: `https://<grafana domain>/login/generic_oauth`
then:
2. Add an App to the Grafana Connector:
- Display Name: Grafana
then:
3. Under the SSO tab on the Grafana App details page you'll find the Client ID and Client Secret.
Your OneLogin Domain will match the url you use to access OneLogin.
Configure Grafana as follows:
```bash
[auth.generic_oauth]
name = OneLogin
enabled = true
allow_sign_up = true
client_id = <client id>
client_secret = <client secret>
scopes = openid email name
auth_url = https://<onelogin domain>.onelogin.com/oidc/auth
token_url = https://<onelogin domain>.onelogin.com/oidc/token
api_url = https://<onelogin domain>.onelogin.com/oidc/me
team_ids =
allowed_organizations =
```
<hr>
## [auth.basic]
@ -731,7 +766,7 @@ Time to live for snapshots.
These options control how images should be made public so they can be shared on services like slack.
### provider
You can choose between (s3, webdav, gcs). If left empty Grafana will ignore the upload action.
You can choose between (s3, webdav, gcs, azure_blob). If left empty Grafana will ignore the upload action.
## [external_image_storage.s3]
@ -786,6 +821,17 @@ Bucket Name on Google Cloud Storage.
### path
Optional extra path inside bucket
## [external_image_storage.azure_blob]
### account_name
Storage account name
### account_key
Storage account key
### container_name
Container name where to store "Blob" images with random names. Creating the blob container beforehand is required. Only public containers are supported.
## [alerting]
### enabled

View File

@ -71,8 +71,8 @@ Each field in the dashboard JSON is explained below with its usage:
| **timepicker** | timepicker metadata, see [timepicker section](#timepicker) for details |
| **templating** | templating metadata, see [templating section](#templating) for details |
| **annotations** | annotations metadata, see [annotations section](#annotations) for details |
| **schemaVersion** | TODO |
| **version** | TODO |
| **schemaVersion** | version of the JSON schema (integer), incremented each time a Grafana update brings changes to the said schema |
| **version** | version of the dashboard (integer), incremented each time the dashboard is updated |
| **links** | TODO |
### rows

View File

@ -0,0 +1,89 @@
+++
title = "Grafana with IIS Reverse Proxy on Windows"
type = "docs"
keywords = ["grafana", "tutorials", "proxy", "IIS", "windows"]
[menu.docs]
parent = "tutorials"
weight = 10
+++
# How to Use IIS with URL Rewrite as a Reverse Proxy for Grafana on Windows
If you want Grafana to be a subdomain under a website in IIS then the URL Rewrite module for ISS can be used to support this.
Example:
- Parent site: http://localhost:8080
- Grafana: http://localhost:3000
Grafana as a subdomain: http://localhost:8080/grafana
## Setup
If you have not already done it, then a requirement is to install URL Rewrite module for IIS.
Download and install the URL Rewrite module for IIS: https://www.iis.net/downloads/microsoft/url-rewrite
## Grafana Config
The Grafana config can be set by creating a file named `custom.ini` in the `conf` subdirectory of your Grafana installation. See the [installation instructions](http://docs.grafana.org/installation/windows/#configure) for more details.
Given that the subpath should be `grafana` and the parent site is `localhost:8080` then add this to the `custom.ini` config file:
```bash
[server]
domain = localhost:8080
root_url = %(protocol)s://%(domain)s:/grafana
```
Restart the Grafana server after changing the config file.
## IIS Config
1. Open the IIS Manager and click on the parent website
2. In the admin console for this website, double click on the Url Rewrite option:
{{< docs-imagebox img="/img/docs/tutorials/IIS_admin_console.png" max-width= "800px" >}}
3. Click on the `Add Rule(s)...` action
4. Choose the Blank Rule template for an Inbound Rule
{{< docs-imagebox img="/img/docs/tutorials/IIS_add_inbound_rule.png" max-width= "800px" >}}
5. Create an Inbound Rule for the parent website (localhost:8080 in this example) with the following settings:
- pattern: `grafana(/)?(.*)`
- check the `Ignore case` checkbox
- rewrite url set to `http://localhost:3000/{R:2}`
- check the `Append query string` checkbox
- check the `Stop processing of subsequent rules` checkbox
{{< docs-imagebox img="/img/docs/tutorials/IIS_url_rewrite.png" max-width= "800px" >}}
Finally, navigate to `http://localhost:8080/grafana` (replace `http://localhost:8080` with your parent domain) and you should come to the Grafana login page.
## Troubleshooting
### 404 error
When navigating to the grafana url (`http://localhost:8080/grafana` in the example above) and a `HTTP Error 404.0 - Not Found` error is returned then either:
- the pattern for the Inbound Rule is incorrect. Edit the rule, click on the `Test pattern...` button, test the part of the url after `http://localhost:8080/` and make sure it matches. For `grafana/login` the test should return 3 capture groups: {R:0}: `grafana` {R:1}: `/` and {R:2}: `login`.
- The `root_url` setting in the Grafana config file does not match the parent url with subpath.
### Grafana Website only shows text with no images or css
{{< docs-imagebox img="/img/docs/tutorials/IIS_proxy_error.png" max-width= "800px" >}}
1. The `root_url` setting in the Grafana config file does not match the parent url with subpath. This could happen if the root_url is commented out by mistake (`;` is used for commenting out a line in .ini files):
`; root_url = %(protocol)s://%(domain)s:/grafana`
2. or if the subpath in the `root_url` setting does not match the subpath used in the pattern in the Inbound Rule in IIS:
`root_url = %(protocol)s://%(domain)s:/grafana`
pattern in Inbound Rule: `wrongsubpath(/)?(.*)`
3. or if the Rewrite Url in the Inbound Rule is incorrect.
The Rewrite Url should not include the subpath.
The Rewrite Url should contain the capture group from the pattern matching that returns the part of the url after the subpath. The pattern used above returns 3 capture groups and the third one {R:2} returns the part of the url after `http://localhost:8080/grafana/`.

View File

@ -24,5 +24,6 @@ module.exports = {
"setupFiles": [
"./public/test/jest-shim.ts",
"./public/test/jest-setup.ts"
]
],
"snapshotSerializers": ["enzyme-to-json/serializer"],
};

View File

@ -25,6 +25,7 @@
"css-loader": "^0.28.7",
"enzyme": "^3.1.0",
"enzyme-adapter-react-16": "^1.0.1",
"enzyme-to-json": "^3.3.0",
"es6-promise": "^3.0.2",
"es6-shim": "^0.35.3",
"expect.js": "~0.2.0",
@ -54,7 +55,7 @@
"html-loader": "^0.5.1",
"html-webpack-plugin": "^2.30.1",
"husky": "^0.14.3",
"jest": "^21.2.1",
"jest": "^22.0.4",
"jshint-stylish": "~2.2.1",
"json-loader": "^0.5.7",
"karma": "1.7.0",
@ -83,14 +84,15 @@
"sinon": "1.17.6",
"systemjs": "0.20.19",
"systemjs-plugin-css": "^0.1.36",
"ts-jest": "^21.1.3",
"ts-loader": "^2.3.7",
"tslint": "^5.7.0",
"ts-jest": "^22.0.0",
"ts-loader": "^3.2.0",
"tslint": "^5.8.0",
"tslint-loader": "^3.5.3",
"typescript": "^2.5.2",
"webpack": "^3.6.0",
"typescript": "^2.6.2",
"webpack": "^3.10.0",
"webpack-bundle-analyzer": "^2.9.0",
"webpack-cleanup-plugin": "^0.5.1",
"angular-mocks": "^1.6.6",
"webpack-merge": "^4.1.0",
"zone.js": "^0.7.2"
},
@ -107,19 +109,23 @@
},
"lint-staged": {
"*.{ts,tsx}": [
"prettier --single-quote --trailing-comma es5 --write",
"prettier --write",
"git add"
],
"*.scss": [
"prettier --single-quote --write",
"prettier --write",
"git add"
]
},
"prettier": {
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 120
},
"license": "Apache-2.0",
"dependencies": {
"angular": "^1.6.6",
"angular-bindonce": "^0.3.1",
"angular-mocks": "^1.6.6",
"angular-native-dragdrop": "^1.2.2",
"angular-route": "^1.6.6",
"angular-sanitize": "^1.6.6",
@ -133,13 +139,19 @@
"file-saver": "^1.3.3",
"jquery": "^3.2.1",
"lodash": "^4.17.4",
"mobx": "^3.4.1",
"mobx-react": "^4.3.5",
"mobx-state-tree": "^1.3.1",
"moment": "^2.18.1",
"mousetrap": "^1.6.0",
"perfect-scrollbar": "^1.2.0",
"prop-types": "^15.6.0",
"react": "^16.1.1",
"react-dom": "^16.1.1",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react-grid-layout": "^0.16.1",
"react-popper": "^0.7.5",
"react-highlight-words": "^0.10.0",
"react-select": "^1.1.0",
"react-sizeme": "^2.3.6",
"remarkable": "^1.7.1",
"rxjs": "^5.4.3",

View File

@ -278,7 +278,7 @@ func PauseAlert(c *middleware.Context, dto dtos.PauseAlertCommand) Response {
}
var response models.AlertStateType = models.AlertStatePending
pausedState := "un paused"
pausedState := "un-paused"
if cmd.Paused {
response = models.AlertStatePaused
pausedState = "paused"
@ -287,7 +287,7 @@ func PauseAlert(c *middleware.Context, dto dtos.PauseAlertCommand) Response {
result := map[string]interface{}{
"alertId": alertId,
"state": response,
"message": "alert " + pausedState,
"message": "Alert " + pausedState,
}
return Json(200, result)

View File

@ -40,8 +40,11 @@ func (hs *HttpServer) registerRoutes() {
r.Get("/datasources/", reqSignedIn, Index)
r.Get("/datasources/new", reqSignedIn, Index)
r.Get("/datasources/edit/*", reqSignedIn, Index)
r.Get("/org/users", reqSignedIn, Index)
r.Get("/org/users/new", reqSignedIn, Index)
r.Get("/org/users/invite", reqSignedIn, Index)
r.Get("/org/teams", reqSignedIn, Index)
r.Get("/org/teams/*", reqSignedIn, Index)
r.Get("/org/apikeys/", reqSignedIn, Index)
r.Get("/dashboard/import/", reqSignedIn, Index)
r.Get("/configuration", reqGrafanaAdmin, Index)

View File

@ -157,11 +157,11 @@ func NewCacheServer() *CacheServer {
func newNotFound() *Avatar {
avatar := &Avatar{notFound: true}
// load transparent png into buffer
path := filepath.Join(setting.StaticRootPath, "img", "transparent.png")
// load user_profile png into buffer
path := filepath.Join(setting.StaticRootPath, "img", "user_profile.png")
if data, err := ioutil.ReadFile(path); err != nil {
log.Error(3, "Failed to read transparent.png, %v", path)
log.Error(3, "Failed to read user_profile.png, %v", path)
} else {
avatar.data = bytes.NewBuffer(data)
}

View File

@ -3,6 +3,7 @@ package dtos
import (
"crypto/md5"
"fmt"
"regexp"
"strings"
"github.com/grafana/grafana/pkg/components/simplejson"
@ -57,3 +58,19 @@ func GetGravatarUrl(text string) string {
hasher.Write([]byte(strings.ToLower(text)))
return fmt.Sprintf(setting.AppSubUrl+"/avatar/%x", hasher.Sum(nil))
}
func GetGravatarUrlWithDefault(text string, defaultText string) string {
if text != "" {
return GetGravatarUrl(text)
}
reg, err := regexp.Compile("[^a-zA-Z0-9]+")
if err != nil {
return ""
}
text = reg.ReplaceAllString(defaultText, "") + "@localhost"
return GetGravatarUrl(text)
}

View File

@ -35,7 +35,7 @@ var (
func GenStateString() string {
rnd := make([]byte, 32)
rand.Read(rnd)
return base64.StdEncoding.EncodeToString(rnd)
return base64.URLEncoding.EncodeToString(rnd)
}
func OAuthLogin(ctx *middleware.Context) {

View File

@ -31,11 +31,12 @@ func RenderToPng(c *middleware.Context) {
pngPath, err := renderer.RenderToPng(renderOpts)
if err != nil {
if err == renderer.ErrTimeout {
if err != nil && err == renderer.ErrTimeout {
c.Handle(500, err.Error(), err)
return
}
if err != nil {
c.Handle(500, "Rendering failed.", err)
return
}

View File

@ -1,6 +1,7 @@
package api
import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models"
@ -70,6 +71,10 @@ func SearchTeams(c *middleware.Context) Response {
return ApiError(500, "Failed to search Teams", err)
}
for _, team := range query.Result.Teams {
team.AvatarUrl = dtos.GetGravatarUrlWithDefault(team.Email, team.Name)
}
query.Result.Page = page
query.Result.PerPage = perPage

View File

@ -1,6 +1,7 @@
package api
import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models"
@ -15,6 +16,10 @@ func GetTeamMembers(c *middleware.Context) Response {
return ApiError(500, "Failed to get Team Members", err)
}
for _, member := range query.Result {
member.AvatarUrl = dtos.GetGravatarUrl(member.Email)
}
return Json(200, query.Result)
}

View File

@ -0,0 +1,320 @@
package imguploader
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/xml"
"fmt"
"io"
"io/ioutil"
"mime"
"net/http"
"net/url"
"os"
"path"
"sort"
"strconv"
"strings"
"time"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/util"
)
type AzureBlobUploader struct {
account_name string
account_key string
container_name string
log log.Logger
}
func NewAzureBlobUploader(account_name string, account_key string, container_name string) *AzureBlobUploader {
return &AzureBlobUploader{
account_name: account_name,
account_key: account_key,
container_name: container_name,
log: log.New("azureBlobUploader"),
}
}
// Receive path of image on disk and return azure blob url
func (az *AzureBlobUploader) Upload(ctx context.Context, imageDiskPath string) (string, error) {
// setup client
blob := NewStorageClient(az.account_name, az.account_key)
file, err := os.Open(imageDiskPath)
if err != nil {
return "", err
}
randomFileName := util.GetRandomString(30) + ".png"
// upload image
az.log.Debug("Uploading image to azure_blob", "conatiner_name", az.container_name, "blob_name", randomFileName)
resp, err := blob.FileUpload(az.container_name, randomFileName, file)
if err != nil {
return "", err
}
if resp.StatusCode > 400 && resp.StatusCode < 600 {
body, _ := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20))
aerr := &Error{
Code: resp.StatusCode,
Status: resp.Status,
Body: body,
Header: resp.Header,
}
aerr.parseXML()
resp.Body.Close()
return "", aerr
}
if err != nil {
return "", err
}
url := fmt.Sprintf("https://%s.blob.core.windows.net/%s/%s", az.account_name, az.container_name, randomFileName)
return url, nil
}
// --- AZURE LIBRARY
type Blobs struct {
XMLName xml.Name `xml:"EnumerationResults"`
Items []Blob `xml:"Blobs>Blob"`
}
type Blob struct {
Name string `xml:"Name"`
Property Property `xml:"Properties"`
}
type Property struct {
LastModified string `xml:"Last-Modified"`
Etag string `xml:"Etag"`
ContentLength int `xml:"Content-Length"`
ContentType string `xml:"Content-Type"`
BlobType string `xml:"BlobType"`
LeaseStatus string `xml:"LeaseStatus"`
}
type Error struct {
Code int
Status string
Body []byte
Header http.Header
AzureCode string
}
func (e *Error) Error() string {
return fmt.Sprintf("status %d: %s", e.Code, e.Body)
}
func (e *Error) parseXML() {
var xe xmlError
_ = xml.NewDecoder(bytes.NewReader(e.Body)).Decode(&xe)
e.AzureCode = xe.Code
}
type xmlError struct {
XMLName xml.Name `xml:"Error"`
Code string
Message string
}
const ms_date_layout = "Mon, 02 Jan 2006 15:04:05 GMT"
const version = "2017-04-17"
var client = &http.Client{}
type StorageClient struct {
Auth *Auth
Transport http.RoundTripper
}
func (c *StorageClient) transport() http.RoundTripper {
if c.Transport != nil {
return c.Transport
}
return http.DefaultTransport
}
func NewStorageClient(account, accessKey string) *StorageClient {
return &StorageClient{
Auth: &Auth{
account,
accessKey,
},
Transport: nil,
}
}
func (c *StorageClient) absUrl(format string, a ...interface{}) string {
part := fmt.Sprintf(format, a...)
return fmt.Sprintf("https://%s.blob.core.windows.net/%s", c.Auth.Account, part)
}
func copyHeadersToRequest(req *http.Request, headers map[string]string) {
for k, v := range headers {
req.Header[k] = []string{v}
}
}
func (c *StorageClient) FileUpload(container, blobName string, body io.Reader) (*http.Response, error) {
blobName = escape(blobName)
extension := strings.ToLower(path.Ext(blobName))
contentType := mime.TypeByExtension(extension)
buf := new(bytes.Buffer)
buf.ReadFrom(body)
req, err := http.NewRequest(
"PUT",
c.absUrl("%s/%s", container, blobName),
buf,
)
if err != nil {
return nil, err
}
copyHeadersToRequest(req, map[string]string{
"x-ms-blob-type": "BlockBlob",
"x-ms-date": time.Now().UTC().Format(ms_date_layout),
"x-ms-version": version,
"Accept-Charset": "UTF-8",
"Content-Type": contentType,
"Content-Length": strconv.Itoa(buf.Len()),
})
c.Auth.SignRequest(req)
return c.transport().RoundTrip(req)
}
func escape(content string) string {
content = url.QueryEscape(content)
// the Azure's behavior uses %20 to represent whitespace instead of + (plus)
content = strings.Replace(content, "+", "%20", -1)
// the Azure's behavior uses slash instead of + %2F
content = strings.Replace(content, "%2F", "/", -1)
return content
}
type Auth struct {
Account string
Key string
}
func (a *Auth) SignRequest(req *http.Request) {
strToSign := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s",
strings.ToUpper(req.Method),
tryget(req.Header, "Content-Encoding"),
tryget(req.Header, "Content-Language"),
tryget(req.Header, "Content-Length"),
tryget(req.Header, "Content-MD5"),
tryget(req.Header, "Content-Type"),
tryget(req.Header, "Date"),
tryget(req.Header, "If-Modified-Since"),
tryget(req.Header, "If-Match"),
tryget(req.Header, "If-None-Match"),
tryget(req.Header, "If-Unmodified-Since"),
tryget(req.Header, "Range"),
a.canonicalizedHeaders(req),
a.canonicalizedResource(req),
)
decodedKey, _ := base64.StdEncoding.DecodeString(a.Key)
sha256 := hmac.New(sha256.New, []byte(decodedKey))
sha256.Write([]byte(strToSign))
signature := base64.StdEncoding.EncodeToString(sha256.Sum(nil))
copyHeadersToRequest(req, map[string]string{
"Authorization": fmt.Sprintf("SharedKey %s:%s", a.Account, signature),
})
}
func tryget(headers map[string][]string, key string) string {
// We default to empty string for "0" values to match server side behavior when generating signatures.
if len(headers[key]) > 0 { // && headers[key][0] != "0" { //&& key != "Content-Length" {
return headers[key][0]
}
return ""
}
//
// The following is copied ~95% verbatim from:
// http://github.com/loldesign/azure/ -> core/core.go
//
/*
Based on Azure docs:
Link: http://msdn.microsoft.com/en-us/library/windowsazure/dd179428.aspx#Constructing_Element
1) Retrieve all headers for the resource that begin with x-ms-, including the x-ms-date header.
2) Convert each HTTP header name to lowercase.
3) Sort the headers lexicographically by header name, in ascending order. Note that each header may appear only once in the string.
4) Unfold the string by replacing any breaking white space with a single space.
5) Trim any white space around the colon in the header.
6) Finally, append a new line character to each canonicalized header in the resulting list. Construct the CanonicalizedHeaders string by concatenating all headers in this list into a single string.
*/
func (a *Auth) canonicalizedHeaders(req *http.Request) string {
var buffer bytes.Buffer
for key, value := range req.Header {
lowerKey := strings.ToLower(key)
if strings.HasPrefix(lowerKey, "x-ms-") {
if buffer.Len() == 0 {
buffer.WriteString(fmt.Sprintf("%s:%s", lowerKey, value[0]))
} else {
buffer.WriteString(fmt.Sprintf("\n%s:%s", lowerKey, value[0]))
}
}
}
splitted := strings.Split(buffer.String(), "\n")
sort.Strings(splitted)
return strings.Join(splitted, "\n")
}
/*
Based on Azure docs
Link: http://msdn.microsoft.com/en-us/library/windowsazure/dd179428.aspx#Constructing_Element
1) Beginning with an empty string (""), append a forward slash (/), followed by the name of the account that owns the resource being accessed.
2) Append the resource's encoded URI path, without any query parameters.
3) Retrieve all query parameters on the resource URI, including the comp parameter if it exists.
4) Convert all parameter names to lowercase.
5) Sort the query parameters lexicographically by parameter name, in ascending order.
6) URL-decode each query parameter name and value.
7) Append each query parameter name and value to the string in the following format, making sure to include the colon (:) between the name and the value:
parameter-name:parameter-value
8) If a query parameter has more than one value, sort all values lexicographically, then include them in a comma-separated list:
parameter-name:parameter-value-1,parameter-value-2,parameter-value-n
9) Append a new line character (\n) after each name-value pair.
Rules:
1) Avoid using the new line character (\n) in values for query parameters. If it must be used, ensure that it does not affect the format of the canonicalized resource string.
2) Avoid using commas in query parameter values.
*/
func (a *Auth) canonicalizedResource(req *http.Request) string {
var buffer bytes.Buffer
buffer.WriteString(fmt.Sprintf("/%s%s", a.Account, req.URL.Path))
queries := req.URL.Query()
for key, values := range queries {
sort.Strings(values)
buffer.WriteString(fmt.Sprintf("\n%s:%s", key, strings.Join(values, ",")))
}
splitted := strings.Split(buffer.String(), "\n")
sort.Strings(splitted)
return strings.Join(splitted, "\n")
}

View File

@ -0,0 +1,24 @@
package imguploader
import (
"context"
"testing"
"github.com/grafana/grafana/pkg/setting"
. "github.com/smartystreets/goconvey/convey"
)
func TestUploadToAzureBlob(t *testing.T) {
SkipConvey("[Integration test] for external_image_store.azure_blob", t, func() {
err := setting.NewConfigContext(&setting.CommandLineArgs{
HomePath: "../../../",
})
uploader, _ := NewImageUploader()
path, err := uploader.Upload(context.Background(), "../../../public/img/logo_transparent_400x.png")
So(err, ShouldBeNil)
So(path, ShouldNotEqual, "")
})
}

View File

@ -3,6 +3,7 @@ package imguploader
import (
"context"
"fmt"
"github.com/grafana/grafana/pkg/log"
"regexp"
"github.com/grafana/grafana/pkg/setting"
@ -76,6 +77,21 @@ func NewImageUploader() (ImageUploader, error) {
path := gcssec.Key("path").MustString("")
return NewGCSUploader(keyFile, bucketName, path), nil
case "azure_blob":
azureBlobSec, err := setting.Cfg.GetSection("external_image_storage.azure_blob")
if err != nil {
return nil, err
}
account_name := azureBlobSec.Key("account_name").MustString("")
account_key := azureBlobSec.Key("account_key").MustString("")
container_name := azureBlobSec.Key("container_name").MustString("")
return NewAzureBlobUploader(account_name, account_key, container_name), nil
}
if setting.ImageUploadProvider != "" {
log.Error2("The external image storage configuration is invalid", "unsupported provider", setting.ImageUploadProvider)
}
return NopImageUploader{}, nil

View File

@ -119,5 +119,29 @@ func TestImageUploaderFactory(t *testing.T) {
So(original.keyFile, ShouldEqual, "/etc/secrets/project-79a52befa3f6.json")
So(original.bucket, ShouldEqual, "project-grafana-east")
})
Convey("AzureBlobUploader config", func() {
setting.NewConfigContext(&setting.CommandLineArgs{
HomePath: "../../../",
})
setting.ImageUploadProvider = "azure_blob"
Convey("with container name", func() {
azureBlobSec, err := setting.Cfg.GetSection("external_image_storage.azure_blob")
azureBlobSec.NewKey("account_name", "account_name")
azureBlobSec.NewKey("account_key", "account_key")
azureBlobSec.NewKey("container_name", "container_name")
uploader, err := NewImageUploader()
So(err, ShouldBeNil)
original, ok := uploader.(*AzureBlobUploader)
So(ok, ShouldBeTrue)
So(original.account_name, ShouldEqual, "account_name")
So(original.account_key, ShouldEqual, "account_key")
So(original.container_name, ShouldEqual, "container_name")
})
})
})
}

View File

@ -139,8 +139,12 @@ func (dash *Dashboard) GetString(prop string, defaultValue string) string {
// UpdateSlug updates the slug
func (dash *Dashboard) UpdateSlug() {
title := strings.ToLower(dash.Data.Get("title").MustString())
dash.Slug = slug.Make(title)
title := dash.Data.Get("title").MustString()
dash.Slug = SlugifyTitle(title)
}
func SlugifyTitle(title string) string {
return slug.Make(strings.ToLower(title))
}
//

View File

@ -16,6 +16,12 @@ func TestDashboardModel(t *testing.T) {
So(dashboard.Slug, ShouldEqual, "grafana-play-home")
})
Convey("Can slugify title", t, func() {
slug := SlugifyTitle("Grafana Play Home")
So(slug, ShouldEqual, "grafana-play-home")
})
Convey("Given a dashboard json", t, func() {
json := simplejson.New()
json.Set("title", "test dash")

View File

@ -16,6 +16,7 @@ type Team struct {
Id int64 `json:"id"`
OrgId int64 `json:"orgId"`
Name string `json:"name"`
Email string `json:"email"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
@ -26,6 +27,7 @@ type Team struct {
type CreateTeamCommand struct {
Name string `json:"name" binding:"Required"`
Email string `json:"email"`
OrgId int64 `json:"-"`
Result Team `json:"-"`
@ -34,6 +36,7 @@ type CreateTeamCommand struct {
type UpdateTeamCommand struct {
Id int64
Name string
Email string
}
type DeleteTeamCommand struct {
@ -64,6 +67,8 @@ type SearchTeamDto struct {
Id int64 `json:"id"`
OrgId int64 `json:"orgId"`
Name string `json:"name"`
Email string `json:"email"`
AvatarUrl string `json:"avatarUrl"`
MemberCount int64 `json:"memberCount"`
}

View File

@ -52,4 +52,5 @@ type TeamMemberDTO struct {
UserId int64 `json:"userId"`
Email string `json:"email"`
Login string `json:"login"`
AvatarUrl string `json:"avatarUrl"`
}

View File

@ -40,7 +40,7 @@ func getPluginLogoUrl(pluginType, path, baseUrl string) string {
}
func (fp *FrontendPluginBase) setPathsBasedOnApp(app *AppPlugin) {
appSubPath := strings.Replace(strings.Replace(fp.PluginDir, app.PluginDir, "", 1), "\\", "/", 1)
appSubPath := strings.Replace(strings.Replace(fp.PluginDir, app.PluginDir, "", 1), "\\", "/", -1)
fp.IncludedInAppId = app.Id
fp.BaseUrl = app.BaseUrl

View File

@ -14,7 +14,7 @@ func TestFrontendPlugin(t *testing.T) {
fp := &FrontendPluginBase{
PluginBase: PluginBase{
PluginDir: "c:\\grafana\\public\\app\\plugins\\app\\testdata\\datasource",
PluginDir: "c:\\grafana\\public\\app\\plugins\\app\\testdata\\datasources\\datasource",
BaseUrl: "fpbase",
},
}
@ -29,6 +29,6 @@ func TestFrontendPlugin(t *testing.T) {
}
fp.setPathsBasedOnApp(app)
So(fp.Module, ShouldEqual, "app/plugins/app/testdata/datasource/module")
So(fp.Module, ShouldEqual, "app/plugins/app/testdata/datasources/datasource/module")
})
}

View File

@ -42,7 +42,7 @@ var (
)
func NewPagerdutyNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
autoResolve := model.Settings.Get("autoResolve").MustBool(true)
autoResolve := model.Settings.Get("autoResolve").MustBool(false)
key := model.Settings.Get("integrationKey").MustString()
if key == "" {
return nil, alerting.ValidationError{Reason: "Could not find integration key property in settings"}

View File

@ -10,7 +10,6 @@ import (
func TestPagerdutyNotifier(t *testing.T) {
Convey("Pagerduty notifier tests", t, func() {
Convey("Parsing alert notification from settings", func() {
Convey("empty settings should return error", func() {
json := `{ }`
@ -26,10 +25,31 @@ func TestPagerdutyNotifier(t *testing.T) {
So(err, ShouldNotBeNil)
})
Convey("auto resolve should default to false", func() {
json := `{ "integrationKey": "abcdefgh0123456789" }`
settingsJSON, _ := simplejson.NewJson([]byte(json))
model := &m.AlertNotification{
Name: "pagerduty_testing",
Type: "pagerduty",
Settings: settingsJSON,
}
not, err := NewPagerdutyNotifier(model)
pagerdutyNotifier := not.(*PagerdutyNotifier)
So(err, ShouldBeNil)
So(pagerdutyNotifier.Name, ShouldEqual, "pagerduty_testing")
So(pagerdutyNotifier.Type, ShouldEqual, "pagerduty")
So(pagerdutyNotifier.Key, ShouldEqual, "abcdefgh0123456789")
So(pagerdutyNotifier.AutoResolve, ShouldBeFalse)
})
Convey("settings should trigger incident", func() {
json := `
{
"integrationKey": "abcdefgh0123456789"
"integrationKey": "abcdefgh0123456789",
"autoResolve": false
}`
settingsJSON, _ := simplejson.NewJson([]byte(json))
@ -46,8 +66,8 @@ func TestPagerdutyNotifier(t *testing.T) {
So(pagerdutyNotifier.Name, ShouldEqual, "pagerduty_testing")
So(pagerdutyNotifier.Type, ShouldEqual, "pagerduty")
So(pagerdutyNotifier.Key, ShouldEqual, "abcdefgh0123456789")
So(pagerdutyNotifier.AutoResolve, ShouldBeFalse)
})
})
})
}

View File

@ -1,17 +1,19 @@
package notifiers
import (
"bytes"
"fmt"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/log"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting"
"io"
"mime/multipart"
"os"
)
var (
telegeramApiUrl string = "https://api.telegram.org/bot%s/%s"
telegramApiUrl string = "https://api.telegram.org/bot%s/%s"
)
func init() {
@ -49,6 +51,7 @@ type TelegramNotifier struct {
NotifierBase
BotToken string
ChatID string
UploadImage bool
log log.Logger
}
@ -59,6 +62,7 @@ func NewTelegramNotifier(model *m.AlertNotification) (alerting.Notifier, error)
botToken := model.Settings.Get("bottoken").MustString()
chatId := model.Settings.Get("chatid").MustString()
uploadImage := model.Settings.Get("uploadImage").MustBool()
if botToken == "" {
return nil, alerting.ValidationError{Reason: "Could not find Bot Token in settings"}
@ -72,32 +76,43 @@ func NewTelegramNotifier(model *m.AlertNotification) (alerting.Notifier, error)
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
BotToken: botToken,
ChatID: chatId,
UploadImage: uploadImage,
log: log.New("alerting.notifier.telegram"),
}, nil
}
func (this *TelegramNotifier) ShouldNotify(context *alerting.EvalContext) bool {
return defaultShouldNotify(context)
func (this *TelegramNotifier) buildMessage(evalContext *alerting.EvalContext, sendImageInline bool) *m.SendWebhookSync {
var imageFile *os.File
var err error
if sendImageInline {
imageFile, err = os.Open(evalContext.ImageOnDiskPath)
defer imageFile.Close()
if err != nil {
sendImageInline = false // fall back to text message
}
}
func (this *TelegramNotifier) Notify(evalContext *alerting.EvalContext) error {
this.log.Info("Sending alert notification to", "bot_token", this.BotToken)
this.log.Info("Sending alert notification to", "chat_id", this.ChatID)
message := ""
bodyJSON := simplejson.New()
bodyJSON.Set("chat_id", this.ChatID)
bodyJSON.Set("parse_mode", "html")
message := fmt.Sprintf("<b>%s</b>\nState: %s\nMessage: %s\n", evalContext.GetNotificationTitle(), evalContext.Rule.Name, evalContext.Rule.Message)
if sendImageInline {
// Telegram's API does not allow HTML formatting for image captions.
message = fmt.Sprintf("%s\nState: %s\nMessage: %s\n", evalContext.GetNotificationTitle(), evalContext.Rule.Name, evalContext.Rule.Message)
} else {
message = fmt.Sprintf("<b>%s</b>\nState: %s\nMessage: %s\n", evalContext.GetNotificationTitle(), evalContext.Rule.Name, evalContext.Rule.Message)
}
ruleUrl, err := evalContext.GetRuleUrl()
if err == nil {
message = message + fmt.Sprintf("URL: %s\n", ruleUrl)
}
if !sendImageInline {
// only attach this if we are not sending it inline.
if evalContext.ImagePublicUrl != "" {
message = message + fmt.Sprintf("Image: %s\n", evalContext.ImagePublicUrl)
}
}
metrics := ""
fieldLimitCount := 4
@ -107,19 +122,69 @@ func (this *TelegramNotifier) Notify(evalContext *alerting.EvalContext) error {
break
}
}
if metrics != "" {
if sendImageInline {
// Telegram's API does not allow HTML formatting for image captions.
message = message + fmt.Sprintf("\nMetrics:%s", metrics)
} else {
message = message + fmt.Sprintf("\n<i>Metrics:</i>%s", metrics)
}
}
bodyJSON.Set("text", message)
var body bytes.Buffer
url := fmt.Sprintf(telegeramApiUrl, this.BotToken, "sendMessage")
body, _ := bodyJSON.MarshalJSON()
w := multipart.NewWriter(&body)
fw, _ := w.CreateFormField("chat_id")
fw.Write([]byte(this.ChatID))
if sendImageInline {
fw, _ = w.CreateFormField("caption")
fw.Write([]byte(message))
fw, _ = w.CreateFormFile("photo", evalContext.ImageOnDiskPath)
io.Copy(fw, imageFile)
} else {
fw, _ = w.CreateFormField("text")
fw.Write([]byte(message))
fw, _ = w.CreateFormField("parse_mode")
fw.Write([]byte("html"))
}
w.Close()
apiMethod := ""
if sendImageInline {
this.log.Info("Sending telegram image notification", "photo", evalContext.ImageOnDiskPath, "chat_id", this.ChatID, "bot_token", this.BotToken)
apiMethod = "sendPhoto"
} else {
this.log.Info("Sending telegram text notification", "chat_id", this.ChatID, "bot_token", this.BotToken)
apiMethod = "sendMessage"
}
url := fmt.Sprintf(telegramApiUrl, this.BotToken, apiMethod)
cmd := &m.SendWebhookSync{
Url: url,
Body: string(body),
Body: body.String(),
HttpMethod: "POST",
HttpHeader: map[string]string{
"Content-Type": w.FormDataContentType(),
},
}
return cmd
}
func (this *TelegramNotifier) ShouldNotify(context *alerting.EvalContext) bool {
return defaultShouldNotify(context)
}
func (this *TelegramNotifier) Notify(evalContext *alerting.EvalContext) error {
var cmd *m.SendWebhookSync
if evalContext.ImagePublicUrl == "" && this.UploadImage == true {
cmd = this.buildMessage(evalContext, true)
} else {
cmd = this.buildMessage(evalContext, false)
}
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {

View File

@ -0,0 +1,33 @@
package dashboards
import (
"github.com/grafana/grafana/pkg/services/dashboards"
gocache "github.com/patrickmn/go-cache"
"time"
)
type dashboardCache struct {
internalCache *gocache.Cache
}
func NewDashboardCache() *dashboardCache {
return &dashboardCache{internalCache: gocache.New(5*time.Minute, 30*time.Minute)}
}
func (fr *dashboardCache) addDashboardCache(key string, json *dashboards.SaveDashboardItem) {
fr.internalCache.Add(key, json, time.Minute*10)
}
func (fr *dashboardCache) getCache(key string) (*dashboards.SaveDashboardItem, bool) {
obj, exist := fr.internalCache.Get(key)
if !exist {
return nil, exist
}
dash, ok := obj.(*dashboards.SaveDashboardItem)
if !ok {
return nil, ok
}
return dash, ok
}

View File

@ -2,6 +2,7 @@ package dashboards
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
@ -15,7 +16,12 @@ import (
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/models"
gocache "github.com/patrickmn/go-cache"
)
var (
checkDiskForChangesInterval time.Duration = time.Second * 3
ErrFolderNameMissing error = errors.New("Folder name missing")
)
type fileReader struct {
@ -23,7 +29,8 @@ type fileReader struct {
Path string
log log.Logger
dashboardRepo dashboards.Repository
cache *gocache.Cache
cache *dashboardCache
createWalk func(fr *fileReader, folderId int64) filepath.WalkFunc
}
func NewDashboardFileReader(cfg *DashboardsAsConfig, log log.Logger) (*fileReader, error) {
@ -41,32 +48,15 @@ func NewDashboardFileReader(cfg *DashboardsAsConfig, log log.Logger) (*fileReade
Path: path,
log: log,
dashboardRepo: dashboards.GetRepository(),
cache: gocache.New(5*time.Minute, 30*time.Minute),
cache: NewDashboardCache(),
createWalk: createWalkFn,
}, nil
}
func (fr *fileReader) addCache(key string, json *dashboards.SaveDashboardItem) {
fr.cache.Add(key, json, time.Minute*10)
}
func (fr *fileReader) getCache(key string) (*dashboards.SaveDashboardItem, bool) {
obj, exist := fr.cache.Get(key)
if !exist {
return nil, exist
}
dash, ok := obj.(*dashboards.SaveDashboardItem)
if !ok {
return nil, ok
}
return dash, ok
}
func (fr *fileReader) ReadAndListen(ctx context.Context) error {
ticker := time.NewTicker(time.Second * 3)
ticker := time.NewTicker(checkDiskForChangesInterval)
if err := fr.walkFolder(); err != nil {
if err := fr.startWalkingDisk(); err != nil {
fr.log.Error("failed to search for dashboards", "error", err)
}
@ -78,7 +68,9 @@ func (fr *fileReader) ReadAndListen(ctx context.Context) error {
if !running { // avoid walking the filesystem in parallel. incase fs is very slow.
running = true
go func() {
fr.walkFolder()
if err := fr.startWalkingDisk(); err != nil {
fr.log.Error("failed to search for dashboards", "error", err)
}
running = false
}()
}
@ -88,14 +80,57 @@ func (fr *fileReader) ReadAndListen(ctx context.Context) error {
}
}
func (fr *fileReader) walkFolder() error {
func (fr *fileReader) startWalkingDisk() error {
if _, err := os.Stat(fr.Path); err != nil {
if os.IsNotExist(err) {
return err
}
}
return filepath.Walk(fr.Path, func(path string, fileInfo os.FileInfo, err error) error {
folderId, err := getOrCreateFolderId(fr.Cfg, fr.dashboardRepo)
if err != nil && err != ErrFolderNameMissing {
return err
}
return filepath.Walk(fr.Path, fr.createWalk(fr, folderId))
}
func getOrCreateFolderId(cfg *DashboardsAsConfig, repo dashboards.Repository) (int64, error) {
if cfg.Folder == "" {
return 0, ErrFolderNameMissing
}
cmd := &models.GetDashboardQuery{Slug: models.SlugifyTitle(cfg.Folder), OrgId: cfg.OrgId}
err := bus.Dispatch(cmd)
if err != nil && err != models.ErrDashboardNotFound {
return 0, err
}
// dashboard folder not found. create one.
if err == models.ErrDashboardNotFound {
dash := &dashboards.SaveDashboardItem{}
dash.Dashboard = models.NewDashboard(cfg.Folder)
dash.Dashboard.IsFolder = true
dash.Overwrite = true
dash.OrgId = cfg.OrgId
dbDash, err := repo.SaveDashboard(dash)
if err != nil {
return 0, err
}
return dbDash.Id, nil
}
if !cmd.Result.IsFolder {
return 0, fmt.Errorf("Got invalid response. Expected folder, found dashboard")
}
return cmd.Result.Id, nil
}
func createWalkFn(fr *fileReader, folderId int64) filepath.WalkFunc {
return func(path string, fileInfo os.FileInfo, err error) error {
if err != nil {
return err
}
@ -110,12 +145,12 @@ func (fr *fileReader) walkFolder() error {
return nil
}
cachedDashboard, exist := fr.getCache(path)
cachedDashboard, exist := fr.cache.getCache(path)
if exist && cachedDashboard.UpdatedAt == fileInfo.ModTime() {
return nil
}
dash, err := fr.readDashboardFromFile(path)
dash, err := fr.readDashboardFromFile(path, folderId)
if err != nil {
fr.log.Error("failed to load dashboard from ", "file", path, "error", err)
return nil
@ -147,10 +182,10 @@ func (fr *fileReader) walkFolder() error {
fr.log.Debug("loading dashboard from disk into database.", "file", path)
_, err = fr.dashboardRepo.SaveDashboard(dash)
return err
})
}
}
func (fr *fileReader) readDashboardFromFile(path string) (*dashboards.SaveDashboardItem, error) {
func (fr *fileReader) readDashboardFromFile(path string, folderId int64) (*dashboards.SaveDashboardItem, error) {
reader, err := os.Open(path)
if err != nil {
return nil, err
@ -167,12 +202,12 @@ func (fr *fileReader) readDashboardFromFile(path string) (*dashboards.SaveDashbo
return nil, err
}
dash, err := createDashboardJson(data, stat.ModTime(), fr.Cfg)
dash, err := createDashboardJson(data, stat.ModTime(), fr.Cfg, folderId)
if err != nil {
return nil, err
}
fr.addCache(path, dash)
fr.cache.addDashboardCache(path, dash)
return dash, nil
}

View File

@ -2,6 +2,7 @@ package dashboards
import (
"os"
"path/filepath"
"testing"
"time"
@ -22,7 +23,7 @@ var (
)
func TestDashboardFileReader(t *testing.T) {
Convey("Reading dashboards from disk", t, func() {
Convey("Dashboard file reader", t, func() {
bus.ClearBusHandlers()
fakeRepo = &fakeDashboardRepo{}
@ -30,6 +31,8 @@ func TestDashboardFileReader(t *testing.T) {
dashboards.SetRepository(fakeRepo)
logger := log.New("test.logger")
Convey("Reading dashboards from disk", func() {
cfg := &DashboardsAsConfig{
Name: "Default",
Type: "file",
@ -40,14 +43,27 @@ func TestDashboardFileReader(t *testing.T) {
Convey("Can read default dashboard", func() {
cfg.Options["folder"] = defaultDashboards
cfg.Folder = "Team A"
reader, err := NewDashboardFileReader(cfg, logger)
So(err, ShouldBeNil)
err = reader.walkFolder()
err = reader.startWalkingDisk()
So(err, ShouldBeNil)
So(len(fakeRepo.inserted), ShouldEqual, 2)
folders := 0
dashboards := 0
for _, i := range fakeRepo.inserted {
if i.Dashboard.IsFolder {
folders++
} else {
dashboards++
}
}
So(dashboards, ShouldEqual, 2)
So(folders, ShouldEqual, 1)
})
Convey("Should not update dashboards when db is newer", func() {
@ -61,7 +77,7 @@ func TestDashboardFileReader(t *testing.T) {
reader, err := NewDashboardFileReader(cfg, logger)
So(err, ShouldBeNil)
err = reader.walkFolder()
err = reader.startWalkingDisk()
So(err, ShouldBeNil)
So(len(fakeRepo.inserted), ShouldEqual, 0)
@ -80,7 +96,7 @@ func TestDashboardFileReader(t *testing.T) {
reader, err := NewDashboardFileReader(cfg, logger)
So(err, ShouldBeNil)
err = reader.walkFolder()
err = reader.startWalkingDisk()
So(err, ShouldBeNil)
So(len(fakeRepo.inserted), ShouldEqual, 1)
@ -99,20 +115,105 @@ func TestDashboardFileReader(t *testing.T) {
})
Convey("Broken dashboards should not cause error", func() {
cfg.Options["folder"] = brokenDashboards
_, err := NewDashboardFileReader(cfg, logger)
So(err, ShouldBeNil)
})
})
Convey("Should not create new folder if folder name is missing", func() {
cfg := &DashboardsAsConfig{
Name: "Default",
Type: "file",
OrgId: 1,
Folder: "",
Options: map[string]interface{}{
"folder": brokenDashboards,
"folder": defaultDashboards,
},
}
_, err := NewDashboardFileReader(cfg, logger)
_, err := getOrCreateFolderId(cfg, fakeRepo)
So(err, ShouldEqual, ErrFolderNameMissing)
})
Convey("can get or Create dashboard folder", func() {
cfg := &DashboardsAsConfig{
Name: "Default",
Type: "file",
OrgId: 1,
Folder: "TEAM A",
Options: map[string]interface{}{
"folder": defaultDashboards,
},
}
folderId, err := getOrCreateFolderId(cfg, fakeRepo)
So(err, ShouldBeNil)
inserted := false
for _, d := range fakeRepo.inserted {
if d.Dashboard.IsFolder && d.Dashboard.Id == folderId {
inserted = true
}
}
So(len(fakeRepo.inserted), ShouldEqual, 1)
So(inserted, ShouldBeTrue)
})
Convey("Walking the folder with dashboards", func() {
cfg := &DashboardsAsConfig{
Name: "Default",
Type: "file",
OrgId: 1,
Folder: "",
Options: map[string]interface{}{
"folder": defaultDashboards,
},
}
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
So(err, ShouldBeNil)
Convey("should skip dirs that starts with .", func() {
shouldSkip := reader.createWalk(reader, 0)("path", &FakeFileInfo{isDirectory: true, name: ".folder"}, nil)
So(shouldSkip, ShouldEqual, filepath.SkipDir)
})
Convey("should keep walking if file is not .json", func() {
shouldSkip := reader.createWalk(reader, 0)("path", &FakeFileInfo{isDirectory: true, name: "folder"}, nil)
So(shouldSkip, ShouldBeNil)
})
})
})
}
type FakeFileInfo struct {
isDirectory bool
name string
}
func (ffi *FakeFileInfo) IsDir() bool {
return ffi.isDirectory
}
func (ffi FakeFileInfo) Size() int64 {
return 1
}
func (ffi FakeFileInfo) Mode() os.FileMode {
return 0777
}
func (ffi FakeFileInfo) Name() string {
return ffi.name
}
func (ffi FakeFileInfo) ModTime() time.Time {
return time.Time{}
}
func (ffi FakeFileInfo) Sys() interface{} {
return nil
}
type fakeDashboardRepo struct {

View File

@ -18,14 +18,16 @@ type DashboardsAsConfig struct {
Options map[string]interface{} `json:"options" yaml:"options"`
}
func createDashboardJson(data *simplejson.Json, lastModified time.Time, cfg *DashboardsAsConfig) (*dashboards.SaveDashboardItem, error) {
func createDashboardJson(data *simplejson.Json, lastModified time.Time, cfg *DashboardsAsConfig, folderId int64) (*dashboards.SaveDashboardItem, error) {
dash := &dashboards.SaveDashboardItem{}
dash.Dashboard = models.NewDashboardFromJson(data)
dash.UpdatedAt = lastModified
dash.Overwrite = true
dash.OrgId = cfg.OrgId
dash.Dashboard.FolderId = folderId
if !cfg.Editable {
dash.Dashboard.Data.Set("editable", cfg.Editable)
}
if dash.Dashboard.Title == "" {
return nil, models.ErrDashboardTitleEmpty

View File

@ -88,7 +88,7 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error {
params = append(params, query.PanelId)
}
if len(query.State) > 0 && query.State[0] != "ALL" {
if len(query.State) > 0 && query.State[0] != "all" {
sql.WriteString(` AND (`)
for i, v := range query.State {
if i > 0 {

View File

@ -45,4 +45,9 @@ func addTeamMigrations(mg *Migrator) {
//------- indexes ------------------
mg.AddMigration("add index team_member.org_id", NewAddIndexMigration(teamMemberV1, teamMemberV1.Indices[0]))
mg.AddMigration("add unique index team_member_org_id_team_id_user_id", NewAddIndexMigration(teamMemberV1, teamMemberV1.Indices[1]))
// add column email
mg.AddMigration("Add column email to team table", NewAddColumnMigration(teamV1, &Column{
Name: "email", Type: DB_NVarchar, Nullable: true, Length: 190,
}))
}

View File

@ -13,7 +13,7 @@ func init() {
bus.AddHandler("sql", GetAdminStats)
}
var activeUserTimeLimit time.Duration = time.Hour * 24 * 14
var activeUserTimeLimit time.Duration = time.Hour * 24 * 30
func GetDataSourceStats(query *m.GetDataSourceStatsQuery) error {
var rawSql = `SELECT COUNT(*) as count, type FROM data_source GROUP BY type`

View File

@ -33,6 +33,7 @@ func CreateTeam(cmd *m.CreateTeamCommand) error {
team := m.Team{
Name: cmd.Name,
Email: cmd.Email,
OrgId: cmd.OrgId,
Created: time.Now(),
Updated: time.Now(),
@ -57,9 +58,12 @@ func UpdateTeam(cmd *m.UpdateTeamCommand) error {
team := m.Team{
Name: cmd.Name,
Email: cmd.Email,
Updated: time.Now(),
}
sess.MustCols("email")
affectedRows, err := sess.Id(cmd.Id).Update(&team)
if err != nil {
@ -125,6 +129,7 @@ func SearchTeams(query *m.SearchTeamsQuery) error {
sql.WriteString(`select
team.id as id,
team.name as name,
team.email as email,
(select count(*) from team_member where team_member.team_id = team.id) as member_count
from team as team
where team.org_id = ?`)

View File

@ -27,8 +27,8 @@ func TestTeamCommandsAndQueries(t *testing.T) {
userIds = append(userIds, userCmd.Result.Id)
}
group1 := m.CreateTeamCommand{Name: "group1 name"}
group2 := m.CreateTeamCommand{Name: "group2 name"}
group1 := m.CreateTeamCommand{Name: "group1 name", Email: "test1@test.com"}
group2 := m.CreateTeamCommand{Name: "group2 name", Email: "test2@test.com"}
err := CreateTeam(&group1)
So(err, ShouldBeNil)
@ -43,6 +43,7 @@ func TestTeamCommandsAndQueries(t *testing.T) {
team1 := query.Result.Teams[0]
So(team1.Name, ShouldEqual, "group1 name")
So(team1.Email, ShouldEqual, "test1@test.com")
err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: team1.Id, UserId: userIds[0]})
So(err, ShouldBeNil)
@ -76,6 +77,7 @@ func TestTeamCommandsAndQueries(t *testing.T) {
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 1)
So(query.Result[0].Name, ShouldEqual, "group2 name")
So(query.Result[0].Email, ShouldEqual, "test2@test.com")
})
Convey("Should be able to remove users from a group", func() {

View File

@ -600,7 +600,7 @@ func NewConfigContext(args *CommandLineArgs) error {
readQuotaSettings()
if VerifyEmailEnabled && !Smtp.Enabled {
log.Warn("require_email_validation is enabled but smpt is disabled")
log.Warn("require_email_validation is enabled but smtp is disabled")
}
// check old key name
@ -610,7 +610,7 @@ func NewConfigContext(args *CommandLineArgs) error {
}
imageUploadingSection := Cfg.Section("external_image_storage")
ImageUploadProvider = imageUploadingSection.Key("provider").MustString("internal")
ImageUploadProvider = imageUploadingSection.Key("provider").MustString("")
return nil
}

View File

@ -37,6 +37,7 @@ var customMetricsDimensionsMap map[string]map[string]map[string]*CustomMetricsCa
func init() {
metricsMap = map[string][]string{
"AWS/AmazonMQ": {"CpuUtilization", "HeapUsage", "NetworkIn", "NetworkOut", "TotalMessageCount", "ConsumerCount", "EnqueueCount", "EnqueueTime", "ExpiredCount", "InflightCount", "DispatchCount", "DequeueCount", "MemoryUsage", "ProducerCount", "QueueSize"},
"AWS/ApiGateway": {"4XXError", "5XXError", "CacheHitCount", "CacheMissCount", "Count", "IntegrationLatency", "Latency"},
"AWS/ApplicationELB": {"ActiveConnectionCount", "ClientTLSNegotiationErrorCount", "HealthyHostCount", "HTTPCode_ELB_4XX_Count", "HTTPCode_ELB_5XX_Count", "HTTPCode_Target_2XX_Count", "HTTPCode_Target_3XX_Count", "HTTPCode_Target_4XX_Count", "HTTPCode_Target_5XX_Count", "IPv6ProcessedBytes", "IPv6RequestCount", "NewConnectionCount", "ProcessedBytes", "RejectedConnectionCount", "RequestCount", "RequestCountPerTarget", "TargetConnectionErrorCount", "TargetResponseTime", "TargetTLSNegotiationErrorCount", "UnHealthyHostCount"},
"AWS/AutoScaling": {"GroupMinSize", "GroupMaxSize", "GroupDesiredCapacity", "GroupInServiceInstances", "GroupPendingInstances", "GroupStandbyInstances", "GroupTerminatingInstances", "GroupTotalInstances"},
@ -106,6 +107,7 @@ func init() {
"KMS": {"SecondsUntilKeyMaterialExpiration"},
}
dimensionsMap = map[string][]string{
"AWS/AmazonMQ": {"Broker", "Topic", "Queue"},
"AWS/ApiGateway": {"ApiName", "Method", "Resource", "Stage"},
"AWS/ApplicationELB": {"LoadBalancer", "TargetGroup", "AvailabilityZone"},
"AWS/AutoScaling": {"AutoScalingGroupName"},

View File

@ -83,24 +83,29 @@ func (e *PrometheusExecutor) getClient(dsInfo *models.DataSource) (apiv1.API, er
}
func (e *PrometheusExecutor) Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) {
result := &tsdb.Response{}
result := &tsdb.Response{
Results: map[string]*tsdb.QueryResult{},
}
client, err := e.getClient(dsInfo)
if err != nil {
return nil, err
}
query, err := parseQuery(dsInfo, tsdbQuery.Queries, tsdbQuery)
querys, err := parseQuery(dsInfo, tsdbQuery.Queries, tsdbQuery)
if err != nil {
return nil, err
}
for _, query := range querys {
timeRange := apiv1.Range{
Start: query.Start,
End: query.End,
Step: query.Step,
}
plog.Debug("Sending query", "start", timeRange.Start, "end", timeRange.End, "step", timeRange.Step, "query", query.Expr)
span, ctx := opentracing.StartSpanFromContext(ctx, "alerting.prometheus")
span.SetTag("expr", query.Expr)
span.SetTag("start_unixnano", int64(query.Start.UnixNano()))
@ -117,7 +122,9 @@ func (e *PrometheusExecutor) Query(ctx context.Context, dsInfo *models.DataSourc
if err != nil {
return nil, err
}
result.Results = queryResult
result.Results[query.RefId] = queryResult
}
return result, nil
}
@ -140,9 +147,9 @@ func formatLegend(metric model.Metric, query *PrometheusQuery) string {
return string(result)
}
func parseQuery(dsInfo *models.DataSource, queries []*tsdb.Query, queryContext *tsdb.TsdbQuery) (*PrometheusQuery, error) {
queryModel := queries[0]
func parseQuery(dsInfo *models.DataSource, queries []*tsdb.Query, queryContext *tsdb.TsdbQuery) ([]*PrometheusQuery, error) {
qs := []*PrometheusQuery{}
for _, queryModel := range queries {
expr, err := queryModel.Model.Get("expr").String()
if err != nil {
return nil, err
@ -169,22 +176,25 @@ func parseQuery(dsInfo *models.DataSource, queries []*tsdb.Query, queryContext *
interval := intervalCalculator.Calculate(queryContext.TimeRange, dsInterval)
step := time.Duration(int64(interval.Value) * intervalFactor)
return &PrometheusQuery{
qs = append(qs, &PrometheusQuery{
Expr: expr,
Step: step,
LegendFormat: format,
Start: start,
End: end,
}, nil
RefId: queryModel.RefId,
})
}
func parseResponse(value model.Value, query *PrometheusQuery) (map[string]*tsdb.QueryResult, error) {
queryResults := make(map[string]*tsdb.QueryResult)
return qs, nil
}
func parseResponse(value model.Value, query *PrometheusQuery) (*tsdb.QueryResult, error) {
queryRes := tsdb.NewQueryResult()
data, ok := value.(model.Matrix)
if !ok {
return queryResults, fmt.Errorf("Unsupported result format: %s", value.Type().String())
return queryRes, fmt.Errorf("Unsupported result format: %s", value.Type().String())
}
for _, v := range data {
@ -204,6 +214,5 @@ func parseResponse(value model.Value, query *PrometheusQuery) (map[string]*tsdb.
queryRes.Series = append(queryRes.Series, &series)
}
queryResults["A"] = queryRes
return queryResults, nil
return queryRes, nil
}

View File

@ -60,9 +60,10 @@ func TestPrometheus(t *testing.T) {
Convey("with 48h time range", func() {
queryContext.TimeRange = tsdb.NewTimeRange("12h", "now")
model, err := parseQuery(dsInfo, queryModels, queryContext)
models, err := parseQuery(dsInfo, queryModels, queryContext)
So(err, ShouldBeNil)
model := models[0]
So(model.Step, ShouldEqual, time.Second*30)
})
})
@ -83,18 +84,22 @@ func TestPrometheus(t *testing.T) {
Convey("with 48h time range", func() {
queryContext.TimeRange = tsdb.NewTimeRange("48h", "now")
model, err := parseQuery(dsInfo, queryModels, queryContext)
models, err := parseQuery(dsInfo, queryModels, queryContext)
So(err, ShouldBeNil)
model := models[0]
So(model.Step, ShouldEqual, time.Minute*2)
})
Convey("with 1h time range", func() {
queryContext.TimeRange = tsdb.NewTimeRange("1h", "now")
model, err := parseQuery(dsInfo, queryModels, queryContext)
models, err := parseQuery(dsInfo, queryModels, queryContext)
So(err, ShouldBeNil)
model := models[0]
So(model.Step, ShouldEqual, time.Second*15)
})
})
@ -116,9 +121,11 @@ func TestPrometheus(t *testing.T) {
Convey("with 48h time range", func() {
queryContext.TimeRange = tsdb.NewTimeRange("48h", "now")
model, err := parseQuery(dsInfo, queryModels, queryContext)
models, err := parseQuery(dsInfo, queryModels, queryContext)
So(err, ShouldBeNil)
model := models[0]
So(model.Step, ShouldEqual, time.Minute*20)
})
})
@ -139,9 +146,11 @@ func TestPrometheus(t *testing.T) {
Convey("with 48h time range", func() {
queryContext.TimeRange = tsdb.NewTimeRange("48h", "now")
model, err := parseQuery(dsInfo, queryModels, queryContext)
models, err := parseQuery(dsInfo, queryModels, queryContext)
So(err, ShouldBeNil)
model := models[0]
So(model.Step, ShouldEqual, time.Minute*2)
})
})

View File

@ -8,4 +8,5 @@ type PrometheusQuery struct {
LegendFormat string
Start time.Time
End time.Time
RefId string
}

View File

@ -27,6 +27,9 @@ _.move = function(array, fromIndex, toIndex) {
};
import { coreModule, registerAngularDirectives } from './core/core';
import { setupAngularRoutes } from './routes/routes';
declare var System: any;
export class GrafanaApp {
registerFunctions: any;
@ -54,15 +57,7 @@ export class GrafanaApp {
moment.locale(config.bootData.user.locale);
app.config(
(
$locationProvider,
$controllerProvider,
$compileProvider,
$filterProvider,
$httpProvider,
$provide
) => {
app.config(($locationProvider, $controllerProvider, $compileProvider, $filterProvider, $httpProvider, $provide) => {
// pre assing bindings before constructor calls
$compileProvider.preAssignBindingsEnabled(true);
@ -95,8 +90,7 @@ export class GrafanaApp {
return $delegate;
},
]);
}
);
});
this.ngModuleDependencies = [
'grafana.core',
@ -111,14 +105,7 @@ export class GrafanaApp {
'react',
];
var module_types = [
'controllers',
'directives',
'factories',
'services',
'filters',
'routes',
];
var module_types = ['controllers', 'directives', 'factories', 'services', 'filters', 'routes'];
_.each(module_types, type => {
var moduleName = 'grafana.' + type;
@ -129,6 +116,7 @@ export class GrafanaApp {
this.useModule(coreModule);
// register react angular wrappers
coreModule.config(setupAngularRoutes);
registerAngularDirectives();
var preBootRequires = [System.import('app/features/all')];
@ -137,6 +125,7 @@ export class GrafanaApp {
.then(() => {
// disable tool tip animation
$.fn.tooltip.defaults.animation = false;
// bootstrap the app
angular.bootstrap(document, this.ngModuleDependencies).invoke(() => {
_.each(this.preBootModules, module => {

View File

@ -0,0 +1,68 @@
import React from 'react';
import moment from 'moment';
import { AlertRuleList } from './AlertRuleList';
import { RootStore } from 'app/stores/RootStore/RootStore';
import { backendSrv, createNavTree } from 'test/mocks/common';
import { mount } from 'enzyme';
import toJson from 'enzyme-to-json';
describe('AlertRuleList', () => {
let page, store;
beforeAll(() => {
backendSrv.get.mockReturnValue(
Promise.resolve([
{
id: 11,
dashboardId: 58,
panelId: 3,
name: 'Panel Title alert',
state: 'ok',
newStateDate: moment()
.subtract(5, 'minutes')
.format(),
evalData: {},
executionError: '',
dashboardUri: 'db/mygool',
},
])
);
store = RootStore.create(
{},
{
backendSrv: backendSrv,
navTree: createNavTree('alerting', 'alert-list'),
}
);
page = mount(<AlertRuleList {...store} />);
});
it('should call api to get rules', () => {
expect(backendSrv.get.mock.calls[0][0]).toEqual('/api/alerts');
});
it('should render 1 rule', () => {
page.update();
let ruleNode = page.find('.alert-rule-item');
expect(toJson(ruleNode)).toMatchSnapshot();
});
it('toggle state should change pause rule if not paused', async () => {
backendSrv.post.mockReturnValue(
Promise.resolve({
state: 'paused',
})
);
page.find('.fa-pause').simulate('click');
// wait for api call to resolve
await Promise.resolve();
page.update();
expect(store.alertList.rules[0].state).toBe('paused');
expect(page.find('.fa-play')).toHaveLength(1);
});
});

View File

@ -0,0 +1,174 @@
import React from 'react';
import classNames from 'classnames';
import { inject, observer } from 'mobx-react';
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import { IAlertRule } from 'app/stores/AlertListStore/AlertListStore';
import appEvents from 'app/core/app_events';
import IContainerProps from 'app/containers/IContainerProps';
import Highlighter from 'react-highlight-words';
@inject('view', 'nav', 'alertList')
@observer
export class AlertRuleList extends React.Component<IContainerProps, any> {
stateFilters = [
{ text: 'All', value: 'all' },
{ text: 'OK', value: 'ok' },
{ text: 'Not OK', value: 'not_ok' },
{ text: 'Alerting', value: 'alerting' },
{ text: 'No Data', value: 'no_data' },
{ text: 'Paused', value: 'paused' },
];
constructor(props) {
super(props);
this.props.nav.load('alerting', 'alert-list');
this.fetchRules();
}
onStateFilterChanged = evt => {
this.props.view.updateQuery({ state: evt.target.value });
this.fetchRules();
};
fetchRules() {
this.props.alertList.loadRules({
state: this.props.view.query.get('state') || 'all',
});
}
onOpenHowTo = () => {
appEvents.emit('show-modal', {
src: 'public/app/features/alerting/partials/alert_howto.html',
modalClass: 'confirm-modal',
model: {},
});
};
onSearchQueryChange = evt => {
this.props.alertList.setSearchQuery(evt.target.value);
};
render() {
const { nav, alertList } = this.props;
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 alerts"
value={alertList.search}
onChange={this.onSearchQueryChange}
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
</div>
<div className="gf-form">
<label className="gf-form-label">States</label>
<div className="gf-form-select-wrapper width-13">
<select className="gf-form-input" onChange={this.onStateFilterChanged} value={alertList.stateFilter}>
{this.stateFilters.map(AlertStateFilterOption)}
</select>
</div>
</div>
<div className="page-action-bar__spacer" />
<a className="btn btn-secondary" onClick={this.onOpenHowTo}>
<i className="fa fa-info-circle" /> How to add an alert
</a>
</div>
<section>
<ol className="alert-rule-list">
{alertList.filteredRules.map(rule => (
<AlertRuleItem rule={rule} key={rule.id} search={alertList.search} />
))}
</ol>
</section>
</div>
</div>
);
}
}
function AlertStateFilterOption({ text, value }) {
return (
<option key={value} value={value}>
{text}
</option>
);
}
export interface AlertRuleItemProps {
rule: IAlertRule;
search: string;
}
@observer
export class AlertRuleItem extends React.Component<AlertRuleItemProps, any> {
toggleState = () => {
this.props.rule.togglePaused();
};
renderText(text: string) {
return (
<Highlighter
highlightClassName="highlight-search-match"
textToHighlight={text}
searchWords={[this.props.search]}
/>
);
}
render() {
const { rule } = this.props;
let stateClass = classNames({
fa: true,
'fa-play': rule.isPaused,
'fa-pause': !rule.isPaused,
});
let ruleUrl = `dashboard/${rule.dashboardUri}?panelId=${rule.panelId}&fullscreen&edit&tab=alert`;
return (
<li className="alert-rule-item">
<span className={`alert-rule-item__icon ${rule.stateClass}`}>
<i className={rule.stateIcon} />
</span>
<div className="alert-rule-item__body">
<div className="alert-rule-item__header">
<div className="alert-rule-item__name">
<a href={ruleUrl}>{this.renderText(rule.name)}</a>
</div>
<div className="alert-rule-item__text">
<span className={`${rule.stateClass}`}>{this.renderText(rule.stateText)}</span>
<span className="alert-rule-item__time"> for {rule.stateAge}</span>
</div>
</div>
{rule.info && <div className="small muted alert-rule-item__info">{this.renderText(rule.info)}</div>}
</div>
<div className="alert-rule-item__actions">
<a
className="btn btn-small btn-inverse alert-list__btn width-2"
title="Pausing an alert rule prevents it from executing"
onClick={this.toggleState}
>
<i className={stateClass} />
</a>
<a className="btn btn-small btn-inverse alert-list__btn width-2" href={ruleUrl} title="Edit alert rule">
<i className="icon-gf icon-gf-settings" />
</a>
</div>
</li>
);
}
}

View File

@ -0,0 +1,103 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AlertRuleList should render 1 rule 1`] = `
<li
className="alert-rule-item"
>
<span
className="alert-rule-item__icon alert-state-ok"
>
<i
className="icon-gf icon-gf-online"
/>
</span>
<div
className="alert-rule-item__body"
>
<div
className="alert-rule-item__header"
>
<div
className="alert-rule-item__name"
>
<a
href="dashboard/db/mygool?panelId=3&fullscreen&edit&tab=alert"
>
<Highlighter
highlightClassName="highlight-search-match"
searchWords={
Array [
"",
]
}
textToHighlight="Panel Title alert"
>
<span>
<span
className=""
key="0"
>
Panel Title alert
</span>
</span>
</Highlighter>
</a>
</div>
<div
className="alert-rule-item__text"
>
<span
className="alert-state-ok"
>
<Highlighter
highlightClassName="highlight-search-match"
searchWords={
Array [
"",
]
}
textToHighlight="OK"
>
<span>
<span
className=""
key="0"
>
OK
</span>
</span>
</Highlighter>
</span>
<span
className="alert-rule-item__time"
>
for
5 minutes
</span>
</div>
</div>
</div>
<div
className="alert-rule-item__actions"
>
<a
className="btn btn-small btn-inverse alert-list__btn width-2"
onClick={[Function]}
title="Pausing an alert rule prevents it from executing"
>
<i
className="fa fa-pause"
/>
</a>
<a
className="btn btn-small btn-inverse alert-list__btn width-2"
href="dashboard/db/mygool?panelId=3&fullscreen&edit&tab=alert"
title="Edit alert rule"
>
<i
className="icon-gf icon-gf-settings"
/>
</a>
</div>
</li>
`;

View File

@ -0,0 +1,15 @@
import { SearchStore } from './../stores/SearchStore/SearchStore';
import { ServerStatsStore } from './../stores/ServerStatsStore/ServerStatsStore';
import { NavStore } from './../stores/NavStore/NavStore';
import { AlertListStore } from './../stores/AlertListStore/AlertListStore';
import { ViewStore } from './../stores/ViewStore/ViewStore';
interface IContainerProps {
search: typeof SearchStore.Type;
serverStats: typeof ServerStatsStore.Type;
nav: typeof NavStore.Type;
alertList: typeof AlertListStore.Type;
view: typeof ViewStore.Type;
}
export default IContainerProps;

View File

@ -0,0 +1,30 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { ServerStats } from './ServerStats';
import { RootStore } from 'app/stores/RootStore/RootStore';
import { backendSrv, createNavTree } from 'test/mocks/common';
describe('ServerStats', () => {
it('Should render table with stats', done => {
backendSrv.get.mockReturnValue(
Promise.resolve({
dashboards: 10,
})
);
const store = RootStore.create(
{},
{
backendSrv: backendSrv,
navTree: createNavTree('cfg', 'admin', 'server-stats'),
}
);
const page = renderer.create(<ServerStats {...store} />);
setTimeout(() => {
expect(page.toJSON()).toMatchSnapshot();
done();
});
});
});

View File

@ -0,0 +1,45 @@
import React from 'react';
import { inject, observer } from 'mobx-react';
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import IContainerProps from 'app/containers/IContainerProps';
@inject('nav', 'serverStats')
@observer
export class ServerStats extends React.Component<IContainerProps, any> {
constructor(props) {
super(props);
const { nav, serverStats } = this.props;
nav.load('cfg', 'admin', 'server-stats');
serverStats.load();
}
render() {
const { nav, serverStats } = this.props;
return (
<div>
<PageHeader model={nav as any} />
<div className="page-container page-body">
<table className="filter-table form-inline">
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody>{serverStats.stats.map(StatItem)}</tbody>
</table>
</div>
</div>
);
}
}
function StatItem(stat) {
return (
<tr key={stat.name}>
<td>{stat.name}</td>
<td>{stat.value}</td>
</tr>
);
}

View File

@ -0,0 +1,170 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ServerStats Should render table with stats 1`] = `
<div>
<div
className="page-header-canvas"
>
<div
className="page-container"
>
<div
className="page-header"
>
<div
className="page-header__inner"
>
<span
className="page-header__logo"
>
</span>
<div
className="page-header__info-block"
>
<h1
className="page-header__title"
>
admin-Text
</h1>
</div>
</div>
<nav>
<div
className="gf-form-select-wrapper width-20 page-header__select-nav"
>
<label
className="gf-form-select-icon "
htmlFor="page-header-select-nav"
/>
<select
className="gf-select-nav gf-form-input"
defaultValue="/url/server-stats"
id="page-header-select-nav"
onChange={[Function]}
>
<option
value="/url/server-stats"
>
server-stats-Text
</option>
</select>
</div>
<ul
className="gf-tabs page-header__tabs"
>
<li
className="gf-tabs-item"
>
<a
className="gf-tabs-link active"
href="/url/server-stats"
target={undefined}
>
<i
className=""
/>
server-stats-Text
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
<div
className="page-container page-body"
>
<table
className="filter-table form-inline"
>
<thead>
<tr>
<th>
Name
</th>
<th>
Value
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
Total dashboards
</td>
<td>
10
</td>
</tr>
<tr>
<td>
Total users
</td>
<td>
0
</td>
</tr>
<tr>
<td>
Active users (seen last 30 days)
</td>
<td>
0
</td>
</tr>
<tr>
<td>
Total orgs
</td>
<td>
0
</td>
</tr>
<tr>
<td>
Total playlists
</td>
<td>
0
</td>
</tr>
<tr>
<td>
Total snapshots
</td>
<td>
0
</td>
</tr>
<tr>
<td>
Total dashboard tags
</td>
<td>
0
</td>
</tr>
<tr>
<td>
Total starred dashboards
</td>
<td>
0
</td>
</tr>
<tr>
<td>
Total alerts
</td>
<td>
0
</td>
</tr>
</tbody>
</table>
</div>
</div>
`;

View File

@ -3,10 +3,14 @@ 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 UserPicker from './components/UserPicker/UserPicker';
export function registerAngularDirectives() {
react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']);
react2AngularDirective('emptyListCta', EmptyListCTA, ['model']);
react2AngularDirective('loginBackground', LoginBackground, []);
react2AngularDirective('searchResult', SearchResult, []);
react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'teamId', 'refreshList']);
}

View File

@ -10,11 +10,10 @@ const model = {
proTip: 'This is a tip',
proTipLink: 'http://url/to/tip/destination',
proTipLinkTitle: 'Learn more',
proTipTarget: '_blank'
proTipTarget: '_blank',
};
describe('CollorPalette', () => {
describe('EmptyListCTA', () => {
it('renders correctly', () => {
const tree = renderer.create(<EmptyListCTA model={model} />).toJSON();
expect(tree).toMatchSnapshot();

View File

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CollorPalette renders correctly 1`] = `
exports[`EmptyListCTA renders correctly 1`] = `
<div
className="empty-list-cta"
>

View File

@ -1,7 +1,7 @@
import React from "react";
import { NavModel, NavModelItem } from "../../nav_model_srv";
import classNames from "classnames";
import appEvents from "app/core/app_events";
import React from 'react';
import { NavModel, NavModelItem } from '../../nav_model_srv';
import classNames from 'classnames';
import appEvents from 'app/core/app_events';
export interface IProps {
model: NavModel;
@ -13,8 +13,8 @@ function TabItem(tab: NavModelItem) {
}
let tabClasses = classNames({
"gf-tabs-link": true,
active: tab.active
'gf-tabs-link': true,
active: tab.active,
});
return (
@ -49,13 +49,7 @@ function Navigation({ main }: { main: NavModelItem }) {
);
}
function SelectNav({
main,
customCss
}: {
main: NavModelItem;
customCss: string;
}) {
function SelectNav({ main, customCss }: { main: NavModelItem; customCss: string }) {
const defaultSelectedItem = main.children.find(navItem => {
return navItem.active === true;
});
@ -63,15 +57,12 @@ function SelectNav({
const gotoUrl = evt => {
var element = evt.target;
var url = element.options[element.selectedIndex].value;
appEvents.emit("location-change", { href: url });
appEvents.emit('location-change', { href: url });
};
return (
<div className={`gf-form-select-wrapper width-20 ${customCss}`}>
<label
className={`gf-form-select-icon ${defaultSelectedItem.icon}`}
htmlFor="page-header-select-nav"
/>
<label className={`gf-form-select-icon ${defaultSelectedItem.icon}`} htmlFor="page-header-select-nav" />
{/* Label to make it clickable */}
<select
className="gf-select-nav gf-form-input"
@ -86,9 +77,7 @@ function SelectNav({
}
function Tabs({ main, customCss }: { main: NavModelItem; customCss: string }) {
return (
<ul className={`gf-tabs ${customCss}`}>{main.children.map(TabItem)}</ul>
);
return <ul className={`gf-tabs ${customCss}`}>{main.children.map(TabItem)}</ul>;
}
export default class PageHeader extends React.Component<IProps, any> {
@ -125,13 +114,9 @@ export default class PageHeader extends React.Component<IProps, any> {
{main.text && <h1 className="page-header__title">{main.text}</h1>}
{main.breadcrumbs &&
main.breadcrumbs.length > 0 && (
<h1 className="page-header__title">
{this.renderBreadcrumb(main.breadcrumbs)}
</h1>
)}
{main.subTitle && (
<div className="page-header__sub-title">{main.subTitle}</div>
<h1 className="page-header__title">{this.renderBreadcrumb(main.breadcrumbs)}</h1>
)}
{main.subTitle && <div className="page-header__sub-title">{main.subTitle}</div>}
{main.subType && (
<div className="page-header__stamps">
<i className={main.subType.icon} />

View File

@ -0,0 +1,16 @@
import React from 'react';
import renderer from 'react-test-renderer';
import Popover from './Popover';
describe('Popover', () => {
it('renders correctly', () => {
const tree = renderer
.create(
<Popover placement="auto" content="Popover text">
<button>Button with Popover</button>
</Popover>
)
.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@ -0,0 +1,34 @@
import React from 'react';
import withTooltip from './withTooltip';
import { Target } from 'react-popper';
interface IPopoverProps {
tooltipSetState: (prevState: object) => void;
}
class Popover extends React.Component<IPopoverProps, any> {
constructor(props) {
super(props);
this.toggleTooltip = this.toggleTooltip.bind(this);
}
toggleTooltip() {
const { tooltipSetState } = this.props;
tooltipSetState(prevState => {
return {
...prevState,
show: !prevState.show,
};
});
}
render() {
return (
<Target className="popper__target" onClick={this.toggleTooltip}>
{this.props.children}
</Target>
);
}
}
export default withTooltip(Popover);

View File

@ -0,0 +1,16 @@
import React from 'react';
import renderer from 'react-test-renderer';
import Tooltip from './Tooltip';
describe('Tooltip', () => {
it('renders correctly', () => {
const tree = renderer
.create(
<Tooltip placement="auto" content="Tooltip text">
<a href="http://www.grafana.com">Link with tooltip</a>
</Tooltip>
)
.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@ -0,0 +1,45 @@
import React from 'react';
import withTooltip from './withTooltip';
import { Target } from 'react-popper';
interface ITooltipProps {
tooltipSetState: (prevState: object) => void;
}
class Tooltip extends React.Component<ITooltipProps, any> {
constructor(props) {
super(props);
this.showTooltip = this.showTooltip.bind(this);
this.hideTooltip = this.hideTooltip.bind(this);
}
showTooltip() {
const { tooltipSetState } = this.props;
tooltipSetState(prevState => {
return {
...prevState,
show: true,
};
});
}
hideTooltip() {
const { tooltipSetState } = this.props;
tooltipSetState(prevState => {
return {
...prevState,
show: false,
};
});
}
render() {
return (
<Target className="popper__target" onMouseOver={this.showTooltip} onMouseOut={this.hideTooltip}>
{this.props.children}
</Target>
);
}
}
export default withTooltip(Tooltip);

View File

@ -0,0 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Popover renders correctly 1`] = `
<div
className="popper__manager"
>
<div
className="popper__target"
onClick={[Function]}
>
<button>
Button with Popover
</button>
</div>
</div>
`;

View File

@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Tooltip renders correctly 1`] = `
<div
className="popper__manager"
>
<div
className="popper__target"
onMouseOut={[Function]}
onMouseOver={[Function]}
>
<a
href="http://www.grafana.com"
>
Link with tooltip
</a>
</div>
</div>
`;

View File

@ -0,0 +1,57 @@
import React from 'react';
import { Manager, Popper, Arrow } from 'react-popper';
interface IwithTooltipProps {
placement?: string;
content: string | ((props: any) => JSX.Element);
}
export default function withTooltip(WrappedComponent) {
return class extends React.Component<IwithTooltipProps, any> {
constructor(props) {
super(props);
this.setState = this.setState.bind(this);
this.state = {
placement: this.props.placement || 'auto',
show: false,
};
}
componentWillReceiveProps(nextProps) {
if (nextProps.placement && nextProps.placement !== this.state.placement) {
this.setState(prevState => {
return {
...prevState,
placement: nextProps.placement,
};
});
}
}
renderContent(content) {
if (typeof content === 'function') {
// If it's a function we assume it's a React component
const ReactComponent = content;
return <ReactComponent />;
}
return content;
}
render() {
const { content } = this.props;
return (
<Manager className="popper__manager">
<WrappedComponent {...this.props} tooltipSetState={this.setState} />
{this.state.show ? (
<Popper placement={this.state.placement} className="popper">
{this.renderContent(content)}
<Arrow className="popper__arrow" />
</Popper>
) : null}
</Manager>
);
}
};
}

View File

@ -0,0 +1,20 @@
import React from 'react';
import renderer from 'react-test-renderer';
import UserPicker from './UserPicker';
const model = {
backendSrv: {
get: () => {
return new Promise((resolve, reject) => {});
},
},
refreshList: () => {},
teamId: '1',
};
describe('UserPicker', () => {
it('renders correctly', () => {
const tree = renderer.create(<UserPicker {...model} />).toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@ -0,0 +1,108 @@
import React, { Component } from 'react';
import { debounce } from 'lodash';
import Select from 'react-select';
import UserPickerOption from './UserPickerOption';
export interface IProps {
backendSrv: any;
teamId: string;
refreshList: any;
}
export interface User {
id: number;
name: string;
login: string;
email: string;
}
class UserPicker extends Component<IProps, any> {
debouncedSearchUsers: any;
backendSrv: any;
teamId: string;
refreshList: any;
constructor(props) {
super(props);
this.backendSrv = this.props.backendSrv;
this.teamId = this.props.teamId;
this.refreshList = this.props.refreshList;
this.searchUsers = this.searchUsers.bind(this);
this.handleChange = this.handleChange.bind(this);
this.addUser = this.addUser.bind(this);
this.toggleLoading = this.toggleLoading.bind(this);
this.debouncedSearchUsers = debounce(this.searchUsers, 300, {
leading: true,
trailing: false,
});
this.state = {
multi: false,
isLoading: false,
};
}
handleChange(user) {
this.addUser(user.id);
}
toggleLoading(isLoading) {
this.setState(prevState => {
return {
...prevState,
isLoading: isLoading,
};
});
}
addUser(userId) {
this.toggleLoading(true);
this.backendSrv.post(`/api/teams/${this.teamId}/members`, { userId: userId }).then(() => {
this.refreshList();
this.toggleLoading(false);
});
}
searchUsers(query) {
this.toggleLoading(true);
return this.backendSrv.get(`/api/users/search?perpage=10&page=1&query=${query}`).then(result => {
const users = result.users.map(user => {
return {
id: user.id,
label: `${user.login} - ${user.email}`,
avatarUrl: user.avatarUrl,
};
});
this.toggleLoading(false);
return { options: users };
});
}
render() {
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
return (
<div className="user-picker">
<AsyncComponent
valueKey="id"
multi={this.state.multi}
labelKey="label"
cache={false}
isLoading={this.state.isLoading}
loadOptions={this.debouncedSearchUsers}
loadingPlaceholder="Loading..."
noResultsText="No users found"
onChange={this.handleChange}
className="width-8 gf-form-input gf-form-input--form-dropdown"
optionComponent={UserPickerOption}
placeholder="Choose"
/>
</div>
);
}
}
export default UserPicker;

View File

@ -0,0 +1,22 @@
import React from 'react';
import renderer from 'react-test-renderer';
import UserPickerOption from './UserPickerOption';
const model = {
onSelect: () => {},
onFocus: () => {},
isFocused: () => {},
option: {
title: 'Model title',
avatarUrl: 'url/to/avatar',
label: 'User picker label',
},
className: 'class-for-user-picker',
};
describe('UserPickerOption', () => {
it('renders correctly', () => {
const tree = renderer.create(<UserPickerOption {...model} />).toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@ -0,0 +1,54 @@
import React, { Component } from 'react';
export interface IProps {
onSelect: any;
onFocus: any;
option: any;
isFocused: any;
className: any;
}
class UserPickerOption extends Component<IProps, any> {
constructor(props) {
super(props);
this.handleMouseDown = this.handleMouseDown.bind(this);
this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this);
}
handleMouseDown(event) {
event.preventDefault();
event.stopPropagation();
this.props.onSelect(this.props.option, event);
}
handleMouseEnter(event) {
this.props.onFocus(this.props.option, event);
}
handleMouseMove(event) {
if (this.props.isFocused) {
return;
}
this.props.onFocus(this.props.option, event);
}
render() {
const { option, children, className } = this.props;
return (
<button
onMouseDown={this.handleMouseDown}
onMouseEnter={this.handleMouseEnter}
onMouseMove={this.handleMouseMove}
title={option.title}
className={`user-picker-option__button btn btn-link ${className}`}
>
<img src={option.avatarUrl} alt={option.label} className="user-picker-option__avatar" />
{children}
</button>
);
}
}
export default UserPickerOption;

View File

@ -0,0 +1,98 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UserPicker renders correctly 1`] = `
<div
className="user-picker"
>
<div
className="Select width-8 gf-form-input gf-form-input--form-dropdown is-clearable is-loading is-searchable Select--single"
style={undefined}
>
<div
className="Select-control"
onKeyDown={[Function]}
onMouseDown={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
style={undefined}
>
<span
className="Select-multi-value-wrapper"
id="react-select-2--value"
>
<div
className="Select-placeholder"
>
Loading...
</div>
<div
className="Select-input"
style={
Object {
"display": "inline-block",
}
}
>
<input
aria-activedescendant="react-select-2--value"
aria-describedby={undefined}
aria-expanded="false"
aria-haspopup="false"
aria-label={undefined}
aria-labelledby={undefined}
aria-owns=""
className={undefined}
id={undefined}
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
required={false}
role="combobox"
style={
Object {
"boxSizing": "content-box",
"width": "5px",
}
}
tabIndex={undefined}
value=""
/>
<div
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
}
}
>
</div>
</div>
</span>
<span
aria-hidden="true"
className="Select-loading-zone"
>
<span
className="Select-loading"
/>
</span>
<span
className="Select-arrow-zone"
onMouseDown={[Function]}
>
<span
className="Select-arrow"
onMouseDown={[Function]}
/>
</span>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UserPickerOption renders correctly 1`] = `
<button
className="user-picker-option__button btn btn-link class-for-user-picker"
onMouseDown={[Function]}
onMouseEnter={[Function]}
onMouseMove={[Function]}
title="Model title"
>
<img
alt="User picker label"
className="user-picker-option__avatar"
src="url/to/avatar"
/>
</button>
`;

View File

@ -56,9 +56,7 @@ function link(scope, elem, attrs) {
let maxLines = attrs.maxLines || DEFAULT_MAX_LINES;
let showGutter = attrs.showGutter !== undefined;
let tabSize = attrs.tabSize || DEFAULT_TAB_SIZE;
let behavioursEnabled = attrs.behavioursEnabled
? attrs.behavioursEnabled === 'true'
: DEFAULT_BEHAVIOURS;
let behavioursEnabled = attrs.behavioursEnabled ? attrs.behavioursEnabled === 'true' : DEFAULT_BEHAVIOURS;
// Initialize editor
let aceElem = elem.get(0);

View File

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

View File

@ -32,13 +32,7 @@ export class FormDropdownCtrl {
lookupText: boolean;
/** @ngInject **/
constructor(
private $scope,
$element,
private $sce,
private templateSrv,
private $q
) {
constructor(private $scope, $element, private $sce, private templateSrv, private $q) {
this.inputElement = $element.find('input').first();
this.linkElement = $element.find('a').first();
this.linkMode = true;
@ -50,8 +44,7 @@ export class FormDropdownCtrl {
if (this.labelMode) {
this.cssClasses = 'gf-form-label ' + this.cssClass;
} else {
this.cssClasses =
'gf-form-input gf-form-input--dropdown ' + this.cssClass;
this.cssClasses = 'gf-form-input gf-form-input--dropdown ' + this.cssClass;
}
this.inputElement.attr('data-provide', 'typeahead');
@ -207,16 +200,11 @@ export class FormDropdownCtrl {
updateDisplay(text) {
this.text = text;
this.display = this.$sce.trustAsHtml(
this.templateSrv.highlightVariablesAsHtml(text)
);
this.display = this.$sce.trustAsHtml(this.templateSrv.highlightVariablesAsHtml(text));
}
open() {
this.inputElement.css(
'width',
Math.max(this.linkElement.width(), 80) + 16 + 'px'
);
this.inputElement.css('width', Math.max(this.linkElement.width(), 80) + 16 + 'px');
this.inputElement.show();
this.inputElement.focus();

View File

@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import coreModule from 'app/core/core_module';
const template = `

View File

@ -6,32 +6,29 @@ import coreModule from 'app/core/core_module';
import { profiler } from 'app/core/profiler';
import appEvents from 'app/core/app_events';
import Drop from 'tether-drop';
import { createStore } from 'app/stores/store';
import colors from 'app/core/utils/colors';
export class GrafanaCtrl {
/** @ngInject */
constructor(
$scope,
alertSrv,
utilSrv,
$rootScope,
$controller,
contextSrv,
globalEventSrv
) {
constructor($scope, alertSrv, utilSrv, $rootScope, $controller, contextSrv, bridgeSrv, backendSrv) {
createStore(backendSrv);
$scope.init = function() {
$scope.contextSrv = contextSrv;
$rootScope.appSubUrl = config.appSubUrl;
$scope.appSubUrl = config.appSubUrl;
$scope._ = _;
profiler.init(config, $rootScope);
alertSrv.init();
utilSrv.init();
globalEventSrv.init();
bridgeSrv.init();
$scope.dashAlerts = alertSrv;
};
$rootScope.colors = colors;
$scope.initDashboard = function(dashboardData, viewScope) {
$scope.appEvent('dashboard-fetch-end', dashboardData);
$controller('DashboardCtrl', { $scope: viewScope }).init(dashboardData);
@ -54,76 +51,12 @@ export class GrafanaCtrl {
appEvents.emit(name, payload);
};
$rootScope.colors = [
'#7EB26D',
'#EAB839',
'#6ED0E0',
'#EF843C',
'#E24D42',
'#1F78C1',
'#BA43A9',
'#705DA0',
'#508642',
'#CCA300',
'#447EBC',
'#C15C17',
'#890F02',
'#0A437C',
'#6D1F62',
'#584477',
'#B7DBAB',
'#F4D598',
'#70DBED',
'#F9BA8F',
'#F29191',
'#82B5D8',
'#E5A8E2',
'#AEA2E0',
'#629E51',
'#E5AC0E',
'#64B0C8',
'#E0752D',
'#BF1B00',
'#0A50A1',
'#962D82',
'#614D93',
'#9AC48A',
'#F2C96D',
'#65C5DB',
'#F9934E',
'#EA6460',
'#5195CE',
'#D683CE',
'#806EB7',
'#3F6833',
'#967302',
'#2F575E',
'#99440A',
'#58140C',
'#052B51',
'#511749',
'#3F2B5B',
'#E0F9D7',
'#FCEACA',
'#CFFAFF',
'#F9E2D2',
'#FCE2DE',
'#BADFF4',
'#F9D9F9',
'#DEDAF7',
];
$scope.init();
}
}
/** @ngInject */
export function grafanaAppDirective(
playlistSrv,
contextSrv,
$timeout,
$rootScope
) {
export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScope, $location) {
return {
restrict: 'E',
controller: GrafanaCtrl,
@ -269,10 +202,7 @@ export function grafanaAppDirective(
// hide search
if (body.find('.search-container').length > 0) {
if (
target.parents('.search-results-container, .search-field-wrapper')
.length === 0
) {
if (target.parents('.search-results-container, .search-field-wrapper').length === 0) {
scope.$apply(function() {
scope.appEvent('hide-dash-search');
});
@ -281,10 +211,7 @@ export function grafanaAppDirective(
// hide popovers
var popover = elem.find('.popover');
if (
popover.length > 0 &&
target.parents('.graph-legend').length === 0
) {
if (popover.length > 0 && target.parents('.graph-legend').length === 0) {
popover.hide();
}
});

View File

@ -1,5 +1,3 @@
///<reference path="../../../headers/common.d.ts" />
import coreModule from '../../core_module';
import appEvents from 'app/core/app_events';

View File

@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
import coreModule from 'app/core/core_module';
import Drop from 'tether-drop';
@ -10,10 +8,10 @@ export function infoPopover() {
template: '<i class="fa fa-info-circle"></i>',
transclude: true,
link: function(scope, elem, attrs, ctrl, transclude) {
var offset = attrs.offset || '0 -10px';
var position = attrs.position || 'right middle';
var classes = 'drop-help drop-hide-out-of-bounds';
var openOn = 'hover';
let offset = attrs.offset || '0 -10px';
let position = attrs.position || 'right middle';
let classes = 'drop-help drop-hide-out-of-bounds';
let openOn = 'hover';
elem.addClass('gf-form-help-icon');
@ -26,14 +24,14 @@ export function infoPopover() {
}
transclude(function(clone, newScope) {
var content = document.createElement('div');
let content = document.createElement('div');
content.className = 'markdown-html';
_.each(clone, node => {
content.appendChild(node);
});
var drop = new Drop({
let dropOptions = {
target: elem[0],
content: content,
position: position,
@ -50,13 +48,18 @@ export function infoPopover() {
},
],
},
});
};
var unbind = scope.$on('$destroy', function() {
// Create drop in next digest after directive content is rendered.
scope.$applyAsync(() => {
let drop = new Drop(dropOptions);
let unbind = scope.$on('$destroy', function() {
drop.destroy();
unbind();
});
});
});
},
};
}

View File

@ -103,11 +103,7 @@ export function cssClass(className: string): string {
* Creates a new DOM element wiht given type and class
* TODO: move me to helpers
*/
export function createElement(
type: string,
className?: string,
content?: Element | string
): Element {
export function createElement(type: string, className?: string, content?: Element | string): Element {
const el = document.createElement(type);
if (className) {
el.classList.add(cssClass(className));

View File

@ -1,14 +1,7 @@
// Based on work https://github.com/mohsen1/json-formatter-js
// Licence MIT, Copyright (c) 2015 Mohsen Azimi
import {
isObject,
getObjectName,
getType,
getValuePreview,
cssClass,
createElement,
} from './helpers';
import { isObject, getObjectName, getType, getValuePreview, cssClass, createElement } from './helpers';
import _ from 'lodash';
@ -112,9 +105,7 @@ export class JsonExplorer {
private get isDate(): boolean {
return (
this.type === 'string' &&
(DATE_STRING_REGEX.test(this.json) ||
JSON_DATE_REGEX.test(this.json) ||
PARTIAL_DATE_REGEX.test(this.json))
(DATE_STRING_REGEX.test(this.json) || JSON_DATE_REGEX.test(this.json) || PARTIAL_DATE_REGEX.test(this.json))
);
}
@ -151,9 +142,7 @@ export class JsonExplorer {
* is this an empty object or array?
*/
private get isEmpty(): boolean {
return (
this.isEmptyObject || (this.keys && !this.keys.length && this.isArray)
);
return this.isEmptyObject || (this.keys && !this.keys.length && this.isArray);
}
/*
@ -234,11 +223,7 @@ export class JsonExplorer {
}
isNumberArray() {
return (
this.json.length > 0 &&
this.json.length < 4 &&
(_.isNumber(this.json[0]) || _.isNumber(this.json[1]))
);
return this.json.length > 0 && this.json.length < 4 && (_.isNumber(this.json[0]) || _.isNumber(this.json[1]));
}
renderArray() {
@ -249,17 +234,13 @@ export class JsonExplorer {
if (this.isNumberArray()) {
this.json.forEach((val, index) => {
if (index > 0) {
arrayWrapperSpan.appendChild(
createElement('span', 'array-comma', ',')
);
arrayWrapperSpan.appendChild(createElement('span', 'array-comma', ','));
}
arrayWrapperSpan.appendChild(createElement('span', 'number', val));
});
this.skipChildren = true;
} else {
arrayWrapperSpan.appendChild(
createElement('span', 'number', this.json.length)
);
arrayWrapperSpan.appendChild(createElement('span', 'number', this.json.length));
}
arrayWrapperSpan.appendChild(createElement('span', 'bracket', ']'));
@ -298,11 +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
);
var constructorName = createElement('span', 'constructor-name', this.constructorName);
objectWrapperSpan.appendChild(constructorName);
// if it's an array append the array specific elements like brackets and length
@ -399,12 +376,7 @@ export class JsonExplorer {
let index = 0;
const addAChild = () => {
const key = this.keys[index];
const formatter = new JsonExplorer(
this.json[key],
this.open - 1,
this.config,
key
);
const formatter = new JsonExplorer(this.json[key], this.open - 1, this.config, key);
children.appendChild(formatter.render());
index += 1;
@ -421,12 +393,7 @@ export class JsonExplorer {
requestAnimationFrame(addAChild);
} else {
this.keys.forEach(key => {
const formatter = new JsonExplorer(
this.json[key],
this.open - 1,
this.config,
key
);
const formatter = new JsonExplorer(this.json[key], this.open - 1, this.config, key);
children.appendChild(formatter.render());
});
}
@ -437,9 +404,7 @@ export class JsonExplorer {
* Animated option is used when user triggers this via a click
*/
removeChildren(animated = false) {
const childrenElement = this.element.querySelector(
`div.${cssClass('children')}`
) as HTMLDivElement;
const childrenElement = this.element.querySelector(`div.${cssClass('children')}`) as HTMLDivElement;
if (animated) {
let childrenRemoved = 0;

View File

@ -5,11 +5,11 @@
<i class="gf-form-input-icon fa fa-search"></i>
</label>
<div class="page-action-bar__spacer"></div>
<a class="btn btn-success" href="/dashboard/new?folderId={{ctrl.folderId}}">
<a class="btn btn-success" ng-href="{{ctrl.createDashboardUrl()}}">
<i class="fa fa-plus"></i>
Dashboard
</a>
<a class="btn btn-success" href="/dashboards/folder/new" ng-if="!ctrl.folderId">
<a class="btn btn-success" href="dashboards/folder/new" ng-if="!ctrl.folderId">
<i class="fa fa-plus"></i>
Folder
</a>
@ -60,22 +60,20 @@
switch-class="gf-form-switch--transparent gf-form-switch--search-result-filter-row__checkbox"
/>
<div class="search-results-filter-row__filters">
<div class="gf-form-select-wrapper">
<div class="gf-form-select-wrapper" ng-show="!(ctrl.canMove || ctrl.canDelete)">
<select
class="search-results-filter-row__filters-item gf-form-input"
ng-model="ctrl.selectedStarredFilter"
ng-options="t.text disable when t.disabled for t in ctrl.starredFilterOptions"
ng-change="ctrl.onStarredFilterChange()"
ng-show="!(ctrl.canMove || ctrl.canDelete)"
/>
</div>
<div class="gf-form-select-wrapper">
<div class="gf-form-select-wrapper" ng-show="!(ctrl.canMove || ctrl.canDelete)">
<select
class="search-results-filter-row__filters-item gf-form-input"
ng-model="ctrl.selectedTagFilter"
ng-options="t.term disable when t.disabled for t in ctrl.tagFilterOptions"
ng-change="ctrl.onTagFilterChange()"
ng-show="!(ctrl.canMove || ctrl.canDelete)"
/>
</div>
<div class="gf-form-button-row" ng-show="ctrl.canMove || ctrl.canDelete">
@ -110,11 +108,11 @@
<empty-list-cta model="{
title: 'This folder doesn\'t have any dashboards yet',
buttonIcon: 'gicon gicon-dashboard-new',
buttonLink: '/dashboard/new?folderId={{ctrl.folderId}}',
buttonLink: 'dashboard/new?folderId={{ctrl.folderId}}',
buttonTitle: 'Create Dashboard',
proTip: 'Add dashboards into your folder at ->',
proTipLink: '/dashboards',
proTipLink: 'dashboards',
proTipLinkTitle: 'Manage dashboards',
proTipTarget: '_blank'
proTipTarget: ''
}" />
</div>

View File

@ -13,11 +13,7 @@ export class ManageDashboardsCtrl {
canMove = false;
hasFilters = false;
selectAllChecked = false;
starredFilterOptions = [
{ text: 'Filter by Starred', disabled: true },
{ text: 'Yes' },
{ text: 'No' },
];
starredFilterOptions = [{ text: 'Filter by Starred', disabled: true }, { text: 'Yes' }, { text: 'No' }];
selectedStarredFilter: any;
folderId?: number;
@ -53,10 +49,7 @@ export class ManageDashboardsCtrl {
this.canMove = false;
this.canDelete = false;
this.selectAllChecked = false;
this.hasFilters =
this.query.query.length > 0 ||
this.query.tag.length > 0 ||
this.query.starred;
this.hasFilters = this.query.query.length > 0 || this.query.tag.length > 0 || this.query.starred;
if (!result) {
this.sections = [];
@ -126,16 +119,10 @@ export class ManageDashboardsCtrl {
let text2;
if (folderCount > 0 && dashCount > 0) {
text += `selected folder${folderCount === 1 ? '' : 's'} and dashboard${
dashCount === 1 ? '' : 's'
}?`;
text2 = `All dashboards of the selected folder${
folderCount === 1 ? '' : 's'
} will also be deleted`;
text += `selected folder${folderCount === 1 ? '' : 's'} and dashboard${dashCount === 1 ? '' : 's'}?`;
text2 = `All dashboards of the selected folder${folderCount === 1 ? '' : 's'} will also be deleted`;
} else if (folderCount > 0) {
text += `selected folder${
folderCount === 1 ? '' : 's'
} and all its dashboards?`;
text += `selected folder${folderCount === 1 ? '' : 's'} and all its dashboards?`;
} else {
text += `selected dashboard${dashCount === 1 ? '' : 's'}?`;
}
@ -165,22 +152,16 @@ export class ManageDashboardsCtrl {
let msg;
if (folderCount > 0 && dashCount > 0) {
header = `Folder${folderCount === 1 ? '' : 's'} And Dashboard${
dashCount === 1 ? '' : 's'
} Deleted`;
header = `Folder${folderCount === 1 ? '' : 's'} And Dashboard${dashCount === 1 ? '' : 's'} Deleted`;
msg = `${folderCount} folder${folderCount === 1 ? '' : 's'} `;
msg += `and ${dashCount} dashboard${
dashCount === 1 ? '' : 's'
} has been deleted`;
msg += `and ${dashCount} dashboard${dashCount === 1 ? '' : 's'} has been deleted`;
} else if (folderCount > 0) {
header = `Folder${folderCount === 1 ? '' : 's'} Deleted`;
if (folderCount === 1) {
msg = `${folders[0].dashboard.title} has been deleted`;
} else {
msg = `${folderCount} folder${
folderCount === 1 ? '' : 's'
} has been deleted`;
msg = `${folderCount} folder${folderCount === 1 ? '' : 's'} has been deleted`;
}
} else if (dashCount > 0) {
header = `Dashboard${dashCount === 1 ? '' : 's'} Deleted`;
@ -188,9 +169,7 @@ export class ManageDashboardsCtrl {
if (dashCount === 1) {
msg = `${dashboards[0].dashboard.title} has been deleted`;
} else {
msg = `${dashCount} dashboard${
dashCount === 1 ? '' : 's'
} has been deleted`;
msg = `${dashCount} dashboard${dashCount === 1 ? '' : 's'} has been deleted`;
}
}
@ -231,9 +210,7 @@ export class ManageDashboardsCtrl {
getTags() {
return this.searchSrv.getDashboardTags().then(results => {
this.tagFilterOptions = [
{ term: 'Filter By Tag', disabled: true },
].concat(results);
this.tagFilterOptions = [{ term: 'Filter By Tag', disabled: true }].concat(results);
this.selectedTagFilter = this.tagFilterOptions[0];
});
}
@ -297,13 +274,22 @@ export class ManageDashboardsCtrl {
this.query.starred = false;
this.getDashboards();
}
createDashboardUrl() {
let url = 'dashboard/new';
if (this.folderId) {
url += `?folderId=${this.folderId}`;
}
return url;
}
}
export function manageDashboardsDirective() {
return {
restrict: 'E',
templateUrl:
'public/app/core/components/manage_dashboards/manage_dashboards.html',
templateUrl: 'public/app/core/components/manage_dashboards/manage_dashboards.html',
controller: ManageDashboardsCtrl,
bindToController: true,
controllerAs: 'ctrl',

View File

@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import coreModule from 'app/core/core_module';
import { contextSrv } from 'app/core/services/context_srv';
@ -63,9 +61,7 @@ export class OrgSwitchCtrl {
setUsingOrg(org) {
return this.backendSrv.post('/api/user/using/' + org.orgId).then(() => {
const re = /orgId=\d+/gi;
this.setWindowLocationHref(
this.getWindowLocationHref().replace(re, 'orgId=' + org.orgId)
);
this.setWindowLocationHref(this.getWindowLocationHref().replace(re, 'orgId=' + org.orgId));
});
}

View File

@ -1,5 +1,3 @@
///<reference path="../../../headers/common.d.ts" />
import _ from 'lodash';
export class QueryPartDef {

View File

@ -1,5 +1,3 @@
///<reference path="../../../headers/common.d.ts" />
import _ from 'lodash';
import $ from 'jquery';
import coreModule from 'app/core/core_module';
@ -17,8 +15,7 @@ var template = `
/** @ngInject */
export function queryPartEditorDirective($compile, templateSrv) {
var paramTemplate =
'<input type="text" class="hide input-mini tight-form-func-param"></input>';
var paramTemplate = '<input type="text" class="hide input-mini tight-form-func-param"></input>';
return {
restrict: 'E',
@ -102,9 +99,7 @@ export function queryPartEditorDirective($compile, templateSrv) {
}
$scope.$apply(function() {
$scope
.handleEvent({ $event: { name: 'get-param-options' } })
.then(function(result) {
$scope.handleEvent({ $event: { name: 'get-param-options' } }).then(function(result) {
var dynamicOptions = _.map(result, function(op) {
return op.value;
});
@ -136,9 +131,7 @@ export function queryPartEditorDirective($compile, templateSrv) {
}
$scope.showActionsMenu = function() {
$scope
.handleEvent({ $event: { name: 'get-part-actions' } })
.then(res => {
$scope.handleEvent({ $event: { name: 'get-part-actions' } }).then(res => {
$scope.partActions = res;
});
};
@ -157,12 +150,8 @@ 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 paramValue = templateSrv.highlightVariablesAsHtml(part.params[index]);
var $paramLink = $('<a class="graphite-func-param-link pointer">' + paramValue + '</a>');
var $input = $(paramTemplate);
$paramLink.appendTo($paramsContainer);

View File

@ -1,5 +1,6 @@
import PerfectScrollbar from 'perfect-scrollbar';
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
export function geminiScrollbar() {
return {
@ -7,6 +8,19 @@ export function geminiScrollbar() {
link: function(scope, elem, attrs) {
let scrollbar = new PerfectScrollbar(elem[0]);
appEvents.on(
'smooth-scroll-top',
() => {
elem.animate(
{
scrollTop: 0,
},
500
);
},
scope
);
scope.$on('$routeChangeSuccess', () => {
elem[0].scrollTop = 0;
});

View File

@ -0,0 +1,86 @@
import React from "react";
import classNames from "classnames";
import { observer } from "mobx-react";
import { store } from "app/stores/store";
export interface SearchResultProps {
search: any;
}
@observer
export class SearchResult extends React.Component<SearchResultProps, any> {
constructor(props) {
super(props);
this.state = {
search: store.search
};
store.search.query();
}
render() {
return this.state.search.sections.map(section => {
return <SearchResultSection section={section} key={section.id} />;
});
}
}
export interface SectionProps {
section: any;
}
@observer
export class SearchResultSection extends React.Component<SectionProps, any> {
constructor(props) {
super(props);
}
renderItem(item) {
return (
<a className="search-item" href={item.url} key={item.id}>
<span className="search-item__icon">
<i className="fa fa-th-large" />
</span>
<span className="search-item__body">
<div className="search-item__body-title">{item.title}</div>
</span>
</a>
);
}
toggleSection = () => {
this.props.section.toggle();
};
render() {
let collapseClassNames = classNames({
fa: true,
"fa-plus": !this.props.section.expanded,
"fa-minus": this.props.section.expanded,
"search-section__header__toggle": true
});
return (
<div className="search-section" key={this.props.section.id}>
<div className="search-section__header">
<i
className={classNames(
"search-section__header__icon",
this.props.section.icon
)}
/>
<span className="search-section__header__text">
{this.props.section.title}
</span>
<i className={collapseClassNames} onClick={this.toggleSection} />
</div>
{this.props.section.expanded && (
<div className="search-section__items">
{this.props.section.items.map(this.renderItem)}
</div>
)}
</div>
);
}
}

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