Merge remote-tracking branch 'upstream/master' into postgres-query-builder

This commit is contained in:
Sven Klemm
2018-06-26 09:26:35 +02:00
104 changed files with 3346 additions and 1170 deletions
-3
View File
@@ -11,8 +11,5 @@ dump.rdb
node_modules
/local
/tmp
/vendor
*.yml
*.md
/vendor
/tmp
+1
View File
@@ -33,6 +33,7 @@ public/css/*.min.css
*.tmp
.DS_Store
.vscode/
.vs/
/data/*
/bin/*
+42
View File
@@ -1,11 +1,35 @@
# 5.3.0 (unreleased)
* **Dataproxy**: Pass configured/auth headers to a Datasource [#10971](https://github.com/grafana/grafana/issues/10971), thx [@mrsiano](https://github.com/mrsiano)
* **Cleanup**: Make temp file time to live configurable [#11607](https://github.com/grafana/grafana/issues/11607), thx [@xapon](https://github.com/xapon)
### Minor
* **Api**: Delete nonexistent datasource should return 404 [#12313](https://github.com/grafana/grafana/issues/12313), thx [@AustinWinstanley](https://github.com/AustinWinstanley)
* **Dashboard**: Fix selecting current dashboard from search should not reload dashboard [#12248](https://github.com/grafana/grafana/issues/12248)
# 5.2.0 (unreleased)
### Minor
* **Plugins**: Handle errors correctly when loading datasource plugin [#12383](https://github.com/grafana/grafana/pull/12383) thx [@rozetko](https://github.com/rozetko)
* **Render**: Enhance error message if phantomjs executable is not found [#11868](https://github.com/grafana/grafana/issues/11868)
# 5.2.0-beta3 (2018-06-21)
### Minor
* **Build**: All rpm packages should be signed [#12359](https://github.com/grafana/grafana/issues/12359)
# 5.2.0-beta2 (2018-06-20)
### New Features
* **Dashboard**: Import dashboard to folder [#10796](https://github.com/grafana/grafana/issues/10796)
### Minor
* **Permissions**: Important security fix for API keys with viewer role [#12343](https://github.com/grafana/grafana/issues/12343)
* **Dashboard**: Fix so panel titles doesn't wrap [#11074](https://github.com/grafana/grafana/issues/11074)
* **Dashboard**: Prevent double-click when saving dashboard [#11963](https://github.com/grafana/grafana/issues/11963)
* **Dashboard**: AutoFocus the add-panel search filter [#12189](https://github.com/grafana/grafana/pull/12189) thx [@ryantxu](https://github.com/ryantxu)
@@ -13,6 +37,19 @@
* **Units**: Litre/min (flow) and milliLitre/min (flow) [#12282](https://github.com/grafana/grafana/pull/12282), thx [@flopp999](https://github.com/flopp999)
* **Alerting**: Fix mobile notifications for Microsoft Teams alert notifier [#11484](https://github.com/grafana/grafana/pull/11484), thx [@manacker](https://github.com/manacker)
* **Influxdb**: Add support for mode function [#12286](https://github.com/grafana/grafana/issues/12286)
* **Cloudwatch**: Fixes panic caused by bad timerange settings [#12199](https://github.com/grafana/grafana/issues/12199)
* **Auth Proxy**: Whitelist proxy IP address instead of client IP address [#10707](https://github.com/grafana/grafana/issues/10707)
* **User Management**: Make sure that a user always has a current org assigned [#11076](https://github.com/grafana/grafana/issues/11076)
* **Snapshots**: Fix: annotations not properly extracted leading to incorrect rendering of annotations [#12278](https://github.com/grafana/grafana/issues/12278)
* **LDAP**: Allow use of DN in group_search_filter_user_attribute and member_of [#3132](https://github.com/grafana/grafana/issues/3132), thx [@mmolnar](https://github.com/mmolnar)
* **Graph**: Fix legend decimals precision calculation [#11792](https://github.com/grafana/grafana/issues/11792)
* **Dashboard**: Make sure to process panels in collapsed rows when exporting dashboard [#12256](https://github.com/grafana/grafana/issues/12256)
### 5.2.0-beta1 fixes
* **Dashboard**: Dashboard link doesn't work when "As dropdown" option is checked [#12315](https://github.com/grafana/grafana/issues/12315)
* **Dashboard**: Fix regressions after save modal changes, including adhoc template issues [#12240](https://github.com/grafana/grafana/issues/12240)
* **Docker**: Config keys ending with _FILE are not respected [#170](https://github.com/grafana/grafana-docker/issues/170)
# 5.2.0-beta1 (2018-06-05)
@@ -54,6 +91,10 @@
* **Dashboard list panel**: Search dashboards by folder [#11525](https://github.com/grafana/grafana/issues/11525)
* **Sidenav**: Always show server admin link in sidenav if grafana admin [#11657](https://github.com/grafana/grafana/issues/11657)
# 5.1.4 (2018-06-19)
* **Permissions**: Important security fix for API keys with viewer role [#12343](https://github.com/grafana/grafana/issues/12343)
# 5.1.3 (2018-05-16)
* **Scroll**: Graph panel / legend texts shifts on the left each time we move scrollbar on firefox [#11830](https://github.com/grafana/grafana/issues/11830)
@@ -1735,3 +1776,4 @@ Thanks to everyone who contributed fixes and provided feedback :+1:
# 1.0.0 (2014-01-19)
First public release
+5 -12
View File
@@ -1,28 +1,21 @@
# Roadmap (2018-05-06)
# Roadmap (2018-06-26)
This roadmap is a tentative plan for the core development team. Things change constantly as PRs come in and priorities change.
But it will give you an idea of our current vision and plan.
### Short term (1-2 months)
- Elasticsearch alerting
- Crossplatform builds
- Backend service refactorings
- Explore UI
- First login registration view
### Mid term (2-4 months)
- Multi-Stat panel
- Metrics & Log Explore UI
### Mid term (2-4 months)
- React Panels
- Change visualization (panel type) on the fly.
- Templating Query Editor UI Plugin hook
### Long term (4 - 8 months)
- Alerting improvements (silence, per series tracking, etc)
- Progress on React migration
- Change visualization (panel type) on the fly.
- Multi stat panel (vertical version of singlestat with bars/graph mode with big number etc)
- Repeat panel by query results
### In a distant future far far away
+3
View File
@@ -14,6 +14,9 @@ instance_name = ${HOSTNAME}
# Path to where grafana can store temp files, sessions, and the sqlite3 db (if that is used)
data = data
# Temporary files in `data` directory older than given duration will be removed
temp_data_lifetime = 24h
# Directory where grafana can store logs
logs = data/log
@@ -40,11 +40,14 @@ apiVersion: 1
# graphiteVersion: "1.1"
# tlsAuth: true
# tlsAuthWithCACert: true
# httpHeaderName1: "Authorization"
# # <string> json object of data that will be encrypted.
# secureJsonData:
# tlsCACert: "..."
# tlsClientCert: "..."
# tlsClientKey: "..."
# # <openshift\kubernetes token example>
# httpHeaderValue1: "Bearer xf5yhfkpsnmgo"
# version: 1
# # <bool> allow users to edit datasources from the UI.
# editable: false
+3
View File
@@ -14,6 +14,9 @@
# Path to where grafana can store temp files, sessions, and the sqlite3 db (if that is used)
;data = /var/lib/grafana
# Temporary files in `data` directory older than given duration will be removed
;temp_data_lifetime = 24h
# Directory where grafana can store logs
;logs = /var/log/grafana
@@ -0,0 +1,592 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"gnetId": null,
"graphTooltip": 0,
"id": 59,
"links": [],
"panels": [
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 9,
"panels": [],
"title": "Row title",
"type": "row"
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fill": 1,
"gridPos": {
"h": 4,
"w": 12,
"x": 0,
"y": 1
},
"id": 12,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "go_goroutines",
"format": "time_series",
"intervalFactor": 1,
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeShift": null,
"title": "Panel Title",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fill": 1,
"gridPos": {
"h": 4,
"w": 12,
"x": 12,
"y": 1
},
"id": 5,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "go_goroutines",
"format": "time_series",
"intervalFactor": 1,
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeShift": null,
"title": "Panel Title",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 5
},
"id": 7,
"panels": [],
"title": "Row",
"type": "row"
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fill": 1,
"gridPos": {
"h": 4,
"w": 12,
"x": 0,
"y": 6
},
"id": 2,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "go_goroutines",
"format": "time_series",
"intervalFactor": 1,
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeShift": null,
"title": "Panel Title",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fill": 1,
"gridPos": {
"h": 4,
"w": 12,
"x": 12,
"y": 6
},
"id": 13,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "go_goroutines",
"format": "time_series",
"intervalFactor": 1,
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeShift": null,
"title": "Panel Title",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 10
},
"id": 11,
"panels": [],
"title": "Row title",
"type": "row"
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fill": 1,
"gridPos": {
"h": 4,
"w": 12,
"x": 0,
"y": 11
},
"id": 4,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "go_goroutines",
"format": "time_series",
"intervalFactor": 1,
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeShift": null,
"title": "Panel Title",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fill": 1,
"gridPos": {
"h": 4,
"w": 12,
"x": 12,
"y": 11
},
"id": 3,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "go_goroutines",
"format": "time_series",
"intervalFactor": 1,
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeShift": null,
"title": "Panel Title",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
}
],
"schemaVersion": 16,
"style": "dark",
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-30m",
"to": "now"
},
"timepicker": {
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"time_options": [
"5m",
"15m",
"1h",
"6h",
"12h",
"24h",
"2d",
"7d",
"30d"
]
},
"timezone": "",
"title": "Dashboard with rows",
"uid": "1DdOzBNmk",
"version": 5
}
@@ -0,0 +1,9 @@
apiVersion: 1
providers:
- name: 'dev dashboards'
folder: 'dev dashboards'
type: file
options:
path: devenv/dashboards/dev-dashboards
+22 -20
View File
@@ -2,41 +2,43 @@
bulkDashboard() {
requiresJsonnet
requiresJsonnet
COUNTER=0
MAX=400
while [ $COUNTER -lt $MAX ]; do
jsonnet -o "dashboards/bulk-testing/dashboard${COUNTER}.json" -e "local bulkDash = import 'dashboards/bulk-testing/bulkdash.jsonnet'; bulkDash + { uid: 'uid-${COUNTER}', title: 'title-${COUNTER}' }"
let COUNTER=COUNTER+1
done
COUNTER=0
MAX=400
while [ $COUNTER -lt $MAX ]; do
jsonnet -o "dashboards/bulk-testing/dashboard${COUNTER}.json" -e "local bulkDash = import 'dashboards/bulk-testing/bulkdash.jsonnet'; bulkDash + { uid: 'uid-${COUNTER}', title: 'title-${COUNTER}' }"
let COUNTER=COUNTER+1
done
ln -s -f -r ./dashboards/bulk-testing/bulk-dashboards.yaml ../conf/provisioning/dashboards/custom.yaml
ln -s -f -r ./dashboards/bulk-testing/bulk-dashboards.yaml ../conf/provisioning/dashboards/custom.yaml
}
requiresJsonnet() {
if ! type "jsonnet" > /dev/null; then
echo "you need you install jsonnet to run this script"
echo "follow the instructions on https://github.com/google/jsonnet"
exit 1
fi
if ! type "jsonnet" > /dev/null; then
echo "you need you install jsonnet to run this script"
echo "follow the instructions on https://github.com/google/jsonnet"
exit 1
fi
}
defaultDashboards() {
echo "not implemented yet"
requiresJsonnet
ln -s -f -r ./dashboards/dev-dashboards/dev-dashboards.yaml ../conf/provisioning/dashboards/custom.yaml
}
defaultDatasources() {
echo "setting up all default datasources using provisioning"
echo "setting up all default datasources using provisioning"
ln -s -f -r ./datasources/default/default.yaml ../conf/provisioning/datasources/custom.yaml
ln -s -f -r ./datasources/default/default.yaml ../conf/provisioning/datasources/custom.yaml
}
usage() {
echo -e "install.sh\n\tThis script installs my basic setup for a debian laptop\n"
echo "Usage:"
echo " bulk-dashboards - create and provisioning 400 dashboards"
echo " default-datasources - provisiong all core datasources"
echo " default-datasources - provisiong all core datasources"
}
main() {
@@ -49,10 +51,10 @@ main() {
if [[ $cmd == "bulk-dashboards" ]]; then
bulkDashboard
elif [[ $cmd == "default-datasources" ]]; then
elif [[ $cmd == "default-datasources" ]]; then
defaultDatasources
elif [[ $cmd == "default-dashboards" ]]; then
bulkDashboard
elif [[ $cmd == "default-dashboards" ]]; then
defaultDashboards
else
usage
fi
+2
View File
@@ -1,3 +1,5 @@
# Fork of https://github.com/dinkel/docker-openldap
FROM debian:jessie
LABEL maintainer="Christian Luginbühl <dinke@pimprecords.com>"
+11
View File
@@ -11,3 +11,14 @@ After adding ldif files to `prepopulate`:
1. Remove your current docker image: `docker rm docker_openldap_1`
2. Build: `docker-compose build`
3. `docker-compose up`
## Enabling LDAP in Grafana
The default `ldap.toml` file in `conf` has host set to `127.0.0.1` and port to set to 389 so all you need to do is enable it in the .ini file to get Grafana to use this block:
```ini
[auth.ldap]
enabled = true
config_file = conf/ldap.toml
; allow_sign_up = true
```
@@ -188,7 +188,7 @@ queries via the Dashboard menu / Annotations view.
An example query:
```SQL
SELECT title, description from events WHERE $timeFilter order asc
SELECT title, description from events WHERE $timeFilter ORDER BY time ASC
```
For InfluxDB you need to enter a query like in the above example. You need to have the ```where $timeFilter```
+30 -1
View File
@@ -17,9 +17,11 @@ Grafana v5.2 brings new features, many enhancements and bug fixes. This article
* [Elasticsearch alerting]({{< relref "#elasticsearch-alerting" >}}) it's finally here!
* [Cross platform build support]({{< relref "#cross-platform-build-support" >}}) enables native builds of Grafana for many more platforms!
* [Improved Docker image]({{< relref "#improved-docker-image" >}}) with support for docker secrets
* [Security]({{< relref "#security" >}}) make your Grafana instance more secure
* [Prometheus]({{< relref "#prometheus" >}}) with alignment enhancements
* [InfluxDB]({{< relref "#influxdb" >}}) with support for a new function
* [Alerting]({{< relref "#alerting" >}}) with alert notification channel type for Discord
* [Dashboards & Panels]({{< relref "#dashboards-panels" >}})
* [Dashboards & Panels]({{< relref "#dashboards-panels" >}}) with save & import enhancements
## Elasticsearch alerting
@@ -42,11 +44,24 @@ We've been longing for native ARM build support for a long time. With the help f
The Grafana docker image now includes support for Docker secrets which enables you to supply Grafana with configuration through files. More
information in the [Installing using Docker documentation](/installation/docker/#reading-secrets-from-files-support-for-docker-secrets).
## Security
{{< docs-imagebox img="/img/docs/v52/login_change_password.png" max-width="800px" class="docs-image--right" >}}
Starting from Grafana v5.2, when you login with the administrator account using the default password you'll be presented with a form to change the password.
By this we hope to encourage users to follow Grafana's best practices and change the default administrator password.
<div class="clearfix"></div>
## Prometheus
The Prometheus datasource now aligns the start/end of the query sent to Prometheus with the step, which ensures PromQL expressions with *rate*
functions get consistent results, and thus avoid graphs jumping around on reload.
## InfluxDB
The InfluxDB datasource now includes support for the *mode* function which allows to return the most frequent value in a list of field values.
## Alerting
By popular demand Grafana now includes support for an alert notification channel type for [Discord](https://discordapp.com/).
@@ -64,6 +79,20 @@ when you actually want to overwrite those settings.
<div class="clearfix"></div>
### Import dashboard enhancements
{{< docs-imagebox img="/img/docs/v52/dashboard_import.png" max-width="800px" class="docs-image--right" >}}
Grafana v5.2 adds support for specifying an existing folder or create a new one when importing a dashboard, a long awaited feature since
Grafana v5.0 introduced support for dashboard folders and permissions. The import dashboard page have also got some general improvements
and should now make it more clear if a possible import will overwrite an existing dashboard, or not.
This release also adds some improvements for those users only having editor or admin permissions in certain folders. Now the links to
*Create Dashboard* and *Import Dashboard* is available in side navigation, dashboard search and manage dashboards/folder page for a
user that has editor role in an organization or edit permission in at least one folder.
<div class="clearfix"></div>
## Changelog
Checkout the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list
@@ -80,6 +80,11 @@ Path to where Grafana stores the sqlite3 database (if used), file based
sessions (if used), and other data. This path is usually specified via
command line in the init.d script or the systemd service file.
### temp_data_lifetime
How long temporary images in `data` directory should be kept. Defaults to: `24h`. Supported modifiers: `h` (hours),
`m` (minutes), for example: `168h`, `30m`, `10h30m`. Use `0` to never clean up temporary files.
### logs
Path to where Grafana will store logs. This path is usually specified via
+3 -3
View File
@@ -15,7 +15,7 @@ weight = 1
Description | Download
------------ | -------------
Stable for Debian-based Linux | [grafana_5.1.3_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.3_amd64.deb)
Stable for Debian-based Linux | [grafana_5.1.4_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.4_amd64.deb)
<!--
Beta for Debian-based Linux | [grafana_5.1.0-beta1_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.0-beta1_amd64.deb)
-->
@@ -27,9 +27,9 @@ installation.
```bash
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.3_amd64.deb
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.4_amd64.deb
sudo apt-get install -y adduser libfontconfig
sudo dpkg -i grafana_5.1.3_amd64.deb
sudo dpkg -i grafana_5.1.4_amd64.deb
```
<!-- ## Install Latest Beta
+6 -6
View File
@@ -52,7 +52,7 @@ $ docker run \
## Running of the master branch
For every successful commit we publish a Grafana container to [`grafana/grafana`](https://hub.docker.com/r/grafana/grafana/tags/) and [`grafana/grafana-dev`](https://hub.docker.com/r/grafana/grafana-dev/tags/). In `grafana/grafana` container we will always overwrite the `master` tag with the latest version. In `grafana/grafana-dev` we will include
the git commit in the tag. If you run Grafana master in production we **strongly** recommend that you use the later since different machines might run different version of grafana if they pull the master tag at different times.
the git commit in the tag. If you run Grafana master in production we **strongly** recommend that you use the later since different machines might run different version of grafana if they pull the master tag at different times.
## Installing Plugins for Grafana
@@ -137,16 +137,16 @@ docker run -d --user $ID --volume "$PWD/data:/var/lib/grafana" -p 3000:3000 graf
## Reading secrets from files (support for Docker Secrets)
> Available in v5.2.0 and later
> Only available in Grafana v5.2+.
It's possible to supply Grafana with configuration through files. This works well with [Docker Secrets](https://docs.docker.com/engine/swarm/secrets/) as the secrets by default gets mapped into `/run/secrets/<name of secret>` of the container.
You can do this with any of the configuration options in conf/grafana.ini by setting `GF_<SectionName>_<KeyName>_FILE` to the path of the file holding the secret.
You can do this with any of the configuration options in conf/grafana.ini by setting `GF_<SectionName>_<KeyName>__FILE` to the path of the file holding the secret.
Let's say you want to set the admin password this way.
- Admin password secret: `/run/secrets/admin_password`
- Environment variable: `GF_SECURITY_ADMIN_PASSWORD_FILE=/run/secrets/admin_password`
- Environment variable: `GF_SECURITY_ADMIN_PASSWORD__FILE=/run/secrets/admin_password`
## Migration from a previous version of the docker container to 5.1 or later
@@ -166,7 +166,7 @@ The docker container for Grafana has seen a major rewrite for 5.1.
Previously `/var/lib/grafana`, `/etc/grafana` and `/var/log/grafana` were defined as volumes in the `Dockerfile`. This led to the creation of three volumes each time a new instance of the Grafana container started, whether you wanted it or not.
You should always be careful to define your own named volume for storage, but if you depended on these volumes you should be aware that an upgraded container will no longer have them.
You should always be careful to define your own named volume for storage, but if you depended on these volumes you should be aware that an upgraded container will no longer have them.
**Warning**: when migrating from an earlier version to 5.1 or later using docker compose and implicit volumes you need to use `docker inspect` to find out which volumes your container is mapped to so that you can map them to the upgraded container as well. You will also have to change file ownership (or user) as documented below.
@@ -201,7 +201,7 @@ services:
#### Modifying permissions
The commands below will run bash inside the Grafana container with your volume mapped in. This makes it possible to modify the file ownership to match the new container. Always be careful when modifying permissions.
The commands below will run bash inside the Grafana container with your volume mapped in. This makes it possible to modify the file ownership to match the new container. Always be careful when modifying permissions.
```bash
$ docker run -ti --user root --volume "<your volume mapping here>" --entrypoint bash grafana/grafana:5.1.0
+5 -5
View File
@@ -15,7 +15,7 @@ weight = 2
Description | Download
------------ | -------------
Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [5.1.3 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.3-1.x86_64.rpm)
Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [5.1.4 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.4-1.x86_64.rpm)
<!--
Latest Beta for CentOS / Fedora / OpenSuse / Redhat Linux | [5.1.0-beta1 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.0-beta1.x86_64.rpm)
-->
@@ -28,7 +28,7 @@ installation.
You can install Grafana using Yum directly.
```bash
$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.3-1.x86_64.rpm
$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.4-1.x86_64.rpm
```
<!-- ## Install Beta
@@ -42,15 +42,15 @@ Or install manually using `rpm`.
#### On CentOS / Fedora / Redhat:
```bash
$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.3-1.x86_64.rpm
$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.4-1.x86_64.rpm
$ sudo yum install initscripts fontconfig
$ sudo rpm -Uvh grafana-5.1.3-1.x86_64.rpm
$ sudo rpm -Uvh grafana-5.1.4-1.x86_64.rpm
```
#### On OpenSuse:
```bash
$ sudo rpm -i --nodeps grafana-5.1.3-1.x86_64.rpm
$ sudo rpm -i --nodeps grafana-5.1.4-1.x86_64.rpm
```
## Install via YUM Repository
+1 -1
View File
@@ -21,7 +21,7 @@ the data source response.
To check this you should use Query Inspector (new in Grafana v4.5). The query Inspector shows query requests and responses.
For more on the query insector read [this guide here](https://community.grafana.com/t/using-grafanas-query-inspector-to-troubleshoot-issues/2630). For
For more on the query inspector read [this guide here](https://community.grafana.com/t/using-grafanas-query-inspector-to-troubleshoot-issues/2630). For
older versions of Grafana read the [how troubleshoot metric query issue](https://community.grafana.com/t/how-to-troubleshoot-metric-query-issues/50/2) article.
## Logging
+1 -1
View File
@@ -12,7 +12,7 @@ weight = 3
Description | Download
------------ | -------------
Latest stable package for Windows | [grafana-5.1.3.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.3.windows-x64.zip)
Latest stable package for Windows | [grafana-5.1.4.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.4.windows-x64.zip)
<!--
Latest beta package for Windows | [grafana.5.1.0-beta1.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta5.windows-x64.zip)
@@ -0,0 +1,99 @@
+++
title = "Authentication for Datasource Plugins"
type = "docs"
[menu.docs]
name = "Authentication for Datasource Plugins"
parent = "developing"
weight = 3
+++
# Authentication for Datasource Plugins
Grafana has a proxy feature that proxies all data requests through the Grafana backend. This is very useful when your datasource plugin calls an external/thirdy-party API. The Grafana proxy adds CORS headers and can authenticate against the external API. This means that a datasource plugin that proxies all requests via Grafana can enable token authentication and the token will be renewed automatically for the user when it expires.
The plugin config page should save the API key/password to be encrypted (using the `secureJsonData` feature) and then when a request from the datasource is made, the Grafana Proxy will:
1. decrypt the API key/password on the backend.
2. carry out authentication and generate an OAuth token that will be added as an `Authorization` HTTP header to all requests (or it will add a HTTP header with the API key).
3. renew the token if it expires.
This means that users that access the datasource config page cannot access the API key or password after is saved the first time and that no secret keys are sent in plain text through the browser where they can be spied on.
For backend authentication to work, the external/third-party API must either have an OAuth endpoint or that the API accepts an API key as a HTTP header for authentication.
## Plugin Routes
You can specify routes in the `plugin.json` file for your datasource plugin. [Here is an example](https://github.com/grafana/azure-monitor-datasource/blob/d74c82145c0a4af07a7e96cc8dde231bfd449bd9/src/plugin.json#L30-L95) with lots of routes (though most plugins will just have one route).
When you build your url to the third-party API in your datasource class, the url should start with the text specified in the path field for a route. The proxy will strip out the path text and replace it with the value in the url field.
For example, if my code makes a call to url `azuremonitor/foo/bar` with this code:
```js
this.backendSrv.datasourceRequest({
url: url,
method: 'GET',
})
```
and this route:
```json
"routes": [{
"path": "azuremonitor",
"method": "GET",
"url": "https://management.azure.com",
...
}]
```
then the Grafana proxy will transform it into "https://management.azure.com/foo/bar" and add CORS headers.
The `method` parameter is optional. It can be set to any HTTP verb to provide more fine-grained control.
## Encrypting Sensitive Data
When a user saves a password or secret with your datasource plugin's Config page, then you can save data to a column in the datasource table called `secureJsonData` that is an encrypted blob. Any data saved in the blob is encrypted by Grafana and can only be decrypted by the Grafana server on the backend. This means once a password is saved, no sensitive data is sent to the browser. If the password is saved in the `jsonData` blob or the `password` field then it is unencrypted and anyone with Admin access (with the help of Chrome Developer Tools) can read it.
This is an example of using the `secureJsonData` blob to save a property called `password`:
```html
<input type="password" class="gf-form-input" ng-model='ctrl.current.secureJsonData.password' placeholder="password"></input>
```
## API Key/HTTP Header Authentication
Some third-party API's accept a HTTP Header for authentication. The [example](https://github.com/grafana/azure-monitor-datasource/blob/d74c82145c0a4af07a7e96cc8dde231bfd449bd9/src/plugin.json#L91-L93) below has a `headers` section that defines the name of the HTTP Header that the API expects and it uses the `SecureJSONData` blob to fetch an encrypted API key. The Grafana server proxy will decrypt the key, add the `X-API-Key` header to the request and forward it to the third-party API.
```json
{
"path": "appinsights",
"method": "GET",
"url": "https://api.applicationinsights.io",
"headers": [
{"name": "X-API-Key", "content": "{{.SecureJsonData.appInsightsApiKey}}"}
]
}
```
## How Token Authentication Works
The token auth section in the `plugin.json` file looks like this:
```json
"tokenAuth": {
"url": "https://login.microsoftonline.com/{{.JsonData.tenantId}}/oauth2/token",
"params": {
"grant_type": "client_credentials",
"client_id": "{{.JsonData.clientId}}",
"client_secret": "{{.SecureJsonData.clientSecret}}",
"resource": "https://management.azure.com/"
}
}
```
This interpolates in data from both `jsonData` and `secureJsonData` to generate the token request to the third-party API. It is common for tokens to have a short expiry period (30 minutes). The proxy in Grafana server will automatically renew the token if it has expired.
## Always Restart the Grafana Server After Route Changes
The plugin.json files are only loaded when the Grafana server starts so when a route is added or changed then the Grafana server has to be restarted for the changes to take effect.
@@ -0,0 +1,175 @@
+++
title = "Plugin Review Guidelines"
type = "docs"
[menu.docs]
name = "Plugin Review Guidelines"
parent = "developing"
weight = 2
+++
# Plugin Review Guidelines
The Grafana team reviews all plugins that are published on Grafana.com. There are two areas we review, the metadata for the plugin and the plugin functionality.
## Metadata
The plugin metadata consists of a `plugin.json` file and the README.md file. These `plugin.json` file is used by Grafana to load the plugin and the README.md file is shown in the plugins section of Grafana and the plugins section of Grafana.com.
### README.md
The README.md file is shown on the plugins page in Grafana and the plugin page on Grafana.com. There are some differences between the GitHub markdown and the markdown allowed in Grafana/Grafana.com:
- Cannot contain inline HTML.
- Any image links should be absolute links. For example: https://raw.githubusercontent.com/grafana/azure-monitor-datasource/master/dist/img/grafana_cloud_install.png
The README should:
- describe the purpose of the plugin.
- contain steps on how to get started.
### Plugin.json
The `plugin.json` file is the same concept as the `package.json` file for an npm package. When the Grafana server starts it will scan the plugin folders (all folders in the data/plugins subfolder) and load every folder that contains a `plugin.json` file unless the folder contains a subfolder named `dist`. In that case, the Grafana server will load the `dist` folder instead.
A minimal `plugin.json` file:
```json
{
"type": "panel",
"name": "Clock",
"id": "yourorg-clock-panel",
"info": {
"description": "Clock panel for grafana",
"author": {
"name": "Author Name",
"url": "http://yourwebsite.com"
},
"keywords": ["clock", "panel"],
"version": "1.0.0",
"updated": "2018-03-24"
},
"dependencies": {
"grafanaVersion": "3.x.x",
"plugins": [ ]
}
}
```
- The convention for the plugin id is [github username/org]-[plugin name]-[datasource|app|panel] and it has to be unique. Although if org and plugin name are the same then [plugin name]-[datasource|app|panel] is also valid. The org **cannot** be `grafana` unless it is a plugin created by the Grafana core team.
Examples:
- raintank-worldping-app
- ryantxu-ajax-panel
- alexanderzobnin-zabbix-app
- hawkular-datasource
- The `type` field should be either `datasource` `app` or `panel`.
- The `version` field should be in the form: x.x.x e.g. `1.0.0` or `0.4.1`.
The full file format for the `plugin.json` file is described [here](http://docs.grafana.org/plugins/developing/plugin.json/).
## Plugin Language
JavaScript, TypeScript, ES6 (or any other language) are all fine as long as the contents of the `dist` subdirectory are transpiled to JavaScript (ES5).
## File and Directory Structure Conventions
Here is a typical directory structure for a plugin.
```bash
johnnyb-awesome-datasource
|-- dist
|-- src
| |-- img
| | |-- logo.svg
| |-- partials
| | |-- annotations.editor.html
| | |-- config.html
| | |-- query.editor.html
| |-- datasource.js
| |-- module.js
| |-- plugin.json
| |-- query_ctrl.js
|-- Gruntfile.js
|-- LICENSE
|-- package.json
|-- README.md
```
Most JavaScript projects have a build step. The generated JavaScript should be placed in the `dist` directory and the source code in the `src` directory. We recommend that the plugin.json file be placed in the src directory and then copied over to the dist directory when building. The `README.md` can be placed in the root or in the dist directory.
Directories:
- `src/` contains plugin source files.
- `src/partials` contains html templates.
- `src/img` contains plugin logos and other images.
- `dist/` contains built content.
## HTML and CSS
For the HTML on editor tabs, we recommend using the inbuilt Grafana styles rather than defining your own. This makes plugins feel like a more natural part of Grafana. If done correctly, the html will also be responsive and adapt to smaller screens. The `gf-form` css classes should be used for labels and inputs.
Below is a minimal example of an editor row with one form group and two fields, a dropdown and a text input:
```html
<div class="editor-row">
<div class="section gf-form-group">
<h5 class="section-heading">My Plugin Options</h5>
<div class="gf-form">
<label class="gf-form-label width-10">Label1</label>
<div class="gf-form-select-wrapper max-width-10">
<select class="input-small gf-form-input" ng-model="ctrl.panel.mySelectProperty" ng-options="t for t in ['option1', 'option2', 'option3']" ng-change="ctrl.onSelectChange()"></select>
</div>
<div class="gf-form">
<label class="gf-form-label width-10">Label2</label>
<input type="text" class="input-small gf-form-input width-10" ng-model="ctrl.panel.myProperty" ng-change="ctrl.onFieldChange()" placeholder="suggestion for user" ng-model-onblur />
</div>
</div>
</div>
</div>
```
Use the `width-x` and `max-width-x` classes to control the width of your labels and input fields. Try to get labels and input fields to line up neatly by having the same width for all the labels in a group and the same width for all inputs in a group if possible.
## Data Sources
A basic guide for data sources can be found [here](http://docs.grafana.org/plugins/developing/datasources/).
### Config Page Guidelines
- It should be as easy as possible for a user to configure a url. If the data source is using the `datasource-http-settings` component, it should use the `suggest-url` attribute to suggest the default url or a url that is similar to what it should be (especially important if the url refers to a REST endpoint that is not common knowledge for most users e.g. `https://yourserver:4000/api/custom-endpoint`).
```html
<datasource-http-settings
current="ctrl.current"
suggest-url="http://localhost:8080">
</datasource-http-settings>
```
- The `testDatasource` function should make a query to the data source that will also test that the authentication details are correct. This is so the data source is correctly configured when the user tries to write a query in a new dashboard.
#### Password Security
If possible, any passwords or secrets should be be saved in the `secureJsonData` blob. To encrypt sensitive data, the Grafana server's proxy feature must be used. The Grafana server has support for token authentication (OAuth) and HTTP Header authentication. If the calls have to be sent directly from the browser to a third-party API then this will not be possible and sensitive data will not be encrypted.
Read more here about how [Authentication for Datasources]({{< relref "auth-for-datasources.md" >}}) works.
If using the proxy feature then the Config page should use the `secureJsonData` blob like this:
- good: `<input type="password" class="gf-form-input" ng-model='ctrl.current.secureJsonData.password' placeholder="password"></input>`
- bad: `<input type="password" class="gf-form-input" ng-model='ctrl.current.password' placeholder="password"></input>`
### Query Editor
Each query editor is unique and can have a unique style. It should be adapted to what the users of the data source are used to.
- Should use the Grafana CSS `gf-form` classes.
- Should be neat and tidy. Labels and fields in columns should be aligned and should be the same width if possible.
- The datasource should be able to handle when a user toggles a query (by clicking on the eye icon) and not execute the query. This is done by checking the `hide` property - an [example](https://github.com/grafana/grafana/blob/master/public/app/plugins/datasource/postgres/datasource.ts#L35-L38).
- Should not execute queries if fields in the Query Editor are empty and the query will throw an exception (defensive programming).
- Should handle errors. There are two main ways to do this:
- use the notification system in Grafana to show a toaster popup with the error message. Example [here](https://github.com/alexanderzobnin/grafana-zabbix/blob/fdbbba2fb03f5f2a4b3b0715415e09d5a4cf6cde/src/panel-triggers/triggers_panel_ctrl.js#L467-L471).
- provide an error notification in the query editor like the MySQL/Postgres data sources do. Example code in the `query_ctrl` [here](https://github.com/grafana/azure-monitor-datasource/blob/b184d077f082a69f962120ef0d1f8296a0d46f03/src/query_ctrl.ts#L36-L51) and in the [html](https://github.com/grafana/azure-monitor-datasource/blob/b184d077f082a69f962120ef0d1f8296a0d46f03/src/partials/query.editor.html#L190-L193).
+1 -1
View File
@@ -89,7 +89,7 @@
"style-loader": "^0.21.0",
"systemjs": "0.20.19",
"systemjs-plugin-css": "^0.1.36",
"ts-jest": "^22.0.0",
"ts-jest": "^22.4.6",
"tslint": "^5.8.0",
"tslint-loader": "^3.5.3",
"typescript": "^2.6.2",
+6 -3
View File
@@ -37,7 +37,6 @@ func GetAnnotations(c *m.ReqContext) Response {
if item.Email != "" {
item.AvatarUrl = dtos.GetGravatarUrl(item.Email)
}
item.Time = item.Time
}
return JSON(200, items)
@@ -214,7 +213,9 @@ func DeleteAnnotations(c *m.ReqContext, cmd dtos.DeleteAnnotationsCmd) Response
repo := annotations.GetRepository()
err := repo.Delete(&annotations.DeleteParams{
AlertId: cmd.PanelId,
OrgId: c.OrgId,
Id: cmd.AnnotationId,
RegionId: cmd.RegionId,
DashboardId: cmd.DashboardId,
PanelId: cmd.PanelId,
})
@@ -235,7 +236,8 @@ func DeleteAnnotationByID(c *m.ReqContext) Response {
}
err := repo.Delete(&annotations.DeleteParams{
Id: annotationID,
OrgId: c.OrgId,
Id: annotationID,
})
if err != nil {
@@ -254,6 +256,7 @@ func DeleteAnnotationRegion(c *m.ReqContext) Response {
}
err := repo.Delete(&annotations.DeleteParams{
OrgId: c.OrgId,
RegionId: regionID,
})
+47
View File
@@ -100,6 +100,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
Id: 1,
}
deleteCmd := dtos.DeleteAnnotationsCmd{
DashboardId: 1,
PanelId: 1,
}
viewerRole := m.ROLE_VIEWER
editorRole := m.ROLE_EDITOR
@@ -171,6 +176,25 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
})
})
})
Convey("When user is an Admin", func() {
role := m.ROLE_ADMIN
Convey("Should be able to do anything", func() {
postAnnotationScenario("When calling POST on", "/api/annotations", "/api/annotations", role, cmd, func(sc *scenarioContext) {
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 200)
})
putAnnotationScenario("When calling PUT on", "/api/annotations/1", "/api/annotations/:annotationId", role, updateCmd, func(sc *scenarioContext) {
sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 200)
})
deleteAnnotationsScenario("When calling POST on", "/api/annotations/mass-delete", "/api/annotations/mass-delete", role, deleteCmd, func(sc *scenarioContext) {
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 200)
})
})
})
})
}
@@ -239,3 +263,26 @@ func putAnnotationScenario(desc string, url string, routePattern string, role m.
fn(sc)
})
}
func deleteAnnotationsScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.DeleteAnnotationsCmd, fn scenarioFunc) {
Convey(desc+" "+url, func() {
defer bus.ClearBusHandlers()
sc := setupScenarioContext(url)
sc.defaultHandler = wrap(func(c *m.ReqContext) Response {
sc.context = c
sc.context.UserId = TestUserID
sc.context.OrgId = TestOrgID
sc.context.OrgRole = role
return DeleteAnnotations(c, cmd)
})
fakeAnnoRepo = &fakeAnnotationsRepo{}
annotations.SetRepository(fakeAnnoRepo)
sc.m.Post(routePattern, sc.defaultHandler)
fn(sc)
})
}
+27 -26
View File
@@ -4,6 +4,7 @@ import (
"github.com/go-macaron/binding"
"github.com/grafana/grafana/pkg/api/avatar"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models"
)
@@ -117,10 +118,10 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/api/login/ping", quota("session"), LoginAPIPing)
// authed api
r.Group("/api", func(apiRoute RouteRegister) {
r.Group("/api", func(apiRoute routing.RouteRegister) {
// user (signed in)
apiRoute.Group("/user", func(userRoute RouteRegister) {
apiRoute.Group("/user", func(userRoute routing.RouteRegister) {
userRoute.Get("/", wrap(GetSignedInUser))
userRoute.Put("/", bind(m.UpdateUserCommand{}), wrap(UpdateSignedInUser))
userRoute.Post("/using/:id", wrap(UserSetUsingOrg))
@@ -140,7 +141,7 @@ func (hs *HTTPServer) registerRoutes() {
})
// users (admin permission required)
apiRoute.Group("/users", func(usersRoute RouteRegister) {
apiRoute.Group("/users", func(usersRoute routing.RouteRegister) {
usersRoute.Get("/", wrap(SearchUsers))
usersRoute.Get("/search", wrap(SearchUsersWithPaging))
usersRoute.Get("/:id", wrap(GetUserByID))
@@ -152,7 +153,7 @@ func (hs *HTTPServer) registerRoutes() {
}, reqGrafanaAdmin)
// team (admin permission required)
apiRoute.Group("/teams", func(teamsRoute RouteRegister) {
apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
teamsRoute.Post("/", bind(m.CreateTeamCommand{}), wrap(CreateTeam))
teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), wrap(UpdateTeam))
teamsRoute.Delete("/:teamId", wrap(DeleteTeamByID))
@@ -162,19 +163,19 @@ func (hs *HTTPServer) registerRoutes() {
}, reqOrgAdmin)
// team without requirement of user to be org admin
apiRoute.Group("/teams", func(teamsRoute RouteRegister) {
apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
teamsRoute.Get("/:teamId", wrap(GetTeamByID))
teamsRoute.Get("/search", wrap(SearchTeams))
})
// org information available to all users.
apiRoute.Group("/org", func(orgRoute RouteRegister) {
apiRoute.Group("/org", func(orgRoute routing.RouteRegister) {
orgRoute.Get("/", wrap(GetOrgCurrent))
orgRoute.Get("/quotas", wrap(GetOrgQuotas))
})
// current org
apiRoute.Group("/org", func(orgRoute RouteRegister) {
apiRoute.Group("/org", func(orgRoute routing.RouteRegister) {
orgRoute.Put("/", bind(dtos.UpdateOrgForm{}), wrap(UpdateOrgCurrent))
orgRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), wrap(UpdateOrgAddressCurrent))
orgRoute.Post("/users", quota("user"), bind(m.AddOrgUserCommand{}), wrap(AddOrgUserToCurrentOrg))
@@ -192,7 +193,7 @@ func (hs *HTTPServer) registerRoutes() {
}, reqOrgAdmin)
// current org without requirement of user to be org admin
apiRoute.Group("/org", func(orgRoute RouteRegister) {
apiRoute.Group("/org", func(orgRoute routing.RouteRegister) {
orgRoute.Get("/users", wrap(GetOrgUsersForCurrentOrg))
})
@@ -203,7 +204,7 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Get("/orgs", reqGrafanaAdmin, wrap(SearchOrgs))
// orgs (admin routes)
apiRoute.Group("/orgs/:orgId", func(orgsRoute RouteRegister) {
apiRoute.Group("/orgs/:orgId", func(orgsRoute routing.RouteRegister) {
orgsRoute.Get("/", wrap(GetOrgByID))
orgsRoute.Put("/", bind(dtos.UpdateOrgForm{}), wrap(UpdateOrg))
orgsRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), wrap(UpdateOrgAddress))
@@ -217,24 +218,24 @@ func (hs *HTTPServer) registerRoutes() {
}, reqGrafanaAdmin)
// orgs (admin routes)
apiRoute.Group("/orgs/name/:name", func(orgsRoute RouteRegister) {
apiRoute.Group("/orgs/name/:name", func(orgsRoute routing.RouteRegister) {
orgsRoute.Get("/", wrap(GetOrgByName))
}, reqGrafanaAdmin)
// auth api keys
apiRoute.Group("/auth/keys", func(keysRoute RouteRegister) {
apiRoute.Group("/auth/keys", func(keysRoute routing.RouteRegister) {
keysRoute.Get("/", wrap(GetAPIKeys))
keysRoute.Post("/", quota("api_key"), bind(m.AddApiKeyCommand{}), wrap(AddAPIKey))
keysRoute.Delete("/:id", wrap(DeleteAPIKey))
}, reqOrgAdmin)
// Preferences
apiRoute.Group("/preferences", func(prefRoute RouteRegister) {
apiRoute.Group("/preferences", func(prefRoute routing.RouteRegister) {
prefRoute.Post("/set-home-dash", bind(m.SavePreferencesCommand{}), wrap(SetHomeDashboard))
})
// Data sources
apiRoute.Group("/datasources", func(datasourceRoute RouteRegister) {
apiRoute.Group("/datasources", func(datasourceRoute routing.RouteRegister) {
datasourceRoute.Get("/", wrap(GetDataSources))
datasourceRoute.Post("/", quota("data_source"), bind(m.AddDataSourceCommand{}), wrap(AddDataSource))
datasourceRoute.Put("/:id", bind(m.UpdateDataSourceCommand{}), wrap(UpdateDataSource))
@@ -250,7 +251,7 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Get("/plugins/:pluginId/settings", wrap(GetPluginSettingByID))
apiRoute.Get("/plugins/:pluginId/markdown/:name", wrap(GetPluginMarkdown))
apiRoute.Group("/plugins", func(pluginRoute RouteRegister) {
apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) {
pluginRoute.Get("/:pluginId/dashboards/", wrap(GetPluginDashboards))
pluginRoute.Post("/:pluginId/settings", bind(m.UpdatePluginSettingCmd{}), wrap(UpdatePluginSetting))
}, reqOrgAdmin)
@@ -260,17 +261,17 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest)
// Folders
apiRoute.Group("/folders", func(folderRoute RouteRegister) {
apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) {
folderRoute.Get("/", wrap(GetFolders))
folderRoute.Get("/id/:id", wrap(GetFolderByID))
folderRoute.Post("/", bind(m.CreateFolderCommand{}), wrap(CreateFolder))
folderRoute.Group("/:uid", func(folderUidRoute RouteRegister) {
folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
folderUidRoute.Get("/", wrap(GetFolderByUID))
folderUidRoute.Put("/", bind(m.UpdateFolderCommand{}), wrap(UpdateFolder))
folderUidRoute.Delete("/", wrap(DeleteFolder))
folderUidRoute.Group("/permissions", func(folderPermissionRoute RouteRegister) {
folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) {
folderPermissionRoute.Get("/", wrap(GetFolderPermissionList))
folderPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateFolderPermissions))
})
@@ -278,7 +279,7 @@ func (hs *HTTPServer) registerRoutes() {
})
// Dashboard
apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) {
apiRoute.Group("/dashboards", func(dashboardRoute routing.RouteRegister) {
dashboardRoute.Get("/uid/:uid", wrap(GetDashboard))
dashboardRoute.Delete("/uid/:uid", wrap(DeleteDashboardByUID))
@@ -292,12 +293,12 @@ func (hs *HTTPServer) registerRoutes() {
dashboardRoute.Get("/tags", GetDashboardTags)
dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), wrap(ImportDashboard))
dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute RouteRegister) {
dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute routing.RouteRegister) {
dashIdRoute.Get("/versions", wrap(GetDashboardVersions))
dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion))
dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion))
dashIdRoute.Group("/permissions", func(dashboardPermissionRoute RouteRegister) {
dashIdRoute.Group("/permissions", func(dashboardPermissionRoute routing.RouteRegister) {
dashboardPermissionRoute.Get("/", wrap(GetDashboardPermissionList))
dashboardPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardPermissions))
})
@@ -305,12 +306,12 @@ func (hs *HTTPServer) registerRoutes() {
})
// Dashboard snapshots
apiRoute.Group("/dashboard/snapshots", func(dashboardRoute RouteRegister) {
apiRoute.Group("/dashboard/snapshots", func(dashboardRoute routing.RouteRegister) {
dashboardRoute.Get("/", wrap(SearchDashboardSnapshots))
})
// Playlist
apiRoute.Group("/playlists", func(playlistRoute RouteRegister) {
apiRoute.Group("/playlists", func(playlistRoute routing.RouteRegister) {
playlistRoute.Get("/", wrap(SearchPlaylists))
playlistRoute.Get("/:id", ValidateOrgPlaylist, wrap(GetPlaylist))
playlistRoute.Get("/:id/items", ValidateOrgPlaylist, wrap(GetPlaylistItems))
@@ -329,7 +330,7 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Get("/tsdb/testdata/gensql", reqGrafanaAdmin, wrap(GenerateSQLTestData))
apiRoute.Get("/tsdb/testdata/random-walk", wrap(GetTestDataRandomWalk))
apiRoute.Group("/alerts", func(alertsRoute RouteRegister) {
apiRoute.Group("/alerts", func(alertsRoute routing.RouteRegister) {
alertsRoute.Post("/test", bind(dtos.AlertTestCommand{}), wrap(AlertTest))
alertsRoute.Post("/:alertId/pause", reqEditorRole, bind(dtos.PauseAlertCommand{}), wrap(PauseAlert))
alertsRoute.Get("/:alertId", ValidateOrgAlert, wrap(GetAlert))
@@ -340,7 +341,7 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Get("/alert-notifications", wrap(GetAlertNotifications))
apiRoute.Get("/alert-notifiers", wrap(GetAlertNotifiers))
apiRoute.Group("/alert-notifications", func(alertNotifications RouteRegister) {
apiRoute.Group("/alert-notifications", func(alertNotifications routing.RouteRegister) {
alertNotifications.Post("/test", bind(dtos.NotificationTestCommand{}), wrap(NotificationTest))
alertNotifications.Post("/", bind(m.CreateAlertNotificationCommand{}), wrap(CreateAlertNotification))
alertNotifications.Put("/:notificationId", bind(m.UpdateAlertNotificationCommand{}), wrap(UpdateAlertNotification))
@@ -351,7 +352,7 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Get("/annotations", wrap(GetAnnotations))
apiRoute.Post("/annotations/mass-delete", reqOrgAdmin, bind(dtos.DeleteAnnotationsCmd{}), wrap(DeleteAnnotations))
apiRoute.Group("/annotations", func(annotationsRoute RouteRegister) {
apiRoute.Group("/annotations", func(annotationsRoute routing.RouteRegister) {
annotationsRoute.Post("/", bind(dtos.PostAnnotationsCmd{}), wrap(PostAnnotation))
annotationsRoute.Delete("/:annotationId", wrap(DeleteAnnotationByID))
annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), wrap(UpdateAnnotation))
@@ -365,7 +366,7 @@ func (hs *HTTPServer) registerRoutes() {
}, reqSignedIn)
// admin api
r.Group("/api/admin", func(adminRoute RouteRegister) {
r.Group("/api/admin", func(adminRoute routing.RouteRegister) {
adminRoute.Get("/settings", AdminGetSettings)
adminRoute.Post("/users", bind(dtos.AdminCreateUserForm{}), AdminCreateUser)
adminRoute.Put("/users/:id/password", bind(dtos.AdminUpdateUserPasswordForm{}), AdminUpdateUserPassword)
+3
View File
@@ -103,6 +103,9 @@ func DeleteDataSourceByName(c *m.ReqContext) Response {
getCmd := &m.GetDataSourceByNameQuery{Name: name, OrgId: c.OrgId}
if err := bus.Dispatch(getCmd); err != nil {
if err == m.ErrDataSourceNotFound {
return Error(404, "Data source not found", nil)
}
return Error(500, "Failed to delete datasource", err)
}
+8
View File
@@ -46,5 +46,13 @@ func TestDataSourcesProxy(t *testing.T) {
So(respJSON[3]["name"], ShouldEqual, "ZZZ")
})
})
Convey("Should be able to save a data source", func() {
loggedInUserScenario("When calling DELETE on non-existing", "/api/datasources/name/12345", func(sc *scenarioContext) {
sc.handlerFunc = DeleteDataSourceByName
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 404)
})
})
})
}
+5 -4
View File
@@ -11,6 +11,7 @@ import (
"path"
"time"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
@@ -43,10 +44,10 @@ type HTTPServer struct {
cache *gocache.Cache
httpSrv *http.Server
RouteRegister RouteRegister `inject:""`
Bus bus.Bus `inject:""`
RenderService rendering.Service `inject:""`
Cfg *setting.Cfg `inject:""`
RouteRegister routing.RouteRegister `inject:""`
Bus bus.Bus `inject:""`
RenderService rendering.Service `inject:""`
Cfg *setting.Cfg `inject:""`
}
func (hs *HTTPServer) Init() error {
+27
View File
@@ -117,6 +117,28 @@ func (proxy *DataSourceProxy) addTraceFromHeaderValue(span opentracing.Span, hea
}
}
func (proxy *DataSourceProxy) useCustomHeaders(req *http.Request) {
decryptSdj := proxy.ds.SecureJsonData.Decrypt()
index := 1
for {
headerNameSuffix := fmt.Sprintf("httpHeaderName%d", index)
headerValueSuffix := fmt.Sprintf("httpHeaderValue%d", index)
if key := proxy.ds.JsonData.Get(headerNameSuffix).MustString(); key != "" {
if val, ok := decryptSdj[headerValueSuffix]; ok {
// remove if exists
if req.Header.Get(key) != "" {
req.Header.Del(key)
}
req.Header.Add(key, val)
logger.Debug("Using custom header ", "CustomHeaders", key)
}
} else {
break
}
index += 1
}
}
func (proxy *DataSourceProxy) getDirector() func(req *http.Request) {
return func(req *http.Request) {
req.URL.Scheme = proxy.targetUrl.Scheme
@@ -146,6 +168,11 @@ func (proxy *DataSourceProxy) getDirector() func(req *http.Request) {
req.Header.Add("Authorization", util.GetBasicAuthHeader(proxy.ds.BasicAuthUser, proxy.ds.BasicAuthPassword))
}
// Lookup and use custom headers
if proxy.ds.SecureJsonData != nil {
proxy.useCustomHeaders(req)
}
dsAuth := req.Header.Get("X-DS-Authorization")
if len(dsAuth) > 0 {
req.Header.Del("X-DS-Authorization")
+32
View File
@@ -12,6 +12,7 @@ import (
macaron "gopkg.in/macaron.v1"
"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/plugins"
"github.com/grafana/grafana/pkg/setting"
@@ -322,6 +323,37 @@ func TestDSRouteRule(t *testing.T) {
So(interpolated, ShouldEqual, "0asd+asd")
})
Convey("When proxying a data source with custom headers specified", func() {
plugin := &plugins.DataSourcePlugin{}
encryptedData, err := util.Encrypt([]byte(`Bearer xf5yhfkpsnmgo`), setting.SecretKey)
ds := &m.DataSource{
Type: m.DS_PROMETHEUS,
Url: "http://prometheus:9090",
JsonData: simplejson.NewFromAny(map[string]interface{}{
"httpHeaderName1": "Authorization",
}),
SecureJsonData: map[string][]byte{
"httpHeaderValue1": encryptedData,
},
}
ctx := &m.ReqContext{}
proxy := NewDataSourceProxy(ds, plugin, ctx, "")
requestURL, _ := url.Parse("http://grafana.com/sub")
req := http.Request{URL: requestURL, Header: make(http.Header)}
proxy.getDirector()(&req)
if err != nil {
log.Fatal(4, err.Error())
}
Convey("Match header value after decryption", func() {
So(req.Header.Get("Authorization"), ShouldEqual, "Bearer xf5yhfkpsnmgo")
})
})
})
}
+11
View File
@@ -3,7 +3,9 @@ package api
import (
"fmt"
"net/http"
"runtime"
"strconv"
"strings"
"time"
m "github.com/grafana/grafana/pkg/models"
@@ -55,6 +57,15 @@ func (hs *HTTPServer) RenderToPng(c *m.ReqContext) {
return
}
if err != nil && err == rendering.ErrPhantomJSNotInstalled {
if strings.HasPrefix(runtime.GOARCH, "arm") {
c.Handle(500, "Rendering failed - PhantomJS isn't included in arm build per default", err)
} else {
c.Handle(500, "Rendering failed - PhantomJS isn't installed correctly", err)
}
return
}
if err != nil {
c.Handle(500, "Rendering failed.", err)
return
@@ -1,9 +1,10 @@
package api
package routing
import (
"net/http"
"strings"
macaron "gopkg.in/macaron.v1"
"gopkg.in/macaron.v1"
)
type Router interface {
@@ -14,16 +15,34 @@ type Router interface {
// RouteRegister allows you to add routes and macaron.Handlers
// that the web server should serve.
type RouteRegister interface {
// Get adds a list of handlers to a given route with a GET HTTP verb
Get(string, ...macaron.Handler)
// Post adds a list of handlers to a given route with a POST HTTP verb
Post(string, ...macaron.Handler)
// Delete adds a list of handlers to a given route with a DELETE HTTP verb
Delete(string, ...macaron.Handler)
// Put adds a list of handlers to a given route with a PUT HTTP verb
Put(string, ...macaron.Handler)
// Patch adds a list of handlers to a given route with a PATCH HTTP verb
Patch(string, ...macaron.Handler)
// Any adds a list of handlers to a given route with any HTTP verb
Any(string, ...macaron.Handler)
// Group allows you to pass a function that can add multiple routes
// with a shared prefix route.
Group(string, func(RouteRegister), ...macaron.Handler)
Register(Router) *macaron.Router
// Insert adds more routes to an existing Group.
Insert(string, func(RouteRegister), ...macaron.Handler)
// Register iterates over all routes added to the RouteRegister
// and add them to the `Router` pass as an parameter.
Register(Router)
}
type RegisterNamedMiddleware func(name string) macaron.Handler
@@ -52,6 +71,24 @@ type routeRegister struct {
groups []*routeRegister
}
func (rr *routeRegister) Insert(pattern string, fn func(RouteRegister), handlers ...macaron.Handler) {
//loop over all groups at current level
for _, g := range rr.groups {
// apply routes if the prefix matches the pattern
if g.prefix == pattern {
g.Group("", fn)
break
}
// go down one level if the prefix can be find in the pattern
if strings.HasPrefix(pattern, g.prefix) {
g.Insert(pattern, fn)
}
}
}
func (rr *routeRegister) Group(pattern string, fn func(rr RouteRegister), handlers ...macaron.Handler) {
group := &routeRegister{
prefix: rr.prefix + pattern,
@@ -64,7 +101,7 @@ func (rr *routeRegister) Group(pattern string, fn func(rr RouteRegister), handle
rr.groups = append(rr.groups, group)
}
func (rr *routeRegister) Register(router Router) *macaron.Router {
func (rr *routeRegister) Register(router Router) {
for _, r := range rr.routes {
// GET requests have to be added to macaron routing using Get()
// Otherwise HEAD requests will not be allowed.
@@ -79,8 +116,6 @@ func (rr *routeRegister) Register(router Router) *macaron.Router {
for _, g := range rr.groups {
g.Register(router)
}
return &macaron.Router{}
}
func (rr *routeRegister) route(pattern, method string, handlers ...macaron.Handler) {
@@ -92,6 +127,12 @@ func (rr *routeRegister) route(pattern, method string, handlers ...macaron.Handl
h = append(h, rr.subfixHandlers...)
h = append(h, handlers...)
for _, r := range rr.routes {
if r.pattern == rr.prefix+pattern && r.method == method {
panic("cannot add duplicate route")
}
}
rr.routes = append(rr.routes, route{
method: method,
pattern: rr.prefix + pattern,
@@ -1,11 +1,11 @@
package api
package routing
import (
"net/http"
"strconv"
"testing"
macaron "gopkg.in/macaron.v1"
"gopkg.in/macaron.v1"
)
type fakeRouter struct {
@@ -33,7 +33,7 @@ func (fr *fakeRouter) Get(pattern string, handlers ...macaron.Handler) *macaron.
}
func emptyHandlers(n int) []macaron.Handler {
res := []macaron.Handler{}
var res []macaron.Handler
for i := 1; n >= i; i++ {
res = append(res, emptyHandler(strconv.Itoa(i)))
}
@@ -138,7 +138,78 @@ func TestRouteGroupedRegister(t *testing.T) {
}
}
}
func TestRouteGroupInserting(t *testing.T) {
testTable := []route{
{method: http.MethodGet, pattern: "/api/", handlers: emptyHandlers(1)},
{method: http.MethodPost, pattern: "/api/group/endpoint", handlers: emptyHandlers(1)},
{method: http.MethodGet, pattern: "/api/group/inserted", handlers: emptyHandlers(1)},
{method: http.MethodDelete, pattern: "/api/inserted-endpoint", handlers: emptyHandlers(1)},
}
// Setup
rr := NewRouteRegister()
rr.Group("/api", func(api RouteRegister) {
api.Get("/", emptyHandler("1"))
api.Group("/group", func(group RouteRegister) {
group.Post("/endpoint", emptyHandler("1"))
})
})
rr.Insert("/api", func(api RouteRegister) {
api.Delete("/inserted-endpoint", emptyHandler("1"))
})
rr.Insert("/api/group", func(group RouteRegister) {
group.Get("/inserted", emptyHandler("1"))
})
fr := &fakeRouter{}
rr.Register(fr)
// Validation
if len(fr.route) != len(testTable) {
t.Fatalf("want %v routes, got %v", len(testTable), len(fr.route))
}
for i := range testTable {
if testTable[i].method != fr.route[i].method {
t.Errorf("want %s got %v", testTable[i].method, fr.route[i].method)
}
if testTable[i].pattern != fr.route[i].pattern {
t.Errorf("want %s got %v", testTable[i].pattern, fr.route[i].pattern)
}
if len(testTable[i].handlers) != len(fr.route[i].handlers) {
t.Errorf("want %d handlers got %d handlers \ntestcase: %v\nroute: %v\n",
len(testTable[i].handlers),
len(fr.route[i].handlers),
testTable[i],
fr.route[i])
}
}
}
func TestDuplicateRoutShouldPanic(t *testing.T) {
defer func() {
if recover() != "cannot add duplicate route" {
t.Errorf("Should cause panic if duplicate routes are added ")
}
}()
rr := NewRouteRegister(func(name string) macaron.Handler {
return emptyHandler(name)
})
rr.Get("/api", emptyHandler("1"))
rr.Get("/api", emptyHandler("1"))
fr := &fakeRouter{}
rr.Register(fr)
}
func TestNamedMiddlewareRouteRegister(t *testing.T) {
testTable := []route{
{method: "DELETE", pattern: "/admin", handlers: emptyHandlers(2)},
+64 -12
View File
@@ -12,21 +12,42 @@ type Msg interface{}
var ErrHandlerNotFound = errors.New("handler not found")
type TransactionManager interface {
InTransaction(ctx context.Context, fn func(ctx context.Context) error) error
}
type Bus interface {
Dispatch(msg Msg) error
DispatchCtx(ctx context.Context, msg Msg) error
Publish(msg Msg) error
// InTransaction starts a transaction and store it in the context.
// The caller can then pass a function with multiple DispatchCtx calls that
// all will be executed in the same transaction. InTransaction will rollback if the
// callback returns an error.
InTransaction(ctx context.Context, fn func(ctx context.Context) error) error
AddHandler(handler HandlerFunc)
AddCtxHandler(handler HandlerFunc)
AddHandlerCtx(handler HandlerFunc)
AddEventListener(handler HandlerFunc)
AddWildcardListener(handler HandlerFunc)
// SetTransactionManager allows the user to replace the internal
// noop TransactionManager that is responsible for manageing
// transactions in `InTransaction`
SetTransactionManager(tm TransactionManager)
}
func (b *InProcBus) InTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
return b.txMng.InTransaction(ctx, fn)
}
type InProcBus struct {
handlers map[string]HandlerFunc
handlersWithCtx map[string]HandlerFunc
listeners map[string][]HandlerFunc
wildcardListeners []HandlerFunc
txMng TransactionManager
}
// temp stuff, not sure how to handle bus instance, and init yet
@@ -35,8 +56,11 @@ var globalBus = New()
func New() Bus {
bus := &InProcBus{}
bus.handlers = make(map[string]HandlerFunc)
bus.handlersWithCtx = make(map[string]HandlerFunc)
bus.listeners = make(map[string][]HandlerFunc)
bus.wildcardListeners = make([]HandlerFunc, 0)
bus.txMng = &noopTransactionManager{}
return bus
}
@@ -45,17 +69,21 @@ func GetBus() Bus {
return globalBus
}
func (b *InProcBus) SetTransactionManager(tm TransactionManager) {
b.txMng = tm
}
func (b *InProcBus) DispatchCtx(ctx context.Context, msg Msg) error {
var msgName = reflect.TypeOf(msg).Elem().Name()
var handler = b.handlers[msgName]
var handler = b.handlersWithCtx[msgName]
if handler == nil {
return ErrHandlerNotFound
}
var params = make([]reflect.Value, 2)
params[0] = reflect.ValueOf(ctx)
params[1] = reflect.ValueOf(msg)
var params = []reflect.Value{}
params = append(params, reflect.ValueOf(ctx))
params = append(params, reflect.ValueOf(msg))
ret := reflect.ValueOf(handler).Call(params)
err := ret[0].Interface()
@@ -68,13 +96,23 @@ func (b *InProcBus) DispatchCtx(ctx context.Context, msg Msg) error {
func (b *InProcBus) Dispatch(msg Msg) error {
var msgName = reflect.TypeOf(msg).Elem().Name()
var handler = b.handlers[msgName]
var handler = b.handlersWithCtx[msgName]
withCtx := true
if handler == nil {
withCtx = false
handler = b.handlers[msgName]
}
if handler == nil {
return ErrHandlerNotFound
}
var params = make([]reflect.Value, 1)
params[0] = reflect.ValueOf(msg)
var params = []reflect.Value{}
if withCtx {
params = append(params, reflect.ValueOf(context.Background()))
}
params = append(params, reflect.ValueOf(msg))
ret := reflect.ValueOf(handler).Call(params)
err := ret[0].Interface()
@@ -120,10 +158,10 @@ func (b *InProcBus) AddHandler(handler HandlerFunc) {
b.handlers[queryTypeName] = handler
}
func (b *InProcBus) AddCtxHandler(handler HandlerFunc) {
func (b *InProcBus) AddHandlerCtx(handler HandlerFunc) {
handlerType := reflect.TypeOf(handler)
queryTypeName := handlerType.In(1).Elem().Name()
b.handlers[queryTypeName] = handler
b.handlersWithCtx[queryTypeName] = handler
}
func (b *InProcBus) AddEventListener(handler HandlerFunc) {
@@ -142,8 +180,8 @@ func AddHandler(implName string, handler HandlerFunc) {
}
// Package level functions
func AddCtxHandler(implName string, handler HandlerFunc) {
globalBus.AddCtxHandler(handler)
func AddHandlerCtx(implName string, handler HandlerFunc) {
globalBus.AddHandlerCtx(handler)
}
// Package level functions
@@ -167,6 +205,20 @@ func Publish(msg Msg) error {
return globalBus.Publish(msg)
}
// InTransaction starts a transaction and store it in the context.
// The caller can then pass a function with multiple DispatchCtx calls that
// all will be executed in the same transaction. InTransaction will rollback if the
// callback returns an error.
func InTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
return globalBus.InTransaction(ctx, fn)
}
func ClearBusHandlers() {
globalBus = New()
}
type noopTransactionManager struct{}
func (*noopTransactionManager) InTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
return fn(ctx)
}
+51 -8
View File
@@ -1,24 +1,67 @@
package bus
import (
"context"
"errors"
"fmt"
"testing"
)
type TestQuery struct {
type testQuery struct {
Id int64
Resp string
}
func TestDispatchCtxCanUseNormalHandlers(t *testing.T) {
bus := New()
handlerWithCtxCallCount := 0
handlerCallCount := 0
handlerWithCtx := func(ctx context.Context, query *testQuery) error {
handlerWithCtxCallCount++
return nil
}
handler := func(query *testQuery) error {
handlerCallCount++
return nil
}
err := bus.DispatchCtx(context.Background(), &testQuery{})
if err != ErrHandlerNotFound {
t.Errorf("expected bus to return HandlerNotFound is no handler is registered")
}
bus.AddHandler(handler)
t.Run("when a normal handler is registered", func(t *testing.T) {
bus.Dispatch(&testQuery{})
if handlerCallCount != 1 {
t.Errorf("Expected normal handler to be called 1 time. was called %d", handlerCallCount)
}
t.Run("when a ctx handler is registered", func(t *testing.T) {
bus.AddHandlerCtx(handlerWithCtx)
bus.Dispatch(&testQuery{})
if handlerWithCtxCallCount != 1 {
t.Errorf("Expected ctx handler to be called 1 time. was called %d", handlerWithCtxCallCount)
}
})
})
}
func TestQueryHandlerReturnsError(t *testing.T) {
bus := New()
bus.AddHandler(func(query *TestQuery) error {
bus.AddHandler(func(query *testQuery) error {
return errors.New("handler error")
})
err := bus.Dispatch(&TestQuery{})
err := bus.Dispatch(&testQuery{})
if err == nil {
t.Fatal("Send query failed " + err.Error())
@@ -30,12 +73,12 @@ func TestQueryHandlerReturnsError(t *testing.T) {
func TestQueryHandlerReturn(t *testing.T) {
bus := New()
bus.AddHandler(func(q *TestQuery) error {
bus.AddHandler(func(q *testQuery) error {
q.Resp = "hello from handler"
return nil
})
query := &TestQuery{}
query := &testQuery{}
err := bus.Dispatch(query)
if err != nil {
@@ -49,17 +92,17 @@ func TestEventListeners(t *testing.T) {
bus := New()
count := 0
bus.AddEventListener(func(query *TestQuery) error {
bus.AddEventListener(func(query *testQuery) error {
count += 1
return nil
})
bus.AddEventListener(func(query *TestQuery) error {
bus.AddEventListener(func(query *testQuery) error {
count += 10
return nil
})
err := bus.Publish(&TestQuery{})
err := bus.Publish(&testQuery{})
if err != nil {
t.Fatal("Publish event failed " + err.Error())
+4 -3
View File
@@ -12,6 +12,7 @@ import (
"time"
"github.com/facebookgo/inject"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/registry"
@@ -61,8 +62,8 @@ type GrafanaServerImpl struct {
shutdownReason string
shutdownInProgress bool
RouteRegister api.RouteRegister `inject:""`
HttpServer *api.HTTPServer `inject:""`
RouteRegister routing.RouteRegister `inject:""`
HttpServer *api.HTTPServer `inject:""`
}
func (g *GrafanaServerImpl) Run() error {
@@ -75,7 +76,7 @@ func (g *GrafanaServerImpl) Run() error {
serviceGraph := inject.Graph{}
serviceGraph.Provide(&inject.Object{Value: bus.GetBus()})
serviceGraph.Provide(&inject.Object{Value: g.cfg})
serviceGraph.Provide(&inject.Object{Value: api.NewRouteRegister(middleware.RequestMetrics, middleware.RequestTracing)})
serviceGraph.Provide(&inject.Object{Value: routing.NewRouteRegister(middleware.RequestMetrics, middleware.RequestTracing)})
// self registered services
services := registry.GetServices()
+2 -1
View File
@@ -308,6 +308,7 @@ func (a *ldapAuther) searchForUser(username string) (*LdapUserInfo, error) {
} else {
filter_replace = getLdapAttr(a.server.GroupSearchFilterUserAttribute, searchResult)
}
filter := strings.Replace(a.server.GroupSearchFilter, "%s", ldap.EscapeFilter(filter_replace), -1)
a.log.Info("Searching for user's groups", "filter", filter)
@@ -348,7 +349,7 @@ func (a *ldapAuther) searchForUser(username string) (*LdapUserInfo, error) {
}
func getLdapAttrN(name string, result *ldap.SearchResult, n int) string {
if name == "DN" {
if strings.ToLower(name) == "dn" {
return result.Entries[n].DN
}
for _, attr := range result.Entries[n].Attributes {
+12 -8
View File
@@ -2,7 +2,6 @@ package middleware
import (
"fmt"
"net"
"net/mail"
"reflect"
"strings"
@@ -29,7 +28,7 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
}
// if auth proxy ip(s) defined, check if request comes from one of those
if err := checkAuthenticationProxy(ctx.Req.RemoteAddr, proxyHeaderValue); err != nil {
if err := checkAuthenticationProxy(ctx.RemoteAddr(), proxyHeaderValue); err != nil {
ctx.Handle(407, "Proxy authentication required", err)
return true
}
@@ -197,18 +196,23 @@ func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error
return nil
}
proxies := strings.Split(setting.AuthProxyWhitelist, ",")
sourceIP, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return err
// Multiple ip addresses? Right-most IP address is the IP address of the most recent proxy
if strings.Contains(remoteAddr, ",") {
sourceIPs := strings.Split(remoteAddr, ",")
remoteAddr = strings.TrimSpace(sourceIPs[len(sourceIPs)-1])
}
remoteAddr = strings.TrimPrefix(remoteAddr, "[")
remoteAddr = strings.TrimSuffix(remoteAddr, "]")
proxies := strings.Split(setting.AuthProxyWhitelist, ",")
// Compare allowed IP addresses to actual address
for _, proxyIP := range proxies {
if sourceIP == strings.TrimSpace(proxyIP) {
if remoteAddr == strings.TrimSpace(proxyIP) {
return nil
}
}
return fmt.Errorf("Request for user (%s) from %s is not from the authentication proxy", proxyHeaderValue, sourceIP)
return fmt.Errorf("Request for user (%s) from %s is not from the authentication proxy", proxyHeaderValue, remoteAddr)
}
+55
View File
@@ -293,6 +293,61 @@ func TestMiddlewareContext(t *testing.T) {
})
})
middlewareScenario("When auth_proxy is enabled and request has X-Forwarded-For that is not trusted", func(sc *scenarioContext) {
setting.AuthProxyEnabled = true
setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
setting.AuthProxyHeaderProperty = "username"
setting.AuthProxyWhitelist = "192.168.1.1, 2001::23"
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 4, UserId: 33}
return nil
})
bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error {
cmd.Result = &m.User{Id: 33}
return nil
})
sc.fakeReq("GET", "/")
sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
sc.req.Header.Add("X-Forwarded-For", "client-ip, 192.168.1.1, 192.168.1.2")
sc.exec()
Convey("should return 407 status code", func() {
So(sc.resp.Code, ShouldEqual, 407)
So(sc.resp.Body.String(), ShouldContainSubstring, "Request for user (torkelo) from 192.168.1.2 is not from the authentication proxy")
})
})
middlewareScenario("When auth_proxy is enabled and request has X-Forwarded-For that is trusted", func(sc *scenarioContext) {
setting.AuthProxyEnabled = true
setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
setting.AuthProxyHeaderProperty = "username"
setting.AuthProxyWhitelist = "192.168.1.1, 2001::23"
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 4, UserId: 33}
return nil
})
bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error {
cmd.Result = &m.User{Id: 33}
return nil
})
sc.fakeReq("GET", "/")
sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
sc.req.Header.Add("X-Forwarded-For", "client-ip, 192.168.1.2, 192.168.1.1")
sc.exec()
Convey("Should init context with user info", func() {
So(sc.context.IsSignedIn, ShouldBeTrue)
So(sc.context.UserId, ShouldEqual, 33)
So(sc.context.OrgId, ShouldEqual, 4)
})
})
middlewareScenario("When session exists for previous user, create a new session", func(sc *scenarioContext) {
setting.AuthProxyEnabled = true
setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
+1
View File
@@ -3,6 +3,7 @@ package notifiers
import (
"github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting"
)
+6 -5
View File
@@ -35,11 +35,12 @@ type PostParams struct {
}
type DeleteParams struct {
Id int64 `json:"id"`
AlertId int64 `json:"alertId"`
DashboardId int64 `json:"dashboardId"`
PanelId int64 `json:"panelId"`
RegionId int64 `json:"regionId"`
OrgId int64
Id int64
AlertId int64
DashboardId int64
PanelId int64
RegionId int64
}
var repositoryInstance Repository
+11 -1
View File
@@ -57,8 +57,10 @@ func (srv *CleanUpService) cleanUpTmpFiles() {
}
var toDelete []os.FileInfo
var now = time.Now()
for _, file := range files {
if file.ModTime().AddDate(0, 0, 1).Before(time.Now()) {
if srv.shouldCleanupTempFile(file.ModTime(), now) {
toDelete = append(toDelete, file)
}
}
@@ -74,6 +76,14 @@ func (srv *CleanUpService) cleanUpTmpFiles() {
srv.log.Debug("Found old rendered image to delete", "deleted", len(toDelete), "keept", len(files))
}
func (srv *CleanUpService) shouldCleanupTempFile(filemtime time.Time, now time.Time) bool {
if srv.Cfg.TempDataLifetime == 0 {
return false
}
return filemtime.Add(srv.Cfg.TempDataLifetime).Before(now)
}
func (srv *CleanUpService) deleteExpiredSnapshots() {
cmd := m.DeleteExpiredSnapshotsCommand{}
if err := bus.Dispatch(&cmd); err != nil {
+41
View File
@@ -0,0 +1,41 @@
package cleanup
import (
"github.com/grafana/grafana/pkg/setting"
. "github.com/smartystreets/goconvey/convey"
"testing"
"time"
)
func TestCleanUpTmpFiles(t *testing.T) {
Convey("Cleanup service tests", t, func() {
cfg := setting.Cfg{}
cfg.TempDataLifetime, _ = time.ParseDuration("24h")
service := CleanUpService{
Cfg: &cfg,
}
now := time.Now()
secondAgo := now.Add(-time.Second)
twoDaysAgo := now.Add(-time.Second * 3600 * 24 * 2)
weekAgo := now.Add(-time.Second * 3600 * 24 * 7)
Convey("Should not cleanup recent files", func() {
So(service.shouldCleanupTempFile(secondAgo, now), ShouldBeFalse)
})
Convey("Should cleanup older files", func() {
So(service.shouldCleanupTempFile(twoDaysAgo, now), ShouldBeTrue)
})
Convey("After increasing temporary files lifetime, older files should be kept", func() {
cfg.TempDataLifetime, _ = time.ParseDuration("1000h")
So(service.shouldCleanupTempFile(weekAgo, now), ShouldBeFalse)
})
Convey("If lifetime is 0, files should never be cleaned up", func() {
cfg.TempDataLifetime = 0
So(service.shouldCleanupTempFile(weekAgo, now), ShouldBeFalse)
})
})
}
+1 -1
View File
@@ -83,7 +83,7 @@ func (g *dashboardGuardianImpl) checkAcl(permission m.PermissionType, acl []*m.D
for _, p := range acl {
// user match
if !g.user.IsAnonymous {
if !g.user.IsAnonymous && p.UserId > 0 {
if p.UserId == g.user.UserId && p.Permission >= permission {
return true, nil
}
+14 -3
View File
@@ -28,7 +28,7 @@ func TestGuardianAdmin(t *testing.T) {
Convey("Guardian admin org role tests", t, func() {
orgRoleScenario("Given user has admin org role", t, m.ROLE_ADMIN, func(sc *scenarioContext) {
// dashboard has default permissions
sc.defaultPermissionScenario(USER, m.PERMISSION_ADMIN, FULL_ACCESS)
sc.defaultPermissionScenario(USER, FULL_ACCESS)
// dashboard has user with permission
sc.dashboardPermissionScenario(USER, m.PERMISSION_ADMIN, FULL_ACCESS)
@@ -76,6 +76,9 @@ func TestGuardianAdmin(t *testing.T) {
func TestGuardianEditor(t *testing.T) {
Convey("Guardian editor org role tests", t, func() {
orgRoleScenario("Given user has editor org role", t, m.ROLE_EDITOR, func(sc *scenarioContext) {
// dashboard has default permissions
sc.defaultPermissionScenario(USER, EDITOR_ACCESS)
// dashboard has user with permission
sc.dashboardPermissionScenario(USER, m.PERMISSION_ADMIN, FULL_ACCESS)
sc.dashboardPermissionScenario(USER, m.PERMISSION_EDIT, EDITOR_ACCESS)
@@ -122,6 +125,9 @@ func TestGuardianEditor(t *testing.T) {
func TestGuardianViewer(t *testing.T) {
Convey("Guardian viewer org role tests", t, func() {
orgRoleScenario("Given user has viewer org role", t, m.ROLE_VIEWER, func(sc *scenarioContext) {
// dashboard has default permissions
sc.defaultPermissionScenario(USER, VIEWER_ACCESS)
// dashboard has user with permission
sc.dashboardPermissionScenario(USER, m.PERMISSION_ADMIN, FULL_ACCESS)
sc.dashboardPermissionScenario(USER, m.PERMISSION_EDIT, EDITOR_ACCESS)
@@ -162,10 +168,15 @@ func TestGuardianViewer(t *testing.T) {
sc.parentFolderPermissionScenario(VIEWER, m.PERMISSION_EDIT, EDITOR_ACCESS)
sc.parentFolderPermissionScenario(VIEWER, m.PERMISSION_VIEW, VIEWER_ACCESS)
})
apiKeyScenario("Given api key with viewer role", t, m.ROLE_VIEWER, func(sc *scenarioContext) {
// dashboard has default permissions
sc.defaultPermissionScenario(VIEWER, VIEWER_ACCESS)
})
})
}
func (sc *scenarioContext) defaultPermissionScenario(pt permissionType, permission m.PermissionType, flag permissionFlags) {
func (sc *scenarioContext) defaultPermissionScenario(pt permissionType, flag permissionFlags) {
_, callerFile, callerLine, _ := runtime.Caller(1)
sc.callerFile = callerFile
sc.callerLine = callerLine
@@ -267,7 +278,7 @@ func (sc *scenarioContext) verifyExpectedPermissionsFlags() {
actualFlag = NO_ACCESS
}
if sc.expectedFlags&actualFlag != sc.expectedFlags {
if actualFlag&sc.expectedFlags != actualFlag {
sc.reportFailure(tc, sc.expectedFlags.String(), actualFlag.String())
}
@@ -48,6 +48,27 @@ func orgRoleScenario(desc string, t *testing.T, role m.RoleType, fn scenarioFunc
})
}
func apiKeyScenario(desc string, t *testing.T, role m.RoleType, fn scenarioFunc) {
user := &m.SignedInUser{
UserId: 0,
OrgId: orgID,
OrgRole: role,
ApiKeyId: 10,
}
guard := New(dashboardID, orgID, user)
sc := &scenarioContext{
t: t,
orgRoleScenario: desc,
givenUser: user,
givenDashboardID: dashboardID,
g: guard,
}
Convey(desc, func() {
fn(sc)
})
}
func permissionScenario(desc string, dashboardID int64, sc *scenarioContext, permissions []*m.DashboardAclInfoDTO, fn scenarioFunc) {
bus.ClearBusHandlers()
+2 -2
View File
@@ -45,8 +45,8 @@ func (ns *NotificationService) Init() error {
ns.Bus.AddHandler(ns.validateResetPasswordCode)
ns.Bus.AddHandler(ns.sendEmailCommandHandler)
ns.Bus.AddCtxHandler(ns.sendEmailCommandHandlerSync)
ns.Bus.AddCtxHandler(ns.SendWebhookSync)
ns.Bus.AddHandlerCtx(ns.sendEmailCommandHandlerSync)
ns.Bus.AddHandlerCtx(ns.SendWebhookSync)
ns.Bus.AddEventListener(ns.signUpStartedHandler)
ns.Bus.AddEventListener(ns.signUpCompletedHandler)
+1
View File
@@ -10,6 +10,7 @@ import (
var ErrTimeout = errors.New("Timeout error. You can set timeout in seconds with &timeout url parameter")
var ErrNoRenderer = errors.New("No renderer plugin found nor is an external render server configured")
var ErrPhantomJSNotInstalled = errors.New("PhantomJS executable not found")
type Opts struct {
Width int
+5
View File
@@ -24,6 +24,11 @@ func (rs *RenderingService) renderViaPhantomJS(ctx context.Context, opts Opts) (
url := rs.getURL(opts.Path)
binPath, _ := filepath.Abs(filepath.Join(rs.Cfg.PhantomDir, executable))
if _, err := os.Stat(binPath); os.IsNotExist(err) {
rs.log.Error("executable not found", "executable", binPath)
return nil, ErrPhantomJSNotInstalled
}
scriptPath, _ := filepath.Abs(filepath.Join(rs.Cfg.PhantomDir, "render.js"))
pngPath := rs.getFilePathForNewImage()
+10 -9
View File
@@ -238,18 +238,19 @@ func (r *SqlAnnotationRepo) Delete(params *annotations.DeleteParams) error {
queryParams []interface{}
)
sqlog.Info("delete", "orgId", params.OrgId)
if params.RegionId != 0 {
annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE region_id = ?)"
sql = "DELETE FROM annotation WHERE region_id = ?"
queryParams = []interface{}{params.RegionId}
annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE region_id = ? AND org_id = ?)"
sql = "DELETE FROM annotation WHERE region_id = ? AND org_id = ?"
queryParams = []interface{}{params.RegionId, params.OrgId}
} else if params.Id != 0 {
annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE id = ?)"
sql = "DELETE FROM annotation WHERE id = ?"
queryParams = []interface{}{params.Id}
annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE id = ? AND org_id = ?)"
sql = "DELETE FROM annotation WHERE id = ? AND org_id = ?"
queryParams = []interface{}{params.Id, params.OrgId}
} else {
annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE dashboard_id = ? AND panel_id = ?)"
sql = "DELETE FROM annotation WHERE dashboard_id = ? AND panel_id = ?"
queryParams = []interface{}{params.DashboardId, params.PanelId}
annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE dashboard_id = ? AND panel_id = ? AND org_id = ?)"
sql = "DELETE FROM annotation WHERE dashboard_id = ? AND panel_id = ? AND org_id = ?"
queryParams = []interface{}{params.DashboardId, params.PanelId, params.OrgId}
}
if _, err := sess.Exec(annoTagSql, queryParams...); err != nil {
+1 -1
View File
@@ -268,7 +268,7 @@ func TestAnnotations(t *testing.T) {
annotationId := items[0].Id
err = repo.Delete(&annotations.DeleteParams{Id: annotationId})
err = repo.Delete(&annotations.DeleteParams{Id: annotationId, OrgId: 1})
So(err, ShouldBeNil)
items, err = repo.Find(query)
+4 -3
View File
@@ -1,6 +1,7 @@
package sqlstore
import (
"context"
"time"
"github.com/grafana/grafana/pkg/bus"
@@ -11,7 +12,7 @@ func init() {
bus.AddHandler("sql", GetApiKeys)
bus.AddHandler("sql", GetApiKeyById)
bus.AddHandler("sql", GetApiKeyByName)
bus.AddHandler("sql", DeleteApiKey)
bus.AddHandlerCtx("sql", DeleteApiKeyCtx)
bus.AddHandler("sql", AddApiKey)
}
@@ -22,8 +23,8 @@ func GetApiKeys(query *m.GetApiKeysQuery) error {
return sess.Find(&query.Result)
}
func DeleteApiKey(cmd *m.DeleteApiKeyCommand) error {
return inTransaction(func(sess *DBSession) error {
func DeleteApiKeyCtx(ctx context.Context, cmd *m.DeleteApiKeyCommand) error {
return withDbSession(ctx, func(sess *DBSession) error {
var rawSql = "DELETE FROM api_key WHERE id=? and org_id=?"
_, err := sess.Exec(rawSql, cmd.Id, cmd.OrgId)
return err
+2 -1
View File
@@ -1,6 +1,7 @@
package sqlstore
import (
"context"
"fmt"
"testing"
"time"
@@ -389,7 +390,7 @@ func createUser(name string, role string, isAdmin bool) m.User {
setting.AutoAssignOrgRole = role
currentUserCmd := m.CreateUserCommand{Login: name, Email: name + "@test.com", Name: "a " + name, IsAdmin: isAdmin}
err := CreateUser(&currentUserCmd)
err := CreateUser(context.Background(), &currentUserCmd)
So(err, ShouldBeNil)
q1 := m.GetUserOrgListQuery{UserId: currentUserCmd.Result.Id}
+20 -7
View File
@@ -1,6 +1,7 @@
package sqlstore
import (
"context"
"testing"
"time"
@@ -22,9 +23,9 @@ func TestAccountDataAccess(t *testing.T) {
ac1cmd := m.CreateUserCommand{Login: "ac1", Email: "ac1@test.com", Name: "ac1 name"}
ac2cmd := m.CreateUserCommand{Login: "ac2", Email: "ac2@test.com", Name: "ac2 name"}
err := CreateUser(&ac1cmd)
err := CreateUser(context.Background(), &ac1cmd)
So(err, ShouldBeNil)
err = CreateUser(&ac2cmd)
err = CreateUser(context.Background(), &ac2cmd)
So(err, ShouldBeNil)
q1 := m.GetUserOrgListQuery{UserId: ac1cmd.Result.Id}
@@ -43,8 +44,8 @@ func TestAccountDataAccess(t *testing.T) {
ac1cmd := m.CreateUserCommand{Login: "ac1", Email: "ac1@test.com", Name: "ac1 name"}
ac2cmd := m.CreateUserCommand{Login: "ac2", Email: "ac2@test.com", Name: "ac2 name", IsAdmin: true}
err := CreateUser(&ac1cmd)
err = CreateUser(&ac2cmd)
err := CreateUser(context.Background(), &ac1cmd)
err = CreateUser(context.Background(), &ac2cmd)
So(err, ShouldBeNil)
ac1 := ac1cmd.Result
@@ -150,7 +151,7 @@ func TestAccountDataAccess(t *testing.T) {
})
Convey("Can set using org", func() {
cmd := m.SetUsingOrgCommand{UserId: ac2.Id, OrgId: ac1.Id}
cmd := m.SetUsingOrgCommand{UserId: ac2.Id, OrgId: ac1.OrgId}
err := SetUsingOrg(&cmd)
So(err, ShouldBeNil)
@@ -159,13 +160,25 @@ func TestAccountDataAccess(t *testing.T) {
err := GetSignedInUser(&query)
So(err, ShouldBeNil)
So(query.Result.OrgId, ShouldEqual, ac1.Id)
So(query.Result.OrgId, ShouldEqual, ac1.OrgId)
So(query.Result.Email, ShouldEqual, "ac2@test.com")
So(query.Result.Name, ShouldEqual, "ac2 name")
So(query.Result.Login, ShouldEqual, "ac2")
So(query.Result.OrgName, ShouldEqual, "ac1@test.com")
So(query.Result.OrgRole, ShouldEqual, "Viewer")
})
Convey("Should set last org as current when removing user from current", func() {
remCmd := m.RemoveOrgUserCommand{OrgId: ac1.OrgId, UserId: ac2.Id}
err := RemoveOrgUser(&remCmd)
So(err, ShouldBeNil)
query := m.GetSignedInUserQuery{UserId: ac2.Id}
err = GetSignedInUser(&query)
So(err, ShouldBeNil)
So(query.Result.OrgId, ShouldEqual, ac2.OrgId)
})
})
Convey("Cannot delete last admin org user", func() {
@@ -182,7 +195,7 @@ func TestAccountDataAccess(t *testing.T) {
Convey("Given an org user with dashboard permissions", func() {
ac3cmd := m.CreateUserCommand{Login: "ac3", Email: "ac3@test.com", Name: "ac3 name", IsAdmin: false}
err := CreateUser(&ac3cmd)
err := CreateUser(context.Background(), &ac3cmd)
So(err, ShouldBeNil)
ac3 := ac3cmd.Result
+62 -2
View File
@@ -20,7 +20,14 @@ func init() {
func AddOrgUser(cmd *m.AddOrgUserCommand) error {
return inTransaction(func(sess *DBSession) error {
// check if user exists
if res, err := sess.Query("SELECT 1 from org_user WHERE org_id=? and user_id=?", cmd.OrgId, cmd.UserId); err != nil {
var user m.User
if exists, err := sess.Id(cmd.UserId).Get(&user); err != nil {
return err
} else if !exists {
return m.ErrUserNotFound
}
if res, err := sess.Query("SELECT 1 from org_user WHERE org_id=? and user_id=?", cmd.OrgId, user.Id); err != nil {
return err
} else if len(res) == 1 {
return m.ErrOrgUserAlreadyAdded
@@ -41,7 +48,26 @@ func AddOrgUser(cmd *m.AddOrgUserCommand) error {
}
_, err := sess.Insert(&entity)
return err
if err != nil {
return err
}
var userOrgs []*m.UserOrgDTO
sess.Table("org_user")
sess.Join("INNER", "org", "org_user.org_id=org.id")
sess.Where("org_user.user_id=? AND org_user.org_id=?", user.Id, user.OrgId)
sess.Cols("org.name", "org_user.role", "org_user.org_id")
err = sess.Find(&userOrgs)
if err != nil {
return err
}
if len(userOrgs) == 0 {
return setUsingOrgInTransaction(sess, user.Id, cmd.OrgId)
}
return nil
})
}
@@ -110,6 +136,14 @@ func GetOrgUsers(query *m.GetOrgUsersQuery) error {
func RemoveOrgUser(cmd *m.RemoveOrgUserCommand) error {
return inTransaction(func(sess *DBSession) error {
// check if user exists
var user m.User
if exists, err := sess.Id(cmd.UserId).Get(&user); err != nil {
return err
} else if !exists {
return m.ErrUserNotFound
}
deletes := []string{
"DELETE FROM org_user WHERE org_id=? and user_id=?",
"DELETE FROM dashboard_acl WHERE org_id=? and user_id = ?",
@@ -123,6 +157,32 @@ func RemoveOrgUser(cmd *m.RemoveOrgUserCommand) error {
}
}
var userOrgs []*m.UserOrgDTO
sess.Table("org_user")
sess.Join("INNER", "org", "org_user.org_id=org.id")
sess.Where("org_user.user_id=?", user.Id)
sess.Cols("org.name", "org_user.role", "org_user.org_id")
err := sess.Find(&userOrgs)
if err != nil {
return err
}
hasCurrentOrgSet := false
for _, userOrg := range userOrgs {
if user.OrgId == userOrg.OrgId {
hasCurrentOrgSet = true
break
}
}
if !hasCurrentOrgSet && len(userOrgs) > 0 {
err = setUsingOrgInTransaction(sess, user.Id, userOrgs[0].OrgId)
if err != nil {
return err
}
}
return validateOneAdminLeftInOrg(cmd.OrgId, sess)
})
}
+71
View File
@@ -0,0 +1,71 @@
package sqlstore
import (
"context"
"reflect"
"github.com/go-xorm/xorm"
)
type DBSession struct {
*xorm.Session
events []interface{}
}
type dbTransactionFunc func(sess *DBSession) error
func (sess *DBSession) publishAfterCommit(msg interface{}) {
sess.events = append(sess.events, msg)
}
func newSession() *DBSession {
return &DBSession{Session: x.NewSession()}
}
func startSession(ctx context.Context, engine *xorm.Engine, beginTran bool) (*DBSession, error) {
value := ctx.Value(ContextSessionName)
var sess *DBSession
sess, ok := value.(*DBSession)
if ok {
return sess, nil
}
newSess := &DBSession{Session: engine.NewSession()}
if beginTran {
err := newSess.Begin()
if err != nil {
return nil, err
}
}
return newSess, nil
}
func withDbSession(ctx context.Context, callback dbTransactionFunc) error {
sess, err := startSession(ctx, x, false)
if err != nil {
return err
}
return callback(sess)
}
func (sess *DBSession) InsertId(bean interface{}) (int64, error) {
table := sess.DB().Mapper.Obj2Table(getTypeName(bean))
dialect.PreInsertId(table, sess.Session)
id, err := sess.Session.InsertOne(bean)
dialect.PostInsertId(table, sess.Session)
return id, err
}
func getTypeName(bean interface{}) (res string) {
t := reflect.TypeOf(bean)
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
return t.Name()
}
-90
View File
@@ -1,90 +0,0 @@
package sqlstore
import (
"reflect"
"time"
"github.com/go-xorm/xorm"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/log"
sqlite3 "github.com/mattn/go-sqlite3"
)
type DBSession struct {
*xorm.Session
events []interface{}
}
type dbTransactionFunc func(sess *DBSession) error
func (sess *DBSession) publishAfterCommit(msg interface{}) {
sess.events = append(sess.events, msg)
}
func newSession() *DBSession {
return &DBSession{Session: x.NewSession()}
}
func inTransaction(callback dbTransactionFunc) error {
return inTransactionWithRetry(callback, 0)
}
func inTransactionWithRetry(callback dbTransactionFunc, retry int) error {
var err error
sess := newSession()
defer sess.Close()
if err = sess.Begin(); err != nil {
return err
}
err = callback(sess)
// special handling of database locked errors for sqlite, then we can retry 3 times
if sqlError, ok := err.(sqlite3.Error); ok && retry < 5 {
if sqlError.Code == sqlite3.ErrLocked {
sess.Rollback()
time.Sleep(time.Millisecond * time.Duration(10))
sqlog.Info("Database table locked, sleeping then retrying", "retry", retry)
return inTransactionWithRetry(callback, retry+1)
}
}
if err != nil {
sess.Rollback()
return err
} else if err = sess.Commit(); err != nil {
return err
}
if len(sess.events) > 0 {
for _, e := range sess.events {
if err = bus.Publish(e); err != nil {
log.Error(3, "Failed to publish event after commit", err)
}
}
}
return nil
}
func (sess *DBSession) InsertId(bean interface{}) (int64, error) {
table := sess.DB().Mapper.Obj2Table(getTypeName(bean))
dialect.PreInsertId(table, sess.Session)
id, err := sess.Session.InsertOne(bean)
dialect.PostInsertId(table, sess.Session)
return id, err
}
func getTypeName(bean interface{}) (res string) {
t := reflect.TypeOf(bean)
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
return t.Name()
}
+92 -20
View File
@@ -1,6 +1,7 @@
package sqlstore
import (
"context"
"fmt"
"net/url"
"os"
@@ -22,10 +23,10 @@ import (
"github.com/go-sql-driver/mysql"
"github.com/go-xorm/xorm"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
_ "github.com/grafana/grafana/pkg/tsdb/mssql"
_ "github.com/lib/pq"
sqlite3 "github.com/mattn/go-sqlite3"
)
var (
@@ -35,6 +36,8 @@ var (
sqlog log.Logger = log.New("sqlstore")
)
const ContextSessionName = "db-session"
func init() {
registry.Register(&registry.Descriptor{
Name: "SqlStore",
@@ -45,6 +48,7 @@ func init() {
type SqlStore struct {
Cfg *setting.Cfg `inject:""`
Bus bus.Bus `inject:""`
dbCfg DatabaseConfig
engine *xorm.Engine
@@ -52,6 +56,64 @@ type SqlStore struct {
skipEnsureAdmin bool
}
// NewSession returns a new DBSession
func (ss *SqlStore) NewSession() *DBSession {
return &DBSession{Session: ss.engine.NewSession()}
}
// WithDbSession calls the callback with an session attached to the context.
func (ss *SqlStore) WithDbSession(ctx context.Context, callback dbTransactionFunc) error {
sess, err := startSession(ctx, ss.engine, false)
if err != nil {
return err
}
return callback(sess)
}
// WithTransactionalDbSession calls the callback with an session within a transaction
func (ss *SqlStore) WithTransactionalDbSession(ctx context.Context, callback dbTransactionFunc) error {
return ss.inTransactionWithRetryCtx(ctx, callback, 0)
}
func (ss *SqlStore) inTransactionWithRetryCtx(ctx context.Context, callback dbTransactionFunc, retry int) error {
sess, err := startSession(ctx, ss.engine, true)
if err != nil {
return err
}
defer sess.Close()
err = callback(sess)
// special handling of database locked errors for sqlite, then we can retry 3 times
if sqlError, ok := err.(sqlite3.Error); ok && retry < 5 {
if sqlError.Code == sqlite3.ErrLocked {
sess.Rollback()
time.Sleep(time.Millisecond * time.Duration(10))
sqlog.Info("Database table locked, sleeping then retrying", "retry", retry)
return ss.inTransactionWithRetryCtx(ctx, callback, retry+1)
}
}
if err != nil {
sess.Rollback()
return err
} else if err = sess.Commit(); err != nil {
return err
}
if len(sess.events) > 0 {
for _, e := range sess.events {
if err = bus.Publish(e); err != nil {
log.Error(3, "Failed to publish event after commit", err)
}
}
}
return nil
}
func (ss *SqlStore) Init() error {
ss.log = log.New("sqlstore")
ss.readConfig()
@@ -77,6 +139,8 @@ func (ss *SqlStore) Init() error {
// Init repo instances
annotations.SetRepository(&SqlAnnotationRepo{})
ss.Bus.SetTransactionManager(ss)
// ensure admin user
if ss.skipEnsureAdmin {
return nil
@@ -88,27 +152,33 @@ func (ss *SqlStore) Init() error {
func (ss *SqlStore) ensureAdminUser() error {
systemUserCountQuery := m.GetSystemUserCountStatsQuery{}
if err := bus.Dispatch(&systemUserCountQuery); err != nil {
return fmt.Errorf("Could not determine if admin user exists: %v", err)
}
err := ss.InTransaction(context.Background(), func(ctx context.Context) error {
err := bus.DispatchCtx(ctx, &systemUserCountQuery)
if err != nil {
return fmt.Errorf("Could not determine if admin user exists: %v", err)
}
if systemUserCountQuery.Result.Count > 0 {
return nil
}
cmd := m.CreateUserCommand{}
cmd.Login = setting.AdminUser
cmd.Email = setting.AdminUser + "@localhost"
cmd.Password = setting.AdminPassword
cmd.IsAdmin = true
if err := bus.DispatchCtx(ctx, &cmd); err != nil {
return fmt.Errorf("Failed to create admin user: %v", err)
}
ss.log.Info("Created default admin", "user", setting.AdminUser)
if systemUserCountQuery.Result.Count > 0 {
return nil
}
})
cmd := m.CreateUserCommand{}
cmd.Login = setting.AdminUser
cmd.Email = setting.AdminUser + "@localhost"
cmd.Password = setting.AdminPassword
cmd.IsAdmin = true
if err := bus.Dispatch(&cmd); err != nil {
return fmt.Errorf("Failed to create admin user: %v", err)
}
ss.log.Info("Created default admin user: %v", setting.AdminUser)
return nil
return err
}
func (ss *SqlStore) buildConnectionString() (string, error) {
@@ -238,8 +308,10 @@ func (ss *SqlStore) readConfig() {
}
func InitTestDB(t *testing.T) *SqlStore {
t.Helper()
sqlstore := &SqlStore{}
sqlstore.skipEnsureAdmin = true
sqlstore.Bus = bus.New()
dbType := migrator.SQLITE
+15 -11
View File
@@ -1,6 +1,7 @@
package sqlstore
import (
"context"
"time"
"github.com/grafana/grafana/pkg/bus"
@@ -12,7 +13,7 @@ func init() {
bus.AddHandler("sql", GetDataSourceStats)
bus.AddHandler("sql", GetDataSourceAccessStats)
bus.AddHandler("sql", GetAdminStats)
bus.AddHandler("sql", GetSystemUserCountStats)
bus.AddHandlerCtx("sql", GetSystemUserCountStats)
}
var activeUserTimeLimit = time.Hour * 24 * 30
@@ -133,15 +134,18 @@ func GetAdminStats(query *m.GetAdminStatsQuery) error {
return err
}
func GetSystemUserCountStats(query *m.GetSystemUserCountStatsQuery) error {
var rawSql = `SELECT COUNT(id) AS Count FROM ` + dialect.Quote("user")
var stats m.SystemUserCountStats
_, err := x.SQL(rawSql).Get(&stats)
if err != nil {
func GetSystemUserCountStats(ctx context.Context, query *m.GetSystemUserCountStatsQuery) error {
return withDbSession(ctx, func(sess *DBSession) error {
var rawSql = `SELECT COUNT(id) AS Count FROM ` + dialect.Quote("user")
var stats m.SystemUserCountStats
_, err := sess.SQL(rawSql).Get(&stats)
if err != nil {
return err
}
query.Result = &stats
return err
}
query.Result = &stats
return err
})
}
+2 -1
View File
@@ -1,6 +1,7 @@
package sqlstore
import (
"context"
"testing"
m "github.com/grafana/grafana/pkg/models"
@@ -20,7 +21,7 @@ func TestStatsDataAccess(t *testing.T) {
Convey("Get system user count stats should not results in error", func() {
query := m.GetSystemUserCountStatsQuery{}
err := GetSystemUserCountStats(&query)
err := GetSystemUserCountStats(context.Background(), &query)
So(err, ShouldBeNil)
})
+2 -1
View File
@@ -1,6 +1,7 @@
package sqlstore
import (
"context"
"fmt"
"testing"
@@ -22,7 +23,7 @@ func TestTeamCommandsAndQueries(t *testing.T) {
Name: fmt.Sprint("user", i),
Login: fmt.Sprint("loginuser", i),
}
err := CreateUser(userCmd)
err := CreateUser(context.Background(), userCmd)
So(err, ShouldBeNil)
userIds = append(userIds, userCmd.Result.Id)
}
+106
View File
@@ -0,0 +1,106 @@
package sqlstore
import (
"context"
"time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/log"
sqlite3 "github.com/mattn/go-sqlite3"
)
func (ss *SqlStore) InTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
return ss.inTransactionWithRetry(ctx, fn, 0)
}
func (ss *SqlStore) inTransactionWithRetry(ctx context.Context, fn func(ctx context.Context) error, retry int) error {
sess, err := startSession(ctx, ss.engine, true)
if err != nil {
return err
}
defer sess.Close()
withValue := context.WithValue(ctx, ContextSessionName, sess)
err = fn(withValue)
// special handling of database locked errors for sqlite, then we can retry 3 times
if sqlError, ok := err.(sqlite3.Error); ok && retry < 5 {
if sqlError.Code == sqlite3.ErrLocked {
sess.Rollback()
time.Sleep(time.Millisecond * time.Duration(10))
ss.log.Info("Database table locked, sleeping then retrying", "retry", retry)
return ss.inTransactionWithRetry(ctx, fn, retry+1)
}
}
if err != nil {
sess.Rollback()
return err
}
if err = sess.Commit(); err != nil {
return err
}
if len(sess.events) > 0 {
for _, e := range sess.events {
if err = bus.Publish(e); err != nil {
ss.log.Error("Failed to publish event after commit", err)
}
}
}
return nil
}
func inTransactionWithRetry(callback dbTransactionFunc, retry int) error {
return inTransactionWithRetryCtx(context.Background(), callback, retry)
}
func inTransactionWithRetryCtx(ctx context.Context, callback dbTransactionFunc, retry int) error {
sess, err := startSession(ctx, x, true)
if err != nil {
return err
}
defer sess.Close()
err = callback(sess)
// special handling of database locked errors for sqlite, then we can retry 3 times
if sqlError, ok := err.(sqlite3.Error); ok && retry < 5 {
if sqlError.Code == sqlite3.ErrLocked {
sess.Rollback()
time.Sleep(time.Millisecond * time.Duration(10))
sqlog.Info("Database table locked, sleeping then retrying", "retry", retry)
return inTransactionWithRetry(callback, retry+1)
}
}
if err != nil {
sess.Rollback()
return err
} else if err = sess.Commit(); err != nil {
return err
}
if len(sess.events) > 0 {
for _, e := range sess.events {
if err = bus.Publish(e); err != nil {
log.Error(3, "Failed to publish event after commit", err)
}
}
}
return nil
}
func inTransaction(callback dbTransactionFunc) error {
return inTransactionWithRetry(callback, 0)
}
func inTransactionCtx(ctx context.Context, callback dbTransactionFunc) error {
return inTransactionWithRetryCtx(ctx, callback, 0)
}
@@ -0,0 +1,60 @@
package sqlstore
import (
"context"
"errors"
"testing"
"github.com/grafana/grafana/pkg/models"
. "github.com/smartystreets/goconvey/convey"
)
type testQuery struct {
result bool
}
var ProvokedError = errors.New("testing error.")
func TestTransaction(t *testing.T) {
ss := InitTestDB(t)
Convey("InTransaction asdf asdf", t, func() {
cmd := &models.AddApiKeyCommand{Key: "secret-key", Name: "key", OrgId: 1}
err := AddApiKey(cmd)
So(err, ShouldBeNil)
deleteApiKeyCmd := &models.DeleteApiKeyCommand{Id: cmd.Result.Id, OrgId: 1}
Convey("can update key", func() {
err := ss.InTransaction(context.Background(), func(ctx context.Context) error {
return DeleteApiKeyCtx(ctx, deleteApiKeyCmd)
})
So(err, ShouldBeNil)
query := &models.GetApiKeyByIdQuery{ApiKeyId: cmd.Result.Id}
err = GetApiKeyById(query)
So(err, ShouldEqual, models.ErrInvalidApiKey)
})
Convey("wont update if one handler fails", func() {
err := ss.InTransaction(context.Background(), func(ctx context.Context) error {
err := DeleteApiKeyCtx(ctx, deleteApiKeyCmd)
if err != nil {
return err
}
return ProvokedError
})
So(err, ShouldEqual, ProvokedError)
query := &models.GetApiKeyByIdQuery{ApiKeyId: cmd.Result.Id}
err = GetApiKeyById(query)
So(err, ShouldBeNil)
So(query.Result.Id, ShouldEqual, cmd.Result.Id)
})
})
}
+16 -10
View File
@@ -1,6 +1,7 @@
package sqlstore
import (
"context"
"strconv"
"strings"
"time"
@@ -15,7 +16,7 @@ import (
)
func init() {
bus.AddHandler("sql", CreateUser)
//bus.AddHandler("sql", CreateUser)
bus.AddHandler("sql", GetUserById)
bus.AddHandler("sql", UpdateUser)
bus.AddHandler("sql", ChangeUserPassword)
@@ -30,6 +31,7 @@ func init() {
bus.AddHandler("sql", DeleteUser)
bus.AddHandler("sql", UpdateUserPermissions)
bus.AddHandler("sql", SetUserHelpFlag)
bus.AddHandlerCtx("sql", CreateUser)
}
func getOrgIdForNewUser(cmd *m.CreateUserCommand, sess *DBSession) (int64, error) {
@@ -79,8 +81,8 @@ func getOrgIdForNewUser(cmd *m.CreateUserCommand, sess *DBSession) (int64, error
return org.Id, nil
}
func CreateUser(cmd *m.CreateUserCommand) error {
return inTransaction(func(sess *DBSession) error {
func CreateUser(ctx context.Context, cmd *m.CreateUserCommand) error {
return inTransactionCtx(ctx, func(sess *DBSession) error {
orgId, err := getOrgIdForNewUser(cmd, sess)
if err != nil {
return err
@@ -290,16 +292,20 @@ func SetUsingOrg(cmd *m.SetUsingOrgCommand) error {
}
return inTransaction(func(sess *DBSession) error {
user := m.User{
Id: cmd.UserId,
OrgId: cmd.OrgId,
}
_, err := sess.Id(cmd.UserId).Update(&user)
return err
return setUsingOrgInTransaction(sess, cmd.UserId, cmd.OrgId)
})
}
func setUsingOrgInTransaction(sess *DBSession, userID int64, orgID int64) error {
user := m.User{
Id: userID,
OrgId: orgID,
}
_, err := sess.Id(userID).Update(&user)
return err
}
func GetUserProfile(query *m.GetUserProfileQuery) error {
var user m.User
has, err := x.Id(query.UserId).Get(&user)
+2 -1
View File
@@ -1,6 +1,7 @@
package sqlstore
import (
"context"
"fmt"
"testing"
@@ -22,7 +23,7 @@ func TestUserAuth(t *testing.T) {
Name: fmt.Sprint("user", i),
Login: fmt.Sprint("loginuser", i),
}
err = CreateUser(cmd)
err = CreateUser(context.Background(), cmd)
So(err, ShouldBeNil)
users = append(users, cmd.Result)
}
+9 -8
View File
@@ -1,6 +1,7 @@
package sqlstore
import (
"context"
"fmt"
"testing"
@@ -24,7 +25,7 @@ func TestUserDataAccess(t *testing.T) {
Name: fmt.Sprint("user", i),
Login: fmt.Sprint("loginuser", i),
}
err = CreateUser(cmd)
err = CreateUser(context.Background(), cmd)
So(err, ShouldBeNil)
users = append(users, cmd.Result)
}
@@ -96,33 +97,33 @@ func TestUserDataAccess(t *testing.T) {
})
Convey("when a user is an org member and has been assigned permissions", func() {
err = AddOrgUser(&m.AddOrgUserCommand{LoginOrEmail: users[0].Login, Role: m.ROLE_VIEWER, OrgId: users[0].OrgId})
err = AddOrgUser(&m.AddOrgUserCommand{LoginOrEmail: users[1].Login, Role: m.ROLE_VIEWER, OrgId: users[0].OrgId, UserId: users[1].Id})
So(err, ShouldBeNil)
testHelperUpdateDashboardAcl(1, m.DashboardAcl{DashboardId: 1, OrgId: users[0].OrgId, UserId: users[0].Id, Permission: m.PERMISSION_EDIT})
testHelperUpdateDashboardAcl(1, m.DashboardAcl{DashboardId: 1, OrgId: users[0].OrgId, UserId: users[1].Id, Permission: m.PERMISSION_EDIT})
So(err, ShouldBeNil)
err = SavePreferences(&m.SavePreferencesCommand{UserId: users[0].Id, OrgId: users[0].OrgId, HomeDashboardId: 1, Theme: "dark"})
err = SavePreferences(&m.SavePreferencesCommand{UserId: users[1].Id, OrgId: users[0].OrgId, HomeDashboardId: 1, Theme: "dark"})
So(err, ShouldBeNil)
Convey("when the user is deleted", func() {
err = DeleteUser(&m.DeleteUserCommand{UserId: users[0].Id})
err = DeleteUser(&m.DeleteUserCommand{UserId: users[1].Id})
So(err, ShouldBeNil)
Convey("Should delete connected org users and permissions", func() {
query := &m.GetOrgUsersQuery{OrgId: 1}
query := &m.GetOrgUsersQuery{OrgId: users[0].OrgId}
err = GetOrgUsersForTest(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 1)
permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 1, OrgId: 1}
permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 1, OrgId: users[0].OrgId}
err = GetDashboardAclInfoList(permQuery)
So(err, ShouldBeNil)
So(len(permQuery.Result), ShouldEqual, 0)
prefsQuery := &m.GetPreferencesQuery{OrgId: users[0].OrgId, UserId: users[0].Id}
prefsQuery := &m.GetPreferencesQuery{OrgId: users[0].OrgId, UserId: users[1].Id}
err = GetPreferences(prefsQuery)
So(err, ShouldBeNil)
+4
View File
@@ -20,6 +20,7 @@ import (
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/util"
"time"
)
type Scheme string
@@ -195,6 +196,8 @@ type Cfg struct {
PhantomDir string
RendererUrl string
DisableBruteForceLoginProtection bool
TempDataLifetime time.Duration
}
type CommandLineArgs struct {
@@ -637,6 +640,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
cfg.RendererUrl = renderSec.Key("server_url").String()
cfg.ImagesDir = filepath.Join(DataPath, "png")
cfg.PhantomDir = filepath.Join(HomePath, "tools/phantomjs")
cfg.TempDataLifetime = iniFile.Section("paths").Key("temp_data_lifetime").MustDuration(time.Second * 3600 * 24)
analytics := iniFile.Section("analytics")
ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true)
+5
View File
@@ -3,6 +3,7 @@ package cloudwatch
import (
"context"
"errors"
"fmt"
"regexp"
"sort"
"strconv"
@@ -144,6 +145,10 @@ func (e *CloudWatchExecutor) executeQuery(ctx context.Context, parameters *simpl
return nil, err
}
if endTime.Before(startTime) {
return nil, fmt.Errorf("Invalid time range: End time can't be before start time")
}
params := &cloudwatch.GetMetricStatisticsInput{
Namespace: aws.String(query.Namespace),
MetricName: aws.String(query.MetricName),
@@ -63,7 +63,8 @@ export class SearchResultsCtrl {
}
onItemClick(item) {
if (this.$location.path().indexOf(item.url) > -1) {
//Check if one string can be found in the other
if (this.$location.path().indexOf(item.url) > -1 || item.url.indexOf(this.$location.path()) > -1) {
appEvents.emit('hide-dash-search');
}
}
+25
View File
@@ -0,0 +1,25 @@
import * as ticks from '../utils/ticks';
describe('ticks', () => {
describe('getFlotTickDecimals()', () => {
let ctx: any = {};
beforeEach(() => {
ctx.axis = {};
});
it('should calculate decimals precision based on graph height', () => {
let dec = ticks.getFlotTickDecimals(0, 10, ctx.axis, 200);
expect(dec.tickDecimals).toBe(1);
expect(dec.scaledDecimals).toBe(1);
dec = ticks.getFlotTickDecimals(0, 100, ctx.axis, 200);
expect(dec.tickDecimals).toBe(0);
expect(dec.scaledDecimals).toBe(-1);
dec = ticks.getFlotTickDecimals(0, 1, ctx.axis, 200);
expect(dec.tickDecimals).toBe(2);
expect(dec.scaledDecimals).toBe(3);
});
});
});
+52
View File
@@ -1,4 +1,5 @@
import TimeSeries from 'app/core/time_series2';
import { updateLegendValues } from 'app/core/time_series2';
describe('TimeSeries', function() {
var points, series;
@@ -311,4 +312,55 @@ describe('TimeSeries', function() {
expect(series.formatValue(-Infinity)).toBe('');
});
});
describe('legend decimals', function() {
let series, panel;
let height = 200;
beforeEach(function() {
testData = {
alias: 'test',
datapoints: [[1, 2], [0, 3], [10, 4], [8, 5]],
};
series = new TimeSeries(testData);
series.getFlotPairs();
panel = {
decimals: null,
yaxes: [
{
decimals: null,
},
],
};
});
it('should set decimals based on Y axis (expect calculated decimals = 1)', function() {
let data = [series];
// Expect ticks with this data will have decimals = 1
updateLegendValues(data, panel, height);
expect(data[0].decimals).toBe(2);
});
it('should set decimals based on Y axis to 0 if calculated decimals = 0)', function() {
testData.datapoints = [[10, 2], [0, 3], [100, 4], [80, 5]];
series = new TimeSeries(testData);
series.getFlotPairs();
let data = [series];
updateLegendValues(data, panel, height);
expect(data[0].decimals).toBe(0);
});
it('should set decimals to Y axis decimals + 1', function() {
panel.yaxes[0].decimals = 2;
let data = [series];
updateLegendValues(data, panel, height);
expect(data[0].decimals).toBe(3);
});
it('should set decimals to legend decimals value if it was set explicitly', function() {
panel.decimals = 3;
let data = [series];
updateLegendValues(data, panel, height);
expect(data[0].decimals).toBe(3);
});
});
});
+9 -5
View File
@@ -23,23 +23,27 @@ function translateFillOption(fill) {
* Calculate decimals for legend and update values for each series.
* @param data series data
* @param panel
* @param height
*/
export function updateLegendValues(data: TimeSeries[], panel) {
export function updateLegendValues(data: TimeSeries[], panel, height) {
for (let i = 0; i < data.length; i++) {
let series = data[i];
let yaxes = panel.yaxes;
const yaxes = panel.yaxes;
const seriesYAxis = series.yaxis || 1;
let axis = yaxes[seriesYAxis - 1];
let { tickDecimals, scaledDecimals } = getFlotTickDecimals(data, axis);
let formater = kbn.valueFormats[panel.yaxes[seriesYAxis - 1].format];
const axis = yaxes[seriesYAxis - 1];
let formater = kbn.valueFormats[axis.format];
// decimal override
if (_.isNumber(panel.decimals)) {
series.updateLegendValues(formater, panel.decimals, null);
} else if (_.isNumber(axis.decimals)) {
series.updateLegendValues(formater, axis.decimals + 1, null);
} else {
// auto decimals
// legend and tooltip gets one more decimal precision
// than graph legend ticks
const { datamin, datamax } = getDataMinMax(data);
let { tickDecimals, scaledDecimals } = getFlotTickDecimals(datamin, datamax, axis, height);
tickDecimals = (tickDecimals || -1) + 1;
series.updateLegendValues(formater, tickDecimals, scaledDecimals + 2);
}
+6 -12
View File
@@ -1,5 +1,3 @@
import { getDataMinMax } from 'app/core/time_series2';
/**
* Calculate tick step.
* Implementation from d3-array (ticks.js)
@@ -121,12 +119,10 @@ export function getFlotRange(panelMin, panelMax, datamin, datamax) {
* Calculate tick decimals.
* Implementation from Flot.
*/
export function getFlotTickDecimals(data, axis) {
let { datamin, datamax } = getDataMinMax(data);
let { min, max } = getFlotRange(axis.min, axis.max, datamin, datamax);
let noTicks = 3;
let tickDecimals, maxDec;
let delta = (max - min) / noTicks;
export function getFlotTickDecimals(datamin, datamax, axis, height) {
const { min, max } = getFlotRange(axis.min, axis.max, datamin, datamax);
const noTicks = 0.3 * Math.sqrt(height);
const delta = (max - min) / noTicks;
let dec = -Math.floor(Math.log(delta) / Math.LN10);
let magn = Math.pow(10, -dec);
@@ -139,19 +135,17 @@ export function getFlotTickDecimals(data, axis) {
} else if (norm < 3) {
size = 2;
// special case for 2.5, requires an extra decimal
if (norm > 2.25 && (maxDec == null || dec + 1 <= maxDec)) {
if (norm > 2.25) {
size = 2.5;
++dec;
}
} else if (norm < 7.5) {
size = 5;
} else {
size = 10;
}
size *= magn;
tickDecimals = Math.max(0, maxDec != null ? maxDec : dec);
const tickDecimals = Math.max(0, -Math.floor(Math.log(delta) / Math.LN10) + 1);
// grafana addition
const scaledDecimals = tickDecimals - Math.floor(Math.log(size) / Math.LN10);
return { tickDecimals, scaledDecimals };
@@ -75,6 +75,7 @@ export class AdminEditUserCtrl {
$scope.removeOrgUser = function(orgUser) {
backendSrv.delete('/api/orgs/' + orgUser.orgId + '/users/' + $scope.user_id).then(function() {
$scope.getUser($scope.user_id);
$scope.getUserOrgs($scope.user_id);
});
};
@@ -108,6 +109,7 @@ export class AdminEditUserCtrl {
$scope.newOrg.loginOrEmail = $scope.user.login;
backendSrv.post('/api/orgs/' + orgInfo.id + '/users/', $scope.newOrg).then(function() {
$scope.getUser($scope.user_id);
$scope.getUserOrgs($scope.user_id);
});
};
@@ -1,15 +1,18 @@
import { describe, beforeEach, it, expect, angularMocks } from 'test/lib/common';
import '../annotations_srv';
import helpers from 'test/specs/helpers';
import 'app/features/dashboard/time_srv';
describe('AnnotationsSrv', function() {
var ctx = new helpers.ServiceTestContext();
beforeEach(angularMocks.module('grafana.core'));
beforeEach(angularMocks.module('grafana.services'));
beforeEach(ctx.createService('timeSrv'));
beforeEach(() => {
ctx.createService('annotationsSrv');
});
describe('When translating the query result', () => {
const annotationSource = {
datasource: '-- Grafana --',
@@ -22,10 +22,10 @@ export class DashboardModel {
editable: any;
graphTooltip: any;
time: any;
originalTime: any;
private originalTime: any;
timepicker: any;
templating: any;
originalTemplating: any;
private originalTemplating: any;
annotations: any;
refresh: any;
snapshot: any;
@@ -50,6 +50,8 @@ export class DashboardModel {
meta: true,
panels: true, // needs special handling
templating: true, // needs special handling
originalTime: true,
originalTemplating: true,
};
constructor(data, meta?) {
@@ -70,12 +72,8 @@ export class DashboardModel {
this.editable = data.editable !== false;
this.graphTooltip = data.graphTooltip || 0;
this.time = data.time || { from: 'now-6h', to: 'now' };
this.originalTime = _.cloneDeep(this.time);
this.timepicker = data.timepicker || {};
this.templating = this.ensureListExist(data.templating);
this.originalTemplating = _.map(this.templating.list, variable => {
return { name: variable.name, current: _.clone(variable.current) };
});
this.annotations = this.ensureListExist(data.annotations);
this.refresh = data.refresh;
this.snapshot = data.snapshot;
@@ -85,6 +83,9 @@ export class DashboardModel {
this.gnetId = data.gnetId || null;
this.panels = _.map(data.panels || [], panelData => new PanelModel(panelData));
this.resetOriginalVariables();
this.resetOriginalTime();
this.initMeta(meta);
this.updateSchema(data);
@@ -138,8 +139,8 @@ export class DashboardModel {
// cleans meta data and other non persistent state
getSaveModelClone(options?) {
let defaults = _.defaults(options || {}, {
saveVariables: false,
saveTimerange: false,
saveVariables: true,
saveTimerange: true,
});
// make clone
@@ -153,15 +154,23 @@ export class DashboardModel {
}
// get variable save models
//console.log(this.templating.list);
copy.templating = {
list: _.map(this.templating.list, variable => (variable.getSaveModel ? variable.getSaveModel() : variable)),
};
if (!defaults.saveVariables && copy.templating.list.length === this.originalTemplating.length) {
if (!defaults.saveVariables) {
for (let i = 0; i < copy.templating.list.length; i++) {
if (copy.templating.list[i].name === this.originalTemplating[i].name) {
copy.templating.list[i].current = this.originalTemplating[i].current;
let current = copy.templating.list[i];
let original = _.find(this.originalTemplating, { name: current.name, type: current.type });
if (!original) {
continue;
}
if (current.type === 'adhoc') {
copy.templating.list[i].filters = original.filters;
} else {
copy.templating.list[i].current = original.current;
}
}
}
@@ -785,4 +794,40 @@ export class DashboardModel {
let migrator = new DashboardMigrator(this);
migrator.updateSchema(old);
}
resetOriginalTime() {
this.originalTime = _.cloneDeep(this.time);
}
hasTimeChanged() {
return !_.isEqual(this.time, this.originalTime);
}
resetOriginalVariables() {
this.originalTemplating = _.map(this.templating.list, variable => {
return {
name: variable.name,
type: variable.type,
current: _.cloneDeep(variable.current),
filters: _.cloneDeep(variable.filters),
};
});
}
hasVariableValuesChanged() {
if (this.templating.list.length !== this.originalTemplating.length) {
return false;
}
const updated = _.map(this.templating.list, variable => {
return {
name: variable.name,
type: variable.type,
current: _.cloneDeep(variable.current),
filters: _.cloneDeep(variable.filters),
};
});
return !_.isEqual(updated, this.originalTemplating);
}
}
@@ -63,8 +63,7 @@ export class DashboardExporter {
);
};
// check up panel data sources
for (let panel of saveModel.panels) {
const processPanel = panel => {
if (panel.datasource !== undefined) {
templateizeDatasourceUsage(panel);
}
@@ -86,6 +85,18 @@ export class DashboardExporter {
version: panelDef.info.version,
};
}
};
// check up panel data sources
for (let panel of saveModel.panels) {
processPanel(panel);
// handle collapsed rows
if (panel.collapsed !== undefined && panel.collapsed === true && panel.panels) {
for (let rowPanel of panel.panels) {
processPanel(rowPanel);
}
}
}
// templatize template vars
@@ -32,11 +32,11 @@ export interface DiffTarget {
export class HistorySrv {
/** @ngInject */
constructor(private backendSrv, private $q) {}
constructor(private backendSrv) {}
getHistoryList(dashboard: DashboardModel, options: HistoryListOpts) {
const id = dashboard && dashboard.id ? dashboard.id : void 0;
return id ? this.backendSrv.get(`api/dashboards/id/${id}/versions`, options) : this.$q.when([]);
return id ? this.backendSrv.get(`api/dashboards/id/${id}/versions`, options) : Promise.resolve([]);
}
calculateDiff(options: CalculateDiffOptions) {
@@ -46,7 +46,8 @@ export class HistorySrv {
restoreDashboard(dashboard: DashboardModel, version: number) {
const id = dashboard && dashboard.id ? dashboard.id : void 0;
const url = `api/dashboards/id/${id}/restore`;
return id && _.isNumber(version) ? this.backendSrv.post(url, { version }) : this.$q.when({});
return id && _.isNumber(version) ? this.backendSrv.post(url, { version }) : Promise.resolve({});
}
}
+15 -37
View File
@@ -1,5 +1,4 @@
import coreModule from 'app/core/core_module';
import _ from 'lodash';
const template = `
<div class="modal-body">
@@ -70,7 +69,6 @@ export class SaveDashboardModalCtrl {
message: string;
saveVariables = false;
saveTimerange = false;
templating: any;
time: any;
originalTime: any;
current = [];
@@ -87,40 +85,8 @@ export class SaveDashboardModalCtrl {
this.message = '';
this.max = 64;
this.isSaving = false;
this.templating = dashboardSrv.dash.templating.list;
this.compareTemplating();
this.compareTime();
}
compareTime() {
if (_.isEqual(this.dashboardSrv.dash.time, this.dashboardSrv.dash.originalTime)) {
this.timeChange = false;
} else {
this.timeChange = true;
}
}
compareTemplating() {
//checks if variables has been added or removed, if so variables will be saved automatically
if (this.dashboardSrv.dash.originalTemplating.length !== this.dashboardSrv.dash.templating.list.length) {
return (this.variableValueChange = false);
}
//checks if variable value has changed
if (this.dashboardSrv.dash.templating.list.length > 0) {
for (let i = 0; i < this.dashboardSrv.dash.templating.list.length; i++) {
if (
this.dashboardSrv.dash.templating.list[i].current.text !==
this.dashboardSrv.dash.originalTemplating[i].current.text
) {
return (this.variableValueChange = true);
}
}
return (this.variableValueChange = false);
} else {
return (this.variableValueChange = false);
}
this.timeChange = this.dashboardSrv.getCurrent().hasTimeChanged();
this.variableValueChange = this.dashboardSrv.getCurrent().hasVariableValuesChanged();
}
save() {
@@ -139,7 +105,19 @@ export class SaveDashboardModalCtrl {
this.isSaving = true;
return this.dashboardSrv.save(saveModel, options).then(this.dismiss);
return this.dashboardSrv.save(saveModel, options).then(this.postSave.bind(this, options));
}
postSave(options) {
if (options.saveVariables) {
this.dashboardSrv.getCurrent().resetOriginalVariables();
}
if (options.saveTimerange) {
this.dashboardSrv.getCurrent().resetOriginalTime();
}
this.dismiss();
}
}
@@ -123,6 +123,9 @@ export class ShareSnapshotCtrl {
enable: annotation.enable,
iconColor: annotation.iconColor,
snapshotData: annotation.snapshotData,
type: annotation.type,
builtIn: annotation.builtIn,
hide: annotation.hide,
};
})
.value();
@@ -435,8 +435,67 @@ describe('DashboardModel', function() {
});
});
describe('save variables and timeline', () => {
let model;
describe('Given model with time', () => {
let model: DashboardModel;
beforeEach(() => {
model = new DashboardModel({
time: {
from: 'now-6h',
to: 'now',
},
});
expect(model.hasTimeChanged()).toBeFalsy();
model.time = {
from: 'now-3h',
to: 'now-1h',
};
});
it('hasTimeChanged should be true', () => {
expect(model.hasTimeChanged()).toBeTruthy();
});
it('getSaveModelClone should return original time when saveTimerange=false', () => {
let options = { saveTimerange: false };
let saveModel = model.getSaveModelClone(options);
expect(saveModel.time.from).toBe('now-6h');
expect(saveModel.time.to).toBe('now');
});
it('getSaveModelClone should return updated time when saveTimerange=true', () => {
let options = { saveTimerange: true };
let saveModel = model.getSaveModelClone(options);
expect(saveModel.time.from).toBe('now-3h');
expect(saveModel.time.to).toBe('now-1h');
});
it('hasTimeChanged should be false when reset original time', () => {
model.resetOriginalTime();
expect(model.hasTimeChanged()).toBeFalsy();
});
it('getSaveModelClone should return original time when saveTimerange=false', () => {
let options = { saveTimerange: false };
let saveModel = model.getSaveModelClone(options);
expect(saveModel.time.from).toBe('now-6h');
expect(saveModel.time.to).toBe('now');
});
it('getSaveModelClone should return updated time when saveTimerange=true', () => {
let options = { saveTimerange: true };
let saveModel = model.getSaveModelClone(options);
expect(saveModel.time.from).toBe('now-3h');
expect(saveModel.time.to).toBe('now-1h');
});
});
describe('Given model with template variable of type query', () => {
let model: DashboardModel;
beforeEach(() => {
model = new DashboardModel({
@@ -444,6 +503,7 @@ describe('DashboardModel', function() {
list: [
{
name: 'Server',
type: 'query',
current: {
selected: true,
text: 'server_001',
@@ -452,45 +512,127 @@ describe('DashboardModel', function() {
},
],
},
time: {
from: 'now-6h',
to: 'now',
},
});
model.templating.list[0] = {
name: 'Server',
expect(model.hasVariableValuesChanged()).toBeFalsy();
});
it('hasVariableValuesChanged should be false when adding a template variable', () => {
model.templating.list.push({
name: 'Server2',
type: 'query',
current: {
selected: true,
text: 'server_002',
value: 'server_002',
},
};
model.time = {
from: 'now-3h',
to: 'now',
};
});
expect(model.hasVariableValuesChanged()).toBeFalsy();
});
it('should not save variables and timeline', () => {
let options = {
saveVariables: false,
saveTimerange: false,
};
it('hasVariableValuesChanged should be false when removing existing template variable', () => {
model.templating.list = [];
expect(model.hasVariableValuesChanged()).toBeFalsy();
});
it('hasVariableValuesChanged should be true when changing value of template variable', () => {
model.templating.list[0].current.text = 'server_002';
expect(model.hasVariableValuesChanged()).toBeTruthy();
});
it('getSaveModelClone should return original variable when saveVariables=false', () => {
model.templating.list[0].current.text = 'server_002';
let options = { saveVariables: false };
let saveModel = model.getSaveModelClone(options);
expect(saveModel.templating.list[0].current.text).toBe('server_001');
expect(saveModel.time.from).toBe('now-6h');
});
it('should save variables and timeline', () => {
let options = {
saveVariables: true,
saveTimerange: true,
};
it('getSaveModelClone should return updated variable when saveVariables=true', () => {
model.templating.list[0].current.text = 'server_002';
let options = { saveVariables: true };
let saveModel = model.getSaveModelClone(options);
expect(saveModel.templating.list[0].current.text).toBe('server_002');
expect(saveModel.time.from).toBe('now-3h');
});
});
describe('Given model with template variable of type adhoc', () => {
let model: DashboardModel;
beforeEach(() => {
model = new DashboardModel({
templating: {
list: [
{
name: 'Filter',
type: 'adhoc',
filters: [
{
key: '@hostname',
operator: '=',
value: 'server 20',
},
],
},
],
},
});
expect(model.hasVariableValuesChanged()).toBeFalsy();
});
it('hasVariableValuesChanged should be false when adding a template variable', () => {
model.templating.list.push({
name: 'Filter',
type: 'adhoc',
filters: [
{
key: '@hostname',
operator: '=',
value: 'server 1',
},
],
});
expect(model.hasVariableValuesChanged()).toBeFalsy();
});
it('hasVariableValuesChanged should be false when removing existing template variable', () => {
model.templating.list = [];
expect(model.hasVariableValuesChanged()).toBeFalsy();
});
it('hasVariableValuesChanged should be true when changing value of filter', () => {
model.templating.list[0].filters[0].value = 'server 1';
expect(model.hasVariableValuesChanged()).toBeTruthy();
});
it('hasVariableValuesChanged should be true when adding an additional condition', () => {
model.templating.list[0].filters[0].condition = 'AND';
model.templating.list[0].filters[1] = {
key: '@metric',
operator: '=',
value: 'logins.count',
};
expect(model.hasVariableValuesChanged()).toBeTruthy();
});
it('getSaveModelClone should return original variable when saveVariables=false', () => {
model.templating.list[0].filters[0].value = 'server 1';
let options = { saveVariables: false };
let saveModel = model.getSaveModelClone(options);
expect(saveModel.templating.list[0].filters[0].value).toBe('server 20');
});
it('getSaveModelClone should return updated variable when saveVariables=true', () => {
model.templating.list[0].filters[0].value = 'server 1';
let options = { saveVariables: true };
let saveModel = model.getSaveModelClone(options);
expect(saveModel.templating.list[0].filters[0].value).toBe('server 1');
});
});
});
@@ -62,6 +62,27 @@ describe('given dashboard with repeated panels', () => {
type: 'graph',
},
{ id: 3, repeat: null, repeatPanelId: 2 },
{
id: 4,
collapsed: true,
panels: [
{ id: 10, datasource: 'gfdb', type: 'table' },
{ id: 11 },
{
id: 12,
datasource: '-- Mixed --',
targets: [{ datasource: 'other' }],
},
{ id: 13, datasource: '$ds' },
{
id: 14,
repeat: 'apps',
datasource: 'gfdb',
type: 'heatmap',
},
{ id: 15, repeat: null, repeatPanelId: 14 },
],
},
],
};
@@ -78,6 +99,18 @@ describe('given dashboard with repeated panels', () => {
info: { version: '1.1.0' },
};
config.panels['table'] = {
id: 'table',
name: 'Table',
info: { version: '1.1.1' },
};
config.panels['heatmap'] = {
id: 'heatmap',
name: 'Heatmap',
info: { version: '1.1.2' },
};
dash = new DashboardModel(dash, {});
var exporter = new DashboardExporter(datasourceSrvStub);
exporter.makeExportable(dash).then(clean => {
@@ -91,6 +124,11 @@ describe('given dashboard with repeated panels', () => {
expect(panel.datasource).toBe('${DS_GFDB}');
});
it('should replace datasource refs in collapsed row', () => {
var panel = exported.panels[5].panels[0];
expect(panel.datasource).toBe('${DS_GFDB}');
});
it('should replace datasource in variable query', () => {
expect(exported.templating.list[0].datasource).toBe('${DS_GFDB}');
expect(exported.templating.list[0].options.length).toBe(0);
@@ -126,13 +164,27 @@ describe('given dashboard with repeated panels', () => {
expect(require).not.toBe(undefined);
});
it('should add panel to required', () => {
it('should add graph panel to required', () => {
var require = _.find(exported.__requires, { name: 'Graph' });
expect(require.name).toBe('Graph');
expect(require.id).toBe('graph');
expect(require.version).toBe('1.1.0');
});
it('should add table panel to required', () => {
var require = _.find(exported.__requires, { name: 'Table' });
expect(require.name).toBe('Table');
expect(require.id).toBe('table');
expect(require.version).toBe('1.1.1');
});
it('should add heatmap panel to required', () => {
var require = _.find(exported.__requires, { name: 'Heatmap' });
expect(require.name).toBe('Heatmap');
expect(require.id).toBe('heatmap');
expect(require.version).toBe('1.1.2');
});
it('should add grafana version', () => {
var require = _.find(exported.__requires, { name: 'Grafana' });
expect(require.type).toBe('grafana');
@@ -0,0 +1,313 @@
import _ from 'lodash';
import { HistoryListCtrl } from 'app/features/dashboard/history/history';
import { versions, compare, restore } from './history_mocks';
import $q from 'q';
describe('HistoryListCtrl', () => {
const RESTORE_ID = 4;
const versionsResponse: any = versions();
restore(7, RESTORE_ID);
let historySrv;
let $rootScope;
let historyListCtrl;
beforeEach(() => {
historySrv = {
calculateDiff: jest.fn(),
restoreDashboard: jest.fn(() => $q.when({})),
};
$rootScope = {
appEvent: jest.fn(),
onAppEvent: jest.fn(),
};
});
describe('when the history list component is loaded', () => {
let deferred;
beforeEach(() => {
deferred = $q.defer({});
historySrv.getHistoryList = jest.fn(() => deferred.promise);
historyListCtrl = new HistoryListCtrl({}, $rootScope, {}, $q, historySrv, {});
historyListCtrl.dashboard = {
id: 2,
version: 3,
formatDate: jest.fn(() => 'date'),
};
});
it('should immediately attempt to fetch the history list', () => {
expect(historySrv.getHistoryList).toHaveBeenCalledTimes(1);
});
describe('and the history list is successfully fetched', () => {
beforeEach(async () => {
deferred.resolve(versionsResponse);
await historyListCtrl.getLog();
});
it("should reset the controller's state", async () => {
expect(historyListCtrl.mode).toBe('list');
expect(historyListCtrl.delta).toEqual({ basic: '', json: '' });
expect(historyListCtrl.canCompare).toBe(false);
expect(_.find(historyListCtrl.revisions, rev => rev.checked)).toBe(undefined);
});
it('should indicate loading has finished', () => {
expect(historyListCtrl.loading).toBe(false);
});
it('should store the revisions sorted desc by version id', () => {
expect(historyListCtrl.revisions[0].version).toBe(4);
expect(historyListCtrl.revisions[1].version).toBe(3);
expect(historyListCtrl.revisions[2].version).toBe(2);
expect(historyListCtrl.revisions[3].version).toBe(1);
});
it('should add a checked property to each revision', () => {
let actual = _.filter(historyListCtrl.revisions, rev => rev.hasOwnProperty('checked'));
expect(actual.length).toBe(4);
});
it('should set all checked properties to false on reset', () => {
historyListCtrl.revisions[0].checked = true;
historyListCtrl.revisions[2].checked = true;
historyListCtrl.reset();
let actual = _.filter(historyListCtrl.revisions, rev => !rev.checked);
expect(actual.length).toBe(4);
});
});
describe('and fetching the history list fails', () => {
beforeEach(async () => {
deferred = $q.defer();
historySrv.getHistoryList = jest.fn(() => deferred.promise);
historyListCtrl = new HistoryListCtrl({}, $rootScope, {}, $q, historySrv, {});
deferred.reject(new Error('HistoryListError'));
await historyListCtrl.getLog();
});
it("should reset the controller's state", () => {
expect(historyListCtrl.mode).toBe('list');
expect(historyListCtrl.delta).toEqual({ basic: '', json: '' });
expect(_.find(historyListCtrl.revisions, rev => rev.checked)).toBe(undefined);
});
it('should indicate loading has finished', () => {
expect(historyListCtrl.loading).toBe(false);
});
it('should have an empty revisions list', () => {
expect(historyListCtrl.revisions).toEqual([]);
});
});
describe('should update the history list when the dashboard is saved', () => {
beforeEach(() => {
historyListCtrl.dashboard = { version: 3 };
historyListCtrl.resetFromSource = jest.fn();
});
it('should listen for the `dashboard-saved` appEvent', () => {
expect($rootScope.onAppEvent).toHaveBeenCalledTimes(1);
expect($rootScope.onAppEvent.mock.calls[0][0]).toBe('dashboard-saved');
});
it('should call `onDashboardSaved` when the appEvent is received', () => {
expect($rootScope.onAppEvent.mock.calls[0][1]).not.toBe(historyListCtrl.onDashboardSaved);
expect($rootScope.onAppEvent.mock.calls[0][1].toString).toBe(historyListCtrl.onDashboardSaved.toString);
});
});
});
describe('when the user wants to compare two revisions', () => {
let deferred;
beforeEach(async () => {
deferred = $q.defer({});
historySrv.getHistoryList = jest.fn(() => $q.when(versionsResponse));
historySrv.calculateDiff = jest.fn(() => deferred.promise);
historyListCtrl = new HistoryListCtrl({}, $rootScope, {}, $q, historySrv, {});
historyListCtrl.dashboard = {
id: 2,
version: 3,
formatDate: jest.fn(() => 'date'),
};
deferred.resolve(versionsResponse);
await historyListCtrl.getLog();
});
it('should have already fetched the history list', () => {
expect(historySrv.getHistoryList).toHaveBeenCalled();
expect(historyListCtrl.revisions.length).toBeGreaterThan(0);
});
it('should check that two valid versions are selected', () => {
// []
expect(historyListCtrl.canCompare).toBe(false);
// single value
historyListCtrl.revisions = [{ checked: true }];
historyListCtrl.revisionSelectionChanged();
expect(historyListCtrl.canCompare).toBe(false);
// both values in range
historyListCtrl.revisions = [{ checked: true }, { checked: true }];
historyListCtrl.revisionSelectionChanged();
expect(historyListCtrl.canCompare).toBe(true);
});
describe('and the basic diff is successfully fetched', () => {
beforeEach(async () => {
deferred = $q.defer({});
historySrv.calculateDiff = jest.fn(() => deferred.promise);
deferred.resolve(compare('basic'));
historyListCtrl.revisions[1].checked = true;
historyListCtrl.revisions[3].checked = true;
await historyListCtrl.getDiff('basic');
});
it('should fetch the basic diff if two valid versions are selected', () => {
expect(historySrv.calculateDiff).toHaveBeenCalledTimes(1);
expect(historyListCtrl.delta.basic).toBe('<div></div>');
expect(historyListCtrl.delta.json).toBe('');
});
it('should set the basic diff view as active', () => {
expect(historyListCtrl.mode).toBe('compare');
expect(historyListCtrl.diff).toBe('basic');
});
it('should indicate loading has finished', () => {
expect(historyListCtrl.loading).toBe(false);
});
});
describe('and the json diff is successfully fetched', () => {
beforeEach(async () => {
deferred = $q.defer({});
historySrv.calculateDiff = jest.fn(() => deferred.promise);
deferred.resolve(compare('json'));
historyListCtrl.revisions[1].checked = true;
historyListCtrl.revisions[3].checked = true;
await historyListCtrl.getDiff('json');
});
it('should fetch the json diff if two valid versions are selected', () => {
expect(historySrv.calculateDiff).toHaveBeenCalledTimes(1);
expect(historyListCtrl.delta.basic).toBe('');
expect(historyListCtrl.delta.json).toBe('<pre><code></code></pre>');
});
it('should set the json diff view as active', () => {
expect(historyListCtrl.mode).toBe('compare');
expect(historyListCtrl.diff).toBe('json');
});
it('should indicate loading has finished', () => {
expect(historyListCtrl.loading).toBe(false);
});
});
describe('and diffs have already been fetched', () => {
beforeEach(async () => {
deferred.resolve(compare('basic'));
historyListCtrl.revisions[3].checked = true;
historyListCtrl.revisions[1].checked = true;
historyListCtrl.delta.basic = 'cached basic';
historyListCtrl.getDiff('basic');
await historySrv.calculateDiff();
});
it('should use the cached diffs instead of fetching', () => {
expect(historySrv.calculateDiff).toHaveBeenCalledTimes(1);
expect(historyListCtrl.delta.basic).toBe('cached basic');
});
it('should indicate loading has finished', () => {
expect(historyListCtrl.loading).toBe(false);
});
});
describe('and fetching the diff fails', () => {
beforeEach(async () => {
deferred = $q.defer({});
historySrv.calculateDiff = jest.fn(() => deferred.promise);
historyListCtrl.revisions[3].checked = true;
historyListCtrl.revisions[1].checked = true;
deferred.reject();
await historyListCtrl.getDiff('basic');
});
it('should fetch the diff if two valid versions are selected', () => {
expect(historySrv.calculateDiff).toHaveBeenCalledTimes(1);
});
it('should return to the history list view', () => {
expect(historyListCtrl.mode).toBe('list');
});
it('should indicate loading has finished', () => {
expect(historyListCtrl.loading).toBe(false);
});
it('should have an empty delta/changeset', () => {
expect(historyListCtrl.delta).toEqual({ basic: '', json: '' });
});
});
});
describe('when the user wants to restore a revision', () => {
let deferred;
beforeEach(async () => {
deferred = $q.defer();
historySrv.getHistoryList = jest.fn(() => $q.when(versionsResponse));
historySrv.restoreDashboard = jest.fn(() => deferred.promise);
historyListCtrl = new HistoryListCtrl({}, $rootScope, {}, $q, historySrv, {});
historyListCtrl.dashboard = {
id: 1,
};
historyListCtrl.restore();
deferred.resolve(versionsResponse);
await historyListCtrl.getLog();
});
it('should display a modal allowing the user to restore or cancel', () => {
expect($rootScope.appEvent).toHaveBeenCalledTimes(1);
expect($rootScope.appEvent.mock.calls[0][0]).toBe('confirm-modal');
});
describe('and restore fails to fetch', () => {
beforeEach(async () => {
deferred = $q.defer();
historySrv.getHistoryList = jest.fn(() => $q.when(versionsResponse));
historySrv.restoreDashboard = jest.fn(() => deferred.promise);
historyListCtrl = new HistoryListCtrl({}, $rootScope, {}, $q, historySrv, {});
deferred.reject(new Error('RestoreError'));
historyListCtrl.restoreConfirm(RESTORE_ID);
await historyListCtrl.getLog();
});
it('should indicate loading has finished', () => {
expect(historyListCtrl.loading).toBe(false);
});
});
});
});
@@ -1,329 +0,0 @@
import { describe, beforeEach, it, sinon, expect, angularMocks } from 'test/lib/common';
import _ from 'lodash';
import { HistoryListCtrl } from 'app/features/dashboard/history/history';
import { versions, compare, restore } from './history_mocks';
describe('HistoryListCtrl', function() {
var RESTORE_ID = 4;
var ctx: any = {};
var versionsResponse: any = versions();
restore(7, RESTORE_ID);
beforeEach(angularMocks.module('grafana.core'));
beforeEach(angularMocks.module('grafana.services'));
beforeEach(
angularMocks.inject($rootScope => {
ctx.scope = $rootScope.$new();
})
);
var historySrv;
var $rootScope;
beforeEach(function() {
historySrv = {
getHistoryList: sinon.stub(),
calculateDiff: sinon.stub(),
restoreDashboard: sinon.stub(),
};
$rootScope = {
appEvent: sinon.spy(),
onAppEvent: sinon.spy(),
};
});
describe('when the history list component is loaded', function() {
var deferred;
beforeEach(
angularMocks.inject(($controller, $q) => {
deferred = $q.defer();
historySrv.getHistoryList.returns(deferred.promise);
ctx.ctrl = $controller(
HistoryListCtrl,
{
historySrv,
$rootScope,
$scope: ctx.scope,
},
{
dashboard: {
id: 2,
version: 3,
formatDate: sinon.stub().returns('date'),
},
}
);
})
);
it('should immediately attempt to fetch the history list', function() {
expect(historySrv.getHistoryList.calledOnce).to.be(true);
});
describe('and the history list is successfully fetched', function() {
beforeEach(function() {
deferred.resolve(versionsResponse);
ctx.ctrl.$scope.$apply();
});
it("should reset the controller's state", function() {
expect(ctx.ctrl.mode).to.be('list');
expect(ctx.ctrl.delta).to.eql({ basic: '', json: '' });
expect(ctx.ctrl.canCompare).to.be(false);
expect(_.find(ctx.ctrl.revisions, rev => rev.checked)).to.be(undefined);
});
it('should indicate loading has finished', function() {
expect(ctx.ctrl.loading).to.be(false);
});
it('should store the revisions sorted desc by version id', function() {
expect(ctx.ctrl.revisions[0].version).to.be(4);
expect(ctx.ctrl.revisions[1].version).to.be(3);
expect(ctx.ctrl.revisions[2].version).to.be(2);
expect(ctx.ctrl.revisions[3].version).to.be(1);
});
it('should add a checked property to each revision', function() {
var actual = _.filter(ctx.ctrl.revisions, rev => rev.hasOwnProperty('checked'));
expect(actual.length).to.be(4);
});
it('should set all checked properties to false on reset', function() {
ctx.ctrl.revisions[0].checked = true;
ctx.ctrl.revisions[2].checked = true;
ctx.ctrl.reset();
var actual = _.filter(ctx.ctrl.revisions, rev => !rev.checked);
expect(actual.length).to.be(4);
});
});
describe('and fetching the history list fails', function() {
beforeEach(function() {
deferred.reject(new Error('HistoryListError'));
ctx.ctrl.$scope.$apply();
});
it("should reset the controller's state", function() {
expect(ctx.ctrl.mode).to.be('list');
expect(ctx.ctrl.delta).to.eql({ basic: '', json: '' });
expect(_.find(ctx.ctrl.revisions, rev => rev.checked)).to.be(undefined);
});
it('should indicate loading has finished', function() {
expect(ctx.ctrl.loading).to.be(false);
});
it('should have an empty revisions list', function() {
expect(ctx.ctrl.revisions).to.eql([]);
});
});
describe('should update the history list when the dashboard is saved', function() {
beforeEach(function() {
ctx.ctrl.dashboard = { version: 3 };
ctx.ctrl.resetFromSource = sinon.spy();
});
it('should listen for the `dashboard-saved` appEvent', function() {
expect($rootScope.onAppEvent.calledOnce).to.be(true);
expect($rootScope.onAppEvent.getCall(0).args[0]).to.be('dashboard-saved');
});
it('should call `onDashboardSaved` when the appEvent is received', function() {
expect($rootScope.onAppEvent.getCall(0).args[1]).to.not.be(ctx.ctrl.onDashboardSaved);
expect($rootScope.onAppEvent.getCall(0).args[1].toString).to.be(ctx.ctrl.onDashboardSaved.toString);
});
});
});
describe('when the user wants to compare two revisions', function() {
var deferred;
beforeEach(
angularMocks.inject(($controller, $q) => {
deferred = $q.defer();
historySrv.getHistoryList.returns($q.when(versionsResponse));
historySrv.calculateDiff.returns(deferred.promise);
ctx.ctrl = $controller(
HistoryListCtrl,
{
historySrv,
$rootScope,
$scope: ctx.scope,
},
{
dashboard: {
id: 2,
version: 3,
formatDate: sinon.stub().returns('date'),
},
}
);
ctx.ctrl.$scope.onDashboardSaved = sinon.spy();
ctx.ctrl.$scope.$apply();
})
);
it('should have already fetched the history list', function() {
expect(historySrv.getHistoryList.calledOnce).to.be(true);
expect(ctx.ctrl.revisions.length).to.be.above(0);
});
it('should check that two valid versions are selected', function() {
// []
expect(ctx.ctrl.canCompare).to.be(false);
// single value
ctx.ctrl.revisions = [{ checked: true }];
ctx.ctrl.revisionSelectionChanged();
expect(ctx.ctrl.canCompare).to.be(false);
// both values in range
ctx.ctrl.revisions = [{ checked: true }, { checked: true }];
ctx.ctrl.revisionSelectionChanged();
expect(ctx.ctrl.canCompare).to.be(true);
});
describe('and the basic diff is successfully fetched', function() {
beforeEach(function() {
deferred.resolve(compare('basic'));
ctx.ctrl.revisions[1].checked = true;
ctx.ctrl.revisions[3].checked = true;
ctx.ctrl.getDiff('basic');
ctx.ctrl.$scope.$apply();
});
it('should fetch the basic diff if two valid versions are selected', function() {
expect(historySrv.calculateDiff.calledOnce).to.be(true);
expect(ctx.ctrl.delta.basic).to.be('<div></div>');
expect(ctx.ctrl.delta.json).to.be('');
});
it('should set the basic diff view as active', function() {
expect(ctx.ctrl.mode).to.be('compare');
expect(ctx.ctrl.diff).to.be('basic');
});
it('should indicate loading has finished', function() {
expect(ctx.ctrl.loading).to.be(false);
});
});
describe('and the json diff is successfully fetched', function() {
beforeEach(function() {
deferred.resolve(compare('json'));
ctx.ctrl.revisions[1].checked = true;
ctx.ctrl.revisions[3].checked = true;
ctx.ctrl.getDiff('json');
ctx.ctrl.$scope.$apply();
});
it('should fetch the json diff if two valid versions are selected', function() {
expect(historySrv.calculateDiff.calledOnce).to.be(true);
expect(ctx.ctrl.delta.basic).to.be('');
expect(ctx.ctrl.delta.json).to.be('<pre><code></code></pre>');
});
it('should set the json diff view as active', function() {
expect(ctx.ctrl.mode).to.be('compare');
expect(ctx.ctrl.diff).to.be('json');
});
it('should indicate loading has finished', function() {
expect(ctx.ctrl.loading).to.be(false);
});
});
describe('and diffs have already been fetched', function() {
beforeEach(function() {
deferred.resolve(compare('basic'));
ctx.ctrl.revisions[3].checked = true;
ctx.ctrl.revisions[1].checked = true;
ctx.ctrl.delta.basic = 'cached basic';
ctx.ctrl.getDiff('basic');
ctx.ctrl.$scope.$apply();
});
it('should use the cached diffs instead of fetching', function() {
expect(historySrv.calculateDiff.calledOnce).to.be(false);
expect(ctx.ctrl.delta.basic).to.be('cached basic');
});
it('should indicate loading has finished', function() {
expect(ctx.ctrl.loading).to.be(false);
});
});
describe('and fetching the diff fails', function() {
beforeEach(function() {
deferred.reject(new Error('DiffError'));
ctx.ctrl.revisions[3].checked = true;
ctx.ctrl.revisions[1].checked = true;
ctx.ctrl.getDiff('basic');
ctx.ctrl.$scope.$apply();
});
it('should fetch the diff if two valid versions are selected', function() {
expect(historySrv.calculateDiff.calledOnce).to.be(true);
});
it('should return to the history list view', function() {
expect(ctx.ctrl.mode).to.be('list');
});
it('should indicate loading has finished', function() {
expect(ctx.ctrl.loading).to.be(false);
});
it('should have an empty delta/changeset', function() {
expect(ctx.ctrl.delta).to.eql({ basic: '', json: '' });
});
});
});
describe('when the user wants to restore a revision', function() {
var deferred;
beforeEach(
angularMocks.inject(($controller, $q) => {
deferred = $q.defer();
historySrv.getHistoryList.returns($q.when(versionsResponse));
historySrv.restoreDashboard.returns(deferred.promise);
ctx.ctrl = $controller(HistoryListCtrl, {
historySrv,
contextSrv: { user: { name: 'Carlos' } },
$rootScope,
$scope: ctx.scope,
});
ctx.ctrl.dashboard = { id: 1 };
ctx.ctrl.restore();
ctx.ctrl.$scope.$apply();
})
);
it('should display a modal allowing the user to restore or cancel', function() {
expect($rootScope.appEvent.calledOnce).to.be(true);
expect($rootScope.appEvent.calledWith('confirm-modal')).to.be(true);
});
describe('and restore fails to fetch', function() {
beforeEach(function() {
deferred.reject(new Error('RestoreError'));
ctx.ctrl.restoreConfirm(RESTORE_ID);
try {
// this throws error, due to promise rejection
ctx.ctrl.$scope.$apply();
} catch (e) {}
});
it('should indicate loading has finished', function() {
expect(ctx.ctrl.loading).to.be(false);
});
});
});
});
@@ -0,0 +1,61 @@
import '../history/history_srv';
import { versions, restore } from './history_mocks';
import { HistorySrv } from '../history/history_srv';
import { DashboardModel } from '../dashboard_model';
jest.mock('app/core/store');
describe('historySrv', function() {
const versionsResponse = versions();
const restoreResponse = restore;
let backendSrv = {
get: jest.fn(() => Promise.resolve({})),
post: jest.fn(() => Promise.resolve({})),
};
let historySrv = new HistorySrv(backendSrv);
const dash = new DashboardModel({ id: 1 });
const emptyDash = new DashboardModel({});
const historyListOpts = { limit: 10, start: 0 };
describe('getHistoryList', function() {
it('should return a versions array for the given dashboard id', function() {
backendSrv.get = jest.fn(() => Promise.resolve(versionsResponse));
historySrv = new HistorySrv(backendSrv);
return historySrv.getHistoryList(dash, historyListOpts).then(function(versions) {
expect(versions).toEqual(versionsResponse);
});
});
it('should return an empty array when not given an id', function() {
return historySrv.getHistoryList(emptyDash, historyListOpts).then(function(versions) {
expect(versions).toEqual([]);
});
});
it('should return an empty array when not given a dashboard', function() {
return historySrv.getHistoryList(null, historyListOpts).then(function(versions) {
expect(versions).toEqual([]);
});
});
});
describe('restoreDashboard', () => {
it('should return a success response given valid parameters', function() {
let version = 6;
backendSrv.post = jest.fn(() => Promise.resolve(restoreResponse(version)));
historySrv = new HistorySrv(backendSrv);
return historySrv.restoreDashboard(dash, version).then(function(response) {
expect(response).toEqual(restoreResponse(version));
});
});
it('should return an empty object when not given an id', async () => {
historySrv = new HistorySrv(backendSrv);
let rsp = await historySrv.restoreDashboard(emptyDash, 6);
expect(rsp).toEqual({});
});
});
});
@@ -1,86 +0,0 @@
import { describe, beforeEach, it, expect, angularMocks } from 'test/lib/common';
import helpers from 'test/specs/helpers';
import '../history/history_srv';
import { versions, restore } from './history_mocks';
describe('historySrv', function() {
var ctx = new helpers.ServiceTestContext();
var versionsResponse = versions();
var restoreResponse = restore;
beforeEach(angularMocks.module('grafana.core'));
beforeEach(angularMocks.module('grafana.services'));
beforeEach(
angularMocks.inject(function($httpBackend) {
ctx.$httpBackend = $httpBackend;
$httpBackend.whenRoute('GET', 'api/dashboards/id/:id/versions').respond(versionsResponse);
$httpBackend
.whenRoute('POST', 'api/dashboards/id/:id/restore')
.respond(function(method, url, data, headers, params) {
const parsedData = JSON.parse(data);
return [200, restoreResponse(parsedData.version)];
});
})
);
beforeEach(ctx.createService('historySrv'));
function wrapPromise(ctx, angularPromise) {
return new Promise((resolve, reject) => {
angularPromise.then(resolve, reject);
ctx.$httpBackend.flush();
});
}
describe('getHistoryList', function() {
it('should return a versions array for the given dashboard id', function() {
return wrapPromise(
ctx,
ctx.service.getHistoryList({ id: 1 }).then(function(versions) {
expect(versions).to.eql(versionsResponse);
})
);
});
it('should return an empty array when not given an id', function() {
return wrapPromise(
ctx,
ctx.service.getHistoryList({}).then(function(versions) {
expect(versions).to.eql([]);
})
);
});
it('should return an empty array when not given a dashboard', function() {
return wrapPromise(
ctx,
ctx.service.getHistoryList().then(function(versions) {
expect(versions).to.eql([]);
})
);
});
});
describe('restoreDashboard', function() {
it('should return a success response given valid parameters', function() {
let version = 6;
return wrapPromise(
ctx,
ctx.service.restoreDashboard({ id: 1 }, version).then(function(response) {
expect(response).to.eql(restoreResponse(version));
})
);
});
it('should return an empty object when not given an id', function() {
return wrapPromise(
ctx,
ctx.service.restoreDashboard({}, 6).then(function(response) {
expect(response).to.eql({});
})
);
});
});
});
@@ -1,128 +1,57 @@
import { SaveDashboardModalCtrl } from '../save_modal';
jest.mock('app/core/services/context_srv', () => ({}));
const setup = (timeChanged, variableValuesChanged, cb) => {
const dash = {
hasTimeChanged: jest.fn().mockReturnValue(timeChanged),
hasVariableValuesChanged: jest.fn().mockReturnValue(variableValuesChanged),
resetOriginalTime: jest.fn(),
resetOriginalVariables: jest.fn(),
getSaveModelClone: jest.fn().mockReturnValue({}),
};
const dashboardSrvMock = {
getCurrent: jest.fn().mockReturnValue(dash),
save: jest.fn().mockReturnValue(Promise.resolve()),
};
const ctrl = new SaveDashboardModalCtrl(dashboardSrvMock);
ctrl.saveForm = {
$valid: true,
};
ctrl.dismiss = () => Promise.resolve();
cb(dash, ctrl, dashboardSrvMock);
};
describe('SaveDashboardModal', () => {
describe('save modal checkboxes', () => {
it('should show checkboxes', () => {
let fakeDashboardSrv = {
dash: {
templating: {
list: [
{
current: {
selected: true,
tags: Array(0),
text: 'server_001',
value: 'server_001',
},
name: 'Server',
},
],
},
originalTemplating: [
{
current: {
selected: true,
text: 'server_002',
value: 'server_002',
},
name: 'Server',
},
],
time: {
from: 'now-3h',
to: 'now',
},
originalTime: {
from: 'now-6h',
to: 'now',
},
},
};
let modal = new SaveDashboardModalCtrl(fakeDashboardSrv);
expect(modal.timeChange).toBe(true);
expect(modal.variableValueChange).toBe(true);
describe('Given time and template variable values have not changed', () => {
setup(false, false, (dash, ctrl: SaveDashboardModalCtrl) => {
it('When creating ctrl should set time and template variable values changed', () => {
expect(ctrl.timeChange).toBeFalsy();
expect(ctrl.variableValueChange).toBeFalsy();
});
});
});
it('should hide checkboxes', () => {
let fakeDashboardSrv = {
dash: {
templating: {
list: [
{
current: {
selected: true,
text: 'server_002',
value: 'server_002',
},
name: 'Server',
},
],
},
originalTemplating: [
{
current: {
selected: true,
text: 'server_002',
value: 'server_002',
},
name: 'Server',
},
],
time: {
from: 'now-3h',
to: 'now',
},
originalTime: {
from: 'now-3h',
to: 'now',
},
},
};
let modal = new SaveDashboardModalCtrl(fakeDashboardSrv);
expect(modal.timeChange).toBe(false);
expect(modal.variableValueChange).toBe(false);
});
describe('Given time and template variable values have changed', () => {
setup(true, true, (dash, ctrl: SaveDashboardModalCtrl) => {
it('When creating ctrl should set time and template variable values changed', () => {
expect(ctrl.timeChange).toBeTruthy();
expect(ctrl.variableValueChange).toBeTruthy();
});
it('should hide variable checkboxes', () => {
let fakeDashboardSrv = {
dash: {
templating: {
list: [
{
current: {
selected: true,
text: 'server_002',
value: 'server_002',
},
name: 'Server',
},
{
current: {
selected: true,
text: 'web_002',
value: 'web_002',
},
name: 'Web',
},
],
},
originalTemplating: [
{
current: {
selected: true,
text: 'server_002',
value: 'server_002',
},
name: 'Server',
},
],
},
};
let modal = new SaveDashboardModalCtrl(fakeDashboardSrv);
expect(modal.variableValueChange).toBe(false);
it('When save time and variable value changes disabled and saving should reset original time and template variable values', async () => {
ctrl.saveTimerange = false;
ctrl.saveVariables = false;
await ctrl.save();
expect(dash.resetOriginalTime).toHaveBeenCalledTimes(0);
expect(dash.resetOriginalVariables).toHaveBeenCalledTimes(0);
});
it('When save time and variable value changes enabled and saving should reset original time and template variable values', async () => {
ctrl.saveTimerange = true;
ctrl.saveVariables = true;
await ctrl.save();
expect(dash.resetOriginalTime).toHaveBeenCalledTimes(1);
expect(dash.resetOriginalVariables).toHaveBeenCalledTimes(1);
});
});
});
});
@@ -0,0 +1,163 @@
import { TimeSrv } from '../time_srv';
import '../time_srv';
import moment from 'moment';
describe('timeSrv', function() {
var rootScope = {
$on: jest.fn(),
onAppEvent: jest.fn(),
appEvent: jest.fn(),
};
var timer = {
register: jest.fn(),
cancel: jest.fn(),
cancelAll: jest.fn(),
};
var location = {
search: jest.fn(() => ({})),
};
var timeSrv;
var _dashboard: any = {
time: { from: 'now-6h', to: 'now' },
getTimezone: jest.fn(() => 'browser'),
};
beforeEach(function() {
timeSrv = new TimeSrv(rootScope, jest.fn(), location, timer, { isGrafanaVisibile: jest.fn() });
timeSrv.init(_dashboard);
});
describe('timeRange', function() {
it('should return unparsed when parse is false', function() {
timeSrv.setTime({ from: 'now', to: 'now-1h' });
var time = timeSrv.timeRange();
expect(time.raw.from).toBe('now');
expect(time.raw.to).toBe('now-1h');
});
it('should return parsed when parse is true', function() {
timeSrv.setTime({ from: 'now', to: 'now-1h' });
var time = timeSrv.timeRange();
expect(moment.isMoment(time.from)).toBe(true);
expect(moment.isMoment(time.to)).toBe(true);
});
});
describe('init time from url', function() {
it('should handle relative times', function() {
location = {
search: jest.fn(() => ({
from: 'now-2d',
to: 'now',
})),
};
timeSrv = new TimeSrv(rootScope, jest.fn(), location, timer, { isGrafanaVisibile: jest.fn() });
timeSrv.init(_dashboard);
var time = timeSrv.timeRange();
expect(time.raw.from).toBe('now-2d');
expect(time.raw.to).toBe('now');
});
it('should handle formatted dates', function() {
location = {
search: jest.fn(() => ({
from: '20140410T052010',
to: '20140520T031022',
})),
};
timeSrv = new TimeSrv(rootScope, jest.fn(), location, timer, { isGrafanaVisibile: jest.fn() });
timeSrv.init(_dashboard);
var time = timeSrv.timeRange();
expect(time.from.valueOf()).toEqual(new Date('2014-04-10T05:20:10Z').getTime());
expect(time.to.valueOf()).toEqual(new Date('2014-05-20T03:10:22Z').getTime());
});
it('should handle formatted dates without time', function() {
location = {
search: jest.fn(() => ({
from: '20140410',
to: '20140520',
})),
};
timeSrv = new TimeSrv(rootScope, jest.fn(), location, timer, { isGrafanaVisibile: jest.fn() });
timeSrv.init(_dashboard);
var time = timeSrv.timeRange();
expect(time.from.valueOf()).toEqual(new Date('2014-04-10T00:00:00Z').getTime());
expect(time.to.valueOf()).toEqual(new Date('2014-05-20T00:00:00Z').getTime());
});
it('should handle epochs', function() {
location = {
search: jest.fn(() => ({
from: '1410337646373',
to: '1410337665699',
})),
};
timeSrv = new TimeSrv(rootScope, jest.fn(), location, timer, { isGrafanaVisibile: jest.fn() });
timeSrv.init(_dashboard);
var time = timeSrv.timeRange();
expect(time.from.valueOf()).toEqual(1410337646373);
expect(time.to.valueOf()).toEqual(1410337665699);
});
it('should handle bad dates', function() {
location = {
search: jest.fn(() => ({
from: '20151126T00010%3C%2Fp%3E%3Cspan%20class',
to: 'now',
})),
};
timeSrv = new TimeSrv(rootScope, jest.fn(), location, timer, { isGrafanaVisibile: jest.fn() });
_dashboard.time.from = 'now-6h';
timeSrv.init(_dashboard);
expect(timeSrv.time.from).toEqual('now-6h');
expect(timeSrv.time.to).toEqual('now');
});
});
describe('setTime', function() {
it('should return disable refresh if refresh is disabled for any range', function() {
_dashboard.refresh = false;
timeSrv.setTime({ from: '2011-01-01', to: '2015-01-01' });
expect(_dashboard.refresh).toBe(false);
});
it('should restore refresh for absolute time range', function() {
_dashboard.refresh = '30s';
timeSrv.setTime({ from: '2011-01-01', to: '2015-01-01' });
expect(_dashboard.refresh).toBe('30s');
});
it('should restore refresh after relative time range is set', function() {
_dashboard.refresh = '10s';
timeSrv.setTime({
from: moment([2011, 1, 1]),
to: moment([2015, 1, 1]),
});
expect(_dashboard.refresh).toBe(false);
timeSrv.setTime({ from: '2011-01-01', to: 'now' });
expect(_dashboard.refresh).toBe('10s');
});
it('should keep refresh after relative time range is changed and now delay exists', function() {
_dashboard.refresh = '10s';
timeSrv.setTime({ from: 'now-1h', to: 'now-10s' });
expect(_dashboard.refresh).toBe('10s');
});
});
});
@@ -1,115 +0,0 @@
import { describe, beforeEach, it, expect, sinon, angularMocks } from 'test/lib/common';
import helpers from 'test/specs/helpers';
import '../time_srv';
import moment from 'moment';
describe('timeSrv', function() {
var ctx = new helpers.ServiceTestContext();
var _dashboard: any = {
time: { from: 'now-6h', to: 'now' },
getTimezone: sinon.stub().returns('browser'),
};
beforeEach(angularMocks.module('grafana.core'));
beforeEach(angularMocks.module('grafana.services'));
beforeEach(ctx.createService('timeSrv'));
beforeEach(function() {
ctx.service.init(_dashboard);
});
describe('timeRange', function() {
it('should return unparsed when parse is false', function() {
ctx.service.setTime({ from: 'now', to: 'now-1h' });
var time = ctx.service.timeRange();
expect(time.raw.from).to.be('now');
expect(time.raw.to).to.be('now-1h');
});
it('should return parsed when parse is true', function() {
ctx.service.setTime({ from: 'now', to: 'now-1h' });
var time = ctx.service.timeRange();
expect(moment.isMoment(time.from)).to.be(true);
expect(moment.isMoment(time.to)).to.be(true);
});
});
describe('init time from url', function() {
it('should handle relative times', function() {
ctx.$location.search({ from: 'now-2d', to: 'now' });
ctx.service.init(_dashboard);
var time = ctx.service.timeRange();
expect(time.raw.from).to.be('now-2d');
expect(time.raw.to).to.be('now');
});
it('should handle formatted dates', function() {
ctx.$location.search({ from: '20140410T052010', to: '20140520T031022' });
ctx.service.init(_dashboard);
var time = ctx.service.timeRange(true);
expect(time.from.valueOf()).to.equal(new Date('2014-04-10T05:20:10Z').getTime());
expect(time.to.valueOf()).to.equal(new Date('2014-05-20T03:10:22Z').getTime());
});
it('should handle formatted dates without time', function() {
ctx.$location.search({ from: '20140410', to: '20140520' });
ctx.service.init(_dashboard);
var time = ctx.service.timeRange(true);
expect(time.from.valueOf()).to.equal(new Date('2014-04-10T00:00:00Z').getTime());
expect(time.to.valueOf()).to.equal(new Date('2014-05-20T00:00:00Z').getTime());
});
it('should handle epochs', function() {
ctx.$location.search({ from: '1410337646373', to: '1410337665699' });
ctx.service.init(_dashboard);
var time = ctx.service.timeRange(true);
expect(time.from.valueOf()).to.equal(1410337646373);
expect(time.to.valueOf()).to.equal(1410337665699);
});
it('should handle bad dates', function() {
ctx.$location.search({
from: '20151126T00010%3C%2Fp%3E%3Cspan%20class',
to: 'now',
});
_dashboard.time.from = 'now-6h';
ctx.service.init(_dashboard);
expect(ctx.service.time.from).to.equal('now-6h');
expect(ctx.service.time.to).to.equal('now');
});
});
describe('setTime', function() {
it('should return disable refresh if refresh is disabled for any range', function() {
_dashboard.refresh = false;
ctx.service.setTime({ from: '2011-01-01', to: '2015-01-01' });
expect(_dashboard.refresh).to.be(false);
});
it('should restore refresh for absolute time range', function() {
_dashboard.refresh = '30s';
ctx.service.setTime({ from: '2011-01-01', to: '2015-01-01' });
expect(_dashboard.refresh).to.be('30s');
});
it('should restore refresh after relative time range is set', function() {
_dashboard.refresh = '10s';
ctx.service.setTime({
from: moment([2011, 1, 1]),
to: moment([2015, 1, 1]),
});
expect(_dashboard.refresh).to.be(false);
ctx.service.setTime({ from: '2011-01-01', to: 'now' });
expect(_dashboard.refresh).to.be('10s');
});
it('should keep refresh after relative time range is changed and now delay exists', function() {
_dashboard.refresh = '10s';
ctx.service.setTime({ from: 'now-1h', to: 'now-10s' });
expect(_dashboard.refresh).to.be('10s');
});
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ import coreModule from 'app/core/core_module';
import kbn from 'app/core/utils/kbn';
import * as dateMath from 'app/core/utils/datemath';
class TimeSrv {
export class TimeSrv {
time: any;
refreshTimer: any;
refresh: boolean;
+5 -3
View File
@@ -48,9 +48,11 @@ function dashLink($compile, $sanitize, linkSrv) {
function update() {
var linkInfo = linkSrv.getAnchorInfo(link);
span.text(linkInfo.title);
anchor.attr('href', linkInfo.href);
sanitizeAnchor();
if (!link.asDropdown) {
anchor.attr('href', linkInfo.href);
sanitizeAnchor();
}
elem.find('a').attr('data-placement', 'bottom');
// tooltip
elem.find('a').tooltip({
title: $sanitize(scope.link.tooltip),
@@ -0,0 +1,42 @@
import '../team_details_ctrl';
import TeamDetailsCtrl from '../team_details_ctrl';
describe('TeamDetailsCtrl', () => {
var backendSrv = {
searchUsers: jest.fn(() => Promise.resolve([])),
get: jest.fn(() => Promise.resolve([])),
post: jest.fn(() => Promise.resolve([])),
};
//Team id
var routeParams = {
id: 1,
};
var navModelSrv = {
getNav: jest.fn(),
};
var teamDetailsCtrl = new TeamDetailsCtrl({ $broadcast: jest.fn() }, backendSrv, routeParams, navModelSrv);
describe('when user is chosen to be added to team', () => {
beforeEach(() => {
teamDetailsCtrl = new TeamDetailsCtrl({ $broadcast: jest.fn() }, backendSrv, routeParams, navModelSrv);
const userItem = {
id: 2,
login: 'user2',
};
teamDetailsCtrl.userPicked(userItem);
});
it('should parse the result and save to db', () => {
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/teams/1/members');
expect(backendSrv.post.mock.calls[0][1].userId).toBe(2);
});
it('should refresh the list after saving.', () => {
expect(backendSrv.get.mock.calls[0][0]).toBe('/api/teams/1');
expect(backendSrv.get.mock.calls[1][0]).toBe('/api/teams/1/members');
});
});
});
@@ -1,48 +0,0 @@
import '../team_details_ctrl';
import { describe, beforeEach, it, expect, sinon, angularMocks } from 'test/lib/common';
import TeamDetailsCtrl from '../team_details_ctrl';
describe('TeamDetailsCtrl', () => {
var ctx: any = {};
var backendSrv = {
searchUsers: sinon.stub().returns(Promise.resolve([])),
get: sinon.stub().returns(Promise.resolve([])),
post: sinon.stub().returns(Promise.resolve([])),
};
beforeEach(angularMocks.module('grafana.core'));
beforeEach(angularMocks.module('grafana.controllers'));
beforeEach(
angularMocks.inject(($rootScope, $controller, $q) => {
ctx.$q = $q;
ctx.scope = $rootScope.$new();
ctx.ctrl = $controller(TeamDetailsCtrl, {
$scope: ctx.scope,
backendSrv: backendSrv,
$routeParams: { id: 1 },
navModelSrv: { getNav: sinon.stub() },
});
})
);
describe('when user is chosen to be added to team', () => {
beforeEach(() => {
const userItem = {
id: 2,
login: 'user2',
};
ctx.ctrl.userPicked(userItem);
});
it('should parse the result and save to db', () => {
expect(backendSrv.post.getCall(0).args[0]).to.eql('/api/teams/1/members');
expect(backendSrv.post.getCall(0).args[1].userId).to.eql(2);
});
it('should refresh the list after saving.', () => {
expect(backendSrv.get.getCall(0).args[0]).to.eql('/api/teams/1');
expect(backendSrv.get.getCall(1).args[0]).to.eql('/api/teams/1/members');
});
});
});
@@ -7,7 +7,7 @@ export class DatasourceSrv {
datasources: any;
/** @ngInject */
constructor(private $q, private $injector, $rootScope, private templateSrv) {
constructor(private $q, private $injector, private $rootScope, private templateSrv) {
this.init();
}
@@ -61,7 +61,7 @@ export class DatasourceSrv {
this.datasources[name] = instance;
deferred.resolve(instance);
})
.catch(function(err) {
.catch(err => {
this.$rootScope.appEvent('alert-error', [dsConfig.name + ' plugin failed', err.toString()]);
});
@@ -13,6 +13,7 @@ var defaults = {
access: 'proxy',
jsonData: {},
secureJsonFields: {},
secureJsonData: {},
};
var datasourceCreated = false;
@@ -374,23 +374,33 @@ export default class CloudWatchDatasource {
getExpandedVariables(target, dimensionKey, variable, templateSrv) {
/* if the all checkbox is marked we should add all values to the targets */
var allSelected = _.find(variable.options, { selected: true, text: 'All' });
return _.chain(variable.options)
.filter(v => {
if (allSelected) {
return v.text !== 'All';
} else {
return v.selected;
}
})
.map(v => {
var t = angular.copy(target);
var scopedVar = {};
scopedVar[variable.name] = v;
t.refId = target.refId + '_' + v.value;
t.dimensions[dimensionKey] = templateSrv.replace(t.dimensions[dimensionKey], scopedVar);
return t;
})
.value();
var selectedVariables = _.filter(variable.options, v => {
if (allSelected) {
return v.text !== 'All';
} else {
return v.selected;
}
});
var currentVariables = !_.isArray(variable.current.value)
? [variable.current]
: variable.current.value.map(v => {
return {
text: v,
value: v,
};
});
let useSelectedVariables =
selectedVariables.some(s => {
return s.value === currentVariables[0].value;
}) || currentVariables[0].value === '$__all';
return (useSelectedVariables ? selectedVariables : currentVariables).map(v => {
var t = angular.copy(target);
var scopedVar = {};
scopedVar[variable.name] = v;
t.refId = target.refId + '_' + v.value;
t.dimensions[dimensionKey] = templateSrv.replace(t.dimensions[dimensionKey], scopedVar);
return t;
});
}
expandTemplateVariable(targets, scopedVars, templateSrv) {
@@ -2,6 +2,7 @@ import '../datasource';
import { describe, beforeEach, it, expect, angularMocks } from 'test/lib/common';
import helpers from 'test/specs/helpers';
import CloudWatchDatasource from '../datasource';
import 'app/features/dashboard/time_srv';
describe('CloudWatchDatasource', function() {
var ctx = new helpers.ServiceTestContext();
@@ -13,6 +14,7 @@ describe('CloudWatchDatasource', function() {
beforeEach(angularMocks.module('grafana.services'));
beforeEach(angularMocks.module('grafana.controllers'));
beforeEach(ctx.providePhase(['templateSrv', 'backendSrv']));
beforeEach(ctx.createService('timeSrv'));
beforeEach(
angularMocks.inject(function($q, $rootScope, $httpBackend, $injector) {
@@ -133,6 +135,10 @@ describe('CloudWatchDatasource', function() {
{ text: 'i-23456789', value: 'i-23456789', selected: false },
{ text: 'i-34567890', value: 'i-34567890', selected: true },
],
current: {
text: 'i-34567890',
value: 'i-34567890',
},
},
],
replace: function(target, scopedVars) {
@@ -169,6 +175,53 @@ describe('CloudWatchDatasource', function() {
var result = ctx.ds.expandTemplateVariable(targets, {}, templateSrv);
expect(result[0].dimensions.InstanceId).to.be('i-34567890');
});
it('should generate the correct targets by expanding template variables from url', function() {
var templateSrv = {
variables: [
{
name: 'instance_id',
options: [
{ text: 'i-23456789', value: 'i-23456789', selected: false },
{ text: 'i-34567890', value: 'i-34567890', selected: false },
],
current: 'i-45678901',
},
],
replace: function(target, scopedVars) {
if (target === '$instance_id') {
return 'i-45678901';
} else {
return '';
}
},
getVariableName: function(e) {
return 'instance_id';
},
variableExists: function(e) {
return true;
},
containsVariable: function(str, variableName) {
return str.indexOf('$' + variableName) !== -1;
},
};
var targets = [
{
region: 'us-east-1',
namespace: 'AWS/EC2',
metricName: 'CPUUtilization',
dimensions: {
InstanceId: '$instance_id',
},
statistics: ['Average'],
period: 300,
},
];
var result = ctx.ds.expandTemplateVariable(targets, {}, templateSrv);
expect(result[0].dimensions.InstanceId).to.be('i-45678901');
});
});
describe('When query region is "default"', function() {
@@ -11,14 +11,30 @@ export default class ResponseParser {
return [];
}
var influxdb11format = query.toLowerCase().indexOf('show tag values') >= 0;
var normalizedQuery = query.toLowerCase();
var isValueFirst =
normalizedQuery.indexOf('show field keys') >= 0 || normalizedQuery.indexOf('show retention policies') >= 0;
var res = {};
_.each(influxResults.series, serie => {
_.each(serie.values, value => {
if (_.isArray(value)) {
if (influxdb11format) {
addUnique(res, value[1] || value[0]);
// In general, there are 2 possible shapes for the returned value.
// The first one is a two-element array,
// where the first element is somewhat a metadata value:
// the tag name for SHOW TAG VALUES queries,
// the time field for SELECT queries, etc.
// The second shape is an one-element array,
// that is containing an immediate value.
// For example, SHOW FIELD KEYS queries return such shape.
// Note, pre-0.11 versions return
// the second shape for SHOW TAG VALUES queries
// (while the newer versions—first).
if (isValueFirst) {
addUnique(res, value[0]);
} else if (value[1] !== undefined) {
addUnique(res, value[1]);
} else {
addUnique(res, value[0]);
}
@@ -29,7 +45,7 @@ export default class ResponseParser {
});
return _.map(res, value => {
return { text: value };
return { text: value.toString() };
});
}
}
@@ -85,30 +85,36 @@ describe('influxdb response parser', () => {
});
});
describe('SELECT response', () => {
var query = 'SELECT "usage_iowait" FROM "cpu" LIMIT 10';
var response = {
results: [
{
series: [
{
name: 'cpu',
columns: ['time', 'usage_iowait'],
values: [[1488465190006040638, 0.0], [1488465190006040638, 15.0], [1488465190006040638, 20.2]],
},
],
},
],
};
var result = parser.parse(query, response);
it('should return second column', () => {
expect(_.size(result)).toBe(3);
expect(result[0].text).toBe('0');
expect(result[1].text).toBe('15');
expect(result[2].text).toBe('20.2');
});
});
describe('SHOW FIELD response', () => {
var query = 'SHOW FIELD KEYS FROM "cpu"';
describe('response from 0.10.0', () => {
var response = {
results: [
{
series: [
{
name: 'measurements',
columns: ['name'],
values: [['cpu'], ['derivative'], ['logins.count'], ['logs'], ['payment.ended'], ['payment.started']],
},
],
},
],
};
var result = parser.parse(query, response);
it('should get two responses', () => {
expect(_.size(result)).toBe(6);
});
});
describe('response from 0.11.0', () => {
describe('response from pre-1.0', () => {
var response = {
results: [
{
@@ -129,5 +135,28 @@ describe('influxdb response parser', () => {
expect(_.size(result)).toBe(1);
});
});
describe('response from 1.0', () => {
var response = {
results: [
{
series: [
{
name: 'cpu',
columns: ['fieldKey', 'fieldType'],
values: [['time', 'float']],
},
],
},
],
};
var result = parser.parse(query, response);
it('should return first column', () => {
expect(_.size(result)).toBe(1);
expect(result[0].text).toBe('time');
});
});
});
});

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