Compare commits
221 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9341412acc | ||
|
|
16cda723d3 | ||
|
|
36a1ab48c5 | ||
|
|
3583057155 | ||
|
|
1940b33dc1 | ||
|
|
d20455ab5f | ||
|
|
934c0fea6f | ||
|
|
c1c1bcb874 | ||
|
|
1da98f5e1e | ||
|
|
74093c700f | ||
|
|
f773a9b4c3 | ||
|
|
205be91a84 | ||
|
|
109fd998ed | ||
|
|
a5afd8152d | ||
|
|
3ae5f7c632 | ||
|
|
a71423481b | ||
|
|
20a2334c87 | ||
|
|
6f4c7a4d65 | ||
|
|
e269b3b2a0 | ||
|
|
1499c2bf74 | ||
|
|
b8aa203707 | ||
|
|
1fd7b60efe | ||
|
|
fb99ddf295 | ||
|
|
8683aff3e9 | ||
|
|
7ea5930a90 | ||
|
|
97a7081b57 | ||
|
|
8634c9d457 | ||
|
|
3ac306a72e | ||
|
|
91ad260517 | ||
|
|
8973b48f96 | ||
|
|
1a61d2814c | ||
|
|
b674b9dba2 | ||
|
|
83fbace6b9 | ||
|
|
12644372c4 | ||
|
|
c12a7d7f59 | ||
|
|
7c840cdf38 | ||
|
|
b63d2b3279 | ||
|
|
8e5672aee6 | ||
|
|
53ea9cfbcf | ||
|
|
1deeef9e91 | ||
|
|
10127e8ac9 | ||
|
|
8c8b1dde8a | ||
|
|
e8d01218d8 | ||
|
|
5aac2d2078 | ||
|
|
60da730c95 | ||
|
|
73fcc919cd | ||
|
|
be29357d22 | ||
|
|
86a73c359b | ||
|
|
a3d22ae9c7 | ||
|
|
b54b43a42e | ||
|
|
bc6a57ce32 | ||
|
|
2479e51a6b | ||
|
|
2a93bed453 | ||
|
|
eaba985f25 | ||
|
|
77136d7a70 | ||
|
|
8440d2d0a2 | ||
|
|
41d300f69d | ||
|
|
9a7e460865 | ||
|
|
8626bdfed8 | ||
|
|
1f4140057b | ||
|
|
0eb297822c | ||
|
|
ad080af38f | ||
|
|
49fdbb3843 | ||
|
|
724368d0cd | ||
|
|
25f88e9b3a | ||
|
|
39ffb04be1 | ||
|
|
056c57d551 | ||
|
|
5d63ad21c1 | ||
|
|
a49e82e447 | ||
|
|
e9c8881d54 | ||
|
|
840099bec0 | ||
|
|
76c4bfe268 | ||
|
|
5f3b5fdcb2 | ||
|
|
6a95df403a | ||
|
|
380e7e7f04 | ||
|
|
581b977787 | ||
|
|
c771dd4bd2 | ||
|
|
cb720d8eaf | ||
|
|
9ff4ab1236 | ||
|
|
1f92e589e8 | ||
|
|
812ac5cb8e | ||
|
|
7d642546b3 | ||
|
|
192c447c2c | ||
|
|
d10d897d65 | ||
|
|
6992b484bc | ||
|
|
cdd5ba6198 | ||
|
|
6c04057285 | ||
|
|
217c746445 | ||
|
|
95c8a76aa6 | ||
|
|
a8e9700334 | ||
|
|
50b09f4f10 | ||
|
|
2c75593c1a | ||
|
|
3c41d0477a | ||
|
|
f7c48c5a5f | ||
|
|
c5d5d7ac5a | ||
|
|
71b62f5cf9 | ||
|
|
922073a357 | ||
|
|
01ff3bbe0a | ||
|
|
61bdc91272 | ||
|
|
08b37186a5 | ||
|
|
5225e4283f | ||
|
|
e7e675e471 | ||
|
|
391dc1e225 | ||
|
|
46412c8475 | ||
|
|
3ba8aeb9a7 | ||
|
|
64d620c987 | ||
|
|
f4debbf501 | ||
|
|
56b3c4a3a0 | ||
|
|
577dfee086 | ||
|
|
8f6c9c5946 | ||
|
|
ef1dfed0d8 | ||
|
|
948e5ae74d | ||
|
|
c4e872b9da | ||
|
|
7b5f7ed553 | ||
|
|
5409f4c0eb | ||
|
|
9b629cd5a6 | ||
|
|
546d489dd3 | ||
|
|
88da3a99e1 | ||
|
|
689e366f59 | ||
|
|
e2061312f5 | ||
|
|
e43d09e15b | ||
|
|
746d6cdc88 | ||
|
|
9c1401849e | ||
|
|
2a52e25d5b | ||
|
|
cabbfe9adc | ||
|
|
f18ebea03e | ||
|
|
82d4d54dc5 | ||
|
|
c87418d060 | ||
|
|
12219cffe0 | ||
|
|
c296ae1178 | ||
|
|
e18007153d | ||
|
|
1b79e17970 | ||
|
|
fdfcd5cbf0 | ||
|
|
bab21c9069 | ||
|
|
f3980504e2 | ||
|
|
1efdd92ae8 | ||
|
|
7c1dc2444d | ||
|
|
f224fd8310 | ||
|
|
4fe9935321 | ||
|
|
d996275f8f | ||
|
|
d2eca2faa1 | ||
|
|
045f5e11fc | ||
|
|
d55cc4e2a3 | ||
|
|
966c2912fc | ||
|
|
d47c47853a | ||
|
|
3bea304bab | ||
|
|
e9d5e037e8 | ||
|
|
77c046aac6 | ||
|
|
1bdf82dca3 | ||
|
|
6783d1000c | ||
|
|
95a4ec8bf2 | ||
|
|
cd3807055e | ||
|
|
26ec874fb1 | ||
|
|
5c1833de1f | ||
|
|
f16e3e38ee | ||
|
|
371625aeec | ||
|
|
beced6f3a6 | ||
|
|
5e0b03928e | ||
|
|
0d39852ef4 | ||
|
|
ab44c7d63e | ||
|
|
ed2092e287 | ||
|
|
c0d5b61403 | ||
|
|
7004a84c30 | ||
|
|
c2885430bd | ||
|
|
e65f86147f | ||
|
|
c17d02e496 | ||
|
|
ee0d0155a5 | ||
|
|
f484b4c347 | ||
|
|
3292a48381 | ||
|
|
8422697199 | ||
|
|
a927b893ae | ||
|
|
e6616cc551 | ||
|
|
60d5d5fb15 | ||
|
|
68397d342b | ||
|
|
09267bbfe8 | ||
|
|
0a1c2a7024 | ||
|
|
b6e46c9eb8 | ||
|
|
d1d47b5697 | ||
|
|
90871ca12e | ||
|
|
ac28c4b233 | ||
|
|
8b7a0100b1 | ||
|
|
dcf7385cc1 | ||
|
|
090594a0bc | ||
|
|
007c08f2a8 | ||
|
|
2d29d7b3d6 | ||
|
|
3133721422 | ||
|
|
28bff0c1f3 | ||
|
|
e6d79dfedf | ||
|
|
5740702cb5 | ||
|
|
a5318def66 | ||
|
|
6541ffe045 | ||
|
|
12c8bf9b18 | ||
|
|
d4048e1423 | ||
|
|
6257fbced6 | ||
|
|
330cb92b24 | ||
|
|
3695337980 | ||
|
|
648e8a9547 | ||
|
|
1e3a42ca68 | ||
|
|
21e61319f1 | ||
|
|
48fdfe721e | ||
|
|
bd8a0be3aa | ||
|
|
7cb6466251 | ||
|
|
d840645dd7 | ||
|
|
a8673a2e33 | ||
|
|
4a2c405ac0 | ||
|
|
499e01d832 | ||
|
|
5e090b84ec | ||
|
|
b8aa6a8e47 | ||
|
|
912301fe24 | ||
|
|
5909f9ef92 | ||
|
|
5fcb966297 | ||
|
|
6ad1a396a5 | ||
|
|
5513d3c9d1 | ||
|
|
9c35d3f87c | ||
|
|
f7a6c9a1e6 | ||
|
|
f65878c21d | ||
|
|
78dbb4dc13 | ||
|
|
d5481fa0f1 | ||
|
|
fbc3e0371d | ||
|
|
86ce3d5e45 | ||
|
|
b65642564a |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,13 +9,12 @@ awsconfig
|
||||
/public/vendor/npm
|
||||
/tmp
|
||||
vendor/phantomjs/phantomjs
|
||||
vendor/phantomjs/phantomjs.exe
|
||||
|
||||
docs/AWS_S3_BUCKET
|
||||
docs/GIT_BRANCH
|
||||
docs/VERSION
|
||||
docs/GITCOMMIT
|
||||
docs/changed-files
|
||||
docs/changed-files
|
||||
|
||||
# locally required config files
|
||||
public/css/*.min.css
|
||||
|
||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -1,4 +1,45 @@
|
||||
# 4.3.0-stable (unreleased)
|
||||
# 4.4.0 (unreleased)
|
||||
|
||||
## New Features
|
||||
**Dashboard History**: View dashboard version history, compare any two versions (summary & json diffs), restore to old version. This big feature
|
||||
was contributed by **Walmart Labs**. Big thanks to them for this massive contribution!
|
||||
Initial feature request: [#4638](https://github.com/grafana/grafana/issues/4638)
|
||||
Pull Request: [#8472](https://github.com/grafana/grafana/pull/8472)
|
||||
|
||||
## Enhancements
|
||||
* **Elasticsearch**: Added filter aggregation label [#8420](https://github.com/grafana/grafana/pull/8420), thx [@tianzk](github.com/tianzk)
|
||||
* **Sensu**: Added option for source and handler [#8405](https://github.com/grafana/grafana/pull/8405), thx [@joemiller](github.com/joemiller)
|
||||
* **CSV**: Configurable csv export datetime format [#8058](https://github.com/grafana/grafana/issues/8058), thx [@cederigo](github.com/cederigo)
|
||||
* **Table Panel**: Column style that preserves formatting/indentation (like pre tag) [#6617](https://github.com/grafana/grafana/issues/6617)
|
||||
* **DingDing**: Add DingDing Alert Notifier [#8473](https://github.com/grafana/grafana/pull/8473) thx [@jiamliang](https://github.com/jiamliang)
|
||||
|
||||
## Minor Enhancements
|
||||
|
||||
* **Elasticsearch**: Add option for result set size in raw_document [#3426](https://github.com/grafana/grafana/issues/3426) [#8527](https://github.com/grafana/grafana/pull/8527), thx [@mk-dhia](github.com/mk-dhia)
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* **Graph**: Bug fix for negative values in histogram mode [#8628](https://github.com/grafana/grafana/issues/8628)
|
||||
|
||||
# 4.3.2 (2017-05-31)
|
||||
|
||||
## Bug fixes
|
||||
|
||||
* **InfluxDB**: Fixed issue with query editor not showing ALIAS BY input field when in text editor mode [#8459](https://github.com/grafana/grafana/issues/8459)
|
||||
* **Graph Log Scale**: Fixed issue with log scale going below x-axis [#8244](https://github.com/grafana/grafana/issues/8244)
|
||||
* **Playlist**: Fixed dashboard play order issue [#7688](https://github.com/grafana/grafana/issues/7688)
|
||||
* **Elasticsearch**: Fixed table query issue with ES 2.x [#8467](https://github.com/grafana/grafana/issues/8467), thx [@goldeelox](https://github.com/goldeelox)
|
||||
|
||||
## Changes
|
||||
* **Lazy Loading Of Panels**: Panels are no longer loaded as they are scrolled into view, this was reverted due to Chrome bug, might be reintroduced when Chrome fixes it's JS blocking behavior on scroll. [#8500](https://github.com/grafana/grafana/issues/8500)
|
||||
|
||||
# 4.3.1 (2017-05-23)
|
||||
|
||||
## Bug fixes
|
||||
|
||||
* **S3 image upload**: Fixed image url issue for us-east-1 (us standard) region. If you were missing slack images for alert notifications this should fix it. [#8444](https://github.com/grafana/grafana/issues/8444)
|
||||
|
||||
# 4.3.0-stable (2017-05-23)
|
||||
|
||||
## Bug fixes
|
||||
|
||||
@@ -30,7 +71,7 @@
|
||||
* **Heatmap**: Heatmap Panel [#7934](https://github.com/grafana/grafana/pull/7934)
|
||||
* **Elasticsearch**: histogram aggregation [#3164](https://github.com/grafana/grafana/issues/3164)
|
||||
|
||||
## Minor Enchancements
|
||||
## Minor Enhancements
|
||||
|
||||
* **InfluxDB**: Small fix for the "glow" when focus the field for LIMIT and SLIMIT [#7799](https://github.com/grafana/grafana/pull/7799) thx [@thuck](https://github.com/thuck)
|
||||
* **Prometheus**: Make Prometheus query field a textarea [#7663](https://github.com/grafana/grafana/issues/7663), thx [@hagen1778](https://github.com/hagen1778)
|
||||
|
||||
@@ -18,6 +18,7 @@ Graphite, Elasticsearch, OpenTSDB, Prometheus and InfluxDB.
|
||||
- [What's New in Grafana 4.1](http://docs.grafana.org/guides/whats-new-in-v4-1/)
|
||||
- [What's New in Grafana 4.2](http://docs.grafana.org/guides/whats-new-in-v4-2/)
|
||||
- [What's New in Grafana 4.3](http://docs.grafana.org/guides/whats-new-in-v4-3/)
|
||||
- [What's New in Grafana 4.4](http://docs.grafana.org/guides/whats-new-in-v4-4/)
|
||||
|
||||
## Features
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ build_script:
|
||||
- grunt release
|
||||
- go run build.go sha-dist
|
||||
- cp dist/* .
|
||||
- go test -v ./pkg/...
|
||||
|
||||
artifacts:
|
||||
- path: grafana-*windows-*.*
|
||||
|
||||
12
build.go
12
build.go
@@ -95,7 +95,9 @@ func main() {
|
||||
|
||||
case "package":
|
||||
grunt(gruntBuildArg("release")...)
|
||||
createLinuxPackages()
|
||||
if runtime.GOOS != "windows" {
|
||||
createLinuxPackages()
|
||||
}
|
||||
|
||||
case "pkg-rpm":
|
||||
grunt(gruntBuildArg("release")...)
|
||||
@@ -235,7 +237,7 @@ func createRpmPackages() {
|
||||
defaultFileSrc: "packaging/rpm/sysconfig/grafana-server",
|
||||
systemdFileSrc: "packaging/rpm/systemd/grafana-server.service",
|
||||
|
||||
depends: []string{"/sbin/service", "fontconfig"},
|
||||
depends: []string{"/sbin/service", "fontconfig", "freetype", "urw-fonts"},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -345,7 +347,11 @@ func ChangeWorkingDir(dir string) {
|
||||
}
|
||||
|
||||
func grunt(params ...string) {
|
||||
runPrint("./node_modules/.bin/grunt", params...)
|
||||
if runtime.GOOS == "windows" {
|
||||
runPrint(`.\node_modules\.bin\grunt`, params...)
|
||||
} else {
|
||||
runPrint("./node_modules/.bin/grunt", params...)
|
||||
}
|
||||
}
|
||||
|
||||
func gruntBuildArg(task string) []string {
|
||||
|
||||
@@ -298,7 +298,7 @@
|
||||
# Use space to separate multiple modes, e.g. "console file"
|
||||
;mode = console file
|
||||
|
||||
# Either "trace", "debug", "info", "warn", "error", "critical", default is "info"
|
||||
# Either "debug", "info", "warn", "error", "critical", default is "info"
|
||||
;level = info
|
||||
|
||||
# optional settings to set different levels for specific loggers. Ex filters = sqlstore:debug
|
||||
|
||||
@@ -4,7 +4,6 @@ graphite:
|
||||
- "8080:80"
|
||||
- "2003:2003"
|
||||
volumes:
|
||||
- /var/docker/gfdev/graphite:/opt/graphite/storage/whisper
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# Building The Docs
|
||||
|
||||
To build the docs locally, you need to have docker installed. The
|
||||
docs are built using a custom [docker](https://www.docker.com/) image
|
||||
and the [mkdocs](http://www.mkdocs.org/) tool.
|
||||
docs are built using [Hugo](http://gohugo.io/) - a static site generator.
|
||||
|
||||
**Prepare the Docker Image**:
|
||||
|
||||
@@ -11,19 +10,40 @@ when running ``make docs-build`` depending on how your system's docker
|
||||
service is configured):
|
||||
|
||||
```
|
||||
$ git clone https://github.com/grafana/grafana.org
|
||||
$ cd grafana.org
|
||||
$ make docs-build
|
||||
git clone https://github.com/grafana/grafana.org
|
||||
cd grafana.org
|
||||
make docs-build
|
||||
```
|
||||
|
||||
**Build the Documentation**:
|
||||
|
||||
Now that the docker image has been prepared we can build the
|
||||
docs. Switch your working directory back to the directory this file
|
||||
(README.md) is in and run (possibly with ``sudo``):
|
||||
grafana docs and start a docs server.
|
||||
|
||||
If you have not cloned the Grafana repository already then:
|
||||
|
||||
```
|
||||
$ make docs
|
||||
cd ..
|
||||
git clone https://github.com/grafana/grafana
|
||||
```
|
||||
|
||||
Switch your working directory to the directory this file
|
||||
(README.md) is in.
|
||||
|
||||
```
|
||||
cd grafana/docs
|
||||
```
|
||||
|
||||
An AWS config file is required to build the docs Docker image and to publish the site to AWS. If you are building locally only and do not have any AWS credentials for docs.grafana.org then create an empty file named `awsconfig` in the current directory.
|
||||
|
||||
```
|
||||
touch awsconfig
|
||||
```
|
||||
|
||||
Then run (possibly with ``sudo``):
|
||||
|
||||
```
|
||||
make watch
|
||||
```
|
||||
|
||||
This command will not return control of the shell to the user. Instead
|
||||
@@ -32,4 +52,21 @@ we created in the previous step.
|
||||
|
||||
Open [localhost:3004](http://localhost:3004) to view the docs.
|
||||
|
||||
### Images & Content
|
||||
|
||||
All markdown files are located in this repo (main grafana repo). But all images are added to the https://github.com/grafana/grafana.org repo. So the process of adding images is a bit complicated.
|
||||
|
||||
First you need create a feature (PR) branch of https://github.com/grafana/grafana.org so you can make change. Then add the image to the `/static/img/docs` directory. Then make a commit that adds the image.
|
||||
|
||||
Then run:
|
||||
```
|
||||
make docs-build
|
||||
```
|
||||
|
||||
This will rebuild the docs docker container.
|
||||
|
||||
To be able to use the image your have to quit (CTRL-C) the `make watch` command (that you run in the same directory as this README). Then simply rerun `make watch`, it will restart the docs server but now with access to your image.
|
||||
|
||||
### Editing content
|
||||
|
||||
Changes to the markdown files should automatically cause a docs rebuild and live reload should reload the page in your browser.
|
||||
|
||||
@@ -112,6 +112,8 @@ Grafana also supports the following Notification Channels:
|
||||
|
||||
- LINE
|
||||
|
||||
- DingDing
|
||||
|
||||
# Enable images in notifications {#external-image-store}
|
||||
|
||||
Grafana can render the panel associated with the alert rule and include that in the notification. Most Notification Channels require that this image be publicly accessable (Slack and PagerDuty for example). In order to include images in alert notifications, Grafana can upload the image to an image store. It currently supports
|
||||
|
||||
@@ -52,12 +52,22 @@ Here you can specify the name of the alert rule and how often the scheduler shou
|
||||
### Conditions
|
||||
|
||||
Currently the only condition type that exists is a `Query` condition that allows you to
|
||||
specify a query letter, time range and an aggregation function. The letter refers to
|
||||
a query you already have added in the **Metrics** tab. The result from the query and the aggregation function is
|
||||
a single value that is then used in the threshold check. The query used in an alert rule cannot
|
||||
contain any template variables. Currently we only support `AND` and `OR` operators between conditions and they are executed serially.
|
||||
specify a query letter, time range and an aggregation function.
|
||||
|
||||
|
||||
### Query condition example
|
||||
|
||||
```sql
|
||||
avg() OF query(A, 5m, now) IS BELOW 14
|
||||
```
|
||||
|
||||
- `avg()` Controls how the values for **each** serie should be reduced to a value that can be compared against the threshold. Click on the function to change it to another aggregation function.
|
||||
- `query(A, 5m, now)` The letter defines what query to execute from the **Metrics** tab. The second two parameters defines the time range, `5m, now` means 5 minutes from now to now. You can also do `10m, now-2m` to define a time range that will be 10 minutes from now to 2 minutes from now. This is useful if you want to ignore the last 2 minutes of data.
|
||||
- `IS BELOW 14` Defines the type of threshold and the threshold value. You can click on `IS BELOW` to change the type of threshold.
|
||||
|
||||
The query used in an alert rule cannot contain any template variables. Currently we only support `AND` and `OR` operators between conditions and they are executed serially.
|
||||
For example, we have 3 conditions in the following order:
|
||||
`condition:A(evaluates to: TRUE) OR condition:B(evaluates to: FALSE) AND condition:C(evaluates to: TRUE)`
|
||||
*condition:A(evaluates to: TRUE) OR condition:B(evaluates to: FALSE) AND condition:C(evaluates to: TRUE)*
|
||||
so the result will be calculated as ((TRUE OR FALSE) AND TRUE) = TRUE.
|
||||
|
||||
We plan to add other condition types in the future, like `Other Alert`, where you can include the state
|
||||
|
||||
@@ -92,9 +92,10 @@ The Elasticsearch data source supports two types of queries you can use in the *
|
||||
Query | Description
|
||||
------------ | -------------
|
||||
*{"find": "fields", "type": "keyword"} | Returns a list of field names with the index type `keyword`.
|
||||
*{"find": "terms", "field": "@hostname"}* | Returns a list of values for a field using term aggregation. Query will user current dashboard time range as time range for query.
|
||||
*{"find": "terms", "field": "@hostname", "size": 1000}* | Returns a list of values for a field using term aggregation. Query will user current dashboard time range as time range for query.
|
||||
*{"find": "terms", "field": "@hostname", "query": '<lucene query>'}* | Returns a list of values for a field using term aggregation & and a specified lucene query filter. Query will use current dashboard time range as time range for query.
|
||||
|
||||
There is a default size limit of 500 on terms queries. Set the size property in your query to set a custom limit.
|
||||
You can use other variables inside the query. Example query definition for a variable named `$host`.
|
||||
|
||||
```
|
||||
|
||||
@@ -88,8 +88,8 @@ You can switch to raw query mode by clicking hamburger icon and then `Switch edi
|
||||
- $m = replaced with measurement name
|
||||
- $measurement = replaced with measurement name
|
||||
- $col = replaced with column name
|
||||
- $tag_hostname = replaced with the value of the hostname tag
|
||||
- You can also use [[tag_hostname]] pattern replacement syntax
|
||||
- $tag_exampletag = replaced with the value of the `exampletag` tag. To use your tag as an alias in the ALIAS BY field then the tag must be used to group by in the query.
|
||||
- You can also use [[tag_hostname]] pattern replacement syntax. For example, in the ALIAS BY field using this text `Host: [[tag_hostname]]` would substitute in the `hostname` tag value for each legend value and an example legend value would be: `Host: server1`.
|
||||
|
||||
### Table query / raw data
|
||||
|
||||
|
||||
50
docs/sources/guides/whats-new-in-v4-4.md
Normal file
50
docs/sources/guides/whats-new-in-v4-4.md
Normal file
@@ -0,0 +1,50 @@
|
||||
+++
|
||||
title = "What's New in Grafana v4.4"
|
||||
description = "Feature & improvement highlights for Grafana v4.4"
|
||||
keywords = ["grafana", "new", "documentation", "4.4.0"]
|
||||
type = "docs"
|
||||
[menu.docs]
|
||||
name = "Version 4.4"
|
||||
identifier = "v4.4"
|
||||
parent = "whatsnew"
|
||||
weight = -2
|
||||
+++
|
||||
|
||||
## What's New in Grafana v4.4
|
||||
|
||||
Grafana v4.4 is now [available for download](https://grafana.com/grafana/download/4.4.0).
|
||||
|
||||
**Highlights**:
|
||||
|
||||
- Dashboard History - version control for dashboards.
|
||||
|
||||
## New Features
|
||||
|
||||
**Dashboard History**: View dashboard version history, compare any two versions (summary & json diffs), restore to old version. This big feature
|
||||
was contributed by **Walmart Labs**. Big thanks to them for this massive contribution!
|
||||
Initial feature request: [#4638](https://github.com/grafana/grafana/issues/4638)
|
||||
Pull Request: [#8472](https://github.com/grafana/grafana/pull/8472)
|
||||
|
||||
## Enhancements
|
||||
* **Elasticsearch**: Added filter aggregation label [#8420](https://github.com/grafana/grafana/pull/8420), thx [@tianzk](github.com/tianzk)
|
||||
* **Sensu**: Added option for source and handler [#8405](https://github.com/grafana/grafana/pull/8405), thx [@joemiller](github.com/joemiller)
|
||||
* **CSV**: Configurable csv export datetime format [#8058](https://github.com/grafana/grafana/issues/8058), thx [@cederigo](github.com/cederigo)
|
||||
* **Table Panel**: Column style that preserves formatting/indentation (like pre tag) [#6617](https://github.com/grafana/grafana/issues/6617)
|
||||
* **DingDing**: Add DingDing Alert Notifier [#8473](https://github.com/grafana/grafana/pull/8473) thx [@jiamliang](https://github.com/jiamliang)
|
||||
|
||||
## Minor Enhancements
|
||||
|
||||
* **Elasticsearch**: Add option for result set size in raw_document [#3426](https://github.com/grafana/grafana/issues/3426) [#8527](https://github.com/grafana/grafana/pull/8527), thx [@mk-dhia](github.com/mk-dhia)
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* **Graph**: Bug fix for negative values in histogram mode [#8628](https://github.com/grafana/grafana/issues/8628)
|
||||
|
||||
## Download
|
||||
|
||||
Head to the [v4.4 download page](https://grafana.com/grafana/download) for download links & instructions.
|
||||
|
||||
## Thanks
|
||||
|
||||
A big thanks to all the Grafana users who contribute by submitting PRs, bug reports, helping out on our [community site](https://community.grafana.com/) and providing feedback!
|
||||
|
||||
321
docs/sources/http_api/dashboard_versions.md
Normal file
321
docs/sources/http_api/dashboard_versions.md
Normal file
@@ -0,0 +1,321 @@
|
||||
+++
|
||||
title = "Dashboard Versions HTTP API "
|
||||
description = "Grafana Dashboard Versions HTTP API"
|
||||
keywords = ["grafana", "http", "documentation", "api", "dashboard", "versions"]
|
||||
aliases = ["/http_api/dashboardversions/"]
|
||||
type = "docs"
|
||||
[menu.docs]
|
||||
name = "Dashboard Versions"
|
||||
parent = "http_api"
|
||||
+++
|
||||
|
||||
# Dashboard Versions
|
||||
|
||||
## Get all dashboard versions
|
||||
|
||||
Query parameters:
|
||||
|
||||
- **limit** - Maximum number of results to return
|
||||
- **start** - Version to start from when returning queries
|
||||
|
||||
`GET /api/dashboards/id/:dashboardId/versions`
|
||||
|
||||
Gets all existing dashboard versions for the dashboard with the given `dashboardId`.
|
||||
|
||||
**Example request for getting all dashboard versions**:
|
||||
|
||||
```http
|
||||
GET /api/dashboards/id/1/versions?limit=2?start=0 HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
```
|
||||
|
||||
**Example Response**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=UTF-8
|
||||
Content-Length: 428
|
||||
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** - Ok
|
||||
- **400** - Errors
|
||||
- **401** - Unauthorized
|
||||
- **404** - Dashboard version not found
|
||||
|
||||
## Get dashboard version
|
||||
|
||||
`GET /api/dashboards/id/:dashboardId/versions/:id`
|
||||
|
||||
Get the dashboard version with the given id, for the dashboard with the given id.
|
||||
|
||||
**Example request for getting a dashboard version**:
|
||||
|
||||
```http
|
||||
GET /api/dashboards/id/1/versions/1 HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
```
|
||||
|
||||
**Example response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=UTF-8
|
||||
Content-Length: 1300
|
||||
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** - Ok
|
||||
- **401** - Unauthorized
|
||||
- **404** - Dashboard version not found
|
||||
|
||||
## Restore dashboard
|
||||
|
||||
`POST /api/dashboards/id/:dashboardId/restore`
|
||||
|
||||
Restores a dashboard to a given dashboard version.
|
||||
|
||||
**Example request for restoring a dashboard version**:
|
||||
|
||||
```http
|
||||
POST /api/dashboards/id/1/restore
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
|
||||
{
|
||||
"version": 1
|
||||
}
|
||||
```
|
||||
|
||||
JSON body schema:
|
||||
|
||||
- **version** - The dashboard version to restore to
|
||||
|
||||
**Example response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=UTF-8
|
||||
Content-Length: 67
|
||||
|
||||
```
|
||||
|
||||
JSON response body schema:
|
||||
|
||||
- **slug** - the URL friendly slug of the dashboard's title
|
||||
- **status** - whether the restoration was successful or not
|
||||
- **version** - the new dashboard version, following the restoration
|
||||
|
||||
Status codes:
|
||||
|
||||
- **200** - OK
|
||||
- **401** - Unauthorized
|
||||
- **404** - Not found (dashboard not found or dashboard version not found)
|
||||
- **500** - Internal server error (indicates issue retrieving dashboard tags from database)
|
||||
|
||||
**Example error response**
|
||||
|
||||
```http
|
||||
HTTP/1.1 404 Not Found
|
||||
Content-Type: application/json; charset=UTF-8
|
||||
Content-Length: 46
|
||||
|
||||
```
|
||||
|
||||
JSON response body schema:
|
||||
|
||||
- **message** - Message explaining the reason for the request failure.
|
||||
|
||||
## Compare dashboard versions
|
||||
|
||||
`POST /api/dashboards/calculate-diff`
|
||||
|
||||
Compares two dashboard versions by calculating the JSON diff of them.
|
||||
|
||||
**Example request**:
|
||||
|
||||
```http
|
||||
POST /api/dashboards/calculate-diff HTTP/1.1
|
||||
Accept: text/html
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
|
||||
```
|
||||
|
||||
JSON body schema:
|
||||
|
||||
- **base** - an object representing the base dashboard version
|
||||
- **new** - an object representing the new dashboard version
|
||||
- **diffType** - the type of diff to return. Can be "json" or "basic".
|
||||
|
||||
**Example response (JSON diff)**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
```
|
||||
|
||||
The response is a textual respresentation of the diff, with the dashboard values being in JSON, similar to the diffs seen on sites like GitHub or GitLab.
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** - Ok
|
||||
- **400** - Bad request (invalid JSON sent)
|
||||
- **401** - Unauthorized
|
||||
- **404** - Not found
|
||||
|
||||
**Example response (basic diff)**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
```
|
||||
|
||||
The response here is a summary of the changes, derived from the diff between the two JSON objects.
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** - OK
|
||||
- **400** - Bad request (invalid JSON sent)
|
||||
- **401** - Unauthorized
|
||||
- **404** - Not found
|
||||
POST /api/dashboards/id/1/restore
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
|
||||
{
|
||||
"version": 1
|
||||
}
|
||||
```
|
||||
|
||||
JSON body schema:
|
||||
|
||||
- **version** - The dashboard version to restore to
|
||||
|
||||
**Example response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=UTF-8
|
||||
Content-Length: 67
|
||||
|
||||
{
|
||||
"slug": "my-dashboard",
|
||||
"status": "success",
|
||||
"version": 3
|
||||
}
|
||||
```
|
||||
|
||||
JSON response body schema:
|
||||
|
||||
- **slug** - the URL friendly slug of the dashboard's title
|
||||
- **status** - whether the restoration was successful or not
|
||||
- **version** - the new dashboard version, following the restoration
|
||||
|
||||
Status codes:
|
||||
|
||||
- **200** - OK
|
||||
- **401** - Unauthorized
|
||||
- **404** - Not found (dashboard not found or dashboard version not found)
|
||||
- **500** - Internal server error (indicates issue retrieving dashboard tags from database)
|
||||
|
||||
**Example error response**
|
||||
|
||||
```http
|
||||
HTTP/1.1 404 Not Found
|
||||
Content-Type: application/json; charset=UTF-8
|
||||
Content-Length: 46
|
||||
|
||||
{
|
||||
"message": "Dashboard version not found"
|
||||
}
|
||||
```
|
||||
|
||||
JSON response body schema:
|
||||
|
||||
- **message** - Message explaining the reason for the request failure.
|
||||
|
||||
## Compare dashboard versions
|
||||
|
||||
`POST /api/dashboards/calculate-diff`
|
||||
|
||||
Compares two dashboard versions by calculating the JSON diff of them.
|
||||
|
||||
**Example request**:
|
||||
|
||||
```http
|
||||
POST /api/dashboards/calculate-diff HTTP/1.1
|
||||
Accept: text/html
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
|
||||
{
|
||||
"base": {
|
||||
"dashboardId": 1,
|
||||
"version": 1
|
||||
},
|
||||
"new": {
|
||||
"dashboardId": 1,
|
||||
"version": 2
|
||||
},
|
||||
"diffType": "json"
|
||||
}
|
||||
```
|
||||
|
||||
JSON body schema:
|
||||
|
||||
- **base** - an object representing the base dashboard version
|
||||
- **new** - an object representing the new dashboard version
|
||||
- **diffType** - the type of diff to return. Can be "json" or "basic".
|
||||
|
||||
**Example response (JSON diff)**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<p id="l1" class="diff-line diff-json-same">
|
||||
<!-- Diff omitted -->
|
||||
</p>
|
||||
```
|
||||
|
||||
The response is a textual respresentation of the diff, with the dashboard values being in JSON, similar to the diffs seen on sites like GitHub or GitLab.
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** - Ok
|
||||
- **400** - Bad request (invalid JSON sent)
|
||||
- **401** - Unauthorized
|
||||
- **404** - Not found
|
||||
|
||||
**Example response (basic diff)**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div class="diff-group">
|
||||
<!-- Diff omitted -->
|
||||
</div>
|
||||
```
|
||||
|
||||
The response here is a summary of the changes, derived from the diff between the two JSON objects.
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** - OK
|
||||
- **400** - Bad request (invalid JSON sent)
|
||||
- **401** - Unauthorized
|
||||
- **404** - Not found
|
||||
@@ -52,6 +52,15 @@ parent = "http_api"
|
||||
"expires": 3600
|
||||
}
|
||||
|
||||
JSON Body schema:
|
||||
|
||||
- **dashboard** – Required. The complete dashboard model.
|
||||
- **name** – Optional. snapshot name
|
||||
- **expires** - Optional. When the snapshot should expire in seconds. 3600 is 1 hour, 86400 is 1 day. Default is never to expire.
|
||||
- **external** - Optional. Save the snapshot on an external server rather than locally. Default is `false`.
|
||||
- **key** - Optional. Define the unique key. Required if **external** is `true`.
|
||||
- **deleteKey** - Optional. Unique key used to delete the snapshot. It is different from the **key** so that only the creator can delete the snapshot. Required if **external** is `true`.
|
||||
|
||||
**Example Response**:
|
||||
|
||||
HTTP/1.1 200
|
||||
|
||||
@@ -203,6 +203,12 @@ For MySQL, use either `true`, `false`, or `skip-verify`.
|
||||
|
||||
(MySQL only) The common name field of the certificate used by the `mysql` server. Not necessary if `ssl_mode` is set to `skip-verify`.
|
||||
|
||||
### max_idle_conn
|
||||
The maximum number of connections in the idle connection pool.
|
||||
|
||||
### max_open_conn
|
||||
The maximum number of open connections to the database.
|
||||
|
||||
<hr />
|
||||
|
||||
## [security]
|
||||
@@ -444,20 +450,29 @@ false only pre-existing Grafana users will be able to login (if ldap authenticat
|
||||
<hr>
|
||||
|
||||
## [auth.proxy]
|
||||
|
||||
This feature allows you to handle authentication in a http reverse proxy.
|
||||
|
||||
### enabled
|
||||
|
||||
Defaults to `false`
|
||||
|
||||
### header_name
|
||||
|
||||
Defaults to X-WEBAUTH-USER
|
||||
|
||||
#### header_property
|
||||
|
||||
Defaults to username but can also be set to email
|
||||
|
||||
### auto_sign_up
|
||||
|
||||
Set to `true` to enable auto sign up of users who do not exist in Grafana DB. Defaults to `true`.
|
||||
|
||||
### whitelist
|
||||
|
||||
Limit where auth proxy requests come from by configuring a list of IP addresses. This can be used to prevent users spoofing the X-WEBAUTH-USER header.
|
||||
|
||||
<hr>
|
||||
|
||||
## [session]
|
||||
|
||||
@@ -15,7 +15,7 @@ weight = 1
|
||||
|
||||
Description | Download
|
||||
------------ | -------------
|
||||
Stable for Debian-based Linux | [grafana_4.3.0_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.3.0_amd64.deb)
|
||||
Stable for Debian-based Linux | [grafana_4.4.0_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.4.0_amd64.deb)
|
||||
|
||||
Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
|
||||
installation.
|
||||
@@ -23,9 +23,9 @@ installation.
|
||||
## Install Stable
|
||||
|
||||
```bash
|
||||
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.2.0_amd64.deb
|
||||
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.4.0_amd64.deb
|
||||
sudo apt-get install -y adduser libfontconfig
|
||||
sudo dpkg -i grafana_4.2.0_amd64.deb
|
||||
sudo dpkg -i grafana_4.4.0_amd64.deb
|
||||
```
|
||||
|
||||
<!--
|
||||
@@ -81,6 +81,7 @@ sudo apt-get install -y apt-transport-https
|
||||
- Installs systemd service (if systemd is available) name `grafana-server.service`
|
||||
- The default configuration sets the log file at `/var/log/grafana/grafana.log`
|
||||
- The default configuration specifies an sqlite3 db at `/var/lib/grafana/grafana.db`
|
||||
- Installs HTML/JS/CSS and other Grafana files at `/usr/share/grafana`
|
||||
|
||||
## Start the server (init.d service)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ weight = 2
|
||||
|
||||
Description | Download
|
||||
------------ | -------------
|
||||
Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.3.0 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.3.0-1.x86_64.rpm)
|
||||
Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.4.0 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.4.0-1.x86_64.rpm)
|
||||
|
||||
Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
|
||||
installation.
|
||||
@@ -24,19 +24,19 @@ installation.
|
||||
|
||||
You can install Grafana using Yum directly.
|
||||
|
||||
$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.2.0-1.x86_64.rpm
|
||||
$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.4.0-1.x86_64.rpm
|
||||
|
||||
Or install manually using `rpm`.
|
||||
|
||||
#### On CentOS / Fedora / Redhat:
|
||||
|
||||
$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.2.0-1.x86_64.rpm
|
||||
$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.4.0-1.x86_64.rpm
|
||||
$ sudo yum install initscripts fontconfig
|
||||
$ sudo rpm -Uvh grafana-4.2.0-1.x86_64.rpm
|
||||
$ sudo rpm -Uvh grafana-4.4.0-1.x86_64.rpm
|
||||
|
||||
#### On OpenSuse:
|
||||
|
||||
$ sudo rpm -i --nodeps grafana-4.2.0-1.x86_64.rpm
|
||||
$ sudo rpm -i --nodeps grafana-4.4.0-1.x86_64.rpm
|
||||
|
||||
## Install via YUM Repository
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ weight = 3
|
||||
|
||||
Description | Download
|
||||
------------ | -------------
|
||||
Latest stable package for Windows | [grafana.4.3.0.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.3.0.windows-x64.zip)
|
||||
Latest stable package for Windows | [grafana.4.4.0.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.4.0.windows-x64.zip)
|
||||
|
||||
Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
|
||||
installation.
|
||||
|
||||
@@ -15,14 +15,21 @@ dev environment. Grafana ships with its own required backend server; also comple
|
||||
|
||||
- [Go 1.8.1](https://golang.org/dl/)
|
||||
- [NodeJS LTS](https://nodejs.org/download/)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
## Get Code
|
||||
Create a directory for the project and set your path accordingly. Then download and install Grafana into your $GOPATH directory
|
||||
Create a directory for the project and set your path accordingly (or use the [default Go workspace directory](https://golang.org/doc/code.html#GOPATH)). Then download and install Grafana into your $GOPATH directory:
|
||||
|
||||
```
|
||||
export GOPATH=`pwd`
|
||||
go get github.com/grafana/grafana
|
||||
```
|
||||
|
||||
On Windows use setx instead of export and then restart your command prompt:
|
||||
```
|
||||
setx GOPATH %cd%
|
||||
```
|
||||
|
||||
You may see an error such as: `package github.com/grafana/grafana: no buildable Go source files`. This is just a warning, and you can proceed with the directions.
|
||||
|
||||
## Building the backend
|
||||
@@ -36,6 +43,12 @@ go run build.go build # (or 'go build ./pkg/cmd/grafana-server')
|
||||
The Grafana backend includes Sqlite3 which requires GCC to compile. So in order to compile Grafana on windows you need
|
||||
to install GCC. We recommend [TDM-GCC](http://tdm-gcc.tdragon.net/download).
|
||||
|
||||
[node-gyp](https://github.com/nodejs/node-gyp#installation) is the Node.js native addon build tool and it requires extra dependencies to be installed on Windows. In a command prompt which is run as administrator, run:
|
||||
|
||||
```
|
||||
npm --add-python-to-path='true' --debug install --global windows-build-tools
|
||||
```
|
||||
|
||||
## Build the Front-end Assets
|
||||
|
||||
To build less to css for the frontend you will need a recent version of node (v0.12.0),
|
||||
@@ -55,6 +68,8 @@ go get github.com/Unknwon/bra
|
||||
bra run
|
||||
```
|
||||
|
||||
If the `bra run` command does not work, make sure that the bin directory in your Go workspace directory is in the path. $GOPATH/bin (or %GOPATH%\bin in Windows) is in your path.
|
||||
|
||||
## Running Grafana Locally
|
||||
You can run a local instance of Grafana by running:
|
||||
```
|
||||
@@ -94,3 +109,24 @@ Learn more about Grafana config options in the [Configuration section](/installa
|
||||
|
||||
## Create a pull requests
|
||||
Please contribute to the Grafana project and submit a pull request! Build new features, write or update documentation, fix bugs and generally make Grafana even more awesome.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Problem**: PhantomJS or node-sass errors when running grunt
|
||||
|
||||
**Solution**: delete the node_modules directory. Install [node-gyp](https://github.com/nodejs/node-gyp#installation) properly for your platform. Then run `yarn install --pure-lockfile` again.
|
||||
<br><br>
|
||||
|
||||
**Problem**: When running `bra run` for the first time you get an error that it is not a recognized command.
|
||||
|
||||
**Solution**: Add the bin directory in your Go workspace directory to the path. Per default this is `$HOME/go/bin` on Linux and `%USERPROFILE%\go\bin` on Windows or `$GOPATH/bin` (`%GOPATH%\bin` on Windows) if you have set your own workspace directory.
|
||||
<br><br>
|
||||
|
||||
**Problem**: When executing a `go get` command on Windows and you get an error about the git repository not existing.
|
||||
|
||||
**Solution**: `go get` requires Git. If you run `go get` without Git then it will create an empty directory in your Go workspace for the library you are trying to get. Even after installing Git, you will get a similar error. To fix this, delete the empty directory (for example: if you tried to run `go get github.com/Unknwon/bra` then delete `%USERPROFILE%\go\src\github.com\Unknwon\bra`) and run the `go get` command again.
|
||||
<br><br>
|
||||
|
||||
**Problem**: On Windows, getting errors about a tool not being installed even though you just installed that tool.
|
||||
|
||||
**Solution**: It is usually because it got added to the path and you have to restart your command prompt to use it.
|
||||
|
||||
@@ -65,7 +65,7 @@ Each field in the dashboard JSON is explained below with its usage:
|
||||
| **timezone** | timezone of dashboard, i.e. `utc` or `browser` |
|
||||
| **editable** | whether a dashboard is editable or not |
|
||||
| **hideControls** | whether row controls on the left in green are hidden or not |
|
||||
| **graphTooltip** | TODO |
|
||||
| **graphTooltip** | 0 for no shared crosshair or tooltip (default), 1 for shared crosshair, 2 for shared crosshair AND shared tooltip |
|
||||
| **rows** | row metadata, see [rows section](#rows) for details |
|
||||
| **time** | time range for dashboard, i.e. last 6 hours, last 7 days, etc |
|
||||
| **timepicker** | timepicker metadata, see [timepicker section](#timepicker) for details |
|
||||
|
||||
40
docs/sources/reference/dashboard_history.md
Normal file
40
docs/sources/reference/dashboard_history.md
Normal file
@@ -0,0 +1,40 @@
|
||||
+++
|
||||
title = "Dashboard Version History"
|
||||
keywords = ["grafana", "dashboard", "documentation", "version", "history"]
|
||||
type = "docs"
|
||||
[menu.docs]
|
||||
name = "Dashboard Version History"
|
||||
parent = "dashboard_features"
|
||||
weight = 100
|
||||
+++
|
||||
|
||||
|
||||
# Dashboard Version History
|
||||
|
||||
Whenever you save a version of your dashboard, a copy of that version is saved so that previous versions of your dashboard are never lost. A list of these versions is available by clicking the dashboard menu dropdown, and clicking "Version history".
|
||||
|
||||
<img class="no-shadow" src="/img/docs/v4/dashboard_versions_list.png">
|
||||
|
||||
The dashboard version history feature lets you compare and restore to previously saved dashboard versions.
|
||||
|
||||
## Comparing two dashboard versions
|
||||
|
||||
To compare two dashboard versions, select the two versions from the list that you wish to compare. Once selected, the "Compare versions" button will become clickable. Click the button to view the diff between the two versions.
|
||||
|
||||
<img class="no-shadow" src="/img/docs/v4/dashboard_versions_select.png">
|
||||
|
||||
Upon clicking the button, you'll be brought to the diff view. By default, you'll see a textual summary of the changes, like in the image below.
|
||||
|
||||
<img class="no-shadow" src="/img/docs/v4/dashboard_versions_diff_basic.png">
|
||||
|
||||
If you want to view the diff of the raw JSON that represents your dashboard, you can do that as well by clicking the "JSON Diff" tab on the left.
|
||||
|
||||
If you want to restore to the version you are diffing against, you can do so by clicking the "Restore to version <x>" button in the top right.
|
||||
|
||||
## Restoring to a previously saved dashboard version
|
||||
|
||||
If you need to restore to a previously saved dashboard version, you can do so by either clicking the "Restore" button on the right of a row in the dashboard version list, or by clicking the "Restore to version <x>" button appearing in the diff view. Clicking the button will bring up the following popup prompting you to confirm the restoration.
|
||||
|
||||
<img class="no-shadow" src="/img/docs/v4/dashboard_versions_restore.png">
|
||||
|
||||
After restoring to a previous version, a new version will be created containing the same exact data as the previous version, only with a different version number. This is indicated in the "Notes column" for the row in the new dashboard version. This is done simply to ensure your previous dashboard versions are not affected by the change.
|
||||
@@ -141,6 +141,42 @@ Use the `Interval` type to create a variable that represents a time span (eg. `1
|
||||
|
||||
This variable type is useful as a parameter to group by time (for InfluxDB), Date histogram interval (for Elasticsearch) or as a *summarize* function parameter (for Graphite).
|
||||
|
||||
Example using the template variable `myinterval` of type `Interval` in a graphite function:
|
||||
|
||||
```
|
||||
summarize($myinterval, sum, false)
|
||||
```
|
||||
|
||||
## Global Built-in Variables
|
||||
|
||||
Grafana has global built-in variables that can be used in expressions in the query editor.
|
||||
|
||||
### The $__interval Variable
|
||||
|
||||
This $__interval variable is similar to the `auto` interval variable that is described above. It can be used as a parameter to group by time (for InfluxDB), Date histogram interval (for Elasticsearch) or as a *summarize* function parameter (for Graphite).
|
||||
|
||||
Grafana automatically calculates an interval that can be used to group by time in queries. When there are more data points than can be shown on a graph then queries can be made more efficient by grouping by a larger interval. It is more efficient to group by 1 day than by 10s when looking at 3 months of data and the graph will look the same and the query will be faster. The `$__interval` is calculated using the time range and the width of the graph (the number of pixels).
|
||||
|
||||
Approximate Calculation: `(from - to) / resolution`
|
||||
|
||||
For example, when the time range is 1 hour and the graph is full screen, then the interval might be calculated to `2m` - points are grouped in 2 minute intervals. If the time range is 6 months and the graph is full screen, then the interval might be `1d` (1 day) - points are grouped by day.
|
||||
|
||||
In the InfluxDB data source, the legacy variable `$interval` is the same variable. `$__interval` should be used instead.
|
||||
|
||||
The InfluxDB and Elasticsearch data sources have `Group by time interval` fields that are used to hard code the interval or to set the minimum limit for the `$__interval` variable (by using the `>` syntax -> `>10m`).
|
||||
|
||||
### The $__interval_ms Variable
|
||||
|
||||
This variable is the `$__interval` variable in milliseconds (and not a time interval formatted string). For example, if the `$__interval` is `20m` then the `$__interval_ms` is `1200000`.
|
||||
|
||||
### The $timeFilter or $__timeFilter Variable
|
||||
|
||||
The `$timeFilter` variable returns the currently selected time range as an expression. For example, the time range interval `Last 7 days` expression is `time > now() - 7d`.
|
||||
|
||||
This is used in the WHERE clause for the InfluxDB data source. Grafana adds it automatically to InfluxDB queries when in Query Editor Mode. It has to be added manually in Text Editor Mode: `WHERE $timeFilter`.
|
||||
|
||||
The `$__timeFilter` is used in the MySQL data source.
|
||||
|
||||
## Repeating Panels
|
||||
|
||||
Template variables can be very useful to dynamically change your queries across a whole dashboard. If you want
|
||||
|
||||
74
docs/sources/tutorials/api_org_token_howto.md
Normal file
74
docs/sources/tutorials/api_org_token_howto.md
Normal file
@@ -0,0 +1,74 @@
|
||||
+++
|
||||
title = "API Tutorial: How To Create API Tokens And Dashboards For A Specific Organization"
|
||||
type = "docs"
|
||||
keywords = ["grafana", "tutorials", "API", "Token", "Org", "Organization"]
|
||||
[menu.docs]
|
||||
parent = "tutorials"
|
||||
weight = 10
|
||||
+++
|
||||
|
||||
# API Tutorial: How To Create API Tokens And Dashboards For A Specific Organization
|
||||
|
||||
A common scenario is to want to via the Grafana API setup new Grafana organizations or to add dynamically generated dashboards to an existing organization.
|
||||
|
||||
## Authentication
|
||||
|
||||
There are two ways to authenticate against the API: basic authentication and API Tokens.
|
||||
|
||||
Some parts of the API are only available through basic authentication and these parts of the API usually require that the user is a Grafana Admin. But all organization actions are accessed via an API Token. An API Token is tied to an organization and can be used to create dashboards etc but only for that organization.
|
||||
|
||||
## How To Create A New Organization and an API Token
|
||||
|
||||
The task is to create a new organization and then add a Token that can be used by other users. In the examples below which use basic auth, the user is `admin` and the password is `admin`.
|
||||
|
||||
1. [Create the org](http://docs.grafana.org/http_api/org/#create-organisation). Here is an example using curl:
|
||||
```
|
||||
curl -X POST -H "Content-Type: application/json" -d '{"name":"apiorg"}' http://admin:admin@localhost:3000/api/orgs
|
||||
```
|
||||
|
||||
This should return a response: `{"message":"Organization created","orgId":6}`. Use the orgId for the next steps.
|
||||
|
||||
2. Optional step. If the org was created previously and/or step 3 fails then first [add your Admin user to the org](http://docs.grafana.org/http_api/org/#add-user-in-organisation):
|
||||
```
|
||||
curl -X POST -H "Content-Type: application/json" -d '{"loginOrEmail":"admin", "role": "Admin"}' http://admin:admin@localhost:3000/api/orgs/<org id of new org>/users
|
||||
```
|
||||
|
||||
3. [Switch the org context for the Admin user to the new org](http://docs.grafana.org/http_api/user/#switch-user-context):
|
||||
```
|
||||
curl -X POST http://admin:admin@localhost:3000/api/user/using/<id of new org>
|
||||
```
|
||||
|
||||
4. [Create the API token](http://docs.grafana.org/http_api/auth/#create-api-key):
|
||||
```
|
||||
curl -X POST -H "Content-Type: application/json" -d '{"name":"apikeycurl", "role": "Admin"}' http://admin:admin@localhost:3000/api/auth/keys
|
||||
```
|
||||
|
||||
This should return a response: `{"name":"apikeycurl","key":"eyJrIjoiR0ZXZmt1UFc0OEpIOGN5RWdUalBJTllUTk83VlhtVGwiLCJuIjoiYXBpa2V5Y3VybCIsImlkIjo2fQ=="}`.
|
||||
|
||||
Save the key returned here in your password manager as it is not possible to fetch again it in the future.
|
||||
|
||||
## How To Add A Dashboard
|
||||
|
||||
Using the Token that was created in the previous step, you can create a dashboard or carry out other actions without having to switch organizations.
|
||||
|
||||
1. [Add a dashboard](http://docs.grafana.org/http_api/dashboard/#create-update-dashboard) using the key (or bearer token as it is also called):
|
||||
|
||||
```
|
||||
curl -X POST --insecure -H "Authorization: Bearer eyJrIjoiR0ZXZmt1UFc0OEpIOGN5RWdUalBJTllUTk83VlhtVGwiLCJuIjoiYXBpa2V5Y3VybCIsImlkIjo2fQ==" -H "Content-Type: application/json" -d '{
|
||||
"dashboard": {
|
||||
"id": null,
|
||||
"title": "Production Overview",
|
||||
"tags": [ "templated" ],
|
||||
"timezone": "browser",
|
||||
"rows": [
|
||||
{
|
||||
}
|
||||
],
|
||||
"schemaVersion": 6,
|
||||
"version": 0
|
||||
},
|
||||
"overwrite": false
|
||||
}' http://localhost:3000/api/dashboards/db
|
||||
```
|
||||
|
||||
This import will not work if you exported the dashboard via the Share -> Export menu in the Grafana UI (it strips out data source names etc.). View the JSON and save it to a file instead or fetch the dashboard JSON via the API.
|
||||
@@ -35,6 +35,4 @@ But we suggest that you store the session in redis/memcache since it makes it ea
|
||||
|
||||
## Alerting
|
||||
|
||||
Currently alerting does not support high availability. But this is something that we will be working on in the future.
|
||||
|
||||
|
||||
Currently alerting supports a limited form of high availability. Since v4.2.0 of Grafana, alert notifications are deduped when running multiple servers. This means all alerts are executed on every server but no duplicate alert notifications are sent due to the deduping logic. Proper load balancing of alerts will be introduced in the future.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"company": "Coding Instinct AB"
|
||||
},
|
||||
"name": "grafana",
|
||||
"version": "4.3.2",
|
||||
"version": "4.4.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "http://github.com/grafana/grafana.git"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#! /usr/bin/env bash
|
||||
version=4.3.0
|
||||
version=4.4.0
|
||||
|
||||
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${version}_amd64.deb
|
||||
|
||||
|
||||
@@ -223,6 +223,13 @@ func (hs *HttpServer) registerRoutes() {
|
||||
// Dashboard
|
||||
r.Group("/dashboards", func() {
|
||||
r.Combo("/db/:slug").Get(GetDashboard).Delete(DeleteDashboard)
|
||||
|
||||
r.Get("/id/:dashboardId/versions", wrap(GetDashboardVersions))
|
||||
r.Get("/id/:dashboardId/versions/:id", wrap(GetDashboardVersion))
|
||||
r.Post("/id/:dashboardId/restore", reqEditorRole, bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion))
|
||||
|
||||
r.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), wrap(CalculateDashboardDiff))
|
||||
|
||||
r.Post("/db", reqEditorRole, bind(m.SaveDashboardCommand{}), wrap(PostDashboard))
|
||||
r.Get("/file/:file", GetDashboardFromJsonFile)
|
||||
r.Get("/home", wrap(GetHomeDashboard))
|
||||
@@ -278,6 +285,7 @@ func (hs *HttpServer) registerRoutes() {
|
||||
}, reqEditorRole)
|
||||
|
||||
r.Get("/annotations", wrap(GetAnnotations))
|
||||
r.Post("/annotations/mass-delete", reqOrgAdmin, bind(dtos.DeleteAnnotationsCmd{}), wrap(DeleteAnnotations))
|
||||
|
||||
r.Group("/annotations", func() {
|
||||
r.Post("/", bind(dtos.PostAnnotationsCmd{}), wrap(PostAnnotation))
|
||||
|
||||
@@ -30,12 +30,13 @@ var customMetricsDimensionsMap map[string]map[string]map[string]*CustomMetricsCa
|
||||
func init() {
|
||||
metricsMap = map[string][]string{
|
||||
"AWS/ApiGateway": {"4XXError", "5XXError", "CacheHitCount", "CacheMissCount", "Count", "IntegrationLatency", "Latency"},
|
||||
"AWS/ApplicationELB": {"ActiveConnectionCount", "ClientTLSNegotiationErrorCount", "HealthyHostCount", "HTTPCode_ELB_4XX_Count", "HTTPCode_ELB_5XX_Count", "HTTPCode_Target_2XX_Count", "HTTPCode_Target_3XX_Count", "HTTPCode_Target_4XX_Count", "HTTPCode_Target_5XX_Count", "NewConnectionCount", "ProcessedBytes", "RejectedConnectionCount", "RequestCount", "TargetConnectionErrorCount", "TargetResponseTime", "TargetTLSNegotiationErrorCount", "UnHealthyHostCount"},
|
||||
"AWS/ApplicationELB": {"ActiveConnectionCount", "ClientTLSNegotiationErrorCount", "HealthyHostCount", "HTTPCode_ELB_4XX_Count", "HTTPCode_ELB_5XX_Count", "HTTPCode_Target_2XX_Count", "HTTPCode_Target_3XX_Count", "HTTPCode_Target_4XX_Count", "HTTPCode_Target_5XX_Count", "IPv6ProcessedBytes", "IPv6RequestCount", "NewConnectionCount", "ProcessedBytes", "RejectedConnectionCount", "RequestCount", "TargetConnectionErrorCount", "TargetResponseTime", "TargetTLSNegotiationErrorCount", "UnHealthyHostCount"},
|
||||
"AWS/AutoScaling": {"GroupMinSize", "GroupMaxSize", "GroupDesiredCapacity", "GroupInServiceInstances", "GroupPendingInstances", "GroupStandbyInstances", "GroupTerminatingInstances", "GroupTotalInstances"},
|
||||
"AWS/Billing": {"EstimatedCharges"},
|
||||
"AWS/CloudFront": {"Requests", "BytesDownloaded", "BytesUploaded", "TotalErrorRate", "4xxErrorRate", "5xxErrorRate"},
|
||||
"AWS/CloudSearch": {"SuccessfulRequests", "SearchableDocuments", "IndexUtilization", "Partitions"},
|
||||
"AWS/DynamoDB": {"ConditionalCheckFailedRequests", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "OnlineIndexConsumedWriteCapacity", "OnlineIndexPercentageProgress", "OnlineIndexThrottleEvents", "ProvisionedReadCapacityUnits", "ProvisionedWriteCapacityUnits", "ReadThrottleEvents", "ReturnedBytes", "ReturnedItemCount", "ReturnedRecordsCount", "SuccessfulRequestLatency", "SystemErrors", "ThrottledRequests", "UserErrors", "WriteThrottleEvents"},
|
||||
"AWS/DMS": {"FreeableMemory", "WriteIOPS", "ReadIOPS", "WriteThroughput", "ReadThroughput", "WriteLatency", "ReadLatency", "SwapUsage", "NetworkTransmitThroughput", "NetworkReceiveThroughput", "FullLoadThroughputBandwidthSource", "FullLoadThroughputBandwidthTarget", "FullLoadThroughputRowsSource", "FullLoadThroughputRowsTarget", "CDCIncomingChanges", "CDCChangesMemorySource", "CDCChangesMemoryTarget", "CDCChangesDiskSource", "CDCChangesDiskTarget", "CDCThroughputBandwidthTarget", "CDCThroughputRowsSource", "CDCThroughputRowsTarget", "CDCLatencySource", "CDCLatencyTarget"},
|
||||
"AWS/DynamoDB": {"ConditionalCheckFailedRequests", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "OnlineIndexConsumedWriteCapacity", "OnlineIndexPercentageProgress", "OnlineIndexThrottleEvents", "ProvisionedReadCapacityUnits", "ProvisionedWriteCapacityUnits", "ReadThrottleEvents", "ReturnedBytes", "ReturnedItemCount", "ReturnedRecordsCount", "SuccessfulRequestLatency", "SystemErrors", "TimeToLiveDeletedItemCount", "ThrottledRequests", "UserErrors", "WriteThrottleEvents"},
|
||||
"AWS/EBS": {"VolumeReadBytes", "VolumeWriteBytes", "VolumeReadOps", "VolumeWriteOps", "VolumeTotalReadTime", "VolumeTotalWriteTime", "VolumeIdleTime", "VolumeQueueLength", "VolumeThroughputPercentage", "VolumeConsumedReadWriteOps", "BurstBalance"},
|
||||
"AWS/EC2": {"CPUCreditUsage", "CPUCreditBalance", "CPUUtilization", "DiskReadOps", "DiskWriteOps", "DiskReadBytes", "DiskWriteBytes", "NetworkIn", "NetworkOut", "NetworkPacketsIn", "NetworkPacketsOut", "StatusCheckFailed", "StatusCheckFailed_Instance", "StatusCheckFailed_System"},
|
||||
"AWS/EC2Spot": {"AvailableInstancePoolsCount", "BidsSubmittedForCapacity", "EligibleInstancePoolCount", "FulfilledCapacity", "MaxPercentCapacityAllocation", "PendingCapacity", "PercentCapacityAllocation", "TargetCapacity", "TerminatingCapacity"},
|
||||
@@ -68,27 +69,28 @@ func init() {
|
||||
"CoreNodesRunning", "CoreNodesPending", "LiveDataNodes", "MRTotalNodes", "MRActiveNodes", "MRLostNodes", "MRUnhealthyNodes", "MRDecommissionedNodes", "MRRebootedNodes",
|
||||
"S3BytesWritten", "S3BytesRead", "HDFSUtilization", "HDFSBytesRead", "HDFSBytesWritten", "MissingBlocks", "CorruptBlocks", "TotalLoad", "MemoryTotalMB", "MemoryReservedMB", "MemoryAvailableMB", "MemoryAllocatedMB", "PendingDeletionBlocks", "UnderReplicatedBlocks", "DfsPendingReplicationBlocks", "CapacityRemainingGB",
|
||||
"HbaseBackupFailed", "MostRecentBackupDuration", "TimeSinceLastSuccessfulBackup"},
|
||||
"AWS/ES": {"ClusterStatus.green", "ClusterStatus.yellow", "ClusterStatus.red", "Nodes", "SearchableDocuments", "DeletedDocuments", "CPUUtilization", "FreeStorageSpace", "JVMMemoryPressure", "AutomatedSnapshotFailure", "MasterCPUUtilization", "MasterFreeStorageSpace", "MasterJVMMemoryPressure", "ReadLatency", "WriteLatency", "ReadThroughput", "WriteThroughput", "DiskQueueLength", "ReadIOPS", "WriteIOPS"},
|
||||
"AWS/ES": {"ClusterStatus.green", "ClusterStatus.yellow", "ClusterStatus.red", "ClusterUsedSpace", "Nodes", "SearchableDocuments", "DeletedDocuments", "CPUCreditBalance", "CPUUtilization", "FreeStorageSpace", "JVMMemoryPressure", "AutomatedSnapshotFailure", "MasterCPUCreditBalance", "MasterCPUUtilization", "MasterFreeStorageSpace", "MasterJVMMemoryPressure", "ReadLatency", "WriteLatency", "ReadThroughput", "WriteThroughput", "DiskQueueDepth", "ReadIOPS", "WriteIOPS"},
|
||||
"AWS/Events": {"Invocations", "FailedInvocations", "TriggeredRules", "MatchedEvents", "ThrottledRules"},
|
||||
"AWS/Firehose": {"DeliveryToElasticsearch.Bytes", "DeliveryToElasticsearch.Records", "DeliveryToElasticsearch.Success", "DeliveryToRedshift.Bytes", "DeliveryToRedshift.Records", "DeliveryToRedshift.Success", "DeliveryToS3.Bytes", "DeliveryToS3.DataFreshness", "DeliveryToS3.Records", "DeliveryToS3.Success", "IncomingBytes", "IncomingRecords", "DescribeDeliveryStream.Latency", "DescribeDeliveryStream.Requests", "ListDeliveryStreams.Latency", "ListDeliveryStreams.Requests", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Requests", "PutRecordBatch.Bytes", "PutRecordBatch.Latency", "PutRecordBatch.Records", "PutRecordBatch.Requests", "UpdateDeliveryStream.Latency", "UpdateDeliveryStream.Requests"},
|
||||
"AWS/IoT": {"PublishIn.Success", "PublishOut.Success", "Subscribe.Success", "Ping.Success", "Connect.Success", "GetThingShadow.Accepted"},
|
||||
"AWS/Kinesis": {"GetRecords.Bytes", "GetRecords.IteratorAge", "GetRecords.IteratorAgeMilliseconds", "GetRecords.Latency", "GetRecords.Records", "GetRecords.Success", "IncomingBytes", "IncomingRecords", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Success", "PutRecords.Bytes", "PutRecords.Latency", "PutRecords.Records", "PutRecords.Success", "ReadProvisionedThroughputExceeded", "WriteProvisionedThroughputExceeded", "IteratorAgeMilliseconds", "OutgoingBytes", "OutgoingRecords"},
|
||||
"AWS/KinesisAnalytics": {"Bytes", "MillisBehindLatest", "Records", "Success"},
|
||||
"AWS/Lambda": {"Invocations", "Errors", "Duration", "Throttles"},
|
||||
"AWS/Lambda": {"Invocations", "Errors", "Duration", "Throttles", "IteratorAge"},
|
||||
"AWS/Logs": {"IncomingBytes", "IncomingLogEvents", "ForwardedBytes", "ForwardedLogEvents", "DeliveryErrors", "DeliveryThrottling"},
|
||||
"AWS/ML": {"PredictCount", "PredictFailureCount"},
|
||||
"AWS/OpsWorks": {"cpu_idle", "cpu_nice", "cpu_system", "cpu_user", "cpu_waitio", "load_1", "load_5", "load_15", "memory_buffers", "memory_cached", "memory_free", "memory_swap", "memory_total", "memory_used", "procs"},
|
||||
"AWS/Redshift": {"CPUUtilization", "DatabaseConnections", "HealthStatus", "MaintenanceMode", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "PercentageDiskSpaceUsed", "ReadIOPS", "ReadLatency", "ReadThroughput", "WriteIOPS", "WriteLatency", "WriteThroughput"},
|
||||
"AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "CommitLatency", "CommitThroughput", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "FailedSqlStatements", "FreeableMemory", "FreeStorageSpace", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "SwapUsage", "TotalConnections", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"},
|
||||
"AWS/Route53": {"HealthCheckStatus", "HealthCheckPercentageHealthy", "ConnectionTime", "SSLHandshakeTime", "TimeToFirstByte"},
|
||||
"AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "CommitLatency", "CommitThroughput", "BinLogDiskUsage", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "EngineUptime", "FailedSqlStatements", "FreeableMemory", "FreeLocalStorage", "FreeStorageSpace", "InsertLatency", "InsertThroughput", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "NetworkThroughput", "Queries", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "SwapUsage", "TotalConnections", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"},
|
||||
"AWS/Route53": {"ChildHealthCheckHealthyCount", "HealthCheckStatus", "HealthCheckPercentageHealthy", "ConnectionTime", "SSLHandshakeTime", "TimeToFirstByte"},
|
||||
"AWS/S3": {"BucketSizeBytes", "NumberOfObjects", "AllRequests", "GetRequests", "PutRequests", "DeleteRequests", "HeadRequests", "PostRequests", "ListRequests", "BytesDownloaded", "BytesUploaded", "4xxErrors", "5xxErrors", "FirstByteLatency", "TotalRequestLatency"},
|
||||
"AWS/SES": {"Bounce", "Complaint", "Delivery", "Reject", "Send"},
|
||||
"AWS/SNS": {"NumberOfMessagesPublished", "PublishSize", "NumberOfNotificationsDelivered", "NumberOfNotificationsFailed"},
|
||||
"AWS/SQS": {"NumberOfMessagesSent", "SentMessageSize", "NumberOfMessagesReceived", "NumberOfEmptyReceives", "NumberOfMessagesDeleted", "ApproximateNumberOfMessagesDelayed", "ApproximateNumberOfMessagesVisible", "ApproximateNumberOfMessagesNotVisible"},
|
||||
"AWS/SQS": {"NumberOfMessagesSent", "SentMessageSize", "NumberOfMessagesReceived", "NumberOfEmptyReceives", "NumberOfMessagesDeleted", "ApproximateAgeOfOldestMessage", "ApproximateNumberOfMessagesDelayed", "ApproximateNumberOfMessagesVisible", "ApproximateNumberOfMessagesNotVisible"},
|
||||
"AWS/StorageGateway": {"CacheHitPercent", "CachePercentUsed", "CachePercentDirty", "CloudBytesDownloaded", "CloudDownloadLatency", "CloudBytesUploaded", "UploadBufferFree", "UploadBufferPercentUsed", "UploadBufferUsed", "QueuedWrites", "ReadBytes", "ReadTime", "TotalCacheSize", "WriteBytes", "WriteTime", "TimeSinceLastRecoveryPoint", "WorkingStorageFree", "WorkingStoragePercentUsed", "WorkingStorageUsed",
|
||||
"CacheHitPercent", "CachePercentUsed", "CachePercentDirty", "ReadBytes", "ReadTime", "WriteBytes", "WriteTime", "QueuedWrites"},
|
||||
"AWS/SWF": {"DecisionTaskScheduleToStartTime", "DecisionTaskStartToCloseTime", "DecisionTasksCompleted", "StartedDecisionTasksTimedOutOnClose", "WorkflowStartToCloseTime", "WorkflowsCanceled", "WorkflowsCompleted", "WorkflowsContinuedAsNew", "WorkflowsFailed", "WorkflowsTerminated", "WorkflowsTimedOut",
|
||||
"ActivityTaskScheduleToCloseTime", "ActivityTaskScheduleToStartTime", "ActivityTaskStartToCloseTime", "ActivityTasksCanceled", "ActivityTasksCompleted", "ActivityTasksFailed", "ScheduledActivityTasksTimedOutOnClose", "ScheduledActivityTasksTimedOutOnStart", "StartedActivityTasksTimedOutOnClose", "StartedActivityTasksTimedOutOnHeartbeat"},
|
||||
"AWS/VPN": {"TunnelState", "TunnelDataIn", "TunnelDataOut"},
|
||||
"AWS/WAF": {"AllowedRequests", "BlockedRequests", "CountedRequests"},
|
||||
"AWS/WorkSpaces": {"Available", "Unhealthy", "ConnectionAttempt", "ConnectionSuccess", "ConnectionFailure", "SessionLaunchTime", "InSessionLatency", "SessionDisconnect"},
|
||||
"KMS": {"SecondsUntilKeyMaterialExpiration"},
|
||||
@@ -100,6 +102,7 @@ func init() {
|
||||
"AWS/Billing": {"ServiceName", "LinkedAccount", "Currency"},
|
||||
"AWS/CloudFront": {"DistributionId", "Region"},
|
||||
"AWS/CloudSearch": {},
|
||||
"AWS/DMS": {"ReplicationInstanceIdentifier", "ReplicationTaskIdentifier"},
|
||||
"AWS/DynamoDB": {"TableName", "GlobalSecondaryIndexName", "Operation", "StreamLabel"},
|
||||
"AWS/EBS": {"VolumeId"},
|
||||
"AWS/EC2": {"AutoScalingGroupName", "ImageId", "InstanceId", "InstanceType"},
|
||||
@@ -121,14 +124,15 @@ func init() {
|
||||
"AWS/ML": {"MLModelId", "RequestMode"},
|
||||
"AWS/OpsWorks": {"StackId", "LayerId", "InstanceId"},
|
||||
"AWS/Redshift": {"NodeID", "ClusterIdentifier"},
|
||||
"AWS/RDS": {"DBInstanceIdentifier", "DBClusterIdentifier", "DatabaseClass", "EngineName"},
|
||||
"AWS/Route53": {"HealthCheckId"},
|
||||
"AWS/RDS": {"DBInstanceIdentifier", "DBClusterIdentifier", "DatabaseClass", "EngineName", "Role"},
|
||||
"AWS/Route53": {"HealthCheckId", "Region"},
|
||||
"AWS/S3": {"BucketName", "StorageType", "FilterId"},
|
||||
"AWS/SES": {},
|
||||
"AWS/SNS": {"Application", "Platform", "TopicName"},
|
||||
"AWS/SQS": {"QueueName"},
|
||||
"AWS/StorageGateway": {"GatewayId", "GatewayName", "VolumeId"},
|
||||
"AWS/SWF": {"Domain", "WorkflowTypeName", "WorkflowTypeVersion", "ActivityTypeName", "ActivityTypeVersion"},
|
||||
"AWS/VPN": {"VpnId", "TunnelIpAddress"},
|
||||
"AWS/WAF": {"Rule", "WebACL"},
|
||||
"AWS/WorkSpaces": {"DirectoryId", "WorkspaceId"},
|
||||
"KMS": {"KeyId"},
|
||||
|
||||
@@ -2,12 +2,14 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/dashdiffs"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/metrics"
|
||||
@@ -60,6 +62,9 @@ func GetDashboard(c *middleware.Context) {
|
||||
creator = getUserLogin(dash.CreatedBy)
|
||||
}
|
||||
|
||||
// make sure db version is in sync with json model version
|
||||
dash.Data.Set("version", dash.Version)
|
||||
|
||||
dto := dtos.DashboardFullWithMeta{
|
||||
Dashboard: dash.Data,
|
||||
Meta: dtos.DashboardMeta{
|
||||
@@ -77,6 +82,7 @@ func GetDashboard(c *middleware.Context) {
|
||||
},
|
||||
}
|
||||
|
||||
// TODO(ben): copy this performance metrics logic for the new API endpoints added
|
||||
c.TimeRequest(metrics.M_Api_Dashboard_Get)
|
||||
c.JSON(200, dto)
|
||||
}
|
||||
@@ -114,18 +120,15 @@ func DeleteDashboard(c *middleware.Context) {
|
||||
|
||||
func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
|
||||
cmd.OrgId = c.OrgId
|
||||
|
||||
if !c.IsSignedIn {
|
||||
cmd.UserId = -1
|
||||
} else {
|
||||
cmd.UserId = c.UserId
|
||||
}
|
||||
cmd.UserId = c.UserId
|
||||
|
||||
dash := cmd.GetDashboardModel()
|
||||
|
||||
// Check if Title is empty
|
||||
if dash.Title == "" {
|
||||
return ApiError(400, m.ErrDashboardTitleEmpty.Error(), nil)
|
||||
}
|
||||
|
||||
if dash.Id == 0 {
|
||||
limitReached, err := middleware.QuotaReached(c, "dashboard")
|
||||
if err != nil {
|
||||
@@ -255,6 +258,135 @@ func GetDashboardFromJsonFile(c *middleware.Context) {
|
||||
c.JSON(200, &dash)
|
||||
}
|
||||
|
||||
// GetDashboardVersions returns all dashboard versions as JSON
|
||||
func GetDashboardVersions(c *middleware.Context) Response {
|
||||
dashboardId := c.ParamsInt64(":dashboardId")
|
||||
limit := c.QueryInt("limit")
|
||||
start := c.QueryInt("start")
|
||||
|
||||
if limit == 0 {
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
query := m.GetDashboardVersionsQuery{
|
||||
OrgId: c.OrgId,
|
||||
DashboardId: dashboardId,
|
||||
Limit: limit,
|
||||
Start: start,
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(&query); err != nil {
|
||||
return ApiError(404, fmt.Sprintf("No versions found for dashboardId %d", dashboardId), err)
|
||||
}
|
||||
|
||||
for _, version := range query.Result {
|
||||
if version.RestoredFrom == version.Version {
|
||||
version.Message = "Initial save (created by migration)"
|
||||
continue
|
||||
}
|
||||
|
||||
if version.RestoredFrom > 0 {
|
||||
version.Message = fmt.Sprintf("Restored from version %d", version.RestoredFrom)
|
||||
continue
|
||||
}
|
||||
|
||||
if version.ParentVersion == 0 {
|
||||
version.Message = "Initial save"
|
||||
}
|
||||
}
|
||||
|
||||
return Json(200, query.Result)
|
||||
}
|
||||
|
||||
// GetDashboardVersion returns the dashboard version with the given ID.
|
||||
func GetDashboardVersion(c *middleware.Context) Response {
|
||||
dashboardId := c.ParamsInt64(":dashboardId")
|
||||
version := c.ParamsInt(":id")
|
||||
|
||||
query := m.GetDashboardVersionQuery{
|
||||
OrgId: c.OrgId,
|
||||
DashboardId: dashboardId,
|
||||
Version: version,
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(&query); err != nil {
|
||||
return ApiError(500, fmt.Sprintf("Dashboard version %d not found for dashboardId %d", version, dashboardId), err)
|
||||
}
|
||||
|
||||
creator := "Anonymous"
|
||||
if query.Result.CreatedBy > 0 {
|
||||
creator = getUserLogin(query.Result.CreatedBy)
|
||||
}
|
||||
|
||||
dashVersionMeta := &m.DashboardVersionMeta{
|
||||
DashboardVersion: *query.Result,
|
||||
CreatedBy: creator,
|
||||
}
|
||||
|
||||
return Json(200, dashVersionMeta)
|
||||
}
|
||||
|
||||
// POST /api/dashboards/calculate-diff performs diffs on two dashboards
|
||||
func CalculateDashboardDiff(c *middleware.Context, apiOptions dtos.CalculateDiffOptions) Response {
|
||||
|
||||
options := dashdiffs.Options{
|
||||
OrgId: c.OrgId,
|
||||
DiffType: dashdiffs.ParseDiffType(apiOptions.DiffType),
|
||||
Base: dashdiffs.DiffTarget{
|
||||
DashboardId: apiOptions.Base.DashboardId,
|
||||
Version: apiOptions.Base.Version,
|
||||
UnsavedDashboard: apiOptions.Base.UnsavedDashboard,
|
||||
},
|
||||
New: dashdiffs.DiffTarget{
|
||||
DashboardId: apiOptions.New.DashboardId,
|
||||
Version: apiOptions.New.Version,
|
||||
UnsavedDashboard: apiOptions.New.UnsavedDashboard,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := dashdiffs.CalculateDiff(&options)
|
||||
if err != nil {
|
||||
if err == m.ErrDashboardVersionNotFound {
|
||||
return ApiError(404, "Dashboard version not found", err)
|
||||
}
|
||||
return ApiError(500, "Unable to compute diff", err)
|
||||
}
|
||||
|
||||
if options.DiffType == dashdiffs.DiffDelta {
|
||||
return Respond(200, result.Delta).Header("Content-Type", "application/json")
|
||||
} else {
|
||||
return Respond(200, result.Delta).Header("Content-Type", "text/html")
|
||||
}
|
||||
}
|
||||
|
||||
// RestoreDashboardVersion restores a dashboard to the given version.
|
||||
func RestoreDashboardVersion(c *middleware.Context, apiCmd dtos.RestoreDashboardVersionCommand) Response {
|
||||
dashboardId := c.ParamsInt64(":dashboardId")
|
||||
|
||||
dashQuery := m.GetDashboardQuery{Id: dashboardId, OrgId: c.OrgId}
|
||||
if err := bus.Dispatch(&dashQuery); err != nil {
|
||||
return ApiError(404, "Dashboard not found", nil)
|
||||
}
|
||||
|
||||
versionQuery := m.GetDashboardVersionQuery{DashboardId: dashboardId, Version: apiCmd.Version, OrgId: c.OrgId}
|
||||
if err := bus.Dispatch(&versionQuery); err != nil {
|
||||
return ApiError(404, "Dashboard version not found", nil)
|
||||
}
|
||||
|
||||
dashboard := dashQuery.Result
|
||||
version := versionQuery.Result
|
||||
|
||||
saveCmd := m.SaveDashboardCommand{}
|
||||
saveCmd.RestoredFrom = version.Version
|
||||
saveCmd.OrgId = c.OrgId
|
||||
saveCmd.UserId = c.UserId
|
||||
saveCmd.Dashboard = version.Data
|
||||
saveCmd.Dashboard.Set("version", dashboard.Version)
|
||||
saveCmd.Message = fmt.Sprintf("Restored from version %d", version.Version)
|
||||
|
||||
return PostDashboard(c, saveCmd)
|
||||
}
|
||||
|
||||
func GetDashboardTags(c *middleware.Context) {
|
||||
query := m.GetDashboardTagsQuery{OrgId: c.OrgId}
|
||||
err := bus.Dispatch(&query)
|
||||
|
||||
49
pkg/api/dtos/dashboard.go
Normal file
49
pkg/api/dtos/dashboard.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package dtos
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
)
|
||||
|
||||
type DashboardMeta struct {
|
||||
IsStarred bool `json:"isStarred,omitempty"`
|
||||
IsHome bool `json:"isHome,omitempty"`
|
||||
IsSnapshot bool `json:"isSnapshot,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
CanSave bool `json:"canSave"`
|
||||
CanEdit bool `json:"canEdit"`
|
||||
CanStar bool `json:"canStar"`
|
||||
Slug string `json:"slug"`
|
||||
Expires time.Time `json:"expires"`
|
||||
Created time.Time `json:"created"`
|
||||
Updated time.Time `json:"updated"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
type DashboardFullWithMeta struct {
|
||||
Meta DashboardMeta `json:"meta"`
|
||||
Dashboard *simplejson.Json `json:"dashboard"`
|
||||
}
|
||||
|
||||
type DashboardRedirect struct {
|
||||
RedirectUri string `json:"redirectUri"`
|
||||
}
|
||||
|
||||
type CalculateDiffOptions struct {
|
||||
Base CalculateDiffTarget `json:"base" binding:"Required"`
|
||||
New CalculateDiffTarget `json:"new" binding:"Required"`
|
||||
DiffType string `json:"diffType" binding:"Required"`
|
||||
}
|
||||
|
||||
type CalculateDiffTarget struct {
|
||||
DashboardId int64 `json:"dashboardId"`
|
||||
Version int `json:"version"`
|
||||
UnsavedDashboard *simplejson.Json `json:"unsavedDashboard"`
|
||||
}
|
||||
|
||||
type RestoreDashboardVersionCommand struct {
|
||||
Version int `json:"version" binding:"Required"`
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
@@ -38,32 +37,6 @@ type CurrentUser struct {
|
||||
HelpFlags1 m.HelpFlags1 `json:"helpFlags1"`
|
||||
}
|
||||
|
||||
type DashboardMeta struct {
|
||||
IsStarred bool `json:"isStarred,omitempty"`
|
||||
IsHome bool `json:"isHome,omitempty"`
|
||||
IsSnapshot bool `json:"isSnapshot,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
CanSave bool `json:"canSave"`
|
||||
CanEdit bool `json:"canEdit"`
|
||||
CanStar bool `json:"canStar"`
|
||||
Slug string `json:"slug"`
|
||||
Expires time.Time `json:"expires"`
|
||||
Created time.Time `json:"created"`
|
||||
Updated time.Time `json:"updated"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
type DashboardFullWithMeta struct {
|
||||
Meta DashboardMeta `json:"meta"`
|
||||
Dashboard *simplejson.Json `json:"dashboard"`
|
||||
}
|
||||
|
||||
type DashboardRedirect struct {
|
||||
RedirectUri string `json:"redirectUri"`
|
||||
}
|
||||
|
||||
type DataSource struct {
|
||||
Id int64 `json:"id"`
|
||||
OrgId int64 `json:"orgId"`
|
||||
|
||||
@@ -61,7 +61,7 @@ func (hs *HttpServer) Start(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
case setting.HTTPS:
|
||||
err = hs.httpSrv.ListenAndServeTLS(setting.CertFile, setting.KeyFile)
|
||||
err = hs.listenAndServeTLS(setting.CertFile, setting.KeyFile)
|
||||
if err == http.ErrServerClosed {
|
||||
hs.log.Debug("server was shutdown gracefully")
|
||||
return nil
|
||||
@@ -92,7 +92,7 @@ func (hs *HttpServer) Shutdown(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (hs *HttpServer) listenAndServeTLS(listenAddr, certfile, keyfile string) error {
|
||||
func (hs *HttpServer) listenAndServeTLS(certfile, keyfile string) error {
|
||||
if certfile == "" {
|
||||
return fmt.Errorf("cert_file cannot be empty when using HTTPS")
|
||||
}
|
||||
@@ -127,14 +127,11 @@ func (hs *HttpServer) listenAndServeTLS(listenAddr, certfile, keyfile string) er
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
},
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: listenAddr,
|
||||
Handler: hs.macaron,
|
||||
TLSConfig: tlsCfg,
|
||||
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler), 0),
|
||||
}
|
||||
|
||||
return srv.ListenAndServeTLS(setting.CertFile, setting.KeyFile)
|
||||
hs.httpSrv.TLSConfig = tlsCfg
|
||||
hs.httpSrv.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler), 0)
|
||||
|
||||
return hs.httpSrv.ListenAndServeTLS(setting.CertFile, setting.KeyFile)
|
||||
}
|
||||
|
||||
func (hs *HttpServer) newMacaron() *macaron.Macaron {
|
||||
@@ -174,6 +171,8 @@ func (hs *HttpServer) newMacaron() *macaron.Macaron {
|
||||
m.Use(middleware.ValidateHostHeader(setting.Domain))
|
||||
}
|
||||
|
||||
m.Use(middleware.AddDefaultResponseHeaders())
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ var (
|
||||
ErrEmailNotAllowed = errors.New("Required email domain not fulfilled")
|
||||
ErrSignUpNotAllowed = errors.New("Signup is not allowed for this adapter")
|
||||
ErrUsersQuotaReached = errors.New("Users quota reached")
|
||||
ErrNoEmail = errors.New("Login provider didn't return an email address")
|
||||
)
|
||||
|
||||
func GenStateString() string {
|
||||
@@ -63,7 +64,7 @@ func OAuthLogin(ctx *middleware.Context) {
|
||||
if setting.OAuthService.OAuthInfos[name].HostedDomain == "" {
|
||||
ctx.Redirect(connect.AuthCodeURL(state, oauth2.AccessTypeOnline))
|
||||
} else {
|
||||
ctx.Redirect(connect.AuthCodeURL(state, oauth2.SetParam("hd", setting.OAuthService.OAuthInfos[name].HostedDomain), oauth2.AccessTypeOnline))
|
||||
ctx.Redirect(connect.AuthCodeURL(state, oauth2.SetAuthURLParam("hd", setting.OAuthService.OAuthInfos[name].HostedDomain), oauth2.AccessTypeOnline))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -134,6 +135,12 @@ func OAuthLogin(ctx *middleware.Context) {
|
||||
|
||||
ctx.Logger.Debug("OAuthLogin got user info", "userInfo", userInfo)
|
||||
|
||||
// validate that we got at least an email address
|
||||
if userInfo.Email == "" {
|
||||
redirectWithError(ctx, ErrNoEmail)
|
||||
return
|
||||
}
|
||||
|
||||
// validate that the email is allowed to login to grafana
|
||||
if !connect.IsEmailAllowed(userInfo.Email) {
|
||||
redirectWithError(ctx, ErrEmailNotAllowed)
|
||||
|
||||
@@ -78,6 +78,11 @@ func AddOrgInvite(c *middleware.Context, inviteDto dtos.AddInviteForm) Response
|
||||
return ApiError(500, "Failed to send email invite", err)
|
||||
}
|
||||
|
||||
emailSentCmd := m.UpdateTempUserWithEmailSentCommand{Code: cmd.Result.Code}
|
||||
if err := bus.Dispatch(&emailSentCmd); err != nil {
|
||||
return ApiError(500, "Failed to update invite with email sent info", err)
|
||||
}
|
||||
|
||||
return ApiSuccess(fmt.Sprintf("Sent invite to %s", inviteDto.LoginOrEmail))
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ func GetUserByLoginOrEmail(c *middleware.Context) Response {
|
||||
}
|
||||
user := query.Result
|
||||
result := m.UserProfileDTO{
|
||||
Id: user.Id,
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
Login: user.Login,
|
||||
|
||||
149
pkg/components/dashdiffs/compare.go
Normal file
149
pkg/components/dashdiffs/compare.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package dashdiffs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
diff "github.com/yudai/gojsondiff"
|
||||
deltaFormatter "github.com/yudai/gojsondiff/formatter"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrUnsupportedDiffType occurs when an invalid diff type is used.
|
||||
ErrUnsupportedDiffType = errors.New("dashdiff: unsupported diff type")
|
||||
|
||||
// ErrNilDiff occurs when two compared interfaces are identical.
|
||||
ErrNilDiff = errors.New("dashdiff: diff is nil")
|
||||
|
||||
diffLogger = log.New("dashdiffs")
|
||||
)
|
||||
|
||||
type DiffType int
|
||||
|
||||
const (
|
||||
DiffJSON DiffType = iota
|
||||
DiffBasic
|
||||
DiffDelta
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
OrgId int64
|
||||
Base DiffTarget
|
||||
New DiffTarget
|
||||
DiffType DiffType
|
||||
}
|
||||
|
||||
type DiffTarget struct {
|
||||
DashboardId int64
|
||||
Version int
|
||||
UnsavedDashboard *simplejson.Json
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Delta []byte `json:"delta"`
|
||||
}
|
||||
|
||||
func ParseDiffType(diff string) DiffType {
|
||||
switch diff {
|
||||
case "json":
|
||||
return DiffJSON
|
||||
case "basic":
|
||||
return DiffBasic
|
||||
case "delta":
|
||||
return DiffDelta
|
||||
}
|
||||
return DiffBasic
|
||||
}
|
||||
|
||||
// CompareDashboardVersionsCommand computes the JSON diff of two versions,
|
||||
// assigning the delta of the diff to the `Delta` field.
|
||||
func CalculateDiff(options *Options) (*Result, error) {
|
||||
baseVersionQuery := models.GetDashboardVersionQuery{
|
||||
DashboardId: options.Base.DashboardId,
|
||||
Version: options.Base.Version,
|
||||
OrgId: options.OrgId,
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(&baseVersionQuery); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newVersionQuery := models.GetDashboardVersionQuery{
|
||||
DashboardId: options.New.DashboardId,
|
||||
Version: options.New.Version,
|
||||
OrgId: options.OrgId,
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(&newVersionQuery); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
baseData := baseVersionQuery.Result.Data
|
||||
newData := newVersionQuery.Result.Data
|
||||
|
||||
left, jsonDiff, err := getDiff(baseData, newData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &Result{}
|
||||
|
||||
switch options.DiffType {
|
||||
case DiffDelta:
|
||||
|
||||
deltaOutput, err := deltaFormatter.NewDeltaFormatter().Format(jsonDiff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Delta = []byte(deltaOutput)
|
||||
|
||||
case DiffJSON:
|
||||
jsonOutput, err := NewJSONFormatter(left).Format(jsonDiff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Delta = []byte(jsonOutput)
|
||||
|
||||
case DiffBasic:
|
||||
basicOutput, err := NewBasicFormatter(left).Format(jsonDiff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Delta = basicOutput
|
||||
|
||||
default:
|
||||
return nil, ErrUnsupportedDiffType
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getDiff computes the diff of two dashboard versions.
|
||||
func getDiff(baseData, newData *simplejson.Json) (interface{}, diff.Diff, error) {
|
||||
leftBytes, err := baseData.Encode()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
rightBytes, err := newData.Encode()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
jsonDiff, err := diff.New().Compare(leftBytes, rightBytes)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !jsonDiff.Modified() {
|
||||
return nil, nil, ErrNilDiff
|
||||
}
|
||||
|
||||
left := make(map[string]interface{})
|
||||
err = json.Unmarshal(leftBytes, &left)
|
||||
return left, jsonDiff, nil
|
||||
}
|
||||
425
pkg/components/dashdiffs/formatter_basic.go
Normal file
425
pkg/components/dashdiffs/formatter_basic.go
Normal file
@@ -0,0 +1,425 @@
|
||||
package dashdiffs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
|
||||
diff "github.com/yudai/gojsondiff"
|
||||
)
|
||||
|
||||
// A BasicDiff holds the stateful values that are used when generating a basic
|
||||
// diff from JSON tokens.
|
||||
type BasicDiff struct {
|
||||
narrow string
|
||||
keysIdent int
|
||||
writing bool
|
||||
LastIndent int
|
||||
Block *BasicBlock
|
||||
Change *BasicChange
|
||||
Summary *BasicSummary
|
||||
}
|
||||
|
||||
// A BasicBlock represents a top-level element in a basic diff.
|
||||
type BasicBlock struct {
|
||||
Title string
|
||||
Old interface{}
|
||||
New interface{}
|
||||
Change ChangeType
|
||||
Changes []*BasicChange
|
||||
Summaries []*BasicSummary
|
||||
LineStart int
|
||||
LineEnd int
|
||||
}
|
||||
|
||||
// A BasicChange represents the change from an old to new value. There are many
|
||||
// BasicChanges in a BasicBlock.
|
||||
type BasicChange struct {
|
||||
Key string
|
||||
Old interface{}
|
||||
New interface{}
|
||||
Change ChangeType
|
||||
LineStart int
|
||||
LineEnd int
|
||||
}
|
||||
|
||||
// A BasicSummary represents the changes within a basic block that're too deep
|
||||
// or verbose to be represented in the top-level BasicBlock element, or in the
|
||||
// BasicChange. Instead of showing the values in this case, we simply print
|
||||
// the key and count how many times the given change was applied to that
|
||||
// element.
|
||||
type BasicSummary struct {
|
||||
Key string
|
||||
Change ChangeType
|
||||
Count int
|
||||
LineStart int
|
||||
LineEnd int
|
||||
}
|
||||
|
||||
type BasicFormatter struct {
|
||||
jsonDiff *JSONFormatter
|
||||
tpl *template.Template
|
||||
}
|
||||
|
||||
func NewBasicFormatter(left interface{}) *BasicFormatter {
|
||||
tpl := template.Must(template.New("block").Funcs(tplFuncMap).Parse(tplBlock))
|
||||
tpl = template.Must(tpl.New("change").Funcs(tplFuncMap).Parse(tplChange))
|
||||
tpl = template.Must(tpl.New("summary").Funcs(tplFuncMap).Parse(tplSummary))
|
||||
|
||||
return &BasicFormatter{
|
||||
jsonDiff: NewJSONFormatter(left),
|
||||
tpl: tpl,
|
||||
}
|
||||
}
|
||||
|
||||
// Format takes the diff of two JSON documents, and returns the difference
|
||||
// between them summarized in an HTML document.
|
||||
func (b *BasicFormatter) Format(d diff.Diff) ([]byte, error) {
|
||||
// calling jsonDiff.Format(d) populates the JSON diff's "Lines" value,
|
||||
// which we use to compute the basic dif
|
||||
_, err := b.jsonDiff.Format(d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bd := &BasicDiff{}
|
||||
blocks := bd.Basic(b.jsonDiff.Lines)
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
err = b.tpl.ExecuteTemplate(buf, "block", blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// Basic transforms a slice of JSONLines into a slice of BasicBlocks.
|
||||
func (b *BasicDiff) Basic(lines []*JSONLine) []*BasicBlock {
|
||||
// init an array you can append to for the basic "blocks"
|
||||
blocks := make([]*BasicBlock, 0)
|
||||
|
||||
for _, line := range lines {
|
||||
if b.returnToTopLevelKey(line) {
|
||||
if b.Block != nil {
|
||||
blocks = append(blocks, b.Block)
|
||||
}
|
||||
}
|
||||
|
||||
// Record the last indent level at each pass in case we need to
|
||||
// check for a change in depth inside the JSON data structures.
|
||||
b.LastIndent = line.Indent
|
||||
|
||||
if line.Indent == 1 {
|
||||
if block, ok := b.handleTopLevelChange(line); ok {
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
}
|
||||
|
||||
// Here is where we handle changes for all types, appending each change
|
||||
// to the current block based on the value.
|
||||
//
|
||||
// Values which only occupy a single line in JSON (like a string or
|
||||
// int, for example) are treated as "Basic Changes" that we append to
|
||||
// the current block as soon as they're detected.
|
||||
//
|
||||
// Values which occupy multiple lines (either slices or maps) are
|
||||
// treated as "Basic Summaries". When we detect the "ChangeNil" type,
|
||||
// we know we've encountered one of these types, so we record the
|
||||
// starting position as well the type of the change, and stop
|
||||
// performing comparisons until we find the end of that change. Upon
|
||||
// finding the change, we append it to the current block, and begin
|
||||
// performing comparisons again.
|
||||
if line.Indent > 1 {
|
||||
// check to ensure a single line change
|
||||
if b.isSingleLineChange(line) {
|
||||
switch line.Change {
|
||||
case ChangeAdded, ChangeDeleted:
|
||||
|
||||
b.Block.Changes = append(b.Block.Changes, &BasicChange{
|
||||
Key: line.Key,
|
||||
Change: line.Change,
|
||||
New: line.Val,
|
||||
LineStart: line.LineNum,
|
||||
})
|
||||
|
||||
case ChangeOld:
|
||||
b.Change = &BasicChange{
|
||||
Key: line.Key,
|
||||
Change: line.Change,
|
||||
Old: line.Val,
|
||||
LineStart: line.LineNum,
|
||||
}
|
||||
|
||||
case ChangeNew:
|
||||
b.Change.New = line.Val
|
||||
b.Change.LineEnd = line.LineNum
|
||||
b.Block.Changes = append(b.Block.Changes, b.Change)
|
||||
|
||||
default:
|
||||
//ok
|
||||
}
|
||||
|
||||
// otherwise, we're dealing with a change at a deeper level. We
|
||||
// know there's a change somewhere in the JSON tree, but we
|
||||
// don't know exactly where, so we go deeper.
|
||||
} else {
|
||||
|
||||
// if the change is anything but unchanged, continue processing
|
||||
//
|
||||
// we keep "narrowing" the key as we go deeper, in order to
|
||||
// correctly report the key name for changes found within an
|
||||
// object or array.
|
||||
if line.Change != ChangeUnchanged {
|
||||
if line.Key != "" {
|
||||
b.narrow = line.Key
|
||||
b.keysIdent = line.Indent
|
||||
}
|
||||
|
||||
// if the change isn't nil, and we're not already writing
|
||||
// out a change, then we've found something.
|
||||
//
|
||||
// First, try to determine the title of the embedded JSON
|
||||
// object. If it's an empty string, then we're in an object
|
||||
// or array, so we default to using the "narrowed" key.
|
||||
//
|
||||
// We also start recording the basic summary, until we find
|
||||
// the next `ChangeUnchanged`.
|
||||
if line.Change != ChangeNil {
|
||||
if !b.writing {
|
||||
b.writing = true
|
||||
key := b.Block.Title
|
||||
|
||||
if b.narrow != "" {
|
||||
key = b.narrow
|
||||
if b.keysIdent > line.Indent {
|
||||
key = b.Block.Title
|
||||
}
|
||||
}
|
||||
|
||||
b.Summary = &BasicSummary{
|
||||
Key: key,
|
||||
Change: line.Change,
|
||||
LineStart: line.LineNum,
|
||||
}
|
||||
}
|
||||
}
|
||||
// if we find a `ChangeUnchanged`, we do one of two things:
|
||||
//
|
||||
// - if we're recording a change already, then we know
|
||||
// we've come to the end of that change block, so we write
|
||||
// that change out be recording the line number of where
|
||||
// that change ends, and append it to the current block's
|
||||
// summary.
|
||||
//
|
||||
// - if we're not recording a change, then we do nothing,
|
||||
// since the BasicDiff doesn't report on unchanged JSON
|
||||
// values.
|
||||
} else {
|
||||
if b.writing {
|
||||
b.writing = false
|
||||
b.Summary.LineEnd = line.LineNum
|
||||
b.Block.Summaries = append(b.Block.Summaries, b.Summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
// returnToTopLevelKey indicates that we've moved from a key at one level deep
|
||||
// in the JSON document to a top level key.
|
||||
//
|
||||
// In order to produce distinct "blocks" when rendering the basic diff,
|
||||
// we need a way to distinguish between differnt sections of data.
|
||||
// To do this, we consider the value(s) of each top-level JSON key to
|
||||
// represent a distinct block for Grafana's JSON data structure, so
|
||||
// we perform this check to see if we've entered a new "block". If we
|
||||
// have, we simply append the existing block to the array of blocks.
|
||||
func (b *BasicDiff) returnToTopLevelKey(line *JSONLine) bool {
|
||||
return b.LastIndent == 2 && line.Indent == 1 && line.Change == ChangeNil
|
||||
}
|
||||
|
||||
// handleTopLevelChange handles a change on one of the top-level keys on a JSON
|
||||
// document.
|
||||
//
|
||||
// If the line's indentation is at level 1, then we know it's a top
|
||||
// level key in the JSON document. As mentioned earlier, we treat these
|
||||
// specially as they indicate their values belong to distinct blocks.
|
||||
//
|
||||
// At level 1, we only record single-line changes, ie, the "added",
|
||||
// "deleted", "old" or "new" cases, since we know those values aren't
|
||||
// arrays or maps. We only handle these cases at level 2 or deeper,
|
||||
// since for those we either output a "change" or "summary". This is
|
||||
// done for formatting reasons only, so we have logical "blocks" to
|
||||
// display.
|
||||
func (b *BasicDiff) handleTopLevelChange(line *JSONLine) (*BasicBlock, bool) {
|
||||
switch line.Change {
|
||||
case ChangeNil:
|
||||
if line.Change == ChangeNil {
|
||||
if line.Key != "" {
|
||||
b.Block = &BasicBlock{
|
||||
Title: line.Key,
|
||||
Change: line.Change,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case ChangeAdded, ChangeDeleted:
|
||||
return &BasicBlock{
|
||||
Title: line.Key,
|
||||
Change: line.Change,
|
||||
New: line.Val,
|
||||
LineStart: line.LineNum,
|
||||
}, true
|
||||
|
||||
case ChangeOld:
|
||||
b.Block = &BasicBlock{
|
||||
Title: line.Key,
|
||||
Old: line.Val,
|
||||
Change: line.Change,
|
||||
LineStart: line.LineNum,
|
||||
}
|
||||
|
||||
case ChangeNew:
|
||||
b.Block.New = line.Val
|
||||
b.Block.LineEnd = line.LineNum
|
||||
|
||||
// For every "old" change there is a corresponding "new", which
|
||||
// is why we wait until we detect the "new" change before
|
||||
// appending the change.
|
||||
return b.Block, true
|
||||
default:
|
||||
// ok
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// isSingleLineChange ensures we're iterating over a single line change (ie,
|
||||
// either a single line or a old-new value pair was changed in the JSON file).
|
||||
func (b *BasicDiff) isSingleLineChange(line *JSONLine) bool {
|
||||
return line.Key != "" && line.Val != nil && !b.writing
|
||||
}
|
||||
|
||||
// encStateMap is used in the template helper
|
||||
var (
|
||||
encStateMap = map[ChangeType]string{
|
||||
ChangeAdded: "added",
|
||||
ChangeDeleted: "deleted",
|
||||
ChangeOld: "changed",
|
||||
ChangeNew: "changed",
|
||||
}
|
||||
|
||||
// tplFuncMap is the function map for each template
|
||||
tplFuncMap = template.FuncMap{
|
||||
"getChange": func(c ChangeType) string {
|
||||
state, ok := encStateMap[c]
|
||||
if !ok {
|
||||
return "changed"
|
||||
}
|
||||
return state
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
// tplBlock is the container for the basic diff. It iterates over each
|
||||
// basic block, expanding each "change" and "summary" belonging to every
|
||||
// block.
|
||||
tplBlock = `{{ define "block" -}}
|
||||
{{ range . }}
|
||||
<div class="diff-group">
|
||||
<div class="diff-block">
|
||||
<h2 class="diff-block-title">
|
||||
<i class="diff-circle diff-circle-{{ getChange .Change }} fa fa-circle"></i>
|
||||
<strong class="diff-title">{{ .Title }}</strong> {{ getChange .Change }}
|
||||
</h2>
|
||||
|
||||
|
||||
<!-- Overview -->
|
||||
{{ if .Old }}
|
||||
<div class="diff-label">{{ .Old }}</div>
|
||||
<i class="diff-arrow fa fa-long-arrow-right"></i>
|
||||
{{ end }}
|
||||
{{ if .New }}
|
||||
<div class="diff-label">{{ .New }}</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if .LineStart }}
|
||||
<diff-link-json
|
||||
line-link="{{ .LineStart }}"
|
||||
line-display="{{ .LineStart }}{{ if .LineEnd }} - {{ .LineEnd }}{{ end }}"
|
||||
switch-view="ctrl.getDiff('html')"
|
||||
/>
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Basic Changes -->
|
||||
{{ range .Changes }}
|
||||
<ul class="diff-change-container">
|
||||
{{ template "change" . }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
|
||||
<!-- Basic Summary -->
|
||||
{{ range .Summaries }}
|
||||
{{ template "summary" . }}
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}`
|
||||
|
||||
// tplChange is the template for basic changes.
|
||||
tplChange = `{{ define "change" -}}
|
||||
<li class="diff-change-group">
|
||||
<span class="bullet-position-container">
|
||||
<div class="diff-change-item diff-change-title">{{ getChange .Change }} {{ .Key }}</div>
|
||||
|
||||
<div class="diff-change-item">
|
||||
{{ if .Old }}
|
||||
<div class="diff-label">{{ .Old }}</div>
|
||||
<i class="diff-arrow fa fa-long-arrow-right"></i>
|
||||
{{ end }}
|
||||
{{ if .New }}
|
||||
<div class="diff-label">{{ .New }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ if .LineStart }}
|
||||
<diff-link-json
|
||||
line-link="{{ .LineStart }}"
|
||||
line-display="{{ .LineStart }}{{ if .LineEnd }} - {{ .LineEnd }}{{ end }}"
|
||||
switch-view="ctrl.getDiff('json')"
|
||||
/>
|
||||
{{ end }}
|
||||
</span>
|
||||
</li>
|
||||
{{ end }}`
|
||||
|
||||
// tplSummary is for basic summaries.
|
||||
tplSummary = `{{ define "summary" -}}
|
||||
<div class="diff-group-name">
|
||||
<i class="diff-circle diff-circle-{{ getChange .Change }} fa fa-circle-o diff-list-circle"></i>
|
||||
|
||||
{{ if .Count }}
|
||||
<strong>{{ .Count }}</strong>
|
||||
{{ end }}
|
||||
|
||||
{{ if .Key }}
|
||||
<strong class="diff-summary-key">{{ .Key }}</strong>
|
||||
{{ getChange .Change }}
|
||||
{{ end }}
|
||||
|
||||
{{ if .LineStart }}
|
||||
<diff-link-json
|
||||
line-link="{{ .LineStart }}"
|
||||
line-display="{{ .LineStart }}{{ if .LineEnd }} - {{ .LineEnd }}{{ end }}"
|
||||
switch-view="ctrl.getDiff('json')"
|
||||
/>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}`
|
||||
)
|
||||
477
pkg/components/dashdiffs/formatter_json.go
Normal file
477
pkg/components/dashdiffs/formatter_json.go
Normal file
@@ -0,0 +1,477 @@
|
||||
package dashdiffs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"sort"
|
||||
|
||||
diff "github.com/yudai/gojsondiff"
|
||||
)
|
||||
|
||||
type ChangeType int
|
||||
|
||||
const (
|
||||
ChangeNil ChangeType = iota
|
||||
ChangeAdded
|
||||
ChangeDeleted
|
||||
ChangeOld
|
||||
ChangeNew
|
||||
ChangeUnchanged
|
||||
)
|
||||
|
||||
var (
|
||||
// changeTypeToSymbol is used for populating the terminating characer in
|
||||
// the diff
|
||||
changeTypeToSymbol = map[ChangeType]string{
|
||||
ChangeNil: "",
|
||||
ChangeAdded: "+",
|
||||
ChangeDeleted: "-",
|
||||
ChangeOld: "-",
|
||||
ChangeNew: "+",
|
||||
}
|
||||
|
||||
// changeTypeToName is used for populating class names in the diff
|
||||
changeTypeToName = map[ChangeType]string{
|
||||
ChangeNil: "same",
|
||||
ChangeAdded: "added",
|
||||
ChangeDeleted: "deleted",
|
||||
ChangeOld: "old",
|
||||
ChangeNew: "new",
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
// tplJSONDiffWrapper is the template that wraps a diff
|
||||
tplJSONDiffWrapper = `{{ define "JSONDiffWrapper" -}}
|
||||
{{ range $index, $element := . }}
|
||||
{{ template "JSONDiffLine" $element }}
|
||||
{{ end }}
|
||||
{{ end }}`
|
||||
|
||||
// tplJSONDiffLine is the template that prints each line in a diff
|
||||
tplJSONDiffLine = `{{ define "JSONDiffLine" -}}
|
||||
<p id="l{{ .LineNum }}" class="diff-line diff-json-{{ cton .Change }}">
|
||||
<span class="diff-line-number">
|
||||
{{if .LeftLine }}{{ .LeftLine }}{{ end }}
|
||||
</span>
|
||||
<span class="diff-line-number">
|
||||
{{if .RightLine }}{{ .RightLine }}{{ end }}
|
||||
</span>
|
||||
<span class="diff-value diff-indent-{{ .Indent }}" title="{{ .Text }}">
|
||||
{{ .Text }}
|
||||
</span>
|
||||
<span class="diff-line-icon">{{ ctos .Change }}</span>
|
||||
</p>
|
||||
{{ end }}`
|
||||
)
|
||||
|
||||
var diffTplFuncs = template.FuncMap{
|
||||
"ctos": func(c ChangeType) string {
|
||||
if symbol, ok := changeTypeToSymbol[c]; ok {
|
||||
return symbol
|
||||
}
|
||||
return ""
|
||||
},
|
||||
"cton": func(c ChangeType) string {
|
||||
if name, ok := changeTypeToName[c]; ok {
|
||||
return name
|
||||
}
|
||||
return ""
|
||||
},
|
||||
}
|
||||
|
||||
// JSONLine contains the data required to render each line of the JSON diff
|
||||
// and contains the data required to produce the tokens output in the basic
|
||||
// diff.
|
||||
type JSONLine struct {
|
||||
LineNum int `json:"line"`
|
||||
LeftLine int `json:"leftLine"`
|
||||
RightLine int `json:"rightLine"`
|
||||
Indent int `json:"indent"`
|
||||
Text string `json:"text"`
|
||||
Change ChangeType `json:"changeType"`
|
||||
Key string `json:"key"`
|
||||
Val interface{} `json:"value"`
|
||||
}
|
||||
|
||||
func NewJSONFormatter(left interface{}) *JSONFormatter {
|
||||
tpl := template.Must(template.New("JSONDiffWrapper").Funcs(diffTplFuncs).Parse(tplJSONDiffWrapper))
|
||||
tpl = template.Must(tpl.New("JSONDiffLine").Funcs(diffTplFuncs).Parse(tplJSONDiffLine))
|
||||
|
||||
return &JSONFormatter{
|
||||
left: left,
|
||||
Lines: []*JSONLine{},
|
||||
tpl: tpl,
|
||||
path: []string{},
|
||||
size: []int{},
|
||||
lineCount: 0,
|
||||
inArray: []bool{},
|
||||
}
|
||||
}
|
||||
|
||||
type JSONFormatter struct {
|
||||
left interface{}
|
||||
path []string
|
||||
size []int
|
||||
inArray []bool
|
||||
lineCount int
|
||||
leftLine int
|
||||
rightLine int
|
||||
line *AsciiLine
|
||||
Lines []*JSONLine
|
||||
tpl *template.Template
|
||||
}
|
||||
|
||||
type AsciiLine struct {
|
||||
// the type of change
|
||||
change ChangeType
|
||||
|
||||
// the actual changes - no formatting
|
||||
key string
|
||||
val interface{}
|
||||
|
||||
// level of indentation for the current line
|
||||
indent int
|
||||
|
||||
// buffer containing the fully formatted line
|
||||
buffer *bytes.Buffer
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) Format(diff diff.Diff) (result string, err error) {
|
||||
if v, ok := f.left.(map[string]interface{}); ok {
|
||||
f.formatObject(v, diff)
|
||||
} else if v, ok := f.left.([]interface{}); ok {
|
||||
f.formatArray(v, diff)
|
||||
} else {
|
||||
return "", fmt.Errorf("expected map[string]interface{} or []interface{}, got %T",
|
||||
f.left)
|
||||
}
|
||||
|
||||
b := &bytes.Buffer{}
|
||||
err = f.tpl.ExecuteTemplate(b, "JSONDiffWrapper", f.Lines)
|
||||
if err != nil {
|
||||
fmt.Printf("%v\n", err)
|
||||
return "", err
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) formatObject(left map[string]interface{}, df diff.Diff) {
|
||||
f.addLineWith(ChangeNil, "{")
|
||||
f.push("ROOT", len(left), false)
|
||||
f.processObject(left, df.Deltas())
|
||||
f.pop()
|
||||
f.addLineWith(ChangeNil, "}")
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) formatArray(left []interface{}, df diff.Diff) {
|
||||
f.addLineWith(ChangeNil, "[")
|
||||
f.push("ROOT", len(left), true)
|
||||
f.processArray(left, df.Deltas())
|
||||
f.pop()
|
||||
f.addLineWith(ChangeNil, "]")
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) processArray(array []interface{}, deltas []diff.Delta) error {
|
||||
patchedIndex := 0
|
||||
for index, value := range array {
|
||||
f.processItem(value, deltas, diff.Index(index))
|
||||
patchedIndex++
|
||||
}
|
||||
|
||||
// additional Added
|
||||
for _, delta := range deltas {
|
||||
switch delta.(type) {
|
||||
case *diff.Added:
|
||||
d := delta.(*diff.Added)
|
||||
// skip items already processed
|
||||
if int(d.Position.(diff.Index)) < len(array) {
|
||||
continue
|
||||
}
|
||||
f.printRecursive(d.Position.String(), d.Value, ChangeAdded)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) processObject(object map[string]interface{}, deltas []diff.Delta) error {
|
||||
names := sortKeys(object)
|
||||
for _, name := range names {
|
||||
value := object[name]
|
||||
f.processItem(value, deltas, diff.Name(name))
|
||||
}
|
||||
|
||||
// Added
|
||||
for _, delta := range deltas {
|
||||
switch delta.(type) {
|
||||
case *diff.Added:
|
||||
d := delta.(*diff.Added)
|
||||
f.printRecursive(d.Position.String(), d.Value, ChangeAdded)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, position diff.Position) error {
|
||||
matchedDeltas := f.searchDeltas(deltas, position)
|
||||
positionStr := position.String()
|
||||
if len(matchedDeltas) > 0 {
|
||||
for _, matchedDelta := range matchedDeltas {
|
||||
|
||||
switch matchedDelta.(type) {
|
||||
case *diff.Object:
|
||||
d := matchedDelta.(*diff.Object)
|
||||
switch value.(type) {
|
||||
case map[string]interface{}:
|
||||
//ok
|
||||
default:
|
||||
return errors.New("Type mismatch")
|
||||
}
|
||||
o := value.(map[string]interface{})
|
||||
|
||||
f.newLine(ChangeNil)
|
||||
f.printKey(positionStr)
|
||||
f.print("{")
|
||||
f.closeLine()
|
||||
f.push(positionStr, len(o), false)
|
||||
f.processObject(o, d.Deltas)
|
||||
f.pop()
|
||||
f.newLine(ChangeNil)
|
||||
f.print("}")
|
||||
f.printComma()
|
||||
f.closeLine()
|
||||
|
||||
case *diff.Array:
|
||||
d := matchedDelta.(*diff.Array)
|
||||
switch value.(type) {
|
||||
case []interface{}:
|
||||
//ok
|
||||
default:
|
||||
return errors.New("Type mismatch")
|
||||
}
|
||||
a := value.([]interface{})
|
||||
|
||||
f.newLine(ChangeNil)
|
||||
f.printKey(positionStr)
|
||||
f.print("[")
|
||||
f.closeLine()
|
||||
f.push(positionStr, len(a), true)
|
||||
f.processArray(a, d.Deltas)
|
||||
f.pop()
|
||||
f.newLine(ChangeNil)
|
||||
f.print("]")
|
||||
f.printComma()
|
||||
f.closeLine()
|
||||
|
||||
case *diff.Added:
|
||||
d := matchedDelta.(*diff.Added)
|
||||
f.printRecursive(positionStr, d.Value, ChangeAdded)
|
||||
f.size[len(f.size)-1]++
|
||||
|
||||
case *diff.Modified:
|
||||
d := matchedDelta.(*diff.Modified)
|
||||
savedSize := f.size[len(f.size)-1]
|
||||
f.printRecursive(positionStr, d.OldValue, ChangeOld)
|
||||
f.size[len(f.size)-1] = savedSize
|
||||
f.printRecursive(positionStr, d.NewValue, ChangeNew)
|
||||
|
||||
case *diff.TextDiff:
|
||||
savedSize := f.size[len(f.size)-1]
|
||||
d := matchedDelta.(*diff.TextDiff)
|
||||
f.printRecursive(positionStr, d.OldValue, ChangeOld)
|
||||
f.size[len(f.size)-1] = savedSize
|
||||
f.printRecursive(positionStr, d.NewValue, ChangeNew)
|
||||
|
||||
case *diff.Deleted:
|
||||
d := matchedDelta.(*diff.Deleted)
|
||||
f.printRecursive(positionStr, d.Value, ChangeDeleted)
|
||||
|
||||
default:
|
||||
return errors.New("Unknown Delta type detected")
|
||||
}
|
||||
|
||||
}
|
||||
} else {
|
||||
f.printRecursive(positionStr, value, ChangeUnchanged)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) searchDeltas(deltas []diff.Delta, postion diff.Position) (results []diff.Delta) {
|
||||
results = make([]diff.Delta, 0)
|
||||
for _, delta := range deltas {
|
||||
switch delta.(type) {
|
||||
case diff.PostDelta:
|
||||
if delta.(diff.PostDelta).PostPosition() == postion {
|
||||
results = append(results, delta)
|
||||
}
|
||||
case diff.PreDelta:
|
||||
if delta.(diff.PreDelta).PrePosition() == postion {
|
||||
results = append(results, delta)
|
||||
}
|
||||
default:
|
||||
panic("heh")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) push(name string, size int, array bool) {
|
||||
f.path = append(f.path, name)
|
||||
f.size = append(f.size, size)
|
||||
f.inArray = append(f.inArray, array)
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) pop() {
|
||||
f.path = f.path[0 : len(f.path)-1]
|
||||
f.size = f.size[0 : len(f.size)-1]
|
||||
f.inArray = f.inArray[0 : len(f.inArray)-1]
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) addLineWith(change ChangeType, value string) {
|
||||
f.line = &AsciiLine{
|
||||
change: change,
|
||||
indent: len(f.path),
|
||||
buffer: bytes.NewBufferString(value),
|
||||
}
|
||||
f.closeLine()
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) newLine(change ChangeType) {
|
||||
f.line = &AsciiLine{
|
||||
change: change,
|
||||
indent: len(f.path),
|
||||
buffer: bytes.NewBuffer([]byte{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) closeLine() {
|
||||
leftLine := 0
|
||||
rightLine := 0
|
||||
f.lineCount++
|
||||
|
||||
switch f.line.change {
|
||||
case ChangeAdded, ChangeNew:
|
||||
f.rightLine++
|
||||
rightLine = f.rightLine
|
||||
|
||||
case ChangeDeleted, ChangeOld:
|
||||
f.leftLine++
|
||||
leftLine = f.leftLine
|
||||
|
||||
case ChangeNil, ChangeUnchanged:
|
||||
f.rightLine++
|
||||
f.leftLine++
|
||||
rightLine = f.rightLine
|
||||
leftLine = f.leftLine
|
||||
}
|
||||
|
||||
s := f.line.buffer.String()
|
||||
f.Lines = append(f.Lines, &JSONLine{
|
||||
LineNum: f.lineCount,
|
||||
RightLine: rightLine,
|
||||
LeftLine: leftLine,
|
||||
Indent: f.line.indent,
|
||||
Text: s,
|
||||
Change: f.line.change,
|
||||
Key: f.line.key,
|
||||
Val: f.line.val,
|
||||
})
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) printKey(name string) {
|
||||
if !f.inArray[len(f.inArray)-1] {
|
||||
f.line.key = name
|
||||
fmt.Fprintf(f.line.buffer, `"%s": `, name)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) printComma() {
|
||||
f.size[len(f.size)-1]--
|
||||
if f.size[len(f.size)-1] > 0 {
|
||||
f.line.buffer.WriteRune(',')
|
||||
}
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) printValue(value interface{}) {
|
||||
switch value.(type) {
|
||||
case string:
|
||||
f.line.val = value
|
||||
fmt.Fprintf(f.line.buffer, `"%s"`, value)
|
||||
case nil:
|
||||
f.line.val = "null"
|
||||
f.line.buffer.WriteString("null")
|
||||
default:
|
||||
f.line.val = value
|
||||
fmt.Fprintf(f.line.buffer, `%#v`, value)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) print(a string) {
|
||||
f.line.buffer.WriteString(a)
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) printRecursive(name string, value interface{}, change ChangeType) {
|
||||
switch value.(type) {
|
||||
case map[string]interface{}:
|
||||
f.newLine(change)
|
||||
f.printKey(name)
|
||||
f.print("{")
|
||||
f.closeLine()
|
||||
|
||||
m := value.(map[string]interface{})
|
||||
size := len(m)
|
||||
f.push(name, size, false)
|
||||
|
||||
keys := sortKeys(m)
|
||||
for _, key := range keys {
|
||||
f.printRecursive(key, m[key], change)
|
||||
}
|
||||
f.pop()
|
||||
|
||||
f.newLine(change)
|
||||
f.print("}")
|
||||
f.printComma()
|
||||
f.closeLine()
|
||||
|
||||
case []interface{}:
|
||||
f.newLine(change)
|
||||
f.printKey(name)
|
||||
f.print("[")
|
||||
f.closeLine()
|
||||
|
||||
s := value.([]interface{})
|
||||
size := len(s)
|
||||
f.push("", size, true)
|
||||
for _, item := range s {
|
||||
f.printRecursive("", item, change)
|
||||
}
|
||||
f.pop()
|
||||
|
||||
f.newLine(change)
|
||||
f.print("]")
|
||||
f.printComma()
|
||||
f.closeLine()
|
||||
|
||||
default:
|
||||
f.newLine(change)
|
||||
f.printKey(name)
|
||||
f.printValue(value)
|
||||
f.printComma()
|
||||
f.closeLine()
|
||||
}
|
||||
}
|
||||
|
||||
func sortKeys(m map[string]interface{}) (keys []string) {
|
||||
keys = make([]string, 0, len(m))
|
||||
for key := range m {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return
|
||||
}
|
||||
131
pkg/components/dashdiffs/formatter_test.go
Normal file
131
pkg/components/dashdiffs/formatter_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package dashdiffs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestDiff(t *testing.T) {
|
||||
// Sample json docs for tests only
|
||||
const (
|
||||
leftJSON = `{
|
||||
"key": "value",
|
||||
"object": {
|
||||
"key": "value",
|
||||
"anotherObject": {
|
||||
"same": "this field is the same in rightJSON",
|
||||
"change": "this field should change in rightJSON",
|
||||
"delete": "this field doesn't appear in rightJSON"
|
||||
}
|
||||
},
|
||||
"array": [
|
||||
"same",
|
||||
"change",
|
||||
"delete"
|
||||
],
|
||||
"embeddedArray": {
|
||||
"array": [
|
||||
"same",
|
||||
"change",
|
||||
"delete"
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
rightJSON = `{
|
||||
"key": "differentValue",
|
||||
"object": {
|
||||
"key": "value",
|
||||
"newKey": "value",
|
||||
"anotherObject": {
|
||||
"same": "this field is the same in rightJSON",
|
||||
"change": "this field should change in rightJSON",
|
||||
"add": "this field is added"
|
||||
}
|
||||
},
|
||||
"array": [
|
||||
"same",
|
||||
"changed!",
|
||||
"add"
|
||||
],
|
||||
"embeddedArray": {
|
||||
"array": [
|
||||
"same",
|
||||
"changed!",
|
||||
"add"
|
||||
]
|
||||
}
|
||||
}`
|
||||
)
|
||||
|
||||
Convey("Testing dashboard diffs", t, func() {
|
||||
|
||||
// Compute the diff between the two JSON objects
|
||||
baseData, err := simplejson.NewJson([]byte(leftJSON))
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
newData, err := simplejson.NewJson([]byte(rightJSON))
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
left, jsonDiff, err := getDiff(baseData, newData)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
Convey("The JSONFormatter should produce the expected JSON tokens", func() {
|
||||
f := NewJSONFormatter(left)
|
||||
_, err := f.Format(jsonDiff)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
// Total up the change types. If the number of different change
|
||||
// types is correct, it means that the diff is producing correct
|
||||
// output to the template rendered.
|
||||
changeCounts := make(map[ChangeType]int)
|
||||
for _, line := range f.Lines {
|
||||
changeCounts[line.Change]++
|
||||
}
|
||||
|
||||
// The expectedChangeCounts here were determined by manually
|
||||
// looking at the JSON
|
||||
expectedChangeCounts := map[ChangeType]int{
|
||||
ChangeNil: 12,
|
||||
ChangeAdded: 2,
|
||||
ChangeDeleted: 1,
|
||||
ChangeOld: 5,
|
||||
ChangeNew: 5,
|
||||
ChangeUnchanged: 5,
|
||||
}
|
||||
So(changeCounts, ShouldResemble, expectedChangeCounts)
|
||||
})
|
||||
|
||||
Convey("The BasicFormatter should produce the expected BasicBlocks", func() {
|
||||
f := NewBasicFormatter(left)
|
||||
_, err := f.Format(jsonDiff)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
bd := &BasicDiff{}
|
||||
blocks := bd.Basic(f.jsonDiff.Lines)
|
||||
|
||||
changeCounts := make(map[ChangeType]int)
|
||||
for _, block := range blocks {
|
||||
for _, change := range block.Changes {
|
||||
changeCounts[change.Change]++
|
||||
}
|
||||
|
||||
for _, summary := range block.Summaries {
|
||||
changeCounts[summary.Change]++
|
||||
}
|
||||
|
||||
changeCounts[block.Change]++
|
||||
}
|
||||
|
||||
expectedChangeCounts := map[ChangeType]int{
|
||||
ChangeNil: 3,
|
||||
ChangeAdded: 2,
|
||||
ChangeDeleted: 1,
|
||||
ChangeOld: 3,
|
||||
}
|
||||
So(changeCounts, ShouldResemble, expectedChangeCounts)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func (m *StandardMeter) Count() int64 {
|
||||
return count
|
||||
}
|
||||
|
||||
// Mark records the occurance of n events.
|
||||
// Mark records the occurrence of n events.
|
||||
func (m *StandardMeter) Mark(n int64) {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
@@ -44,6 +44,7 @@ var (
|
||||
M_Alerting_Notification_Sent_Slack Counter
|
||||
M_Alerting_Notification_Sent_Email Counter
|
||||
M_Alerting_Notification_Sent_Webhook Counter
|
||||
M_Alerting_Notification_Sent_DingDing Counter
|
||||
M_Alerting_Notification_Sent_PagerDuty Counter
|
||||
M_Alerting_Notification_Sent_LINE Counter
|
||||
M_Alerting_Notification_Sent_Victorops Counter
|
||||
@@ -116,6 +117,7 @@ func initMetricVars(settings *MetricSettings) {
|
||||
M_Alerting_Notification_Sent_Slack = RegCounter("alerting.notifications_sent", "type", "slack")
|
||||
M_Alerting_Notification_Sent_Email = RegCounter("alerting.notifications_sent", "type", "email")
|
||||
M_Alerting_Notification_Sent_Webhook = RegCounter("alerting.notifications_sent", "type", "webhook")
|
||||
M_Alerting_Notification_Sent_DingDing = RegCounter("alerting.notifications_sent", "type", "dingding")
|
||||
M_Alerting_Notification_Sent_PagerDuty = RegCounter("alerting.notifications_sent", "type", "pagerduty")
|
||||
M_Alerting_Notification_Sent_Victorops = RegCounter("alerting.notifications_sent", "type", "victorops")
|
||||
M_Alerting_Notification_Sent_OpsGenie = RegCounter("alerting.notifications_sent", "type", "opsgenie")
|
||||
|
||||
@@ -49,9 +49,9 @@ func Logger() macaron.Handler {
|
||||
if ctx, ok := c.Data["ctx"]; ok {
|
||||
ctxTyped := ctx.(*Context)
|
||||
if status == 500 {
|
||||
ctxTyped.Logger.Error("Request Completed", "method", req.Method, "path", req.URL.Path, "status", status, "remote_addr", c.RemoteAddr(), "time_ms", int64(timeTakenMs), "size", rw.Size())
|
||||
ctxTyped.Logger.Error("Request Completed", "method", req.Method, "path", req.URL.Path, "status", status, "remote_addr", c.RemoteAddr(), "time_ms", int64(timeTakenMs), "size", rw.Size(), "referer", req.Referer())
|
||||
} else {
|
||||
ctxTyped.Logger.Info("Request Completed", "method", req.Method, "path", req.URL.Path, "status", status, "remote_addr", c.RemoteAddr(), "time_ms", int64(timeTakenMs), "size", rw.Size())
|
||||
ctxTyped.Logger.Info("Request Completed", "method", req.Method, "path", req.URL.Path, "status", status, "remote_addr", c.RemoteAddr(), "time_ms", int64(timeTakenMs), "size", rw.Size(), "referer", req.Referer())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,3 +245,11 @@ func (ctx *Context) HasHelpFlag(flag m.HelpFlags1) bool {
|
||||
func (ctx *Context) TimeRequest(timer metrics.Timer) {
|
||||
ctx.Data["perfmon.timer"] = timer
|
||||
}
|
||||
|
||||
func AddDefaultResponseHeaders() macaron.Handler {
|
||||
return func(ctx *Context) {
|
||||
if ctx.IsApiRequest() && ctx.Req.Method == "GET" {
|
||||
ctx.Resp.Header().Add("Cache-Control", "no-cache")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,16 @@ func TestMiddlewareContext(t *testing.T) {
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
})
|
||||
|
||||
middlewareScenario("middleware should add Cache-Control header for GET requests to API", func(sc *scenarioContext) {
|
||||
sc.fakeReq("GET", "/api/search").exec()
|
||||
So(sc.resp.Header().Get("Cache-Control"), ShouldEqual, "no-cache")
|
||||
})
|
||||
|
||||
middlewareScenario("middleware should not add Cache-Control header to for non-API GET requests", func(sc *scenarioContext) {
|
||||
sc.fakeReq("GET", "/").exec()
|
||||
So(sc.resp.Header().Get("Cache-Control"), ShouldBeEmpty)
|
||||
})
|
||||
|
||||
middlewareScenario("Non api request should init session", func(sc *scenarioContext) {
|
||||
sc.fakeReq("GET", "/").exec()
|
||||
So(sc.resp.Header().Get("Set-Cookie"), ShouldContainSubstring, "grafana_sess")
|
||||
@@ -327,6 +337,7 @@ func middlewareScenario(desc string, fn scenarioFunc) {
|
||||
startSessionGC = func() {}
|
||||
sc.m.Use(Sessioner(&session.Options{}))
|
||||
sc.m.Use(OrgRedirect())
|
||||
sc.m.Use(AddDefaultResponseHeaders())
|
||||
|
||||
sc.defaultHandler = func(c *Context) {
|
||||
sc.context = c
|
||||
|
||||
71
pkg/models/dashboard_version.go
Normal file
71
pkg/models/dashboard_version.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDashboardVersionNotFound = errors.New("Dashboard version not found")
|
||||
ErrNoVersionsForDashboardId = errors.New("No dashboard versions found for the given DashboardId")
|
||||
)
|
||||
|
||||
// A DashboardVersion represents the comparable data in a dashboard, allowing
|
||||
// diffs of the dashboard to be performed.
|
||||
type DashboardVersion struct {
|
||||
Id int64 `json:"id"`
|
||||
DashboardId int64 `json:"dashboardId"`
|
||||
ParentVersion int `json:"parentVersion"`
|
||||
RestoredFrom int `json:"restoredFrom"`
|
||||
Version int `json:"version"`
|
||||
|
||||
Created time.Time `json:"created"`
|
||||
CreatedBy int64 `json:"createdBy"`
|
||||
|
||||
Message string `json:"message"`
|
||||
Data *simplejson.Json `json:"data"`
|
||||
}
|
||||
|
||||
// DashboardVersionMeta extends the dashboard version model with the names
|
||||
// associated with the UserIds, overriding the field with the same name from
|
||||
// the DashboardVersion model.
|
||||
type DashboardVersionMeta struct {
|
||||
DashboardVersion
|
||||
CreatedBy string `json:"createdBy"`
|
||||
}
|
||||
|
||||
// DashboardVersionDTO represents a dashboard version, without the dashboard
|
||||
// map.
|
||||
type DashboardVersionDTO struct {
|
||||
Id int64 `json:"id"`
|
||||
DashboardId int64 `json:"dashboardId"`
|
||||
ParentVersion int `json:"parentVersion"`
|
||||
RestoredFrom int `json:"restoredFrom"`
|
||||
Version int `json:"version"`
|
||||
Created time.Time `json:"created"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
//
|
||||
// Queries
|
||||
//
|
||||
|
||||
type GetDashboardVersionQuery struct {
|
||||
DashboardId int64
|
||||
OrgId int64
|
||||
Version int
|
||||
|
||||
Result *DashboardVersion
|
||||
}
|
||||
|
||||
type GetDashboardVersionsQuery struct {
|
||||
DashboardId int64
|
||||
OrgId int64
|
||||
Limit int
|
||||
Start int
|
||||
|
||||
Result []*DashboardVersionDTO
|
||||
}
|
||||
@@ -98,12 +98,17 @@ func NewDashboardFromJson(data *simplejson.Json) *Dashboard {
|
||||
// GetDashboardModel turns the command into the savable model
|
||||
func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard {
|
||||
dash := NewDashboardFromJson(cmd.Dashboard)
|
||||
userId := cmd.UserId
|
||||
|
||||
if dash.Data.Get("version").MustInt(0) == 0 {
|
||||
dash.CreatedBy = cmd.UserId
|
||||
if userId == 0 {
|
||||
userId = -1
|
||||
}
|
||||
|
||||
dash.UpdatedBy = cmd.UserId
|
||||
if dash.Data.Get("version").MustInt(0) == 0 {
|
||||
dash.CreatedBy = userId
|
||||
}
|
||||
|
||||
dash.UpdatedBy = userId
|
||||
dash.OrgId = cmd.OrgId
|
||||
dash.PluginId = cmd.PluginId
|
||||
dash.UpdateSlug()
|
||||
@@ -126,11 +131,13 @@ func (dash *Dashboard) UpdateSlug() {
|
||||
//
|
||||
|
||||
type SaveDashboardCommand struct {
|
||||
Dashboard *simplejson.Json `json:"dashboard" binding:"Required"`
|
||||
UserId int64 `json:"userId"`
|
||||
OrgId int64 `json:"-"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
PluginId string `json:"-"`
|
||||
Dashboard *simplejson.Json `json:"dashboard" binding:"Required"`
|
||||
UserId int64 `json:"userId"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
Message string `json:"message"`
|
||||
OrgId int64 `json:"-"`
|
||||
RestoredFrom int `json:"-"`
|
||||
PluginId string `json:"-"`
|
||||
|
||||
Result *Dashboard
|
||||
}
|
||||
@@ -145,7 +152,8 @@ type DeleteDashboardCommand struct {
|
||||
//
|
||||
|
||||
type GetDashboardQuery struct {
|
||||
Slug string
|
||||
Slug string // required if no Id is specified
|
||||
Id int64 // optional if slug is set
|
||||
OrgId int64
|
||||
|
||||
Result *Dashboard
|
||||
|
||||
@@ -60,6 +60,10 @@ type UpdateTempUserStatusCommand struct {
|
||||
Status TempUserStatus
|
||||
}
|
||||
|
||||
type UpdateTempUserWithEmailSentCommand struct {
|
||||
Code string
|
||||
}
|
||||
|
||||
type GetTempUsersQuery struct {
|
||||
OrgId int64
|
||||
Email string
|
||||
|
||||
@@ -163,6 +163,7 @@ type SignedInUser struct {
|
||||
}
|
||||
|
||||
type UserProfileDTO struct {
|
||||
Id int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Login string `json:"login"`
|
||||
|
||||
@@ -89,7 +89,7 @@ func (e *DashAlertExtractor) GetAlerts() ([]*m.Alert, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// backward compatability check, can be removed later
|
||||
// backward compatibility check, can be removed later
|
||||
enabled, hasEnabled := jsonAlert.CheckGet("enabled")
|
||||
if hasEnabled && enabled.MustBool() == false {
|
||||
continue
|
||||
|
||||
90
pkg/services/alerting/notifiers/dingding.go
Normal file
90
pkg/services/alerting/notifiers/dingding.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/metrics"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
)
|
||||
|
||||
func init() {
|
||||
alerting.RegisterNotifier(&alerting.NotifierPlugin{
|
||||
Type: "dingding",
|
||||
Name: "DingDing",
|
||||
Description: "Sends HTTP POST request to DingDing",
|
||||
Factory: NewDingDingNotifier,
|
||||
OptionsTemplate: `
|
||||
<h3 class="page-heading">DingDing settings</h3>
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-10">Url</span>
|
||||
<input type="text" required class="gf-form-input max-width-26" ng-model="ctrl.model.settings.url"></input>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func NewDingDingNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
|
||||
url := model.Settings.Get("url").MustString()
|
||||
if url == "" {
|
||||
return nil, alerting.ValidationError{Reason: "Could not find url property in settings"}
|
||||
}
|
||||
|
||||
return &DingDingNotifier{
|
||||
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
|
||||
Url: url,
|
||||
log: log.New("alerting.notifier.dingding"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type DingDingNotifier struct {
|
||||
NotifierBase
|
||||
Url string
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
func (this *DingDingNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
this.log.Info("Sending dingding")
|
||||
metrics.M_Alerting_Notification_Sent_DingDing.Inc(1)
|
||||
|
||||
messageUrl, err := evalContext.GetRuleUrl()
|
||||
if err != nil {
|
||||
this.log.Error("Failed to get messageUrl", "error", err, "dingding", this.Name)
|
||||
messageUrl = ""
|
||||
}
|
||||
this.log.Info("messageUrl:" + messageUrl)
|
||||
|
||||
message := evalContext.Rule.Message
|
||||
picUrl := evalContext.ImagePublicUrl
|
||||
title := evalContext.GetNotificationTitle()
|
||||
|
||||
bodyJSON, err := simplejson.NewJson([]byte(`{
|
||||
"msgtype": "link",
|
||||
"link": {
|
||||
"text": "` + message + `",
|
||||
"title": "` + title + `",
|
||||
"picUrl": "` + picUrl + `",
|
||||
"messageUrl": "` + messageUrl + `"
|
||||
}
|
||||
}`))
|
||||
|
||||
if err != nil {
|
||||
this.log.Error("Failed to create Json data", "error", err, "dingding", this.Name)
|
||||
}
|
||||
|
||||
body, _ := bodyJSON.MarshalJSON()
|
||||
|
||||
cmd := &m.SendWebhookSync{
|
||||
Url: this.Url,
|
||||
Body: string(body),
|
||||
}
|
||||
|
||||
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
|
||||
this.log.Error("Failed to send DingDing", "error", err, "dingding", this.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
49
pkg/services/alerting/notifiers/dingding_test.go
Normal file
49
pkg/services/alerting/notifiers/dingding_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestDingDingNotifier(t *testing.T) {
|
||||
Convey("Line notifier tests", t, func() {
|
||||
Convey("empty settings should return error", func() {
|
||||
json := `{ }`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &m.AlertNotification{
|
||||
Name: "dingding_testing",
|
||||
Type: "dingding",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
_, err := NewDingDingNotifier(model)
|
||||
So(err, ShouldNotBeNil)
|
||||
|
||||
})
|
||||
Convey("settings should trigger incident", func() {
|
||||
json := `
|
||||
{
|
||||
"url": "https://www.google.com"
|
||||
}`
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &m.AlertNotification{
|
||||
Name: "dingding_testing",
|
||||
Type: "dingding",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewDingDingNotifier(model)
|
||||
notifier := not.(*DingDingNotifier)
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
So(notifier.Name, ShouldEqual, "dingding_testing")
|
||||
So(notifier.Type, ShouldEqual, "dingding")
|
||||
So(notifier.Url, ShouldEqual, "https://www.google.com")
|
||||
})
|
||||
|
||||
})
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/metrics"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -23,6 +24,14 @@ func init() {
|
||||
<span class="gf-form-label width-10">Url</span>
|
||||
<input type="text" required class="gf-form-input max-width-26" ng-model="ctrl.model.settings.url" placeholder="http://sensu-api.local:4567/results"></input>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-10">Source</span>
|
||||
<input type="text" class="gf-form-input max-width-14" ng-model="ctrl.model.settings.source" bs-tooltip="'If empty rule id will be used'" data-placement="right"></input>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-10">Handler</span>
|
||||
<input type="text" class="gf-form-input max-width-14" ng-model="ctrl.model.settings.handler" placeholder="default"></input>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-10">Username</span>
|
||||
<input type="text" class="gf-form-input max-width-14" ng-model="ctrl.model.settings.username"></input>
|
||||
@@ -46,7 +55,9 @@ func NewSensuNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
|
||||
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
|
||||
Url: url,
|
||||
User: model.Settings.Get("username").MustString(),
|
||||
Source: model.Settings.Get("source").MustString(),
|
||||
Password: model.Settings.Get("password").MustString(),
|
||||
Handler: model.Settings.Get("handler").MustString(),
|
||||
log: log.New("alerting.notifier.sensu"),
|
||||
}, nil
|
||||
}
|
||||
@@ -54,8 +65,10 @@ func NewSensuNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
|
||||
type SensuNotifier struct {
|
||||
NotifierBase
|
||||
Url string
|
||||
Source string
|
||||
User string
|
||||
Password string
|
||||
Handler string
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
@@ -67,9 +80,13 @@ func (this *SensuNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
bodyJSON.Set("ruleId", evalContext.Rule.Id)
|
||||
// Sensu alerts cannot have spaces in them
|
||||
bodyJSON.Set("name", strings.Replace(evalContext.Rule.Name, " ", "_", -1))
|
||||
// Sensu alerts require a command
|
||||
// We set it to the grafana ruleID
|
||||
bodyJSON.Set("source", "grafana_rule_"+strconv.FormatInt(evalContext.Rule.Id, 10))
|
||||
// Sensu alerts require a source. We set it to the user-specified value (optional),
|
||||
// else we fallback and use the grafana ruleID.
|
||||
if this.Source != "" {
|
||||
bodyJSON.Set("source", this.Source)
|
||||
} else {
|
||||
bodyJSON.Set("source", "grafana_rule_"+strconv.FormatInt(evalContext.Rule.Id, 10))
|
||||
}
|
||||
// Finally, sensu expects an output
|
||||
// We set it to a default output
|
||||
bodyJSON.Set("output", "Grafana Metric Condition Met")
|
||||
@@ -83,6 +100,10 @@ func (this *SensuNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
bodyJSON.Set("status", 0)
|
||||
}
|
||||
|
||||
if this.Handler != "" {
|
||||
bodyJSON.Set("handler", this.Handler)
|
||||
}
|
||||
|
||||
ruleUrl, err := evalContext.GetRuleUrl()
|
||||
if err == nil {
|
||||
bodyJSON.Set("ruleUrl", ruleUrl)
|
||||
|
||||
@@ -29,7 +29,9 @@ func TestSensuNotifier(t *testing.T) {
|
||||
Convey("from settings", func() {
|
||||
json := `
|
||||
{
|
||||
"url": "http://sensu-api.example.com:4567/results"
|
||||
"url": "http://sensu-api.example.com:4567/results",
|
||||
"source": "grafana_instance_01",
|
||||
"handler": "myhandler"
|
||||
}`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
@@ -46,6 +48,8 @@ func TestSensuNotifier(t *testing.T) {
|
||||
So(sensuNotifier.Name, ShouldEqual, "sensu")
|
||||
So(sensuNotifier.Type, ShouldEqual, "sensu")
|
||||
So(sensuNotifier.Url, ShouldEqual, "http://sensu-api.example.com:4567/results")
|
||||
So(sensuNotifier.Source, ShouldEqual, "grafana_instance_01")
|
||||
So(sensuNotifier.Handler, ShouldEqual, "myhandler")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -146,7 +146,7 @@ func signUpStartedHandler(evt *events.SignUpStarted) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
return sendEmailCommandHandler(&m.SendEmailCommand{
|
||||
err := sendEmailCommandHandler(&m.SendEmailCommand{
|
||||
To: []string{evt.Email},
|
||||
Template: tmplSignUpStarted,
|
||||
Data: map[string]interface{}{
|
||||
@@ -155,6 +155,12 @@ func signUpStartedHandler(evt *events.SignUpStarted) error {
|
||||
"SignUpUrl": setting.ToAbsUrl(fmt.Sprintf("signup/?email=%s&code=%s", url.QueryEscape(evt.Email), url.QueryEscape(evt.Code))),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
emailSentCmd := m.UpdateTempUserWithEmailSentCommand{Code: evt.Code}
|
||||
return bus.Dispatch(&emailSentCmd)
|
||||
}
|
||||
|
||||
func signUpCompletedHandler(evt *events.SignUpCompleted) error {
|
||||
|
||||
@@ -3,6 +3,7 @@ package sqlstore
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/metrics"
|
||||
@@ -62,16 +63,20 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
|
||||
if dash.Id != sameTitle.Id {
|
||||
if cmd.Overwrite {
|
||||
dash.Id = sameTitle.Id
|
||||
dash.Version = sameTitle.Version
|
||||
} else {
|
||||
return m.ErrDashboardWithSameNameExists
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parentVersion := dash.Version
|
||||
affectedRows := int64(0)
|
||||
|
||||
if dash.Id == 0 {
|
||||
dash.Version = 1
|
||||
metrics.M_Models_Dashboard_Insert.Inc(1)
|
||||
dash.Data.Set("version", dash.Version)
|
||||
affectedRows, err = sess.Insert(dash)
|
||||
} else {
|
||||
dash.Version += 1
|
||||
@@ -79,10 +84,32 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
|
||||
affectedRows, err = sess.Id(dash.Id).Update(dash)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if affectedRows == 0 {
|
||||
return m.ErrDashboardNotFound
|
||||
}
|
||||
|
||||
dashVersion := &m.DashboardVersion{
|
||||
DashboardId: dash.Id,
|
||||
ParentVersion: parentVersion,
|
||||
RestoredFrom: cmd.RestoredFrom,
|
||||
Version: dash.Version,
|
||||
Created: time.Now(),
|
||||
CreatedBy: dash.UpdatedBy,
|
||||
Message: cmd.Message,
|
||||
Data: dash.Data,
|
||||
}
|
||||
|
||||
// insert version entry
|
||||
if affectedRows, err = sess.Insert(dashVersion); err != nil {
|
||||
return err
|
||||
} else if affectedRows == 0 {
|
||||
return m.ErrDashboardNotFound
|
||||
}
|
||||
|
||||
// delete existing tabs
|
||||
_, err = sess.Exec("DELETE FROM dashboard_tag WHERE dashboard_id=?", dash.Id)
|
||||
if err != nil {
|
||||
@@ -106,8 +133,9 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
|
||||
}
|
||||
|
||||
func GetDashboard(query *m.GetDashboardQuery) error {
|
||||
dashboard := m.Dashboard{Slug: query.Slug, OrgId: query.OrgId}
|
||||
dashboard := m.Dashboard{Slug: query.Slug, OrgId: query.OrgId, Id: query.Id}
|
||||
has, err := x.Get(&dashboard)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if has == false {
|
||||
@@ -116,7 +144,6 @@ func GetDashboard(query *m.GetDashboardQuery) error {
|
||||
|
||||
dashboard.Data.Set("id", dashboard.Id)
|
||||
query.Result = &dashboard
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -233,6 +260,7 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error {
|
||||
"DELETE FROM star WHERE dashboard_id = ? ",
|
||||
"DELETE FROM dashboard WHERE id = ?",
|
||||
"DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?",
|
||||
"DELETE FROM dashboard_version WHERE dashboard_id = ?",
|
||||
}
|
||||
|
||||
for _, sql := range deletes {
|
||||
|
||||
60
pkg/services/sqlstore/dashboard_version.go
Normal file
60
pkg/services/sqlstore/dashboard_version.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
bus.AddHandler("sql", GetDashboardVersion)
|
||||
bus.AddHandler("sql", GetDashboardVersions)
|
||||
}
|
||||
|
||||
// GetDashboardVersion gets the dashboard version for the given dashboard ID and version number.
|
||||
func GetDashboardVersion(query *m.GetDashboardVersionQuery) error {
|
||||
version := m.DashboardVersion{}
|
||||
has, err := x.Where("dashboard_version.dashboard_id=? AND dashboard_version.version=? AND dashboard.org_id=?", query.DashboardId, query.Version, query.OrgId).
|
||||
Join("LEFT", "dashboard", `dashboard.id = dashboard_version.dashboard_id`).
|
||||
Get(&version)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !has {
|
||||
return m.ErrDashboardVersionNotFound
|
||||
}
|
||||
|
||||
version.Data.Set("id", version.DashboardId)
|
||||
query.Result = &version
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDashboardVersions gets all dashboard versions for the given dashboard ID.
|
||||
func GetDashboardVersions(query *m.GetDashboardVersionsQuery) error {
|
||||
err := x.Table("dashboard_version").
|
||||
Select(`dashboard_version.id,
|
||||
dashboard_version.dashboard_id,
|
||||
dashboard_version.parent_version,
|
||||
dashboard_version.restored_from,
|
||||
dashboard_version.version,
|
||||
dashboard_version.created,
|
||||
dashboard_version.created_by as created_by_id,
|
||||
dashboard_version.message,
|
||||
dashboard_version.data,`+
|
||||
dialect.Quote("user")+`.login as created_by`).
|
||||
Join("LEFT", "user", `dashboard_version.created_by = `+dialect.Quote("user")+`.id`).
|
||||
Join("LEFT", "dashboard", `dashboard.id = dashboard_version.dashboard_id`).
|
||||
Where("dashboard_version.dashboard_id=? AND dashboard.org_id=?", query.DashboardId, query.OrgId).
|
||||
OrderBy("dashboard_version.version DESC").
|
||||
Limit(query.Limit, query.Start).
|
||||
Find(&query.Result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(query.Result) < 1 {
|
||||
return m.ErrNoVersionsForDashboardId
|
||||
}
|
||||
return nil
|
||||
}
|
||||
103
pkg/services/sqlstore/dashboard_version_test.go
Normal file
103
pkg/services/sqlstore/dashboard_version_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
)
|
||||
|
||||
func updateTestDashboard(dashboard *m.Dashboard, data map[string]interface{}) {
|
||||
data["title"] = dashboard.Title
|
||||
|
||||
saveCmd := m.SaveDashboardCommand{
|
||||
OrgId: dashboard.OrgId,
|
||||
Overwrite: true,
|
||||
Dashboard: simplejson.NewFromAny(data),
|
||||
}
|
||||
|
||||
err := SaveDashboard(&saveCmd)
|
||||
So(err, ShouldBeNil)
|
||||
}
|
||||
|
||||
func TestGetDashboardVersion(t *testing.T) {
|
||||
Convey("Testing dashboard version retrieval", t, func() {
|
||||
InitTestDB(t)
|
||||
|
||||
Convey("Get a Dashboard ID and version ID", func() {
|
||||
savedDash := insertTestDashboard("test dash 26", 1, "diff")
|
||||
|
||||
query := m.GetDashboardVersionQuery{
|
||||
DashboardId: savedDash.Id,
|
||||
Version: savedDash.Version,
|
||||
OrgId: 1,
|
||||
}
|
||||
|
||||
err := GetDashboardVersion(&query)
|
||||
So(err, ShouldBeNil)
|
||||
So(savedDash.Id, ShouldEqual, query.DashboardId)
|
||||
So(savedDash.Version, ShouldEqual, query.Version)
|
||||
|
||||
dashCmd := m.GetDashboardQuery{
|
||||
OrgId: savedDash.OrgId,
|
||||
Slug: savedDash.Slug,
|
||||
}
|
||||
|
||||
err = GetDashboard(&dashCmd)
|
||||
So(err, ShouldBeNil)
|
||||
eq := reflect.DeepEqual(dashCmd.Result.Data, query.Result.Data)
|
||||
So(eq, ShouldEqual, true)
|
||||
})
|
||||
|
||||
Convey("Attempt to get a version that doesn't exist", func() {
|
||||
query := m.GetDashboardVersionQuery{
|
||||
DashboardId: int64(999),
|
||||
Version: 123,
|
||||
OrgId: 1,
|
||||
}
|
||||
|
||||
err := GetDashboardVersion(&query)
|
||||
So(err, ShouldNotBeNil)
|
||||
So(err, ShouldEqual, m.ErrDashboardVersionNotFound)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetDashboardVersions(t *testing.T) {
|
||||
Convey("Testing dashboard versions retrieval", t, func() {
|
||||
InitTestDB(t)
|
||||
savedDash := insertTestDashboard("test dash 43", 1, "diff-all")
|
||||
|
||||
Convey("Get all versions for a given Dashboard ID", func() {
|
||||
query := m.GetDashboardVersionsQuery{DashboardId: savedDash.Id, OrgId: 1}
|
||||
|
||||
err := GetDashboardVersions(&query)
|
||||
So(err, ShouldBeNil)
|
||||
So(len(query.Result), ShouldEqual, 1)
|
||||
})
|
||||
|
||||
Convey("Attempt to get the versions for a non-existent Dashboard ID", func() {
|
||||
query := m.GetDashboardVersionsQuery{DashboardId: int64(999), OrgId: 1}
|
||||
|
||||
err := GetDashboardVersions(&query)
|
||||
So(err, ShouldNotBeNil)
|
||||
So(err, ShouldEqual, m.ErrNoVersionsForDashboardId)
|
||||
So(len(query.Result), ShouldEqual, 0)
|
||||
})
|
||||
|
||||
Convey("Get all versions for an updated dashboard", func() {
|
||||
updateTestDashboard(savedDash, map[string]interface{}{
|
||||
"tags": "different-tag",
|
||||
})
|
||||
|
||||
query := m.GetDashboardVersionsQuery{DashboardId: savedDash.Id, OrgId: 1}
|
||||
err := GetDashboardVersions(&query)
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
So(len(query.Result), ShouldEqual, 2)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,8 @@ func InitTestDB(t *testing.T) {
|
||||
//x, err := xorm.NewEngine(sqlutil.TestDB_Mysql.DriverName, sqlutil.TestDB_Mysql.ConnStr)
|
||||
//x, err := xorm.NewEngine(sqlutil.TestDB_Postgres.DriverName, sqlutil.TestDB_Postgres.ConnStr)
|
||||
|
||||
// x.ShowSQL()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to init in memory sqllite3 db %v", err)
|
||||
}
|
||||
|
||||
@@ -23,67 +23,59 @@ func NewXormLogger(level glog.Lvl, grafanaLog glog.Logger) *XormLogger {
|
||||
}
|
||||
|
||||
// Error implement core.ILogger
|
||||
func (s *XormLogger) Err(v ...interface{}) error {
|
||||
func (s *XormLogger) Error(v ...interface{}) {
|
||||
if s.level <= glog.LvlError {
|
||||
s.grafanaLog.Error(fmt.Sprint(v...))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Errorf implement core.ILogger
|
||||
func (s *XormLogger) Errf(format string, v ...interface{}) error {
|
||||
func (s *XormLogger) Errorf(format string, v ...interface{}) {
|
||||
if s.level <= glog.LvlError {
|
||||
s.grafanaLog.Error(fmt.Sprintf(format, v...))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Debug implement core.ILogger
|
||||
func (s *XormLogger) Debug(v ...interface{}) error {
|
||||
func (s *XormLogger) Debug(v ...interface{}) {
|
||||
if s.level <= glog.LvlDebug {
|
||||
s.grafanaLog.Debug(fmt.Sprint(v...))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Debugf implement core.ILogger
|
||||
func (s *XormLogger) Debugf(format string, v ...interface{}) error {
|
||||
func (s *XormLogger) Debugf(format string, v ...interface{}) {
|
||||
if s.level <= glog.LvlDebug {
|
||||
s.grafanaLog.Debug(fmt.Sprintf(format, v...))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Info implement core.ILogger
|
||||
func (s *XormLogger) Info(v ...interface{}) error {
|
||||
func (s *XormLogger) Info(v ...interface{}) {
|
||||
if s.level <= glog.LvlInfo {
|
||||
s.grafanaLog.Info(fmt.Sprint(v...))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Infof implement core.ILogger
|
||||
func (s *XormLogger) Infof(format string, v ...interface{}) error {
|
||||
func (s *XormLogger) Infof(format string, v ...interface{}) {
|
||||
if s.level <= glog.LvlInfo {
|
||||
s.grafanaLog.Info(fmt.Sprintf(format, v...))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Warn implement core.ILogger
|
||||
func (s *XormLogger) Warning(v ...interface{}) error {
|
||||
func (s *XormLogger) Warn(v ...interface{}) {
|
||||
if s.level <= glog.LvlWarn {
|
||||
s.grafanaLog.Warn(fmt.Sprint(v...))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Warnf implement core.ILogger
|
||||
func (s *XormLogger) Warningf(format string, v ...interface{}) error {
|
||||
func (s *XormLogger) Warnf(format string, v ...interface{}) {
|
||||
if s.level <= glog.LvlWarn {
|
||||
s.grafanaLog.Warn(fmt.Sprintf(format, v...))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Level implement core.ILogger
|
||||
@@ -103,8 +95,7 @@ func (s *XormLogger) Level() core.LogLevel {
|
||||
}
|
||||
|
||||
// SetLevel implement core.ILogger
|
||||
func (s *XormLogger) SetLevel(l core.LogLevel) error {
|
||||
return nil
|
||||
func (s *XormLogger) SetLevel(l core.LogLevel) {
|
||||
}
|
||||
|
||||
// ShowSQL implement core.ILogger
|
||||
|
||||
@@ -8,7 +8,7 @@ func addDashboardMigration(mg *Migrator) {
|
||||
Columns: []*Column{
|
||||
{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
|
||||
{Name: "version", Type: DB_Int, Nullable: false},
|
||||
{Name: "slug", Type: DB_NVarchar, Length: 190, Nullable: false},
|
||||
{Name: "slug", Type: DB_NVarchar, Length: 189, Nullable: false},
|
||||
{Name: "title", Type: DB_NVarchar, Length: 255, Nullable: false},
|
||||
{Name: "data", Type: DB_Text, Nullable: false},
|
||||
{Name: "account_id", Type: DB_BigInt, Nullable: false},
|
||||
@@ -114,7 +114,7 @@ func addDashboardMigration(mg *Migrator) {
|
||||
|
||||
// add column to store plugin_id
|
||||
mg.AddMigration("Add column plugin_id in dashboard", NewAddColumnMigration(dashboardV2, &Column{
|
||||
Name: "plugin_id", Type: DB_NVarchar, Nullable: true, Length: 255,
|
||||
Name: "plugin_id", Type: DB_NVarchar, Nullable: true, Length: 189,
|
||||
}))
|
||||
|
||||
mg.AddMigration("Add index for plugin_id in dashboard", NewAddIndexMigration(dashboardV2, &Index{
|
||||
@@ -129,7 +129,7 @@ func addDashboardMigration(mg *Migrator) {
|
||||
mg.AddMigration("Update dashboard table charset", NewTableCharsetMigration("dashboard", []*Column{
|
||||
{Name: "slug", Type: DB_NVarchar, Length: 189, Nullable: false},
|
||||
{Name: "title", Type: DB_NVarchar, Length: 255, Nullable: false},
|
||||
{Name: "plugin_id", Type: DB_NVarchar, Nullable: true, Length: 255},
|
||||
{Name: "plugin_id", Type: DB_NVarchar, Nullable: true, Length: 189},
|
||||
{Name: "data", Type: DB_MediumText, Nullable: false},
|
||||
}))
|
||||
|
||||
|
||||
61
pkg/services/sqlstore/migrations/dashboard_version_mig.go
Normal file
61
pkg/services/sqlstore/migrations/dashboard_version_mig.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package migrations
|
||||
|
||||
import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
|
||||
func addDashboardVersionMigration(mg *Migrator) {
|
||||
dashboardVersionV1 := Table{
|
||||
Name: "dashboard_version",
|
||||
Columns: []*Column{
|
||||
{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
|
||||
{Name: "dashboard_id", Type: DB_BigInt},
|
||||
{Name: "parent_version", Type: DB_Int, Nullable: false},
|
||||
{Name: "restored_from", Type: DB_Int, Nullable: false},
|
||||
{Name: "version", Type: DB_Int, Nullable: false},
|
||||
{Name: "created", Type: DB_DateTime, Nullable: false},
|
||||
{Name: "created_by", Type: DB_BigInt, Nullable: false},
|
||||
{Name: "message", Type: DB_Text, Nullable: false},
|
||||
{Name: "data", Type: DB_Text, Nullable: false},
|
||||
},
|
||||
Indices: []*Index{
|
||||
{Cols: []string{"dashboard_id"}},
|
||||
{Cols: []string{"dashboard_id", "version"}, Type: UniqueIndex},
|
||||
},
|
||||
}
|
||||
|
||||
mg.AddMigration("create dashboard_version table v1", NewAddTableMigration(dashboardVersionV1))
|
||||
mg.AddMigration("add index dashboard_version.dashboard_id", NewAddIndexMigration(dashboardVersionV1, dashboardVersionV1.Indices[0]))
|
||||
mg.AddMigration("add unique index dashboard_version.dashboard_id and dashboard_version.version", NewAddIndexMigration(dashboardVersionV1, dashboardVersionV1.Indices[1]))
|
||||
|
||||
// before new dashboards where created with version 0, now they are always inserted with version 1
|
||||
const setVersionTo1WhereZeroSQL = `UPDATE dashboard SET version = 1 WHERE version = 0`
|
||||
mg.AddMigration("Set dashboard version to 1 where 0", new(RawSqlMigration).
|
||||
Sqlite(setVersionTo1WhereZeroSQL).
|
||||
Postgres(setVersionTo1WhereZeroSQL).
|
||||
Mysql(setVersionTo1WhereZeroSQL))
|
||||
|
||||
const rawSQL = `INSERT INTO dashboard_version
|
||||
(
|
||||
dashboard_id,
|
||||
version,
|
||||
parent_version,
|
||||
restored_from,
|
||||
created,
|
||||
created_by,
|
||||
message,
|
||||
data
|
||||
)
|
||||
SELECT
|
||||
dashboard.id,
|
||||
dashboard.version,
|
||||
dashboard.version,
|
||||
dashboard.version,
|
||||
dashboard.updated,
|
||||
COALESCE(dashboard.updated_by, -1),
|
||||
'',
|
||||
dashboard.data
|
||||
FROM dashboard;`
|
||||
mg.AddMigration("save existing dashboard data in dashboard_version table v1", new(RawSqlMigration).
|
||||
Sqlite(rawSQL).
|
||||
Postgres(rawSQL).
|
||||
Mysql(rawSQL))
|
||||
}
|
||||
@@ -25,6 +25,7 @@ func AddMigrations(mg *Migrator) {
|
||||
addAlertMigrations(mg)
|
||||
addAnnotationMig(mg)
|
||||
addTestDataMigrations(mg)
|
||||
addDashboardVersionMigration(mg)
|
||||
}
|
||||
|
||||
func addMigrationLogMigrations(mg *Migrator) {
|
||||
|
||||
@@ -199,7 +199,7 @@ func LoadConfig() {
|
||||
|
||||
if DbCfg.Type == "sqlite3" {
|
||||
UseSQLite3 = true
|
||||
// only allow one connection as sqlite3 has multi threading issues that casue table locks
|
||||
// only allow one connection as sqlite3 has multi threading issues that cause table locks
|
||||
// DbCfg.MaxIdleConn = 1
|
||||
// DbCfg.MaxOpenConn = 1
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ func init() {
|
||||
bus.AddHandler("sql", GetTempUsersQuery)
|
||||
bus.AddHandler("sql", UpdateTempUserStatus)
|
||||
bus.AddHandler("sql", GetTempUserByCode)
|
||||
bus.AddHandler("sql", UpdateTempUserWithEmailSent)
|
||||
}
|
||||
|
||||
func UpdateTempUserStatus(cmd *m.UpdateTempUserStatusCommand) error {
|
||||
@@ -35,6 +36,7 @@ func CreateTempUser(cmd *m.CreateTempUserCommand) error {
|
||||
Status: cmd.Status,
|
||||
RemoteAddr: cmd.RemoteAddr,
|
||||
InvitedByUserId: cmd.InvitedByUserId,
|
||||
EmailSentOn: time.Now(),
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
}
|
||||
@@ -48,6 +50,19 @@ func CreateTempUser(cmd *m.CreateTempUserCommand) error {
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateTempUserWithEmailSent(cmd *m.UpdateTempUserWithEmailSentCommand) error {
|
||||
return inTransaction(func(sess *DBSession) error {
|
||||
user := &m.TempUser{
|
||||
EmailSent: true,
|
||||
EmailSentOn: time.Now(),
|
||||
}
|
||||
|
||||
_, err := sess.Where("code = ?", cmd.Code).Cols("email_sent", "email_sent_on").Update(user)
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func GetTempUsersQuery(query *m.GetTempUsersQuery) error {
|
||||
rawSql := `SELECT
|
||||
tu.id as id,
|
||||
|
||||
@@ -54,6 +54,19 @@ func TestTempUserCommandsAndQueries(t *testing.T) {
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
|
||||
Convey("Should be able update email sent and email sent on", func() {
|
||||
cmd3 := m.UpdateTempUserWithEmailSentCommand{Code: cmd.Result.Code}
|
||||
err := UpdateTempUserWithEmailSent(&cmd3)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
query := m.GetTempUsersQuery{OrgId: 2256, Status: m.TmpUserInvitePending}
|
||||
err = GetTempUsersQuery(&query)
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
So(query.Result[0].EmailSent, ShouldBeTrue)
|
||||
So(query.Result[0].EmailSentOn, ShouldHappenOnOrAfter, (query.Result[0].Created))
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ func evalEnvVarExpression(value string) string {
|
||||
envVar = strings.TrimSuffix(envVar, "}")
|
||||
envValue := os.Getenv(envVar)
|
||||
|
||||
// if env variable is hostname and it is emtpy use os.Hostname as default
|
||||
// if env variable is hostname and it is empty use os.Hostname as default
|
||||
if envVar == "HOSTNAME" && envValue == "" {
|
||||
envValue, _ = os.Hostname()
|
||||
}
|
||||
@@ -635,14 +635,14 @@ func LogConfigurationInfo() {
|
||||
|
||||
if len(appliedCommandLineProperties) > 0 {
|
||||
for _, prop := range appliedCommandLineProperties {
|
||||
logger.Info("Config overriden from command line", "arg", prop)
|
||||
logger.Info("Config overridden from command line", "arg", prop)
|
||||
}
|
||||
}
|
||||
|
||||
if len(appliedEnvOverrides) > 0 {
|
||||
text.WriteString("\tEnvironment variables used:\n")
|
||||
for _, prop := range appliedEnvOverrides {
|
||||
logger.Info("Config overriden from Environment variable", "var", prop)
|
||||
logger.Info("Config overridden from Environment variable", "var", prop)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package setting
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
@@ -52,13 +53,22 @@ func TestLoadingSettings(t *testing.T) {
|
||||
})
|
||||
|
||||
Convey("Should be able to override via command line", func() {
|
||||
NewConfigContext(&CommandLineArgs{
|
||||
HomePath: "../../",
|
||||
Args: []string{"cfg:paths.data=/tmp/data", "cfg:paths.logs=/tmp/logs"},
|
||||
})
|
||||
if runtime.GOOS == "windows" {
|
||||
NewConfigContext(&CommandLineArgs{
|
||||
HomePath: "../../",
|
||||
Args: []string{`cfg:paths.data=c:\tmp\data`, `cfg:paths.logs=c:\tmp\logs`},
|
||||
})
|
||||
So(DataPath, ShouldEqual, `c:\tmp\data`)
|
||||
So(LogsPath, ShouldEqual, `c:\tmp\logs`)
|
||||
} else {
|
||||
NewConfigContext(&CommandLineArgs{
|
||||
HomePath: "../../",
|
||||
Args: []string{"cfg:paths.data=/tmp/data", "cfg:paths.logs=/tmp/logs"},
|
||||
})
|
||||
|
||||
So(DataPath, ShouldEqual, "/tmp/data")
|
||||
So(LogsPath, ShouldEqual, "/tmp/logs")
|
||||
So(DataPath, ShouldEqual, "/tmp/data")
|
||||
So(LogsPath, ShouldEqual, "/tmp/logs")
|
||||
}
|
||||
})
|
||||
|
||||
Convey("Should be able to override defaults via command line", func() {
|
||||
@@ -73,37 +83,67 @@ func TestLoadingSettings(t *testing.T) {
|
||||
So(Domain, ShouldEqual, "test2")
|
||||
})
|
||||
|
||||
Convey("Defaults can be overriden in specified config file", func() {
|
||||
NewConfigContext(&CommandLineArgs{
|
||||
HomePath: "../../",
|
||||
Config: filepath.Join(HomePath, "tests/config-files/override.ini"),
|
||||
Args: []string{"cfg:default.paths.data=/tmp/data"},
|
||||
})
|
||||
Convey("Defaults can be overridden in specified config file", func() {
|
||||
if runtime.GOOS == "windows" {
|
||||
NewConfigContext(&CommandLineArgs{
|
||||
HomePath: "../../",
|
||||
Config: filepath.Join(HomePath, "tests/config-files/override_windows.ini"),
|
||||
Args: []string{`cfg:default.paths.data=c:\tmp\data`},
|
||||
})
|
||||
|
||||
So(DataPath, ShouldEqual, "/tmp/override")
|
||||
So(DataPath, ShouldEqual, `c:\tmp\override`)
|
||||
} else {
|
||||
NewConfigContext(&CommandLineArgs{
|
||||
HomePath: "../../",
|
||||
Config: filepath.Join(HomePath, "tests/config-files/override.ini"),
|
||||
Args: []string{"cfg:default.paths.data=/tmp/data"},
|
||||
})
|
||||
|
||||
So(DataPath, ShouldEqual, "/tmp/override")
|
||||
}
|
||||
})
|
||||
|
||||
Convey("Command line overrides specified config file", func() {
|
||||
NewConfigContext(&CommandLineArgs{
|
||||
HomePath: "../../",
|
||||
Config: filepath.Join(HomePath, "tests/config-files/override.ini"),
|
||||
Args: []string{"cfg:paths.data=/tmp/data"},
|
||||
})
|
||||
if runtime.GOOS == "windows" {
|
||||
NewConfigContext(&CommandLineArgs{
|
||||
HomePath: "../../",
|
||||
Config: filepath.Join(HomePath, "tests/config-files/override_windows.ini"),
|
||||
Args: []string{`cfg:paths.data=c:\tmp\data`},
|
||||
})
|
||||
|
||||
So(DataPath, ShouldEqual, "/tmp/data")
|
||||
So(DataPath, ShouldEqual, `c:\tmp\data`)
|
||||
} else {
|
||||
NewConfigContext(&CommandLineArgs{
|
||||
HomePath: "../../",
|
||||
Config: filepath.Join(HomePath, "tests/config-files/override.ini"),
|
||||
Args: []string{"cfg:paths.data=/tmp/data"},
|
||||
})
|
||||
|
||||
So(DataPath, ShouldEqual, "/tmp/data")
|
||||
}
|
||||
})
|
||||
|
||||
Convey("Can use environment variables in config values", func() {
|
||||
os.Setenv("GF_DATA_PATH", "/tmp/env_override")
|
||||
NewConfigContext(&CommandLineArgs{
|
||||
HomePath: "../../",
|
||||
Args: []string{"cfg:paths.data=${GF_DATA_PATH}"},
|
||||
})
|
||||
if runtime.GOOS == "windows" {
|
||||
os.Setenv("GF_DATA_PATH", `c:\tmp\env_override`)
|
||||
NewConfigContext(&CommandLineArgs{
|
||||
HomePath: "../../",
|
||||
Args: []string{"cfg:paths.data=${GF_DATA_PATH}"},
|
||||
})
|
||||
|
||||
So(DataPath, ShouldEqual, "/tmp/env_override")
|
||||
So(DataPath, ShouldEqual, `c:\tmp\env_override`)
|
||||
} else {
|
||||
os.Setenv("GF_DATA_PATH", "/tmp/env_override")
|
||||
NewConfigContext(&CommandLineArgs{
|
||||
HomePath: "../../",
|
||||
Args: []string{"cfg:paths.data=${GF_DATA_PATH}"},
|
||||
})
|
||||
|
||||
So(DataPath, ShouldEqual, "/tmp/env_override")
|
||||
}
|
||||
})
|
||||
|
||||
Convey("instance_name default to hostname even if hostname env is emtpy", func() {
|
||||
Convey("instance_name default to hostname even if hostname env is empty", func() {
|
||||
NewConfigContext(&CommandLineArgs{
|
||||
HomePath: "../../",
|
||||
})
|
||||
|
||||
@@ -2,7 +2,11 @@ package social
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
)
|
||||
|
||||
func isEmailAllowed(email string, allowedDomains []string) bool {
|
||||
@@ -18,3 +22,25 @@ func isEmailAllowed(email string, allowedDomains []string) bool {
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
func HttpGet(client *http.Client, url string) ([]byte, error) {
|
||||
r, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
body, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if r.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf(string(body))
|
||||
}
|
||||
|
||||
log.Trace("HTTP GET %s: %s %s", url, r.Status, string(body))
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
@@ -84,22 +83,14 @@ func (s *GenericOAuth) FetchPrivateEmail(client *http.Client) (string, error) {
|
||||
IsConfirmed bool `json:"is_confirmed"`
|
||||
}
|
||||
|
||||
emailsUrl := fmt.Sprintf(s.apiUrl + "/emails")
|
||||
r, err := client.Get(emailsUrl)
|
||||
body, err := HttpGet(client, fmt.Sprintf(s.apiUrl+"/emails"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("Error getting email address: %s", err)
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
var records []Record
|
||||
|
||||
body, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, records)
|
||||
err = json.Unmarshal(body, &records)
|
||||
if err != nil {
|
||||
var data struct {
|
||||
Values []Record `json:"values"`
|
||||
@@ -107,7 +98,7 @@ func (s *GenericOAuth) FetchPrivateEmail(client *http.Client) (string, error) {
|
||||
|
||||
err = json.Unmarshal(body, &data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("Error getting email address: %s", err)
|
||||
}
|
||||
|
||||
records = data.Values
|
||||
@@ -129,18 +120,16 @@ func (s *GenericOAuth) FetchTeamMemberships(client *http.Client) ([]int, error)
|
||||
Id int `json:"id"`
|
||||
}
|
||||
|
||||
membershipUrl := fmt.Sprintf(s.apiUrl + "/teams")
|
||||
r, err := client.Get(membershipUrl)
|
||||
body, err := HttpGet(client, fmt.Sprintf(s.apiUrl+"/teams"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("Error getting team memberships: %s", err)
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
var records []Record
|
||||
|
||||
if err = json.NewDecoder(r.Body).Decode(&records); err != nil {
|
||||
return nil, err
|
||||
err = json.Unmarshal(body, &records)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Error getting team memberships: %s", err)
|
||||
}
|
||||
|
||||
var ids = make([]int, len(records))
|
||||
@@ -156,18 +145,16 @@ func (s *GenericOAuth) FetchOrganizations(client *http.Client) ([]string, error)
|
||||
Login string `json:"login"`
|
||||
}
|
||||
|
||||
url := fmt.Sprintf(s.apiUrl + "/orgs")
|
||||
r, err := client.Get(url)
|
||||
body, err := HttpGet(client, fmt.Sprintf(s.apiUrl+"/orgs"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("Error getting organizations: %s", err)
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
var records []Record
|
||||
|
||||
if err = json.NewDecoder(r.Body).Decode(&records); err != nil {
|
||||
return nil, err
|
||||
err = json.Unmarshal(body, &records)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Error getting organizations: %s", err)
|
||||
}
|
||||
|
||||
var logins = make([]string, len(records))
|
||||
@@ -188,16 +175,14 @@ func (s *GenericOAuth) UserInfo(client *http.Client) (*BasicUserInfo, error) {
|
||||
Attributes map[string][]string `json:"attributes"`
|
||||
}
|
||||
|
||||
var err error
|
||||
r, err := client.Get(s.apiUrl)
|
||||
body, err := HttpGet(client, s.apiUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("Error getting user info: %s", err)
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
if err = json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
return nil, err
|
||||
err = json.Unmarshal(body, &data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Error getting user info: %s", err)
|
||||
}
|
||||
|
||||
userInfo := &BasicUserInfo{
|
||||
|
||||
@@ -85,18 +85,16 @@ func (s *SocialGithub) FetchPrivateEmail(client *http.Client) (string, error) {
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
emailsUrl := fmt.Sprintf(s.apiUrl + "/emails")
|
||||
r, err := client.Get(emailsUrl)
|
||||
body, err := HttpGet(client, fmt.Sprintf(s.apiUrl+"/emails"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("Error getting email address: %s", err)
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
var records []Record
|
||||
|
||||
if err = json.NewDecoder(r.Body).Decode(&records); err != nil {
|
||||
return "", err
|
||||
err = json.Unmarshal(body, &records)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Error getting email address: %s", err)
|
||||
}
|
||||
|
||||
var email = ""
|
||||
@@ -114,18 +112,16 @@ func (s *SocialGithub) FetchTeamMemberships(client *http.Client) ([]int, error)
|
||||
Id int `json:"id"`
|
||||
}
|
||||
|
||||
membershipUrl := fmt.Sprintf(s.apiUrl + "/teams")
|
||||
r, err := client.Get(membershipUrl)
|
||||
body, err := HttpGet(client, fmt.Sprintf(s.apiUrl+"/teams"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("Error getting team memberships: %s", err)
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
var records []Record
|
||||
|
||||
if err = json.NewDecoder(r.Body).Decode(&records); err != nil {
|
||||
return nil, err
|
||||
err = json.Unmarshal(body, &records)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Error getting team memberships: %s", err)
|
||||
}
|
||||
|
||||
var ids = make([]int, len(records))
|
||||
@@ -141,18 +137,16 @@ func (s *SocialGithub) FetchOrganizations(client *http.Client) ([]string, error)
|
||||
Login string `json:"login"`
|
||||
}
|
||||
|
||||
url := fmt.Sprintf(s.apiUrl + "/orgs")
|
||||
r, err := client.Get(url)
|
||||
body, err := HttpGet(client, fmt.Sprintf(s.apiUrl+"/orgs"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("Error getting organizations: %s", err)
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
var records []Record
|
||||
|
||||
if err = json.NewDecoder(r.Body).Decode(&records); err != nil {
|
||||
return nil, err
|
||||
err = json.Unmarshal(body, &records)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Error getting organizations: %s", err)
|
||||
}
|
||||
|
||||
var logins = make([]string, len(records))
|
||||
@@ -170,16 +164,14 @@ func (s *SocialGithub) UserInfo(client *http.Client) (*BasicUserInfo, error) {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
var err error
|
||||
r, err := client.Get(s.apiUrl)
|
||||
body, err := HttpGet(client, s.apiUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("Error getting user info: %s", err)
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
if err = json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
return nil, err
|
||||
err = json.Unmarshal(body, &data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Error getting user info: %s", err)
|
||||
}
|
||||
|
||||
userInfo := &BasicUserInfo{
|
||||
|
||||
@@ -2,6 +2,7 @@ package social
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
@@ -34,16 +35,17 @@ func (s *SocialGoogle) UserInfo(client *http.Client) (*BasicUserInfo, error) {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
var err error
|
||||
|
||||
r, err := client.Get(s.apiUrl)
|
||||
body, err := HttpGet(client, s.apiUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("Error getting user info: %s", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
if err = json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
return nil, err
|
||||
|
||||
err = json.Unmarshal(body, &data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Error getting user info: %s", err)
|
||||
}
|
||||
|
||||
return &BasicUserInfo{
|
||||
Name: data.Name,
|
||||
Email: data.Email,
|
||||
|
||||
@@ -2,6 +2,7 @@ package social
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
@@ -57,16 +58,14 @@ func (s *SocialGrafanaCom) UserInfo(client *http.Client) (*BasicUserInfo, error)
|
||||
Orgs []OrgRecord `json:"orgs"`
|
||||
}
|
||||
|
||||
var err error
|
||||
r, err := client.Get(s.url + "/api/oauth2/user")
|
||||
body, err := HttpGet(client, s.url+"/api/oauth2/user")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("Error getting user info: %s", err)
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
if err = json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
return nil, err
|
||||
err = json.Unmarshal(body, &data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Error getting user info: %s", err)
|
||||
}
|
||||
|
||||
userInfo := &BasicUserInfo{
|
||||
|
||||
@@ -73,7 +73,7 @@ func (m *MySqlMacroEngine) EvaluateMacro(name string, args []string) (string, er
|
||||
if len(args) == 0 {
|
||||
return "", fmt.Errorf("missing time column argument for macro %v", name)
|
||||
}
|
||||
return fmt.Sprintf("%s > FROM_UNIXTIME(%d) AND %s < FROM_UNIXTIME(%d)", args[0], uint64(m.TimeRange.GetFromAsMsEpoch()/1000), args[0], uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil
|
||||
return fmt.Sprintf("%s >= FROM_UNIXTIME(%d) AND %s <= FROM_UNIXTIME(%d)", args[0], uint64(m.TimeRange.GetFromAsMsEpoch()/1000), args[0], uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil
|
||||
default:
|
||||
return "", fmt.Errorf("Unknown macro %v", name)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestMacroEngine(t *testing.T) {
|
||||
sql, err := engine.Interpolate("WHERE $__timeFilter(time_column)")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(sql, ShouldEqual, "WHERE time_column > FROM_UNIXTIME(18446744066914186738) AND time_column < FROM_UNIXTIME(18446744066914187038)")
|
||||
So(sql, ShouldEqual, "WHERE time_column >= FROM_UNIXTIME(18446744066914186738) AND time_column <= FROM_UNIXTIME(18446744066914187038)")
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -183,6 +183,7 @@ func (e MysqlExecutor) getTypedRowData(types []*sql.ColumnType, rows *core.Rows)
|
||||
values := make([]interface{}, len(types))
|
||||
|
||||
for i, stype := range types {
|
||||
e.log.Info("type", "type", stype)
|
||||
switch stype.DatabaseTypeName() {
|
||||
case mysql.FieldTypeNameTiny:
|
||||
values[i] = new(int8)
|
||||
@@ -209,7 +210,7 @@ func (e MysqlExecutor) getTypedRowData(types []*sql.ColumnType, rows *core.Rows)
|
||||
case mysql.FieldTypeNameDateTime:
|
||||
values[i] = new(time.Time)
|
||||
case mysql.FieldTypeNameTime:
|
||||
values[i] = new(time.Duration)
|
||||
values[i] = new(string)
|
||||
case mysql.FieldTypeNameYear:
|
||||
values[i] = new(int16)
|
||||
case mysql.FieldTypeNameNULL:
|
||||
@@ -307,6 +308,9 @@ func (s *stringStringScan) Update(rows *sql.Rows) error {
|
||||
return err
|
||||
}
|
||||
|
||||
s.time = null.FloatFromPtr(nil)
|
||||
s.value = null.FloatFromPtr(nil)
|
||||
|
||||
for i := 0; i < s.columnCount; i++ {
|
||||
if rb, ok := s.rowPtrs[i].(*sql.RawBytes); ok {
|
||||
s.rowValues[i] = string(*rb)
|
||||
|
||||
@@ -52,8 +52,8 @@ func TestTimeRange(t *testing.T) {
|
||||
})
|
||||
|
||||
Convey("now-10m ", func() {
|
||||
fiveMinAgo, _ := time.ParseDuration("-10m")
|
||||
expected := now.Add(fiveMinAgo)
|
||||
tenMinAgo, _ := time.ParseDuration("-10m")
|
||||
expected := now.Add(tenMinAgo)
|
||||
res, err := tr.ParseTo()
|
||||
So(err, ShouldBeNil)
|
||||
So(res.Unix(), ShouldEqual, expected.Unix())
|
||||
|
||||
58
public/app/core/components/collapse_box.ts
Normal file
58
public/app/core/components/collapse_box.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
///<reference path="../../headers/common.d.ts" />
|
||||
|
||||
import coreModule from 'app/core/core_module';
|
||||
|
||||
const template = `
|
||||
<div class="collapse-box">
|
||||
<div class="collapse-box__header">
|
||||
<a class="collapse-box__header-title pointer" ng-click="ctrl.toggle()">
|
||||
<span class="fa fa-fw fa-caret-right" ng-hide="ctrl.isOpen"></span>
|
||||
<span class="fa fa-fw fa-caret-down" ng-hide="!ctrl.isOpen"></span>
|
||||
{{ctrl.title}}
|
||||
</a>
|
||||
<div class="collapse-box__header-actions" ng-transclude="actions" ng-if="ctrl.isOpen"></div>
|
||||
</div>
|
||||
<div class="collapse-box__body" ng-transclude="body" ng-if="ctrl.isOpen">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export class CollapseBoxCtrl {
|
||||
isOpen: boolean;
|
||||
stateChanged: () => void;
|
||||
|
||||
/** @ngInject **/
|
||||
constructor(private $timeout) {
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.isOpen = !this.isOpen;
|
||||
this.$timeout(() => {
|
||||
this.stateChanged();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function collapseBox() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: template,
|
||||
controller: CollapseBoxCtrl,
|
||||
bindToController: true,
|
||||
controllerAs: 'ctrl',
|
||||
scope: {
|
||||
"title": "@",
|
||||
"isOpen": "=?",
|
||||
"stateChanged": "&"
|
||||
},
|
||||
transclude: {
|
||||
'actions': '?collapseBoxActions',
|
||||
'body': 'collapseBoxBody',
|
||||
},
|
||||
link: function(scope, elem, attrs) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
coreModule.directive('collapseBox', collapseBox);
|
||||
251
public/app/core/components/form_dropdown/form_dropdown.ts
Normal file
251
public/app/core/components/form_dropdown/form_dropdown.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
///<reference path="../../../headers/common.d.ts" />
|
||||
|
||||
import config from 'app/core/config';
|
||||
import _ from 'lodash';
|
||||
import $ from 'jquery';
|
||||
import coreModule from '../../core_module';
|
||||
|
||||
function typeaheadMatcher(item) {
|
||||
var str = this.query;
|
||||
if (str[0] === '/') { str = str.substring(1); }
|
||||
if (str[str.length - 1] === '/') { str = str.substring(0, str.length-1); }
|
||||
return item.toLowerCase().match(str.toLowerCase());
|
||||
}
|
||||
|
||||
export class FormDropdownCtrl {
|
||||
inputElement: any;
|
||||
linkElement: any;
|
||||
model: any;
|
||||
display: any;
|
||||
text: any;
|
||||
options: any;
|
||||
cssClass: any;
|
||||
cssClasses: any;
|
||||
allowCustom: any;
|
||||
labelMode: boolean;
|
||||
linkMode: boolean;
|
||||
cancelBlur: any;
|
||||
onChange: any;
|
||||
getOptions: any;
|
||||
optionCache: any;
|
||||
lookupText: boolean;
|
||||
|
||||
/** @ngInject **/
|
||||
constructor(private $scope, $element, private $sce, private templateSrv, private $q) {
|
||||
this.inputElement = $element.find('input').first();
|
||||
this.linkElement = $element.find('a').first();
|
||||
this.linkMode = true;
|
||||
this.cancelBlur = null;
|
||||
|
||||
// listen to model changes
|
||||
$scope.$watch("ctrl.model", this.modelChanged.bind(this));
|
||||
|
||||
if (this.labelMode) {
|
||||
this.cssClasses = 'gf-form-label ' + this.cssClass;
|
||||
} else {
|
||||
this.cssClasses = 'gf-form-input gf-form-input--dropdown ' + this.cssClass;
|
||||
}
|
||||
|
||||
this.inputElement.attr('data-provide', 'typeahead');
|
||||
this.inputElement.typeahead({
|
||||
source: this.typeaheadSource.bind(this),
|
||||
minLength: 0,
|
||||
items: 10000,
|
||||
updater: this.typeaheadUpdater.bind(this),
|
||||
matcher: typeaheadMatcher,
|
||||
});
|
||||
|
||||
// modify typeahead lookup
|
||||
// this = typeahead
|
||||
var typeahead = this.inputElement.data('typeahead');
|
||||
typeahead.lookup = function () {
|
||||
this.query = this.$element.val() || '';
|
||||
var items = this.source(this.query, $.proxy(this.process, this));
|
||||
return items ? this.process(items) : items;
|
||||
};
|
||||
|
||||
this.linkElement.keydown(evt => {
|
||||
// trigger typeahead on down arrow or enter key
|
||||
if (evt.keyCode === 40 || evt.keyCode === 13) {
|
||||
this.linkElement.click();
|
||||
}
|
||||
});
|
||||
|
||||
this.inputElement.keydown(evt => {
|
||||
if (evt.keyCode === 13) {
|
||||
setTimeout(() => {
|
||||
this.inputElement.blur();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
this.inputElement.blur(this.inputBlur.bind(this));
|
||||
}
|
||||
|
||||
getOptionsInternal(query) {
|
||||
var result = this.getOptions({$query: query});
|
||||
if (this.isPromiseLike(result)) {
|
||||
return result;
|
||||
}
|
||||
return this.$q.when(result);
|
||||
}
|
||||
|
||||
isPromiseLike(obj) {
|
||||
return obj && (typeof obj.then === 'function');
|
||||
}
|
||||
|
||||
modelChanged() {
|
||||
if (_.isObject(this.model)) {
|
||||
this.updateDisplay(this.model.text);
|
||||
} else {
|
||||
// if we have text use it
|
||||
if (this.lookupText) {
|
||||
this.getOptionsInternal("").then(options => {
|
||||
var item = _.find(options, {value: this.model});
|
||||
this.updateDisplay(item ? item.text : this.model);
|
||||
});
|
||||
} else {
|
||||
this.updateDisplay(this.model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typeaheadSource(query, callback) {
|
||||
this.getOptionsInternal(query).then(options => {
|
||||
this.optionCache = options;
|
||||
|
||||
// extract texts
|
||||
let optionTexts = _.map(options, 'text');
|
||||
|
||||
// add custom values
|
||||
if (this.allowCustom) {
|
||||
if (_.indexOf(optionTexts, this.text) === -1) {
|
||||
options.unshift(this.text);
|
||||
}
|
||||
}
|
||||
|
||||
callback(optionTexts);
|
||||
});
|
||||
}
|
||||
|
||||
typeaheadUpdater(text) {
|
||||
if (text === this.text) {
|
||||
clearTimeout(this.cancelBlur);
|
||||
this.inputElement.focus();
|
||||
return text;
|
||||
}
|
||||
|
||||
this.inputElement.val(text);
|
||||
this.switchToLink(true);
|
||||
return text;
|
||||
}
|
||||
|
||||
switchToLink(fromClick) {
|
||||
if (this.linkMode && !fromClick) { return; }
|
||||
|
||||
clearTimeout(this.cancelBlur);
|
||||
this.cancelBlur = null;
|
||||
this.linkMode = true;
|
||||
this.inputElement.hide();
|
||||
this.linkElement.show();
|
||||
this.updateValue(this.inputElement.val());
|
||||
}
|
||||
|
||||
inputBlur() {
|
||||
// happens long before the click event on the typeahead options
|
||||
// need to have long delay because the blur
|
||||
this.cancelBlur = setTimeout(this.switchToLink.bind(this), 200);
|
||||
}
|
||||
|
||||
updateValue(text) {
|
||||
if (text === '' || this.text === text) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$scope.$apply(() => {
|
||||
var option = _.find(this.optionCache, {text: text});
|
||||
|
||||
if (option) {
|
||||
if (_.isObject(this.model)) {
|
||||
this.model = option;
|
||||
} else {
|
||||
this.model = option.value;
|
||||
}
|
||||
this.text = option.text;
|
||||
} else if (this.allowCustom) {
|
||||
if (_.isObject(this.model)) {
|
||||
this.model.text = this.model.value = text;
|
||||
} else {
|
||||
this.model = text;
|
||||
}
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
// needs to call this after digest so
|
||||
// property is synced with outerscope
|
||||
this.$scope.$$postDigest(() => {
|
||||
this.$scope.$apply(() => {
|
||||
this.onChange({$option: option});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
updateDisplay(text) {
|
||||
this.text = text;
|
||||
this.display = this.$sce.trustAsHtml(this.templateSrv.highlightVariablesAsHtml(text));
|
||||
}
|
||||
|
||||
open() {
|
||||
this.inputElement.show();
|
||||
|
||||
this.inputElement.css('width', (Math.max(this.linkElement.width(), 80) + 16) + 'px');
|
||||
this.inputElement.focus();
|
||||
|
||||
this.linkElement.hide();
|
||||
this.linkMode = false;
|
||||
|
||||
var typeahead = this.inputElement.data('typeahead');
|
||||
if (typeahead) {
|
||||
this.inputElement.val('');
|
||||
typeahead.lookup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const template = `
|
||||
<input type="text"
|
||||
data-provide="typeahead"
|
||||
class="gf-form-input"
|
||||
spellcheck="false"
|
||||
style="display:none">
|
||||
</input>
|
||||
<a ng-class="ctrl.cssClasses"
|
||||
tabindex="1"
|
||||
ng-click="ctrl.open()"
|
||||
give-focus="ctrl.focus"
|
||||
ng-bind-html="ctrl.display">
|
||||
</a>
|
||||
`;
|
||||
|
||||
export function formDropdownDirective() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: template,
|
||||
controller: FormDropdownCtrl,
|
||||
bindToController: true,
|
||||
controllerAs: 'ctrl',
|
||||
scope: {
|
||||
model: "=",
|
||||
getOptions: "&",
|
||||
onChange: "&",
|
||||
cssClass: "@",
|
||||
allowCustom: "@",
|
||||
labelMode: "@",
|
||||
lookupText: "@",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
coreModule.directive('gfFormDropdown', formDropdownDirective);
|
||||
@@ -105,10 +105,14 @@ export function grafanaAppDirective(playlistSrv, contextSrv) {
|
||||
if (pageClass) {
|
||||
body.removeClass(pageClass);
|
||||
}
|
||||
pageClass = data.$$route.pageClass;
|
||||
if (pageClass) {
|
||||
body.addClass(pageClass);
|
||||
|
||||
if (data.$$route) {
|
||||
pageClass = data.$$route.pageClass;
|
||||
if (pageClass) {
|
||||
body.addClass(pageClass);
|
||||
}
|
||||
}
|
||||
|
||||
$("#tooltip, .tooltip").remove();
|
||||
|
||||
// check for kiosk url param
|
||||
@@ -194,6 +198,15 @@ export function grafanaAppDirective(playlistSrv, contextSrv) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// hide menus
|
||||
var openMenus = body.find('.navbar-page-btn--open');
|
||||
if (openMenus.length > 0) {
|
||||
if (target.parents('.navbar-page-btn--open').length === 0) {
|
||||
openMenus.removeClass('navbar-page-btn--open');
|
||||
}
|
||||
}
|
||||
|
||||
// hide sidemenu
|
||||
if (!ignoreSideMenuHide && !contextSrv.pinned && body.find('.sidemenu').length > 0) {
|
||||
if (target.parents('.sidemenu').length === 0) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="modal-body">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-header-title">
|
||||
<i class="fa fa-keyboard"></i>
|
||||
<i class="fa fa-keyboard-o"></i>
|
||||
<span class="p-l-1">Shortcuts</span>
|
||||
</h2>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
<div class="modal-content help-modal">
|
||||
|
||||
<p class="small" style="position: absolute; top: 48px; right: 10px">
|
||||
<p class="small" style="position: absolute; top: 13px; right: 44px">
|
||||
<span class="shortcut-table-key">mod</span> =
|
||||
<span class="muted">CTRL on windows or linux and CMD key on Mac</span>
|
||||
</p>
|
||||
|
||||
113
public/app/core/components/json_explorer/helpers.ts
Normal file
113
public/app/core/components/json_explorer/helpers.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// Based on work https://github.com/mohsen1/json-formatter-js
|
||||
// Licence MIT, Copyright (c) 2015 Mohsen Azimi
|
||||
|
||||
/*
|
||||
* Escapes `"` charachters from string
|
||||
*/
|
||||
function escapeString(str: string): string {
|
||||
return str.replace('"', '\"');
|
||||
}
|
||||
|
||||
/*
|
||||
* Determines if a value is an object
|
||||
*/
|
||||
export function isObject(value: any): boolean {
|
||||
var type = typeof value;
|
||||
return !!value && (type === 'object');
|
||||
}
|
||||
|
||||
/*
|
||||
* Gets constructor name of an object.
|
||||
* From http://stackoverflow.com/a/332429
|
||||
*
|
||||
*/
|
||||
export function getObjectName(object: Object): string {
|
||||
if (object === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (object === null) {
|
||||
return 'Object';
|
||||
}
|
||||
if (typeof object === 'object' && !object.constructor) {
|
||||
return 'Object';
|
||||
}
|
||||
|
||||
const funcNameRegex = /function ([^(]*)/;
|
||||
const results = (funcNameRegex).exec((object).constructor.toString());
|
||||
if (results && results.length > 1) {
|
||||
return results[1];
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Gets type of an object. Returns "null" for null objects
|
||||
*/
|
||||
export function getType(object: Object): string {
|
||||
if (object === null) { return 'null'; }
|
||||
return typeof object;
|
||||
}
|
||||
|
||||
/*
|
||||
* Generates inline preview for a JavaScript object based on a value
|
||||
*/
|
||||
export function getValuePreview (object: Object, value: string): string {
|
||||
var type = getType(object);
|
||||
|
||||
if (type === 'null' || type === 'undefined') { return type; }
|
||||
|
||||
if (type === 'string') {
|
||||
value = '"' + escapeString(value) + '"';
|
||||
}
|
||||
if (type === 'function'){
|
||||
|
||||
// Remove content of the function
|
||||
return object.toString()
|
||||
.replace(/[\r\n]/g, '')
|
||||
.replace(/\{.*\}/, '') + '{…}';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/*
|
||||
* Generates inline preview for a JavaScript object
|
||||
*/
|
||||
export function getPreview(object: string): string {
|
||||
let value = '';
|
||||
if (isObject(object)) {
|
||||
value = getObjectName(object);
|
||||
if (Array.isArray(object)) {
|
||||
value += '[' + object.length + ']';
|
||||
}
|
||||
} else {
|
||||
value = getValuePreview(object, object);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/*
|
||||
* Generates a prefixed CSS class name
|
||||
*/
|
||||
export function cssClass(className: string): string {
|
||||
return `json-formatter-${className}`;
|
||||
}
|
||||
|
||||
/*
|
||||
* Creates a new DOM element wiht given type and class
|
||||
* TODO: move me to helpers
|
||||
*/
|
||||
export function createElement(type: string, className?: string, content?: Element|string): Element {
|
||||
const el = document.createElement(type);
|
||||
if (className) {
|
||||
el.classList.add(cssClass(className));
|
||||
}
|
||||
if (content !== undefined) {
|
||||
if (content instanceof Node) {
|
||||
el.appendChild(content);
|
||||
} else {
|
||||
el.appendChild(document.createTextNode(String(content)));
|
||||
}
|
||||
}
|
||||
return el;
|
||||
}
|
||||
431
public/app/core/components/json_explorer/json_explorer.ts
Normal file
431
public/app/core/components/json_explorer/json_explorer.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
// Based on work https://github.com/mohsen1/json-formatter-js
|
||||
// Licence MIT, Copyright (c) 2015 Mohsen Azimi
|
||||
|
||||
import {
|
||||
isObject,
|
||||
getObjectName,
|
||||
getType,
|
||||
getValuePreview,
|
||||
getPreview,
|
||||
cssClass,
|
||||
createElement
|
||||
} from './helpers';
|
||||
|
||||
import _ from 'lodash';
|
||||
|
||||
const DATE_STRING_REGEX = /(^\d{1,4}[\.|\\/|-]\d{1,2}[\.|\\/|-]\d{1,4})(\s*(?:0?[1-9]:[0-5]|1(?=[012])\d:[0-5])\d\s*[ap]m)?$/;
|
||||
const PARTIAL_DATE_REGEX = /\d{2}:\d{2}:\d{2} GMT-\d{4}/;
|
||||
const JSON_DATE_REGEX = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
|
||||
|
||||
// When toggleing, don't animated removal or addition of more than a few items
|
||||
const MAX_ANIMATED_TOGGLE_ITEMS = 10;
|
||||
|
||||
const requestAnimationFrame = window.requestAnimationFrame || function(cb: ()=>void) { cb(); return 0; };
|
||||
|
||||
export interface JsonExplorerConfig {
|
||||
animateOpen?: boolean;
|
||||
animateClose?: boolean;
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
const _defaultConfig: JsonExplorerConfig = {
|
||||
animateOpen: true,
|
||||
animateClose: true,
|
||||
theme: null
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @class JsonExplorer
|
||||
*
|
||||
* JsonExplorer allows you to render JSON objects in HTML with a
|
||||
* **collapsible** navigation.
|
||||
*/
|
||||
export class JsonExplorer {
|
||||
|
||||
// Hold the open state after the toggler is used
|
||||
private _isOpen: boolean = null;
|
||||
|
||||
// A reference to the element that we render to
|
||||
private element: Element;
|
||||
|
||||
private skipChildren = false;
|
||||
|
||||
/**
|
||||
* @param {object} json The JSON object you want to render. It has to be an
|
||||
* object or array. Do NOT pass raw JSON string.
|
||||
*
|
||||
* @param {number} [open=1] his number indicates up to how many levels the
|
||||
* rendered tree should expand. Set it to `0` to make the whole tree collapsed
|
||||
* or set it to `Infinity` to expand the tree deeply
|
||||
*
|
||||
* @param {object} [config=defaultConfig] -
|
||||
* defaultConfig = {
|
||||
* hoverPreviewEnabled: false,
|
||||
* hoverPreviewArrayCount: 100,
|
||||
* hoverPreviewFieldCount: 5
|
||||
* }
|
||||
*
|
||||
* Available configurations:
|
||||
* #####Hover Preview
|
||||
* * `hoverPreviewEnabled`: enable preview on hover
|
||||
* * `hoverPreviewArrayCount`: number of array items to show in preview Any
|
||||
* array larger than this number will be shown as `Array[XXX]` where `XXX`
|
||||
* is length of the array.
|
||||
* * `hoverPreviewFieldCount`: number of object properties to show for object
|
||||
* preview. Any object with more properties that thin number will be
|
||||
* truncated.
|
||||
*
|
||||
* @param {string} [key=undefined] The key that this object in it's parent
|
||||
* context
|
||||
*/
|
||||
constructor(public json: any, private open = 1, private config: JsonExplorerConfig = _defaultConfig, private key?: string) {
|
||||
}
|
||||
|
||||
/*
|
||||
* is formatter open?
|
||||
*/
|
||||
private get isOpen(): boolean {
|
||||
if (this._isOpen !== null) {
|
||||
return this._isOpen;
|
||||
} else {
|
||||
return this.open > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* set open state (from toggler)
|
||||
*/
|
||||
private set isOpen(value: boolean) {
|
||||
this._isOpen = value;
|
||||
}
|
||||
|
||||
/*
|
||||
* is this a date string?
|
||||
*/
|
||||
private get isDate(): boolean {
|
||||
return (this.type === 'string') &&
|
||||
(DATE_STRING_REGEX.test(this.json) ||
|
||||
JSON_DATE_REGEX.test(this.json) ||
|
||||
PARTIAL_DATE_REGEX.test(this.json));
|
||||
}
|
||||
|
||||
/*
|
||||
* is this a URL string?
|
||||
*/
|
||||
private get isUrl(): boolean {
|
||||
return this.type === 'string' && (this.json.indexOf('http') === 0);
|
||||
}
|
||||
|
||||
/*
|
||||
* is this an array?
|
||||
*/
|
||||
private get isArray(): boolean {
|
||||
return Array.isArray(this.json);
|
||||
}
|
||||
|
||||
/*
|
||||
* is this an object?
|
||||
* Note: In this context arrays are object as well
|
||||
*/
|
||||
private get isObject(): boolean {
|
||||
return isObject(this.json);
|
||||
}
|
||||
|
||||
/*
|
||||
* is this an empty object with no properties?
|
||||
*/
|
||||
private get isEmptyObject(): boolean {
|
||||
return !this.keys.length && !this.isArray;
|
||||
}
|
||||
|
||||
/*
|
||||
* is this an empty object or array?
|
||||
*/
|
||||
private get isEmpty(): boolean {
|
||||
return this.isEmptyObject || (this.keys && !this.keys.length && this.isArray);
|
||||
}
|
||||
|
||||
/*
|
||||
* did we recieve a key argument?
|
||||
* This means that the formatter was called as a sub formatter of a parent formatter
|
||||
*/
|
||||
private get hasKey(): boolean {
|
||||
return typeof this.key !== 'undefined';
|
||||
}
|
||||
|
||||
/*
|
||||
* if this is an object, get constructor function name
|
||||
*/
|
||||
private get constructorName(): string {
|
||||
return getObjectName(this.json);
|
||||
}
|
||||
|
||||
/*
|
||||
* get type of this value
|
||||
* Possible values: all JavaScript primitive types plus "array" and "null"
|
||||
*/
|
||||
private get type(): string {
|
||||
return getType(this.json);
|
||||
}
|
||||
|
||||
/*
|
||||
* get object keys
|
||||
* If there is an empty key we pad it wit quotes to make it visible
|
||||
*/
|
||||
private get keys(): string[] {
|
||||
if (this.isObject) {
|
||||
return Object.keys(this.json).map((key)=> key ? key : '""');
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles `isOpen` state
|
||||
*
|
||||
*/
|
||||
toggleOpen() {
|
||||
this.isOpen = !this.isOpen;
|
||||
|
||||
if (this.element) {
|
||||
if (this.isOpen) {
|
||||
this.appendChildren(this.config.animateOpen);
|
||||
} else{
|
||||
this.removeChildren(this.config.animateClose);
|
||||
}
|
||||
this.element.classList.toggle(cssClass('open'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open all children up to a certain depth.
|
||||
* Allows actions such as expand all/collapse all
|
||||
*
|
||||
*/
|
||||
openAtDepth(depth = 1) {
|
||||
if (depth < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.open = depth;
|
||||
this.isOpen = (depth !== 0);
|
||||
|
||||
if (this.element) {
|
||||
this.removeChildren(false);
|
||||
|
||||
if (depth === 0) {
|
||||
this.element.classList.remove(cssClass('open'));
|
||||
} else {
|
||||
this.appendChildren(this.config.animateOpen);
|
||||
this.element.classList.add(cssClass('open'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isNumberArray() {
|
||||
return (this.json.length > 0 && this.json.length < 4) &&
|
||||
(_.isNumber(this.json[0]) || _.isNumber(this.json[1]));
|
||||
}
|
||||
|
||||
renderArray() {
|
||||
const arrayWrapperSpan = createElement('span');
|
||||
arrayWrapperSpan.appendChild(createElement('span', 'bracket', '['));
|
||||
|
||||
// some pretty handling of number arrays
|
||||
if (this.isNumberArray()) {
|
||||
this.json.forEach((val, index) => {
|
||||
if (index > 0) {
|
||||
arrayWrapperSpan.appendChild(createElement('span', 'array-comma', ','));
|
||||
}
|
||||
arrayWrapperSpan.appendChild(createElement('span', 'number', val));
|
||||
});
|
||||
this.skipChildren = true;
|
||||
} else {
|
||||
arrayWrapperSpan.appendChild(createElement('span', 'number', (this.json.length)));
|
||||
}
|
||||
|
||||
arrayWrapperSpan.appendChild(createElement('span', 'bracket', ']'));
|
||||
return arrayWrapperSpan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an HTML element and installs event listeners
|
||||
*
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
render(skipRoot = false): HTMLDivElement {
|
||||
// construct the root element and assign it to this.element
|
||||
this.element = createElement('div', 'row');
|
||||
|
||||
// construct the toggler link
|
||||
const togglerLink = createElement('a', 'toggler-link');
|
||||
const togglerIcon = createElement('span', 'toggler');
|
||||
|
||||
// if this is an object we need a wrapper span (toggler)
|
||||
if (this.isObject) {
|
||||
togglerLink.appendChild(togglerIcon);
|
||||
}
|
||||
|
||||
// if this is child of a parent formatter we need to append the key
|
||||
if (this.hasKey) {
|
||||
togglerLink.appendChild(createElement('span', 'key', `${this.key}:`));
|
||||
}
|
||||
|
||||
// Value for objects and arrays
|
||||
if (this.isObject) {
|
||||
// construct the value holder element
|
||||
const value = createElement('span', 'value');
|
||||
|
||||
// we need a wrapper span for objects
|
||||
const objectWrapperSpan = createElement('span');
|
||||
|
||||
// get constructor name and append it to wrapper span
|
||||
var constructorName = createElement('span', 'constructor-name', this.constructorName);
|
||||
objectWrapperSpan.appendChild(constructorName);
|
||||
|
||||
// if it's an array append the array specific elements like brackets and length
|
||||
if (this.isArray) {
|
||||
const arrayWrapperSpan = this.renderArray();
|
||||
objectWrapperSpan.appendChild(arrayWrapperSpan);
|
||||
}
|
||||
|
||||
// append object wrapper span to toggler link
|
||||
value.appendChild(objectWrapperSpan);
|
||||
togglerLink.appendChild(value);
|
||||
// Primitive values
|
||||
} else {
|
||||
|
||||
// make a value holder element
|
||||
const value = this.isUrl ? createElement('a') : createElement('span');
|
||||
|
||||
// add type and other type related CSS classes
|
||||
value.classList.add(cssClass(this.type));
|
||||
if (this.isDate) {
|
||||
value.classList.add(cssClass('date'));
|
||||
}
|
||||
if (this.isUrl) {
|
||||
value.classList.add(cssClass('url'));
|
||||
value.setAttribute('href', this.json);
|
||||
}
|
||||
|
||||
// Append value content to value element
|
||||
const valuePreview = getValuePreview(this.json, this.json);
|
||||
value.appendChild(document.createTextNode(valuePreview));
|
||||
|
||||
// append the value element to toggler link
|
||||
togglerLink.appendChild(value);
|
||||
}
|
||||
|
||||
// construct a children element
|
||||
const children = createElement('div', 'children');
|
||||
|
||||
// set CSS classes for children
|
||||
if (this.isObject) {
|
||||
children.classList.add(cssClass('object'));
|
||||
}
|
||||
if (this.isArray) {
|
||||
children.classList.add(cssClass('array'));
|
||||
}
|
||||
if (this.isEmpty) {
|
||||
children.classList.add(cssClass('empty'));
|
||||
}
|
||||
|
||||
// set CSS classes for root element
|
||||
if (this.config && this.config.theme) {
|
||||
this.element.classList.add(cssClass(this.config.theme));
|
||||
}
|
||||
if (this.isOpen) {
|
||||
this.element.classList.add(cssClass('open'));
|
||||
}
|
||||
|
||||
// append toggler and children elements to root element
|
||||
if (!skipRoot) {
|
||||
this.element.appendChild(togglerLink);
|
||||
}
|
||||
|
||||
if (!this.skipChildren) {
|
||||
this.element.appendChild(children);
|
||||
} else {
|
||||
// remove togglerIcon
|
||||
togglerLink.removeChild(togglerIcon);
|
||||
}
|
||||
|
||||
// if formatter is set to be open call appendChildren
|
||||
if (this.isObject && this.isOpen) {
|
||||
this.appendChildren();
|
||||
}
|
||||
|
||||
// add event listener for toggling
|
||||
if (this.isObject) {
|
||||
togglerLink.addEventListener('click', this.toggleOpen.bind(this));
|
||||
}
|
||||
|
||||
return this.element as HTMLDivElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends all the children to children element
|
||||
* Animated option is used when user triggers this via a click
|
||||
*/
|
||||
appendChildren(animated = false) {
|
||||
const children = this.element.querySelector(`div.${cssClass('children')}`);
|
||||
|
||||
if (!children || this.isEmpty) { return; }
|
||||
|
||||
if (animated) {
|
||||
let index = 0;
|
||||
const addAChild = ()=> {
|
||||
const key = this.keys[index];
|
||||
const formatter = new JsonExplorer(this.json[key], this.open - 1, this.config, key);
|
||||
children.appendChild(formatter.render());
|
||||
|
||||
index += 1;
|
||||
|
||||
if (index < this.keys.length) {
|
||||
if (index > MAX_ANIMATED_TOGGLE_ITEMS) {
|
||||
addAChild();
|
||||
} else {
|
||||
requestAnimationFrame(addAChild);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(addAChild);
|
||||
|
||||
} else {
|
||||
this.keys.forEach(key => {
|
||||
const formatter = new JsonExplorer(this.json[key], this.open - 1, this.config, key);
|
||||
children.appendChild(formatter.render());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all the children from children element
|
||||
* Animated option is used when user triggers this via a click
|
||||
*/
|
||||
removeChildren(animated = false) {
|
||||
const childrenElement = this.element.querySelector(`div.${cssClass('children')}`) as HTMLDivElement;
|
||||
|
||||
if (animated) {
|
||||
let childrenRemoved = 0;
|
||||
const removeAChild = ()=> {
|
||||
if (childrenElement && childrenElement.children.length) {
|
||||
childrenElement.removeChild(childrenElement.children[0]);
|
||||
childrenRemoved += 1;
|
||||
if (childrenRemoved > MAX_ANIMATED_TOGGLE_ITEMS) {
|
||||
removeAChild();
|
||||
} else {
|
||||
requestAnimationFrame(removeAChild);
|
||||
}
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(removeAChild);
|
||||
} else {
|
||||
if (childrenElement) {
|
||||
childrenElement.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,36 @@
|
||||
<i class="fa fa-chevron-left"></i>
|
||||
</a>
|
||||
|
||||
<a href="{{ctrl.titleUrl}}" class="navbar-page-btn" ng-show="ctrl.title">
|
||||
<i class="{{ctrl.icon}}" ng-show="ctrl.icon"></i>
|
||||
<img ng-src="{{ctrl.iconUrl}}" ng-show="ctrl.iconUrl"></i>
|
||||
{{ctrl.title}}
|
||||
</a>
|
||||
<!-- <a class="navbar-page-btn navbar-page-btn--search" ng-click="ctrl.showSearch()"> -->
|
||||
<!-- <i class="fa fa-search"></i> -->
|
||||
<!-- </a> -->
|
||||
|
||||
<div ng-transclude></div>
|
||||
<div ng-if="::!ctrl.hasMenu">
|
||||
<a href="{{::ctrl.section.url}}" class="navbar-page-btn">
|
||||
<i class="{{::ctrl.section.icon}}" ng-show="::ctrl.section.icon"></i>
|
||||
<img ng-src="{{::ctrl.section.iconUrl}}" ng-show="::ctrl.section.iconUrl"></i>
|
||||
{{::ctrl.section.title}}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="dropdown navbar-section-wrapper" ng-if="::ctrl.hasMenu">
|
||||
<a href="{{::ctrl.section.url}}" class="navbar-page-btn" data-toggle="dropdown">
|
||||
<i class="{{::ctrl.section.icon}}" ng-show="::ctrl.section.icon"></i>
|
||||
<img ng-src="{{::ctrl.section.iconUrl}}" ng-show="::ctrl.section.iconUrl"></i>
|
||||
{{::ctrl.section.title}}
|
||||
<i class="fa fa-caret-down"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu--navbar">
|
||||
<li ng-repeat="navItem in ::ctrl.model.menu" ng-class="{active: navItem.active}">
|
||||
<a class="pointer" ng-href="{{::navItem.url}}" ng-click="ctrl.navItemClicked(navItem, $event)">
|
||||
<i class="{{::navItem.icon}}" ng-show="::navItem.icon"></i>
|
||||
{{::navItem.title}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div ng-transclude></div>
|
||||
</div>
|
||||
|
||||
<dashboard-search></dashboard-search>
|
||||
|
||||
@@ -4,10 +4,28 @@ import config from 'app/core/config';
|
||||
import _ from 'lodash';
|
||||
import $ from 'jquery';
|
||||
import coreModule from '../../core_module';
|
||||
import {NavModel, NavModelItem} from '../../nav_model_srv';
|
||||
|
||||
export class NavbarCtrl {
|
||||
model: NavModel;
|
||||
section: NavModelItem;
|
||||
hasMenu: boolean;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private $scope, private contextSrv) {
|
||||
constructor(private $scope, private $rootScope, private contextSrv) {
|
||||
this.section = this.model.section;
|
||||
this.hasMenu = this.model.menu.length > 0;
|
||||
}
|
||||
|
||||
showSearch() {
|
||||
this.$rootScope.appEvent('show-dash-search');
|
||||
}
|
||||
|
||||
navItemClicked(navItem, evt) {
|
||||
if (navItem.clickHandler) {
|
||||
navItem.clickHandler();
|
||||
evt.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,12 +38,9 @@ export function navbarDirective() {
|
||||
transclude: true,
|
||||
controllerAs: 'ctrl',
|
||||
scope: {
|
||||
title: "@",
|
||||
titleUrl: "@",
|
||||
iconUrl: "@",
|
||||
model: "=",
|
||||
},
|
||||
link: function(scope, elem, attrs, ctrl) {
|
||||
ctrl.icon = attrs.icon;
|
||||
link: function(scope, elem) {
|
||||
elem.addClass('navbar');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
|
||||
<div class="search-backdrop" ng-if="ctrl.isOpen"></div>
|
||||
|
||||
<div class="search-container" ng-if="ctrl.isOpen">
|
||||
|
||||
<div class="search-field-wrapper">
|
||||
<span style="position: relative;">
|
||||
<input type="text" placeholder="Find dashboards by name" give-focus="ctrl.giveSearchFocus" tabindex="1"
|
||||
ng-keydown="ctrl.keyDown($event)" ng-model="ctrl.query.query" ng-model-options="{ debounce: 500 }" spellcheck='false' ng-change="ctrl.search()" />
|
||||
</span>
|
||||
<div class="search-field-icon pointer" ng-click="ctrl.closeSearch()">
|
||||
<i class="fa fa-search"></i>
|
||||
</div>
|
||||
|
||||
<input type="text" placeholder="Find dashboards by name" give-focus="ctrl.giveSearchFocus" tabindex="1"
|
||||
ng-keydown="ctrl.keyDown($event)"
|
||||
ng-model="ctrl.query.query"
|
||||
ng-model-options="{ debounce: 500 }"
|
||||
spellcheck='false'
|
||||
ng-change="ctrl.search()"
|
||||
ng-blur="ctrl.searchInputBlur()"
|
||||
/>
|
||||
|
||||
<div class="search-switches">
|
||||
<i class="fa fa-filter"></i>
|
||||
<a class="pointer" href="javascript:void 0;" ng-click="ctrl.showStarred()" tabindex="2">
|
||||
@@ -24,54 +37,55 @@
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="search-field-spacer"></div>
|
||||
</div>
|
||||
|
||||
<div class="search-results-container" ng-if="ctrl.tagsMode">
|
||||
<div ng-repeat="tag in ctrl.results" class="pointer" style="width: 180px; float: left;"
|
||||
ng-class="{'selected': $index === ctrl.selectedIndex }"
|
||||
ng-click="ctrl.filterByTag(tag.term, $event)">
|
||||
<a class="search-result-tag label label-tag" tag-color-from-name="tag.term">
|
||||
<i class="fa fa-tag"></i>
|
||||
<span>{{tag.term}} ({{tag.count}})</span>
|
||||
<div class="search-dropdown" ng-class="{'search-dropdown--fade-in': ctrl.openCompleted}">
|
||||
<div class="search-results-container" ng-if="ctrl.tagsMode">
|
||||
<div ng-repeat="tag in ctrl.results" class="pointer" style="width: 180px; float: left;"
|
||||
ng-class="{'selected': $index === ctrl.selectedIndex }"
|
||||
ng-click="ctrl.filterByTag(tag.term, $event)">
|
||||
<a class="search-result-tag label label-tag" tag-color-from-name="tag.term">
|
||||
<i class="fa fa-tag"></i>
|
||||
<span>{{tag.term}} ({{tag.count}})</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-results-container" ng-if="!ctrl.tagsMode">
|
||||
<h6 ng-hide="ctrl.results.length">No dashboards matching your query were found.</h6>
|
||||
|
||||
<a class="search-item pointer search-item-{{row.type}}" bindonce ng-repeat="row in ctrl.results"
|
||||
ng-class="{'selected': $index == ctrl.selectedIndex}" ng-href="{{row.url}}">
|
||||
|
||||
<span class="search-result-tags">
|
||||
<span ng-click="ctrl.filterByTag(tag, $event)" ng-repeat="tag in row.tags" tag-color-from-name="tag" class="label label-tag">
|
||||
{{tag}}
|
||||
</span>
|
||||
<i class="fa" ng-class="{'fa-star': row.isStarred, 'fa-star-o': !row.isStarred}"></i>
|
||||
</span>
|
||||
|
||||
<span class="search-result-link">
|
||||
<i class="fa search-result-icon"></i>
|
||||
<span bo-text="row.title"></span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="search-button-row">
|
||||
<a class="btn btn-secondary" href="dashboard/new" ng-show="ctrl.contextSrv.isEditor" ng-click="ctrl.isOpen = false;">
|
||||
<i class="fa fa-plus"></i> New Dashboard
|
||||
</a>
|
||||
|
||||
<a class="btn btn-inverse" href="dashboard/new/?editview=import" ng-show="ctrl.contextSrv.isEditor" ng-click="ctrl.isOpen = false;">
|
||||
<i class="fa fa-upload"></i> Import Dashboard
|
||||
</a>
|
||||
|
||||
<a class="search-button-row-explore-link" target="_blank" href="https://grafana.com/dashboards?utm_source=grafana_search">
|
||||
Find <img src="public/img/icn-dashboard-tiny.svg" width="14" /> dashboards on Grafana.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-results-container" ng-if="!ctrl.tagsMode">
|
||||
<h6 ng-hide="ctrl.results.length">No dashboards matching your query were found.</h6>
|
||||
|
||||
<a class="search-item pointer search-item-{{row.type}}" bindonce ng-repeat="row in ctrl.results"
|
||||
ng-class="{'selected': $index == ctrl.selectedIndex}" ng-href="{{row.url}}">
|
||||
|
||||
<span class="search-result-tags">
|
||||
<span ng-click="ctrl.filterByTag(tag, $event)" ng-repeat="tag in row.tags" tag-color-from-name="tag" class="label label-tag">
|
||||
{{tag}}
|
||||
</span>
|
||||
<i class="fa" ng-class="{'fa-star': row.isStarred, 'fa-star-o': !row.isStarred}"></i>
|
||||
</span>
|
||||
|
||||
<span class="search-result-link">
|
||||
<i class="fa search-result-icon"></i>
|
||||
<span bo-text="row.title"></span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="search-button-row">
|
||||
<a class="btn btn-inverse pull-left" href="dashboard/new" ng-show="ctrl.contextSrv.isEditor" ng-click="ctrl.isOpen = false;">
|
||||
<i class="fa fa-plus"></i>
|
||||
Create New
|
||||
</a>
|
||||
|
||||
<a class="btn btn-inverse pull-left" href="dashboard/new/?editview=import" ng-show="ctrl.contextSrv.isEditor" ng-click="ctrl.isOpen = false;">
|
||||
<i class="fa fa-upload"></i>
|
||||
Import
|
||||
</a>
|
||||
|
||||
<a class="pull-right search-button-row-explore-link" target="_blank" href="https://grafana.com/dashboards?utm_source=grafana_search">
|
||||
Find <img src="public/img/icn-dashboard-tiny.svg" width="14" /> dashboards on Grafana.com
|
||||
</a>
|
||||
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export class SearchCtrl {
|
||||
showImport: boolean;
|
||||
dismiss: any;
|
||||
ignoreClose: any;
|
||||
// triggers fade animation class
|
||||
openCompleted: boolean;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private $scope, private $location, private $timeout, private backendSrv, private contextSrv, private $rootScope) {
|
||||
@@ -27,6 +29,7 @@ export class SearchCtrl {
|
||||
|
||||
closeSearch() {
|
||||
this.isOpen = this.ignoreClose;
|
||||
this.openCompleted = false;
|
||||
}
|
||||
|
||||
openSearch(evt, payload) {
|
||||
@@ -56,6 +59,7 @@ export class SearchCtrl {
|
||||
}
|
||||
|
||||
this.$timeout(() => {
|
||||
this.openCompleted = true;
|
||||
this.ignoreClose = false;
|
||||
this.giveSearchFocus = this.giveSearchFocus + 1;
|
||||
this.search();
|
||||
@@ -73,7 +77,7 @@ export class SearchCtrl {
|
||||
this.moveSelection(-1);
|
||||
}
|
||||
if (evt.keyCode === 13) {
|
||||
if (this.$scope.tagMode) {
|
||||
if (this.tagsMode) {
|
||||
var tag = this.results[this.selectedIndex];
|
||||
if (tag) {
|
||||
this.filterByTag(tag.term, null);
|
||||
@@ -169,6 +173,7 @@ export function searchDirective() {
|
||||
controller: SearchCtrl,
|
||||
bindToController: true,
|
||||
controllerAs: 'ctrl',
|
||||
scope: {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import coreModule from 'app/core/core_module';
|
||||
import Drop from 'tether-drop';
|
||||
|
||||
var template = `
|
||||
<label for="check-{{ctrl.id}}" class="gf-form-label {{ctrl.labelClass}} pointer">
|
||||
<label for="check-{{ctrl.id}}" class="gf-form-label {{ctrl.labelClass}} pointer" ng-show="ctrl.label">
|
||||
{{ctrl.label}}
|
||||
<info-popover mode="right-normal" ng-if="ctrl.tooltip" position="top center">
|
||||
{{ctrl.tooltip}}
|
||||
@@ -24,6 +24,7 @@ export class SwitchCtrl {
|
||||
checked: any;
|
||||
show: any;
|
||||
id: any;
|
||||
label: string;
|
||||
|
||||
/** @ngInject */
|
||||
constructor($scope, private $timeout) {
|
||||
|
||||
@@ -5,7 +5,9 @@ define([
|
||||
function (angular, coreModule) {
|
||||
'use strict';
|
||||
|
||||
coreModule.default.controller('ErrorCtrl', function($scope, contextSrv) {
|
||||
coreModule.default.controller('ErrorCtrl', function($scope, contextSrv, navModelSrv) {
|
||||
|
||||
$scope.navModel = navModelSrv.getNotFoundNav();
|
||||
|
||||
var showSideMenu = contextSrv.sidemenu;
|
||||
contextSrv.sidemenu = false;
|
||||
|
||||
@@ -15,6 +15,7 @@ import "./directives/value_select_dropdown";
|
||||
import "./directives/plugin_component";
|
||||
import "./directives/rebuild_on_change";
|
||||
import "./directives/give_focus";
|
||||
import "./directives/diff-view";
|
||||
import './jquery_extended';
|
||||
import './partials';
|
||||
import './components/jsontree/jsontree';
|
||||
@@ -33,6 +34,7 @@ import {switchDirective} from './components/switch';
|
||||
import {dashboardSelector} from './components/dashboard_selector';
|
||||
import {queryPartEditorDirective} from './components/query_part/query_part_editor';
|
||||
import {WizardFlow} from './components/wizard/wizard';
|
||||
import {formDropdownDirective} from './components/form_dropdown/form_dropdown';
|
||||
import 'app/core/controllers/all';
|
||||
import 'app/core/services/all';
|
||||
import 'app/core/routes/routes';
|
||||
@@ -44,6 +46,9 @@ import {assignModelProperties} from './utils/model_utils';
|
||||
import {contextSrv} from './services/context_srv';
|
||||
import {KeybindingSrv} from './services/keybindingSrv';
|
||||
import {helpModal} from './components/help/help';
|
||||
import {collapseBox} from './components/collapse_box';
|
||||
import {JsonExplorer} from './components/json_explorer/json_explorer';
|
||||
import {NavModelSrv, NavModel} from './nav_model_srv';
|
||||
|
||||
|
||||
export {
|
||||
@@ -64,8 +69,13 @@ export {
|
||||
queryPartEditorDirective,
|
||||
WizardFlow,
|
||||
colors,
|
||||
formDropdownDirective,
|
||||
assignModelProperties,
|
||||
contextSrv,
|
||||
KeybindingSrv,
|
||||
helpModal,
|
||||
collapseBox,
|
||||
JsonExplorer,
|
||||
NavModelSrv,
|
||||
NavModel,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
define([
|
||||
'jquery',
|
||||
'angular',
|
||||
'../core_module',
|
||||
],
|
||||
function ($, coreModule) {
|
||||
function ($, angular, coreModule) {
|
||||
'use strict';
|
||||
|
||||
var editViewMap = {
|
||||
'settings': { src: 'public/app/features/dashboard/partials/settings.html'},
|
||||
'annotations': { src: 'public/app/features/annotations/partials/editor.html'},
|
||||
'templating': { src: 'public/app/features/templating/partials/editor.html'},
|
||||
'import': { src: '<dash-import></dash-import>' }
|
||||
'history': { html: '<gf-dashboard-history dashboard="dashboard"></gf-dashboard-history>'},
|
||||
'timepicker': { src: 'public/app/features/dashboard/timepicker/dropdown.html' },
|
||||
'import': { html: '<dash-import></dash-import>' }
|
||||
};
|
||||
|
||||
coreModule.default.directive('dashEditorView', function($compile, $location, $rootScope) {
|
||||
@@ -17,47 +20,53 @@ function ($, coreModule) {
|
||||
restrict: 'A',
|
||||
link: function(scope, elem) {
|
||||
var editorScope;
|
||||
var lastEditor;
|
||||
var lastEditView;
|
||||
|
||||
function hideEditorPane() {
|
||||
function hideEditorPane(hideToShowOtherView) {
|
||||
if (editorScope) {
|
||||
scope.appEvent('dash-editor-hidden', lastEditor);
|
||||
editorScope.dismiss();
|
||||
editorScope.dismiss(hideToShowOtherView);
|
||||
scope.appEvent('dash-editor-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function showEditorPane(evt, payload, editview) {
|
||||
if (editview) {
|
||||
scope.contextSrv.editview = editViewMap[editview];
|
||||
payload.src = scope.contextSrv.editview.src;
|
||||
function showEditorPane(evt, options) {
|
||||
if (options.editview) {
|
||||
options.src = editViewMap[options.editview].src;
|
||||
options.html = editViewMap[options.editview].html;
|
||||
}
|
||||
|
||||
if (lastEditor === payload.src) {
|
||||
hideEditorPane();
|
||||
if (lastEditView && lastEditView === options.editview) {
|
||||
hideEditorPane(false);
|
||||
return;
|
||||
}
|
||||
|
||||
hideEditorPane();
|
||||
hideEditorPane(true);
|
||||
|
||||
lastEditor = payload.src;
|
||||
editorScope = payload.scope ? payload.scope.$new() : scope.$new();
|
||||
lastEditView = options.editview;
|
||||
editorScope = options.scope ? options.scope.$new() : scope.$new();
|
||||
|
||||
editorScope.dismiss = function() {
|
||||
editorScope.dismiss = function(hideToShowOtherView) {
|
||||
editorScope.$destroy();
|
||||
elem.empty();
|
||||
lastEditor = null;
|
||||
lastEditView = null;
|
||||
editorScope = null;
|
||||
elem.removeClass('dash-edit-view--open');
|
||||
|
||||
if (editview) {
|
||||
if (!hideToShowOtherView) {
|
||||
setTimeout(function() {
|
||||
elem.empty();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
if (options.editview) {
|
||||
var urlParams = $location.search();
|
||||
if (editview === urlParams.editview) {
|
||||
if (options.editview === urlParams.editview) {
|
||||
delete urlParams.editview;
|
||||
$location.search(urlParams);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (editview === 'import') {
|
||||
if (options.editview === 'import') {
|
||||
var modalScope = $rootScope.$new();
|
||||
modalScope.$on("$destroy", function() {
|
||||
editorScope.dismiss();
|
||||
@@ -72,31 +81,50 @@ function ($, coreModule) {
|
||||
return;
|
||||
}
|
||||
|
||||
var view = payload.src;
|
||||
if (view.indexOf('.html') > 0) {
|
||||
view = $('<div class="tabbed-view" ng-include="' + "'" + view + "'" + '"></div>');
|
||||
var view;
|
||||
if (options.src) {
|
||||
view = angular.element(document.createElement('div'));
|
||||
view.html('<div class="tabbed-view" ng-include="' + "'" + options.src + "'" + '"></div>');
|
||||
} else {
|
||||
view = angular.element(document.createElement('div'));
|
||||
view.addClass('tabbed-view');
|
||||
view.html(options.html);
|
||||
}
|
||||
|
||||
elem.append(view);
|
||||
$compile(elem.contents())(editorScope);
|
||||
$compile(view)(editorScope);
|
||||
|
||||
setTimeout(function() {
|
||||
elem.empty();
|
||||
elem.append(view);
|
||||
setTimeout(function() {
|
||||
elem.addClass('dash-edit-view--open');
|
||||
}, 10);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
scope.$watch("dashboardViewState.state.editview", function(newValue, oldValue) {
|
||||
if (newValue) {
|
||||
showEditorPane(null, {}, newValue);
|
||||
showEditorPane(null, {editview: newValue});
|
||||
} else if (oldValue) {
|
||||
scope.contextSrv.editview = null;
|
||||
if (lastEditor === editViewMap[oldValue]) {
|
||||
if (lastEditView === oldValue) {
|
||||
hideEditorPane();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
scope.contextSrv.editview = null;
|
||||
scope.$on("$destroy", hideEditorPane);
|
||||
scope.onAppEvent('hide-dash-editor', hideEditorPane);
|
||||
|
||||
scope.onAppEvent('hide-dash-editor', function() {
|
||||
hideEditorPane(false);
|
||||
});
|
||||
|
||||
scope.onAppEvent('show-dash-editor', showEditorPane);
|
||||
|
||||
scope.onAppEvent('panel-fullscreen-enter', function() {
|
||||
scope.appEvent('hide-dash-editor');
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
77
public/app/core/directives/diff-view.ts
Normal file
77
public/app/core/directives/diff-view.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
///<reference path="../../headers/common.d.ts" />
|
||||
|
||||
import angular from 'angular';
|
||||
import coreModule from '../core_module';
|
||||
|
||||
export class DeltaCtrl {
|
||||
observer: any;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private $rootScope) {
|
||||
const waitForCompile = function(mutations) {
|
||||
if (mutations.length === 1) {
|
||||
this.$rootScope.appEvent('json-diff-ready');
|
||||
}
|
||||
};
|
||||
|
||||
this.observer = new MutationObserver(waitForCompile.bind(this));
|
||||
|
||||
const observerConfig = {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
characterData: false,
|
||||
childList: true,
|
||||
subtree: false,
|
||||
};
|
||||
|
||||
this.observer.observe(angular.element('.delta-html')[0], observerConfig);
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export function delta() {
|
||||
return {
|
||||
controller: DeltaCtrl,
|
||||
replace: false,
|
||||
restrict: 'A',
|
||||
};
|
||||
}
|
||||
coreModule.directive('diffDelta', delta);
|
||||
|
||||
// Link to JSON line number
|
||||
export class LinkJSONCtrl {
|
||||
/** @ngInject */
|
||||
constructor(private $scope, private $rootScope, private $anchorScroll) {}
|
||||
|
||||
goToLine(line: number) {
|
||||
let unbind;
|
||||
|
||||
const scroll = () => {
|
||||
this.$anchorScroll(`l${line}`);
|
||||
unbind();
|
||||
};
|
||||
|
||||
this.$scope.switchView().then(() => {
|
||||
unbind = this.$rootScope.$on('json-diff-ready', scroll.bind(this));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function linkJson() {
|
||||
return {
|
||||
controller: LinkJSONCtrl,
|
||||
controllerAs: 'ctrl',
|
||||
replace: true,
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
line: '@lineDisplay',
|
||||
link: '@lineLink',
|
||||
switchView: '&',
|
||||
},
|
||||
template: `<a class="diff-linenum btn btn-inverse btn-small" ng-click="ctrl.goToLine(link)">Line {{ line }}</a>`
|
||||
};
|
||||
}
|
||||
coreModule.directive('diffLinkJson', linkJson);
|
||||
@@ -1,9 +1,10 @@
|
||||
define([
|
||||
'angular',
|
||||
'require',
|
||||
'../core_module',
|
||||
'app/core/utils/kbn',
|
||||
],
|
||||
function (angular, coreModule, kbn) {
|
||||
function (angular, require, coreModule, kbn) {
|
||||
'use strict';
|
||||
|
||||
coreModule.default.directive('tip', function($compile) {
|
||||
@@ -18,6 +19,43 @@ function (angular, coreModule, kbn) {
|
||||
};
|
||||
});
|
||||
|
||||
coreModule.default.directive('clipboardButton', function() {
|
||||
return {
|
||||
scope: {
|
||||
getText: '&clipboardButton'
|
||||
},
|
||||
link: function(scope, elem) {
|
||||
require(['vendor/clipboard/dist/clipboard'], function(Clipboard) {
|
||||
scope.clipboard = new Clipboard(elem[0], {
|
||||
text: function() {
|
||||
return scope.getText();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
scope.$on('$destroy', function() {
|
||||
if (scope.clipboard) {
|
||||
scope.clipboard.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
coreModule.default.directive('compile', function($compile) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function(scope, element, attrs) {
|
||||
scope.$watch(function(scope) {
|
||||
return scope.$eval(attrs.compile);
|
||||
}, function(value) {
|
||||
element.html(value);
|
||||
$compile(element.contents())(scope);
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
coreModule.default.directive('watchChange', function() {
|
||||
return {
|
||||
scope: { onchange: '&watchChange' },
|
||||
@@ -63,10 +101,10 @@ function (angular, coreModule, kbn) {
|
||||
text + tip + '</label>';
|
||||
|
||||
var template =
|
||||
'<input class="cr1" id="' + scope.$id + model + '" type="checkbox" ' +
|
||||
' ng-model="' + model + '"' + ngchange +
|
||||
' ng-checked="' + model + '"></input>' +
|
||||
' <label for="' + scope.$id + model + '" class="cr1"></label>';
|
||||
'<input class="cr1" id="' + scope.$id + model + '" type="checkbox" ' +
|
||||
' ng-model="' + model + '"' + ngchange +
|
||||
' ng-checked="' + model + '"></input>' +
|
||||
' <label for="' + scope.$id + model + '" class="cr1"></label>';
|
||||
|
||||
template = template + label;
|
||||
elem.addClass('gf-form-checkbox');
|
||||
@@ -91,7 +129,7 @@ function (angular, coreModule, kbn) {
|
||||
var li = '<li' + (item.submenu && item.submenu.length ? ' class="dropdown-submenu"' : '') + '>' +
|
||||
'<a tabindex="-1" ng-href="' + (item.href || '') + '"' + (item.click ? ' ng-click="' + item.click + '"' : '') +
|
||||
(item.target ? ' target="' + item.target + '"' : '') + (item.method ? ' data-method="' + item.method + '"' : '') +
|
||||
'>' + (item.text || '') + '</a>';
|
||||
'>' + (item.text || '') + '</a>';
|
||||
|
||||
if (item.submenu && item.submenu.length) {
|
||||
li += buildTemplate(item.submenu).join('\n');
|
||||
|
||||
@@ -109,7 +109,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
|
||||
baseUrl: ds.meta.baseUrl,
|
||||
name: 'query-ctrl-' + ds.meta.id,
|
||||
bindings: {target: "=", panelCtrl: "=", datasource: "="},
|
||||
attrs: {"target": "target", "panel-ctrl": "ctrl", datasource: "datasource"},
|
||||
attrs: {"target": "target", "panel-ctrl": "ctrl.panelCtrl", datasource: "datasource"},
|
||||
Component: dsModule.QueryCtrl
|
||||
};
|
||||
});
|
||||
@@ -127,7 +127,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
|
||||
baseUrl: ds.meta.baseUrl,
|
||||
name: 'query-options-ctrl-' + ds.meta.id,
|
||||
bindings: {panelCtrl: "="},
|
||||
attrs: {"panel-ctrl": "ctrl"},
|
||||
attrs: {"panel-ctrl": "ctrl.panelCtrl"},
|
||||
Component: dsModule.QueryOptionsCtrl
|
||||
};
|
||||
});
|
||||
@@ -181,7 +181,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
|
||||
return System.import(appModel.module).then(function(appModule) {
|
||||
return {
|
||||
baseUrl: appModel.baseUrl,
|
||||
name: 'app-page-' + appModel.appId + '-' + scope.ctrl.page.slug,
|
||||
name: 'app-page-' + appModel.id + '-' + scope.ctrl.page.slug,
|
||||
bindings: {appModel: "="},
|
||||
attrs: {"app-model": "ctrl.appModel"},
|
||||
Component: appModule[scope.ctrl.page.component],
|
||||
|
||||
226
public/app/core/nav_model_srv.ts
Normal file
226
public/app/core/nav_model_srv.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
///<reference path="../headers/common.d.ts" />
|
||||
|
||||
import coreModule from 'app/core/core_module';
|
||||
|
||||
export interface NavModelItem {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: string;
|
||||
iconUrl?: string;
|
||||
}
|
||||
|
||||
export interface NavModel {
|
||||
section: NavModelItem;
|
||||
menu: NavModelItem[];
|
||||
}
|
||||
|
||||
export class NavModelSrv {
|
||||
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private contextSrv) {
|
||||
}
|
||||
|
||||
getAlertingNav(subPage) {
|
||||
return {
|
||||
section: {
|
||||
title: 'Alerting',
|
||||
url: 'plugins',
|
||||
icon: 'icon-gf icon-gf-alert'
|
||||
},
|
||||
menu: [
|
||||
{title: 'Alert List', active: subPage === 0, url: 'alerting/list', icon: 'fa fa-list-ul'},
|
||||
{title: 'Notification channels', active: subPage === 1, url: 'alerting/notifications', icon: 'fa fa-bell-o'},
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
getDatasourceNav(subPage) {
|
||||
return {
|
||||
section: {
|
||||
title: 'Data Sources',
|
||||
url: 'datasources',
|
||||
icon: 'icon-gf icon-gf-datasources'
|
||||
},
|
||||
menu: [
|
||||
{title: 'List view', active: subPage === 0, url: 'datasources', icon: 'fa fa-list-ul'},
|
||||
{title: 'Add data source', active: subPage === 1, url: 'datasources/new', icon: 'fa fa-plus'},
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
getPlaylistsNav(subPage) {
|
||||
return {
|
||||
section: {
|
||||
title: 'Playlists',
|
||||
url: 'playlists',
|
||||
icon: 'fa fa-fw fa-film'
|
||||
},
|
||||
menu: [
|
||||
{title: 'List view', active: subPage === 0, url: 'playlists', icon: 'fa fa-list-ul'},
|
||||
{title: 'Add Playlist', active: subPage === 1, url: 'playlists/create', icon: 'fa fa-plus'},
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
getProfileNav() {
|
||||
return {
|
||||
section: {
|
||||
title: 'User Profile',
|
||||
url: 'profile',
|
||||
icon: 'fa fa-fw fa-user'
|
||||
},
|
||||
menu: []
|
||||
};
|
||||
}
|
||||
|
||||
getNotFoundNav() {
|
||||
return {
|
||||
section: {
|
||||
title: 'Page',
|
||||
url: '',
|
||||
icon: 'fa fa-fw fa-warning'
|
||||
},
|
||||
menu: []
|
||||
};
|
||||
}
|
||||
|
||||
getOrgNav(subPage) {
|
||||
return {
|
||||
section: {
|
||||
title: 'Organization',
|
||||
url: 'org',
|
||||
icon: 'icon-gf icon-gf-users'
|
||||
},
|
||||
menu: [
|
||||
{title: 'Preferences', active: subPage === 0, url: 'org', icon: 'fa fa-fw fa-cog'},
|
||||
{title: 'Org Users', active: subPage === 1, url: 'org/users', icon: 'fa fa-fw fa-users'},
|
||||
{title: 'API Keys', active: subPage === 2, url: 'org/apikeys', icon: 'fa fa-fw fa-key'},
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
getAdminNav(subPage) {
|
||||
return {
|
||||
section: {
|
||||
title: 'Admin',
|
||||
url: 'admin',
|
||||
icon: 'fa fa-fw fa-cogs'
|
||||
},
|
||||
menu: [
|
||||
{title: 'Users', active: subPage === 0, url: 'admin/users', icon: 'fa fa-fw fa-user'},
|
||||
{title: 'Orgs', active: subPage === 1, url: 'admin/orgs', icon: 'fa fa-fw fa-users'},
|
||||
{title: 'Server Settings', active: subPage === 2, url: 'admin/settings', icon: 'fa fa-fw fa-cogs'},
|
||||
{title: 'Server Stats', active: subPage === 2, url: 'admin/stats', icon: 'fa fa-fw fa-line-chart'},
|
||||
{title: 'Style Guide', active: subPage === 2, url: 'styleguide', icon: 'fa fa-fw fa-key'},
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
getPluginsNav() {
|
||||
return {
|
||||
section: {
|
||||
title: 'Plugins',
|
||||
url: 'plugins',
|
||||
icon: 'icon-gf icon-gf-apps'
|
||||
},
|
||||
menu: []
|
||||
};
|
||||
}
|
||||
|
||||
getDashboardNav(dashboard, dashNavCtrl) {
|
||||
// special handling for snapshots
|
||||
if (dashboard.meta.isSnapshot) {
|
||||
return {
|
||||
section: {
|
||||
title: dashboard.title,
|
||||
icon: 'icon-gf icon-gf-snapshot'
|
||||
},
|
||||
menu: [
|
||||
{
|
||||
title: 'Go to original dashboard',
|
||||
icon: 'fa fa-fw fa-external-link',
|
||||
url: dashboard.snapshot.originalUrl,
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
var menu = [];
|
||||
|
||||
if (dashboard.meta.canEdit) {
|
||||
menu.push({
|
||||
title: 'Settings',
|
||||
icon: 'fa fa-fw fa-cog',
|
||||
clickHandler: () => dashNavCtrl.openEditView('settings')
|
||||
});
|
||||
|
||||
menu.push({
|
||||
title: 'Templating',
|
||||
icon: 'fa fa-fw fa-code',
|
||||
clickHandler: () => dashNavCtrl.openEditView('templating')
|
||||
});
|
||||
|
||||
menu.push({
|
||||
title: 'Annotations',
|
||||
icon: 'fa fa-fw fa-bolt',
|
||||
clickHandler: () => dashNavCtrl.openEditView('annotations')
|
||||
});
|
||||
|
||||
if (!dashboard.meta.isHome) {
|
||||
menu.push({
|
||||
title: 'Version history',
|
||||
icon: 'fa fa-fw fa-history',
|
||||
clickHandler: () => dashNavCtrl.openEditView('history')
|
||||
});
|
||||
}
|
||||
|
||||
menu.push({
|
||||
title: 'View JSON',
|
||||
icon: 'fa fa-fw fa-eye',
|
||||
clickHandler: () => dashNavCtrl.viewJson()
|
||||
});
|
||||
}
|
||||
|
||||
if (this.contextSrv.isEditor && !dashboard.editable) {
|
||||
menu.push({
|
||||
title: 'Make Editable',
|
||||
icon: 'fa fa-fw fa-edit',
|
||||
clickHandler: () => dashNavCtrl.makeEditable()
|
||||
});
|
||||
}
|
||||
|
||||
menu.push({
|
||||
title: 'Shortcuts',
|
||||
icon: 'fa fa-fw fa-keyboard-o',
|
||||
clickHandler: () => dashNavCtrl.showHelpModal()
|
||||
});
|
||||
|
||||
if (this.contextSrv.isEditor) {
|
||||
menu.push({
|
||||
title: 'Save As ...',
|
||||
icon: 'fa fa-fw fa-save',
|
||||
clickHandler: () => dashNavCtrl.saveDashboardAs()
|
||||
});
|
||||
}
|
||||
|
||||
if (dashboard.meta.canSave) {
|
||||
menu.push({
|
||||
title: 'Delete',
|
||||
icon: 'fa fa-fw fa-trash',
|
||||
clickHandler: () => dashNavCtrl.deleteDashboard()
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
return {
|
||||
section: {
|
||||
title: dashboard.title,
|
||||
icon: 'icon-gf icon-gf-dashboard'
|
||||
},
|
||||
menu: menu
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
coreModule.service('navModelSrv', NavModelSrv);
|
||||
@@ -103,11 +103,13 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||
.when('/admin', {
|
||||
templateUrl: 'public/app/features/admin/partials/admin_home.html',
|
||||
controller : 'AdminHomeCtrl',
|
||||
controllerAs: 'ctrl',
|
||||
resolve: loadAdminBundle,
|
||||
})
|
||||
.when('/admin/settings', {
|
||||
templateUrl: 'public/app/features/admin/partials/settings.html',
|
||||
controller : 'AdminSettingsCtrl',
|
||||
controllerAs: 'ctrl',
|
||||
resolve: loadAdminBundle,
|
||||
})
|
||||
.when('/admin/users', {
|
||||
@@ -129,11 +131,13 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||
.when('/admin/orgs', {
|
||||
templateUrl: 'public/app/features/admin/partials/orgs.html',
|
||||
controller : 'AdminListOrgsCtrl',
|
||||
controllerAs: 'ctrl',
|
||||
resolve: loadAdminBundle,
|
||||
})
|
||||
.when('/admin/orgs/edit/:id', {
|
||||
templateUrl: 'public/app/features/admin/partials/edit_org.html',
|
||||
controller : 'AdminEditOrgCtrl',
|
||||
controllerAs: 'ctrl',
|
||||
resolve: loadAdminBundle,
|
||||
})
|
||||
.when('/admin/stats', {
|
||||
|
||||
@@ -4,6 +4,7 @@ import angular from 'angular';
|
||||
import _ from 'lodash';
|
||||
import config from 'app/core/config';
|
||||
import coreModule from 'app/core/core_module';
|
||||
import appEvents from 'app/core/app_events';
|
||||
|
||||
export class BackendSrv {
|
||||
inFlightRequests = {};
|
||||
@@ -150,7 +151,10 @@ export class BackendSrv {
|
||||
}
|
||||
}
|
||||
|
||||
return this.$http(options).catch(err => {
|
||||
return this.$http(options).then(response => {
|
||||
appEvents.emit('ds-request-response', response);
|
||||
return response;
|
||||
}).catch(err => {
|
||||
if (err.status === this.HTTP_REQUEST_CANCELLED) {
|
||||
throw {err, cancelled: true};
|
||||
}
|
||||
@@ -166,7 +170,7 @@ export class BackendSrv {
|
||||
});
|
||||
}
|
||||
|
||||
//populate error obj on Internal Error
|
||||
// populate error obj on Internal Error
|
||||
if (_.isString(err.data) && err.status === 500) {
|
||||
err.data = {
|
||||
error: err.statusText,
|
||||
@@ -175,11 +179,13 @@ export class BackendSrv {
|
||||
}
|
||||
|
||||
// for Prometheus
|
||||
if (!err.data.message && _.isString(err.data.error)) {
|
||||
if (err.data && !err.data.message && _.isString(err.data.error)) {
|
||||
err.data.message = err.data.error;
|
||||
}
|
||||
|
||||
appEvents.emit('ds-request-error', err);
|
||||
throw err;
|
||||
|
||||
}).finally(() => {
|
||||
// clean up
|
||||
if (options.requestId) {
|
||||
@@ -202,7 +208,12 @@ export class BackendSrv {
|
||||
|
||||
saveDashboard(dash, options) {
|
||||
options = (options || {});
|
||||
return this.post('/api/dashboards/db/', {dashboard: dash, overwrite: options.overwrite === true});
|
||||
|
||||
return this.post('/api/dashboards/db/', {
|
||||
dashboard: dash,
|
||||
overwrite: options.overwrite === true,
|
||||
message: options.message || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,10 +113,6 @@ export class KeybindingSrv {
|
||||
scope.appEvent('shift-time-forward');
|
||||
});
|
||||
|
||||
this.bind('mod+i', () => {
|
||||
scope.appEvent('quick-snapshot');
|
||||
});
|
||||
|
||||
// edit panel
|
||||
this.bind('e', () => {
|
||||
if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {
|
||||
@@ -225,7 +221,7 @@ export class KeybindingSrv {
|
||||
}
|
||||
|
||||
scope.appEvent('hide-dash-editor');
|
||||
scope.exitFullscreen();
|
||||
scope.appEvent('panel-change-view', {fullscreen: false, edit: false});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,9 +87,11 @@ export default class TimeSeries {
|
||||
if (override.fill !== void 0) { this.lines.fill = translateFillOption(override.fill); }
|
||||
if (override.stack !== void 0) { this.stack = override.stack; }
|
||||
if (override.linewidth !== void 0) {
|
||||
this.lines.lineWidth = override.linewidth;
|
||||
this.lines.lineWidth = this.dashes.show ? 0: override.linewidth;
|
||||
this.dashes.lineWidth = override.linewidth;
|
||||
}
|
||||
if (override.dashLength !== void 0) { this.dashes.dashLength[0] = override.dashLength; }
|
||||
if (override.spaceLength !== void 0) { this.dashes.dashLength[1] = override.spaceLength; }
|
||||
if (override.nullPointMode !== void 0) { this.nullPointMode = override.nullPointMode; }
|
||||
if (override.pointradius !== void 0) { this.points.radius = override.pointradius; }
|
||||
if (override.steppedLine !== void 0) { this.lines.steps = override.steppedLine; }
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
///<reference path="../../headers/common.d.ts" />
|
||||
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
|
||||
declare var window: any;
|
||||
|
||||
export function exportSeriesListToCsv(seriesList) {
|
||||
var text = 'sep=;\nSeries;Time;Value\n';
|
||||
const DEFAULT_DATETIME_FORMAT: String = 'YYYY-MM-DDTHH:mm:ssZ';
|
||||
|
||||
export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT) {
|
||||
var text = 'Series;Time;Value\n';
|
||||
_.each(seriesList, function(series) {
|
||||
_.each(series.datapoints, function(dp) {
|
||||
text += series.alias + ';' + new Date(dp[1]).toISOString() + ';' + dp[0] + '\n';
|
||||
text += series.alias + ';' + moment(dp[1]).format(dateTimeFormat) + ';' + dp[0] + '\n';
|
||||
});
|
||||
});
|
||||
saveSaveBlob(text, 'grafana_data_export.csv');
|
||||
}
|
||||
|
||||
export function exportSeriesListToCsvColumns(seriesList) {
|
||||
var text = 'sep=;\nTime;';
|
||||
export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT) {
|
||||
var text = 'Time;';
|
||||
// add header
|
||||
_.each(seriesList, function(series) {
|
||||
text += series.alias + ';';
|
||||
@@ -30,7 +33,7 @@ export function exportSeriesListToCsvColumns(seriesList) {
|
||||
var cIndex = 0;
|
||||
dataArr.push([]);
|
||||
_.each(series.datapoints, function(dp) {
|
||||
dataArr[0][cIndex] = new Date(dp[1]).toISOString();
|
||||
dataArr[0][cIndex] = moment(dp[1]).format(dateTimeFormat);
|
||||
dataArr[sIndex][cIndex] = dp[0];
|
||||
cIndex++;
|
||||
});
|
||||
@@ -50,7 +53,7 @@ export function exportSeriesListToCsvColumns(seriesList) {
|
||||
}
|
||||
|
||||
export function exportTableDataToCsv(table) {
|
||||
var text = 'sep=;\n';
|
||||
var text = '';
|
||||
// add header
|
||||
_.each(table.columns, function(column) {
|
||||
text += (column.title || column.text) + ';';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user