mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge remote-tracking branch 'upstream/master' into postgres-query-builder
This commit is contained in:
commit
1a051ce320
20
CHANGELOG.md
20
CHANGELOG.md
@ -1,3 +1,23 @@
|
||||
# 5.0.0-stable (2018-03-01)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **oauth** Fix Github OAuth not working with private Organizations [#11028](https://github.com/grafana/grafana/pull/11028) [@lostick](https://github.com/lostick)
|
||||
- **kiosk** white area over bottom panels in kiosk mode [#11010](https://github.com/grafana/grafana/issues/11010)
|
||||
- **alerting** Fix OK state doesn't show up in Microsoft Teams [#11032](https://github.com/grafana/grafana/pull/11032), thx [@manacker](https://github.com/manacker)
|
||||
|
||||
# 5.0.0-beta5 (2018-02-26)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Orgs** Unable to switch org when too many orgs listed [#10774](https://github.com/grafana/grafana/issues/10774)
|
||||
- **Folders** Make it easier/explicit to access/modify folders using the API [#10630](https://github.com/grafana/grafana/issues/10630)
|
||||
- **Dashboard** Scrollbar works incorrectly in Grafana 5.0 Beta4 in some cases [#10982](https://github.com/grafana/grafana/issues/10982)
|
||||
- **ElasticSearch** Custom aggregation sizes no longer allowed for Elasticsearch [#10124](https://github.com/grafana/grafana/issues/10124)
|
||||
- **oauth** Github OAuth with allowed organizations fails to login [#10964](https://github.com/grafana/grafana/issues/10964)
|
||||
- **heatmap** Heatmap panel has partially hidden legend [#10793](https://github.com/grafana/grafana/issues/10793)
|
||||
- **snapshots** Expired snapshots not being cleaned up [#10996](https://github.com/grafana/grafana/pull/10996)
|
||||
|
||||
# 5.0.0-beta4 (2018-02-19)
|
||||
|
||||
### Fixes
|
||||
|
@ -248,7 +248,7 @@ enabled = false
|
||||
allow_sign_up = true
|
||||
client_id = some_id
|
||||
client_secret = some_secret
|
||||
scopes = user:email
|
||||
scopes = user:email,read:org
|
||||
auth_url = https://github.com/login/oauth/authorize
|
||||
token_url = https://github.com/login/oauth/access_token
|
||||
api_url = https://api.github.com/user
|
||||
|
@ -1 +1 @@
|
||||
v4.3
|
||||
v5.0
|
||||
|
@ -65,13 +65,46 @@ Permission levels:
|
||||
|
||||
- **Admin**: Can edit & create dashboards and edit permissions.
|
||||
- **Edit**: Can edit & create dashboards. **Cannot** edit folder/dashboard permissions.
|
||||
- **View**: Can only view existing dashboards/folders.
|
||||
|
||||
#### Restricting Access
|
||||
|
||||
The highest permission always wins so if you for example want to hide a folder or dashboard from others you need to remove the **Organization Role** based permission from the Access Control List (ACL).
|
||||
|
||||
- You cannot override permissions for users with the **Org Admin Role**. Admins always have access to everything.
|
||||
- A more specific permission with a lower permission level will not have any effect if a more general rule exists with higher permission level. You need to remove or lower the permission level of the more general rule.
|
||||
|
||||
#### How Grafana Resolves Multiple Permissions - Examples
|
||||
|
||||
##### Example 1 (`user1` has the Editor Role)
|
||||
|
||||
Permissions for a dashboard:
|
||||
|
||||
- `Everyone with Editor Role Can Edit`
|
||||
- `user1 Can View`
|
||||
|
||||
Result: `user1` has Edit permission as the highest permission always wins.
|
||||
|
||||
##### Example 2 (`user1` has the Viewer Role and is a member of `team1`)
|
||||
|
||||
Permissions for a dashboard:
|
||||
|
||||
- `Everyone with Viewer Role Can View`
|
||||
- `user1 Can Edit`
|
||||
- `team1 Can Admin`
|
||||
|
||||
Result: `user1` has Admin permission as the highest permission always wins.
|
||||
|
||||
##### Example 3
|
||||
|
||||
Permissions for a dashboard:
|
||||
|
||||
- `user1 Can Admin (inherited from parent folder)`
|
||||
- `user1 Can Edit`
|
||||
|
||||
Result: You cannot override to a lower permission. `user1` has Admin permission as the highest permission always wins.
|
||||
|
||||
- **View**: Can only view existing dashboars/folders.
|
||||
|
||||
#### Restricting access
|
||||
|
||||
The highest permission always wins so if you for example want to hide a folder or dashboard from others you need to remove the **Organization Role** based permission from the
|
||||
Access Control List (ACL).
|
||||
|
||||
- You cannot override permissions for users with **Org Admin Role**
|
||||
- A more specific permission with lower permission level will not have any effect if a more general rule exists with higher permission level. For example if "Everyone with Editor Role Can Edit" exists in the ACL list then **John Doe** will still have Edit permission even after you have specifically added a permission for this user with the permission set to **View**. You need to remove or lower the permission level of the more general rule.
|
||||
|
||||
|
@ -169,6 +169,8 @@ Secure json data is a map of settings that will be encrypted with [secret key](/
|
||||
| tlsClientKey | string | *All* |TLS Client key for outgoing requests |
|
||||
| password | string | Postgre | password |
|
||||
| user | string | Postgre | user |
|
||||
| accessKey | string | Cloudwatch | Access key for connecting to Cloudwatch |
|
||||
| secretKey | string | Cloudwatch | Secret key for connecting to Cloudwatch |
|
||||
|
||||
### Dashboards
|
||||
|
||||
@ -190,8 +192,13 @@ providers:
|
||||
path: /var/lib/grafana/dashboards
|
||||
```
|
||||
|
||||
When Grafana starts, it will update/insert all dashboards available in the configured path. Then later on poll that path and look for updated json files and insert those update/insert those into the database.
|
||||
|
||||
### Reuseable dashboard urls
|
||||
|
||||
If the dashboard in the json file contains an [uid](/reference/dashboard/#json-fields), Grafana will force insert/update on that uid. This allows you to migrate dashboards betweens Grafana instances and provisioning Grafana from configuration without breaking the urls given since the new dashboard url uses the uid as identifer.
|
||||
When Grafana starts, it will update/insert all dashboards available in the configured folders. If you modify the file, the dashboard will also be updated.
|
||||
By default Grafana will delete dashboards in the database if the file is removed. You can disable this behavior using the `disableDeletion` setting.
|
||||
By default Grafana will delete dashboards in the database if the file is removed. You can disable this behavior using the `disableDeletion` setting.
|
||||
|
||||
> **Note.** Provisioning allows you to overwrite existing dashboards
|
||||
> which leads to problems if you re-use settings that are supposed to be unique.
|
||||
|
@ -20,7 +20,7 @@ to add and configure a `notification` channel (can be email, PagerDuty or other
|
||||
|
||||
## Notification Channel Setup
|
||||
|
||||
{{< imgbox max-width="40%" img="/img/docs/v43/alert_notifications_menu.png" caption="Alerting Notification Channels" >}}
|
||||
{{< imgbox max-width="30%" img="/img/docs/v50/alerts_notifications_menu.png" caption="Alerting Notification Channels" >}}
|
||||
|
||||
On the Notification Channels page hit the `New Channel` button to go the page where you
|
||||
can configure and setup a new Notification Channel.
|
||||
|
@ -8,7 +8,7 @@ weight = 7
|
||||
|
||||
# Keyboard shortcuts
|
||||
|
||||
{{< docs-imagebox img="/img/docs/v4/shortcuts.png" max-width="20rem" class="docs-image--right" >}}
|
||||
{{< docs-imagebox img="/img/docs/v50/shortcuts.png" max-width="20rem" class="docs-image--right" >}}
|
||||
|
||||
Grafana v4 introduces a number of really powerful keyboard shortcuts. You can now focus a panel
|
||||
by hovering over it with your mouse. With a panel focused you can simple hit `e` to toggle panel
|
||||
@ -34,6 +34,8 @@ Hit `?` on your keyboard to open the shortcuts help modal.
|
||||
- `d` `s` Dashboard settings
|
||||
- `d` `v` Toggle in-active / view mode
|
||||
- `d` `k` Toggle kiosk mode (hides top nav)
|
||||
- `d` `E` Expand all rows
|
||||
- `d` `C` Collapse all rows
|
||||
- `mod+o` Toggle shared graph crosshair
|
||||
|
||||
### Focused Panel
|
||||
@ -42,12 +44,9 @@ Hit `?` on your keyboard to open the shortcuts help modal.
|
||||
- `p` `s` Open Panel Share Modal
|
||||
- `p` `r` Remove Panel
|
||||
|
||||
### Focused Row
|
||||
- `r` `c` Collapse Row
|
||||
- `r` `r` Remove Row
|
||||
|
||||
### Time Range
|
||||
- `t` `z` Zoom out time range
|
||||
- `t` Move time range back
|
||||
- `t` Move time range forward
|
||||
|
||||
mod = CTRL on windows or linux and CMD key on Mac
|
||||
|
@ -27,36 +27,36 @@ Read the [Basic Concepts](/guides/basic_concepts) document to get a crash course
|
||||
|
||||
Let's start with creating a new Dashboard. You can find the new Dashboard link on the right side of the Dashboard picker. You now have a blank Dashboard.
|
||||
|
||||
<img class="no-shadow" src="/img/docs/v45/top_nav_annotated.png">
|
||||
<img class="no-shadow" src="/img/docs/v50/top_nav_annotated.png" width="580px">
|
||||
|
||||
The image above shows you the top header for a Dashboard.
|
||||
|
||||
1. Side menubar toggle: This toggles the side menu, allowing you to focus on the data presented in the dashboard. The side menu provides access to features unrelated to a Dashboard such as Users, Organizations, and Data Sources.
|
||||
2. Dashboard dropdown: This dropdown shows you which Dashboard you are currently viewing, and allows you to easily switch to a new Dashboard. From here you can also create a new Dashboard, Import existing Dashboards, and manage Dashboard playlists.
|
||||
3. Star Dashboard: Star (or unstar) the current Dashboard. Starred Dashboards will show up on your own Home Dashboard by default, and are a convenient way to mark Dashboards that you're interested in.
|
||||
4. Share Dashboard: Share the current dashboard by creating a link or create a static Snapshot of it. Make sure the Dashboard is saved before sharing.
|
||||
5. Save dashboard: The current Dashboard will be saved with the current Dashboard name.
|
||||
6. Settings: Manage Dashboard settings and features such as Templating and Annotations.
|
||||
2. Dashboard dropdown: This dropdown shows you which Dashboard you are currently viewing, and allows you to easily switch to a new Dashboard. From here you can also create a new Dashboard or folder, Import existing Dashboards, and manage Dashboard playlists.
|
||||
3. Add Panel: Adds a new panel to the current Dashboard
|
||||
4. Star Dashboard: Star (or unstar) the current Dashboard. Starred Dashboards will show up on your own Home Dashboard by default, and are a convenient way to mark Dashboards that you're interested in.
|
||||
5. Share Dashboard: Share the current dashboard by creating a link or create a static Snapshot of it. Make sure the Dashboard is saved before sharing.
|
||||
6. Save dashboard: The current Dashboard will be saved with the current Dashboard name.
|
||||
7. Settings: Manage Dashboard settings and features such as Templating and Annotations.
|
||||
|
||||
## Dashboards, Panels, Rows, the building blocks of Grafana...
|
||||
## Dashboards, Panels, the building blocks of Grafana...
|
||||
|
||||
Dashboards are at the core of what Grafana is all about. Dashboards are composed of individual Panels arranged on a number of Rows. Grafana ships with a variety of Panels. Grafana makes it easy to construct the right queries, and customize the display properties so that you can create the perfect Dashboard for your need. Each Panel can interact with data from any configured Grafana Data Source (currently InfluxDB, Graphite, OpenTSDB, Prometheus and Cloudwatch). The [Basic Concepts](/guides/basic_concepts) guide explores these key ideas in detail.
|
||||
Dashboards are at the core of what Grafana is all about. Dashboards are composed of individual Panels arranged on a grid. Grafana ships with a variety of Panels. Grafana makes it easy to construct the right queries, and customize the display properties so that you can create the perfect Dashboard for your need. Each Panel can interact with data from any configured Grafana Data Source (currently InfluxDB, Graphite, OpenTSDB, Prometheus and Cloudwatch). The [Basic Concepts](/guides/basic_concepts) guide explores these key ideas in detail.
|
||||
|
||||
<img src="/img/docs/v45/dashboard_annotated.png" class="no-shadow">
|
||||
<img src="/img/docs/v50/dashboard_annotated.png" class="no-shadow" width="700px">
|
||||
|
||||
1. Zoom out time range
|
||||
2. Time picker dropdown. Here you can access relative time range options, auto refresh options and set custom absolute time ranges.
|
||||
3. Manual refresh button. Will cause all panels to refresh (fetch new data).
|
||||
4. Row controls menu. Via this menu you can add panels to the row, set row height and more.
|
||||
5. Dashboard panel. You edit panels by clicking the panel title.
|
||||
6. Graph legend. You can change series colors, y-axis and series visibility directly from the legend.
|
||||
4. Dashboard panel. You edit panels by clicking the panel title.
|
||||
5. Graph legend. You can change series colors, y-axis and series visibility directly from the legend.
|
||||
|
||||
|
||||
## Adding & Editing Graphs and Panels
|
||||
|
||||

|
||||
|
||||
1. You add panels via row menu. The row menu is the icon to the left of each row.
|
||||
1. You add panels by clicking the Add panel icon on the top menu.
|
||||
2. To edit the graph you click on the graph title to open the panel menu, then `Edit`.
|
||||
3. This should take you to the `Metrics` tab. In this tab you should see the editor for your default data source.
|
||||
|
||||
@ -64,7 +64,7 @@ When you click the `Metrics` tab, you are presented with a Query Editor that is
|
||||
|
||||
## Drag-and-Drop panels
|
||||
|
||||
You can Drag-and-Drop Panels within and between Rows. Click and hold the Panel title, and drag it to its new location. You can also easily resize panels by clicking the (-) and (+) icons.
|
||||
You can Drag-and-Drop Panels by simply clicking and holding the Panel title, and drag it to its new location. You can also easily resize panels by clicking the (-) and (+) icons.
|
||||
|
||||

|
||||
|
||||
|
@ -3,11 +3,6 @@ title = "What's New in Grafana v2.1"
|
||||
description = "Feature & improvement highlights for Grafana v2.1"
|
||||
keywords = ["grafana", "new", "documentation", "2.1"]
|
||||
type = "docs"
|
||||
[menu.docs]
|
||||
name = "Version 2.1"
|
||||
identifier = "v2.1"
|
||||
parent = "whatsnew"
|
||||
weight = 10
|
||||
+++
|
||||
|
||||
# What's new in Grafana v2.1
|
||||
|
@ -3,11 +3,6 @@ title = "What's New in Grafana v2.5"
|
||||
description = "Feature & improvement highlights for Grafana v2.5"
|
||||
keywords = ["grafana", "new", "documentation", "2.5"]
|
||||
type = "docs"
|
||||
[menu.docs]
|
||||
name = "Version 2.5"
|
||||
identifier = "v2.5"
|
||||
parent = "whatsnew"
|
||||
weight = 9
|
||||
+++
|
||||
|
||||
# What's new in Grafana v2.5
|
||||
|
@ -3,11 +3,6 @@ title = "What's New in Grafana v2.6"
|
||||
description = "Feature & improvement highlights for Grafana v2.6"
|
||||
keywords = ["grafana", "new", "documentation", "2.6"]
|
||||
type = "docs"
|
||||
[menu.docs]
|
||||
name = "Version 2.6"
|
||||
identifier = "v2.6"
|
||||
parent = "whatsnew"
|
||||
weight = 7
|
||||
+++
|
||||
|
||||
# What's new in Grafana v2.6
|
||||
|
@ -3,11 +3,6 @@ title = "What's New in Grafana v2.0"
|
||||
description = "Feature & improvement highlights for Grafana v2.0"
|
||||
keywords = ["grafana", "new", "documentation", "2.0"]
|
||||
type = "docs"
|
||||
[menu.docs]
|
||||
name = "Version 2.0"
|
||||
identifier = "v2.0"
|
||||
parent = "whatsnew"
|
||||
weight = 11
|
||||
+++
|
||||
|
||||
# What's New in Grafana v2.0
|
||||
|
@ -61,7 +61,7 @@ Content-Type: application/json
|
||||
"client_id":"some_id",
|
||||
"client_secret":"************",
|
||||
"enabled":"false",
|
||||
"scopes":"user:email",
|
||||
"scopes":"user:email,read:org",
|
||||
"team_ids":"",
|
||||
"token_url":"https://github.com/login/oauth/access_token"
|
||||
},
|
||||
|
@ -11,6 +11,17 @@ parent = "http_api"
|
||||
|
||||
# Dashboard API
|
||||
|
||||
## Identifier (id) vs unique identifier (uid)
|
||||
|
||||
The identifier (id) of a dashboard is an auto-incrementing numeric value and is only unique per Grafana install.
|
||||
|
||||
The unique identifier (uid) of a dashboard can be used for uniquely identify a dashboard between multiple Grafana installs.
|
||||
It's automatically generated if not provided when creating a dashboard. The uid allows having consistent URL's for accessing
|
||||
dashboards and when syncing dashboards between multiple Grafana installs, see [dashboard provisioning](/administration/provisioning/#dashboards)
|
||||
for more information. This means that changing the title of a dashboard will not break any bookmarked links to that dashboard.
|
||||
|
||||
The uid can have a maximum length of 40 characters.
|
||||
|
||||
## Create / Update dashboard
|
||||
|
||||
`POST /api/dashboards/db`
|
||||
@ -28,6 +39,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
{
|
||||
"dashboard": {
|
||||
"id": null,
|
||||
"uid": null,
|
||||
"title": "Production Overview",
|
||||
"tags": [ "templated" ],
|
||||
"timezone": "browser",
|
||||
@ -38,14 +50,18 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
"schemaVersion": 6,
|
||||
"version": 0
|
||||
},
|
||||
"folderId": 0,
|
||||
"overwrite": false
|
||||
}
|
||||
```
|
||||
|
||||
JSON Body schema:
|
||||
|
||||
- **dashboard** – The complete dashboard model, id = null to create a new dashboard
|
||||
- **overwrite** – Set to true if you want to overwrite existing dashboard with newer version or with same dashboard title.
|
||||
- **dashboard** – The complete dashboard model, id = null to create a new dashboard.
|
||||
- **dashboard.id** – id = null to create a new dashboard.
|
||||
- **dashboard.uid** – Optional [unique identifier](/http_api/dashboard/#identifier-id-vs-unique-identifier-uid) when creating a dashboard. uid = null will generate a new uid.
|
||||
- **folderId** – The id of the folder to save the dashboard in.
|
||||
- **overwrite** – Set to true if you want to overwrite existing dashboard with newer version, same dashboard title in folder or same dashboard uid.
|
||||
- **message** - Set a commit message for the version history.
|
||||
|
||||
**Example Response**:
|
||||
@ -56,9 +72,12 @@ Content-Type: application/json; charset=UTF-8
|
||||
Content-Length: 78
|
||||
|
||||
{
|
||||
"slug": "production-overview",
|
||||
"status": "success",
|
||||
"version": 1
|
||||
"id": 1,
|
||||
"uid": "cIBgcSjkk",
|
||||
"url": "/d/cIBgcSjkk/production-overview",
|
||||
"status": "success",
|
||||
"version": 1,
|
||||
"slug": "production-overview" //deprecated in Grafana v5.0
|
||||
}
|
||||
```
|
||||
|
||||
@ -67,10 +86,18 @@ Status Codes:
|
||||
- **200** – Created
|
||||
- **400** – Errors (invalid json, missing or invalid fields, etc)
|
||||
- **401** – Unauthorized
|
||||
- **403** – Access denied
|
||||
- **412** – Precondition failed
|
||||
|
||||
The **412** status code is used when a newer dashboard already exists (newer, its version is greater than the version that was sent). The
|
||||
same status code is also used if another dashboard exists with the same title. The response body will look like this:
|
||||
The **412** status code is used for explaing that you cannot create the dashboard and why.
|
||||
There can be different reasons for this:
|
||||
|
||||
- The dashboard has been changed by someone else, `status=version-mismatch`
|
||||
- A dashboard with the same name in the folder already exists, `status=name-exists`
|
||||
- A dashboard with the same uid already exists, `status=name-exists`
|
||||
- The dashboard belongs to plugin `<plugin title>`, `status=plugin-dashboard`
|
||||
|
||||
The response body will have the following properties:
|
||||
|
||||
```http
|
||||
HTTP/1.1 412 Precondition Failed
|
||||
@ -85,16 +112,16 @@ Content-Length: 97
|
||||
|
||||
In case of title already exists the `status` property will be `name-exists`.
|
||||
|
||||
## Get dashboard
|
||||
## Get dashboard by uid
|
||||
|
||||
`GET /api/dashboards/db/:slug`
|
||||
`GET /api/dashboards/uid/:uid`
|
||||
|
||||
Will return the dashboard given the dashboard slug. Slug is the url friendly version of the dashboard title.
|
||||
Will return the dashboard given the dashboard unique identifier (uid).
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```http
|
||||
GET /api/dashboards/db/production-overview HTTP/1.1
|
||||
GET /api/dashboards/uid/cIBgcSjkk HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
@ -107,12 +134,9 @@ HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"meta": {
|
||||
"isStarred": false,
|
||||
"slug": "production-overview"
|
||||
},
|
||||
"dashboard": {
|
||||
"id": null,
|
||||
"id": 1,
|
||||
"uid": "cIBgcSjkk",
|
||||
"title": "Production Overview",
|
||||
"tags": [ "templated" ],
|
||||
"timezone": "browser",
|
||||
@ -122,20 +146,32 @@ Content-Type: application/json
|
||||
],
|
||||
"schemaVersion": 6,
|
||||
"version": 0
|
||||
},
|
||||
"meta": {
|
||||
"isStarred": false,
|
||||
"url": "/d/cIBgcSjkk/production-overview",
|
||||
"slug": "production-overview" //deprecated in Grafana v5.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Delete dashboard
|
||||
Status Codes:
|
||||
|
||||
`DELETE /api/dashboards/db/:slug`
|
||||
- **200** – Found
|
||||
- **401** – Unauthorized
|
||||
- **403** – Access denied
|
||||
- **404** – Not found
|
||||
|
||||
The above will delete the dashboard with the specified slug. The slug is the url friendly (unique) version of the dashboard title.
|
||||
## Delete dashboard by uid
|
||||
|
||||
`DELETE /api/dashboards/uid/:uid`
|
||||
|
||||
Will delete the dashboard given the specified unique identifier (uid).
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```http
|
||||
DELETE /api/dashboards/db/test HTTP/1.1
|
||||
DELETE /api/dashboards/uid/cIBgcSjkk HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
@ -147,9 +183,16 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
{"title": "Test"}
|
||||
{"title": "Production Overview"}
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** – Deleted
|
||||
- **401** – Unauthorized
|
||||
- **403** – Access denied
|
||||
- **404** – Not found
|
||||
|
||||
## Gets the home dashboard
|
||||
|
||||
`GET /api/dashboards/home`
|
||||
@ -172,15 +215,6 @@ HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"meta": {
|
||||
"isHome":true,
|
||||
"canSave":false,
|
||||
"canEdit":false,
|
||||
"canStar":false,
|
||||
"slug":"",
|
||||
"expires":"0001-01-01T00:00:00Z",
|
||||
"created":"0001-01-01T00:00:00Z"
|
||||
},
|
||||
"dashboard": {
|
||||
"editable":false,
|
||||
"hideControls":true,
|
||||
@ -206,13 +240,21 @@ Content-Type: application/json
|
||||
"timezone":"browser",
|
||||
"title":"Home",
|
||||
"version":5
|
||||
},
|
||||
"meta": {
|
||||
"isHome":true,
|
||||
"canSave":false,
|
||||
"canEdit":false,
|
||||
"canStar":false,
|
||||
"url":"",
|
||||
"expires":"0001-01-01T00:00:00Z",
|
||||
"created":"0001-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tags for Dashboard
|
||||
|
||||
|
||||
`GET /api/dashboards/tags`
|
||||
|
||||
Get all tags of dashboards
|
||||
@ -244,21 +286,24 @@ Content-Type: application/json
|
||||
]
|
||||
```
|
||||
|
||||
## Search Dashboards
|
||||
## Dashboard Search
|
||||
See [Folder/Dashboard Search API](/http_api/folder_dashboard_search).
|
||||
|
||||
`GET /api/search/`
|
||||
## Deprecated resources
|
||||
Please note that these resource have been deprecated and will be removed in a future release.
|
||||
|
||||
Query parameters:
|
||||
### Get dashboard by slug
|
||||
**Deprecated starting from Grafana v5.0. Please update to use the new *Get dashboard by uid* resource instead**
|
||||
|
||||
- **query** – Search Query
|
||||
- **tag** – Tag to use
|
||||
- **starred** – Flag indicating if only starred Dashboards should be returned
|
||||
- **tagcloud** - Flag indicating if a tagcloud should be returned
|
||||
`GET /api/dashboards/db/:slug`
|
||||
|
||||
Will return the dashboard given the dashboard slug. Slug is the url friendly version of the dashboard title.
|
||||
If there exists multiple dashboards with the same slug, one of them will be returned in the response.
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```http
|
||||
GET /api/search?query=Production%20Overview&starred=true&tag=prod HTTP/1.1
|
||||
GET /api/dashboards/db/production-overview HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
@ -270,14 +315,78 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
[
|
||||
{
|
||||
"id":1,
|
||||
"title":"Production Overview",
|
||||
"uri":"db/production-overview",
|
||||
"type":"dash-db",
|
||||
"tags":[prod],
|
||||
"isStarred":true
|
||||
{
|
||||
"dashboard": {
|
||||
"id": 1,
|
||||
"uid": "cIBgcSjkk",
|
||||
"title": "Production Overview",
|
||||
"tags": [ "templated" ],
|
||||
"timezone": "browser",
|
||||
"rows": [
|
||||
{
|
||||
}
|
||||
],
|
||||
"schemaVersion": 6,
|
||||
"version": 0
|
||||
},
|
||||
"meta": {
|
||||
"isStarred": false,
|
||||
"url": "/d/cIBgcSjkk/production-overview",
|
||||
"slug": "production-overview" // deprecated in Grafana v5.0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** – Found
|
||||
- **401** – Unauthorized
|
||||
- **403** – Access denied
|
||||
- **404** – Not found
|
||||
|
||||
### Delete dashboard by slug
|
||||
**Deprecated starting from Grafana v5.0. Please update to use the *Delete dashboard by uid* resource instead.**
|
||||
|
||||
`DELETE /api/dashboards/db/:slug`
|
||||
|
||||
Will delete the dashboard given the specified slug. Slug is the url friendly version of the dashboard title.
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```http
|
||||
DELETE /api/dashboards/db/test HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
{"title": "Production Overview"}
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** – Deleted
|
||||
- **401** – Unauthorized
|
||||
- **403** – Access denied
|
||||
- **404** – Not found
|
||||
- **412** – Precondition failed
|
||||
|
||||
The **412** status code is used when there exists multiple dashboards with the same slug.
|
||||
The response body will look like this:
|
||||
|
||||
```http
|
||||
HTTP/1.1 412 Precondition Failed
|
||||
Content-Type: application/json; charset=UTF-8
|
||||
Content-Length: 97
|
||||
|
||||
{
|
||||
"message": "Multiple dashboards with the same slug exists",
|
||||
"status": "multiple-slugs-exists"
|
||||
}
|
||||
```
|
||||
|
149
docs/sources/http_api/dashboard_permissions.md
Normal file
149
docs/sources/http_api/dashboard_permissions.md
Normal file
@ -0,0 +1,149 @@
|
||||
+++
|
||||
title = "Dashboard Permissions HTTP API "
|
||||
description = "Grafana Dashboard Permissions HTTP API"
|
||||
keywords = ["grafana", "http", "documentation", "api", "dashboard", "permission", "permissions", "acl"]
|
||||
aliases = ["/http_api/dashboardpermissions/"]
|
||||
type = "docs"
|
||||
[menu.docs]
|
||||
name = "Dashboard Permissions"
|
||||
parent = "http_api"
|
||||
+++
|
||||
|
||||
# Dashboard Permissions API
|
||||
|
||||
This API can be used to update/get the permissions for a dashboard.
|
||||
|
||||
Permissions with `dashboardId=-1` are the default permissions for users with the Viewer and Editor roles. Permissions can be set for a user, a team or a role (Viewer or Editor). Permissions cannot be set for Admins - they always have access to everything.
|
||||
|
||||
The permission levels for the permission field:
|
||||
|
||||
- 1 = View
|
||||
- 2 = Edit
|
||||
- 4 = Admin
|
||||
|
||||
## Get permissions for a dashboard
|
||||
|
||||
`GET /api/dashboards/id/:dashboardId/permissions`
|
||||
|
||||
Gets all existing permissions for the dashboard with the given `dashboardId`.
|
||||
|
||||
**Example request**:
|
||||
|
||||
```http
|
||||
GET /api/dashboards/id/1/permissions 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: 551
|
||||
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"dashboardId": -1,
|
||||
"created": "2017-06-20T02:00:00+02:00",
|
||||
"updated": "2017-06-20T02:00:00+02:00",
|
||||
"userId": 0,
|
||||
"userLogin": "",
|
||||
"userEmail": "",
|
||||
"teamId": 0,
|
||||
"team": "",
|
||||
"role": "Viewer",
|
||||
"permission": 1,
|
||||
"permissionName": "View",
|
||||
"uid": "",
|
||||
"title": "",
|
||||
"slug": "",
|
||||
"isFolder": false,
|
||||
"url": ""
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"dashboardId": -1,
|
||||
"created": "2017-06-20T02:00:00+02:00",
|
||||
"updated": "2017-06-20T02:00:00+02:00",
|
||||
"userId": 0,
|
||||
"userLogin": "",
|
||||
"userEmail": "",
|
||||
"teamId": 0,
|
||||
"team": "",
|
||||
"role": "Editor",
|
||||
"permission": 2,
|
||||
"permissionName": "Edit",
|
||||
"uid": "",
|
||||
"title": "",
|
||||
"slug": "",
|
||||
"isFolder": false,
|
||||
"url": ""
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** - Ok
|
||||
- **401** - Unauthorized
|
||||
- **403** - Access denied
|
||||
- **404** - Dashboard not found
|
||||
|
||||
## Update permissions for a dashboard
|
||||
|
||||
`POST /api/dashboards/id/:dashboardId/permissions`
|
||||
|
||||
Updates permissions for a dashboard. This operation will remove existing permissions if they're not included in the request.
|
||||
|
||||
**Example request**:
|
||||
|
||||
```http
|
||||
POST /api/dashboards/id/1/permissions
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
|
||||
"items": [
|
||||
{
|
||||
"role": "Viewer",
|
||||
"permission": 1
|
||||
},
|
||||
{
|
||||
"role": "Editor",
|
||||
"permission": 2
|
||||
},
|
||||
{
|
||||
"teamId": 1,
|
||||
"permission": 1
|
||||
},
|
||||
{
|
||||
"userId": 11,
|
||||
"permission": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
JSON body schema:
|
||||
|
||||
- **items** - The permission items to add/update. Items that are omitted from the list will be removed.
|
||||
|
||||
**Example response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=UTF-8
|
||||
Content-Length: 35
|
||||
|
||||
{"message":"Dashboard permissions updated"}
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** - Ok
|
||||
- **401** - Unauthorized
|
||||
- **403** - Access denied
|
||||
- **404** - Dashboard not found
|
317
docs/sources/http_api/folder.md
Normal file
317
docs/sources/http_api/folder.md
Normal file
@ -0,0 +1,317 @@
|
||||
+++
|
||||
title = "Folder HTTP API "
|
||||
description = "Grafana Folder HTTP API"
|
||||
keywords = ["grafana", "http", "documentation", "api", "folder"]
|
||||
aliases = ["/http_api/folder/"]
|
||||
type = "docs"
|
||||
[menu.docs]
|
||||
name = "Folder"
|
||||
parent = "http_api"
|
||||
+++
|
||||
|
||||
# Folder API
|
||||
|
||||
## Identifier (id) vs unique identifier (uid)
|
||||
|
||||
The identifier (id) of a folder is an auto-incrementing numeric value and is only unique per Grafana install.
|
||||
|
||||
The unique identifier (uid) of a folder can be used for uniquely identify folders between multiple Grafana installs. It's automatically generated if not provided when creating a folder. The uid allows having consistent URL's for accessing folders and when syncing folders between multiple Grafana installs. This means that changing the title of a folder will not break any bookmarked links to that folder.
|
||||
|
||||
The uid can have a maximum length of 40 characters.
|
||||
|
||||
|
||||
## Get all folders
|
||||
|
||||
`GET /api/folders`
|
||||
|
||||
Returns all folders that the authenticated user has permission to view.
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```http
|
||||
GET /api/folders?limit=10 HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
[
|
||||
{
|
||||
"id":1,
|
||||
"uid": "nErXDvCkzz",
|
||||
"title": "Departmenet ABC",
|
||||
"url": "/dashboards/f/nErXDvCkzz/department-abc",
|
||||
"hasAcl": false,
|
||||
"canSave": true,
|
||||
"canEdit": true,
|
||||
"canAdmin": true,
|
||||
"createdBy": "admin",
|
||||
"created": "2018-01-31T17:43:12+01:00",
|
||||
"updatedBy": "admin",
|
||||
"updated": "2018-01-31T17:43:12+01:00",
|
||||
"version": 1
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Get folder by uid
|
||||
|
||||
`GET /api/folders/:uid`
|
||||
|
||||
Will return the folder given the folder uid.
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```http
|
||||
GET /api/folders/nErXDvCkzzh HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id":1,
|
||||
"uid": "nErXDvCkzz",
|
||||
"title": "Departmenet ABC",
|
||||
"url": "/dashboards/f/nErXDvCkzz/department-abc",
|
||||
"hasAcl": false,
|
||||
"canSave": true,
|
||||
"canEdit": true,
|
||||
"canAdmin": true,
|
||||
"createdBy": "admin",
|
||||
"created": "2018-01-31T17:43:12+01:00",
|
||||
"updatedBy": "admin",
|
||||
"updated": "2018-01-31T17:43:12+01:00",
|
||||
"version": 1
|
||||
}
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** – Found
|
||||
- **401** – Unauthorized
|
||||
- **403** – Access Denied
|
||||
- **404** – Folder not found
|
||||
|
||||
## Create folder
|
||||
|
||||
`POST /api/folders`
|
||||
|
||||
Creates a new folder.
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```http
|
||||
POST /api/folders HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
|
||||
{
|
||||
"uid": "nErXDvCkzz",
|
||||
"title": "Department ABC"
|
||||
}
|
||||
```
|
||||
|
||||
JSON Body schema:
|
||||
|
||||
- **uid** – Optional [unique identifier](/http_api/folder/#identifier-id-vs-unique-identifier-uid).
|
||||
- **title** – The title of the folder.
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id":1,
|
||||
"uid": "nErXDvCkzz",
|
||||
"title": "Departmenet ABC",
|
||||
"url": "/dashboards/f/nErXDvCkzz/department-abc",
|
||||
"hasAcl": false,
|
||||
"canSave": true,
|
||||
"canEdit": true,
|
||||
"canAdmin": true,
|
||||
"createdBy": "admin",
|
||||
"created": "2018-01-31T17:43:12+01:00",
|
||||
"updatedBy": "admin",
|
||||
"updated": "2018-01-31T17:43:12+01:00",
|
||||
"version": 1
|
||||
}
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** – Created
|
||||
- **400** – Errors (invalid json, missing or invalid fields, etc)
|
||||
- **401** – Unauthorized
|
||||
- **403** – Access Denied
|
||||
|
||||
## Update folder
|
||||
|
||||
`PUT /api/folders/:uid`
|
||||
|
||||
Updates an existing folder identified by uid.
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```http
|
||||
PUT /api/folders/nErXDvCkzz HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
|
||||
{
|
||||
"title":"Department DEF",
|
||||
"version": 1
|
||||
}
|
||||
```
|
||||
|
||||
JSON Body schema:
|
||||
|
||||
- **uid** – Provide another [unique identifier](/http_api/folder/#identifier-id-vs-unique-identifier-uid) than stored to change the unique identifier.
|
||||
- **title** – The title of the folder.
|
||||
- **version** – Provide the current version to be able to update the folder. Not needed if `overwrite=true`.
|
||||
- **overwrite** – Set to true if you want to overwrite existing folder with newer version.
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id":1,
|
||||
"uid": "nErXDvCkzz",
|
||||
"title": "Departmenet DEF",
|
||||
"url": "/dashboards/f/nErXDvCkzz/department-def",
|
||||
"hasAcl": false,
|
||||
"canSave": true,
|
||||
"canEdit": true,
|
||||
"canAdmin": true,
|
||||
"createdBy": "admin",
|
||||
"created": "2018-01-31T17:43:12+01:00",
|
||||
"updatedBy": "admin",
|
||||
"updated": "2018-01-31T17:43:12+01:00",
|
||||
"version": 1
|
||||
}
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** – Updated
|
||||
- **400** – Errors (invalid json, missing or invalid fields, etc)
|
||||
- **401** – Unauthorized
|
||||
- **403** – Access Denied
|
||||
- **404** – Folder not found
|
||||
- **412** – Precondition failed
|
||||
|
||||
The **412** status code is used for explaing that you cannot update the folder and why.
|
||||
There can be different reasons for this:
|
||||
|
||||
- The folder has been changed by someone else, `status=version-mismatch`
|
||||
|
||||
The response body will have the following properties:
|
||||
|
||||
```http
|
||||
HTTP/1.1 412 Precondition Failed
|
||||
Content-Type: application/json; charset=UTF-8
|
||||
Content-Length: 97
|
||||
|
||||
{
|
||||
"message": "The folder has been changed by someone else",
|
||||
"status": "version-mismatch"
|
||||
}
|
||||
```
|
||||
|
||||
## Delete folder
|
||||
|
||||
`DELETE /api/folders/:uid`
|
||||
|
||||
Deletes an existing folder identified by uid together with all dashboards stored in the folder, if any. This operation cannot be reverted.
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```http
|
||||
DELETE /api/folders/nErXDvCkzz HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"message":"Folder deleted"
|
||||
}
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** – Deleted
|
||||
- **401** – Unauthorized
|
||||
- **403** – Access Denied
|
||||
- **404** – Folder not found
|
||||
|
||||
## Get folder by id
|
||||
|
||||
`GET /api/folders/:id`
|
||||
|
||||
Will return the folder identified by id.
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```http
|
||||
GET /api/folders/1 HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id":1,
|
||||
"uid": "nErXDvCkzz",
|
||||
"title": "Departmenet ABC",
|
||||
"url": "/dashboards/f/nErXDvCkzz/department-abc",
|
||||
"hasAcl": false,
|
||||
"canSave": true,
|
||||
"canEdit": true,
|
||||
"canAdmin": true,
|
||||
"createdBy": "admin",
|
||||
"created": "2018-01-31T17:43:12+01:00",
|
||||
"updatedBy": "admin",
|
||||
"updated": "2018-01-31T17:43:12+01:00",
|
||||
"version": 1
|
||||
}
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** – Found
|
||||
- **401** – Unauthorized
|
||||
- **403** – Access Denied
|
||||
- **404** – Folder not found
|
98
docs/sources/http_api/folder_dashboard_search.md
Normal file
98
docs/sources/http_api/folder_dashboard_search.md
Normal file
@ -0,0 +1,98 @@
|
||||
+++
|
||||
title = "Folder/Dashboard Search HTTP API "
|
||||
description = "Grafana Folder/Dashboard Search HTTP API"
|
||||
keywords = ["grafana", "http", "documentation", "api", "search", "folder", "dashboard"]
|
||||
aliases = ["/http_api/folder_dashboard_search/"]
|
||||
type = "docs"
|
||||
[menu.docs]
|
||||
name = "Folder/dashboard search"
|
||||
parent = "http_api"
|
||||
+++
|
||||
|
||||
# Folder/Dashboard Search API
|
||||
|
||||
## Search folders and dashboards
|
||||
|
||||
`GET /api/search/`
|
||||
|
||||
Query parameters:
|
||||
|
||||
- **query** – Search Query
|
||||
- **tag** – List of tags to search for
|
||||
- **type** – Type to search for, `dash-folder` or `dash-db`
|
||||
- **dashboardIds** – List of dashboard id's to search for
|
||||
- **folderIds** – List of folder id's to search in for dashboards
|
||||
- **starred** – Flag indicating if only starred Dashboards should be returned
|
||||
- **limit** – Limit the number of returned results
|
||||
|
||||
**Example request for retrieving folders and dashboards of the general folder**:
|
||||
|
||||
```http
|
||||
GET /api/search?folderIds=0&query=&starred=false HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
```
|
||||
|
||||
**Example response for retrieving folders and dashboards of the general folder**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
[
|
||||
{
|
||||
"id": 163,
|
||||
"uid": "000000163",
|
||||
"title": "Folder",
|
||||
"url": "/dashboards/f/000000163/folder",
|
||||
"type": "dash-folder",
|
||||
"tags": [],
|
||||
"isStarred": false,
|
||||
"uri":"db/folder" // deprecated in Grafana v5.0
|
||||
},
|
||||
{
|
||||
"id":1,
|
||||
"uid": "cIBgcSjkk",
|
||||
"title":"Production Overview",
|
||||
"url": "/d/cIBgcSjkk/production-overview",
|
||||
"type":"dash-db",
|
||||
"tags":[prod],
|
||||
"isStarred":true,
|
||||
"uri":"db/production-overview" // deprecated in Grafana v5.0
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Example request searching for dashboards**:
|
||||
|
||||
```http
|
||||
GET /api/search?query=Production%20Overview&starred=true&tag=prod HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
```
|
||||
|
||||
**Example response searching for dashboards**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
[
|
||||
{
|
||||
"id":1,
|
||||
"uid": "cIBgcSjkk",
|
||||
"title":"Production Overview",
|
||||
"url": "/d/cIBgcSjkk/production-overview",
|
||||
"type":"dash-db",
|
||||
"tags":[prod],
|
||||
"isStarred":true,
|
||||
"folderId": 2,
|
||||
"folderUid": "000000163",
|
||||
"folderTitle": "Folder",
|
||||
"folderUrl": "/dashboards/f/000000163/folder",
|
||||
"uri":"db/production-overview" // deprecated in Grafana v5.0
|
||||
}
|
||||
]
|
||||
```
|
149
docs/sources/http_api/folder_permissions.md
Normal file
149
docs/sources/http_api/folder_permissions.md
Normal file
@ -0,0 +1,149 @@
|
||||
+++
|
||||
title = "Folder Permissions HTTP API "
|
||||
description = "Grafana Folder Permissions HTTP API"
|
||||
keywords = ["grafana", "http", "documentation", "api", "folder", "permission", "permissions", "acl"]
|
||||
aliases = ["/http_api/dashboardpermissions/"]
|
||||
type = "docs"
|
||||
[menu.docs]
|
||||
name = "Folder Permissions"
|
||||
parent = "http_api"
|
||||
+++
|
||||
|
||||
# Folder Permissions API
|
||||
|
||||
This API can be used to update/get the permissions for a folder.
|
||||
|
||||
Permissions with `folderId=-1` are the default permissions for users with the Viewer and Editor roles. Permissions can be set for a user, a team or a role (Viewer or Editor). Permissions cannot be set for Admins - they always have access to everything.
|
||||
|
||||
The permission levels for the permission field:
|
||||
|
||||
- 1 = View
|
||||
- 2 = Edit
|
||||
- 4 = Admin
|
||||
|
||||
## Get permissions for a folder
|
||||
|
||||
`GET /api/folders/:uid/permissions`
|
||||
|
||||
Gets all existing permissions for the folder with the given `uid`.
|
||||
|
||||
**Example request**:
|
||||
|
||||
```http
|
||||
GET /api/folders/nErXDvCkzz/permissions 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: 551
|
||||
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"folderId": -1,
|
||||
"created": "2017-06-20T02:00:00+02:00",
|
||||
"updated": "2017-06-20T02:00:00+02:00",
|
||||
"userId": 0,
|
||||
"userLogin": "",
|
||||
"userEmail": "",
|
||||
"teamId": 0,
|
||||
"team": "",
|
||||
"role": "Viewer",
|
||||
"permission": 1,
|
||||
"permissionName": "View",
|
||||
"uid": "nErXDvCkzz",
|
||||
"title": "",
|
||||
"slug": "",
|
||||
"isFolder": false,
|
||||
"url": ""
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"dashboardId": -1,
|
||||
"created": "2017-06-20T02:00:00+02:00",
|
||||
"updated": "2017-06-20T02:00:00+02:00",
|
||||
"userId": 0,
|
||||
"userLogin": "",
|
||||
"userEmail": "",
|
||||
"teamId": 0,
|
||||
"team": "",
|
||||
"role": "Editor",
|
||||
"permission": 2,
|
||||
"permissionName": "Edit",
|
||||
"uid": "",
|
||||
"title": "",
|
||||
"slug": "",
|
||||
"isFolder": false,
|
||||
"url": ""
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** - Ok
|
||||
- **401** - Unauthorized
|
||||
- **403** - Access denied
|
||||
- **404** - Folder not found
|
||||
|
||||
## Update permissions for a folder
|
||||
|
||||
`POST /api/folders/:uid/permissions`
|
||||
|
||||
Updates permissions for a folder. This operation will remove existing permissions if they're not included in the request.
|
||||
|
||||
**Example request**:
|
||||
|
||||
```http
|
||||
POST /api/folders/nErXDvCkzz/permissions
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
|
||||
"items": [
|
||||
{
|
||||
"role": "Viewer",
|
||||
"permission": 1
|
||||
},
|
||||
{
|
||||
"role": "Editor",
|
||||
"permission": 2
|
||||
},
|
||||
{
|
||||
"teamId": 1,
|
||||
"permission": 1
|
||||
},
|
||||
{
|
||||
"userId": 11,
|
||||
"permission": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
JSON body schema:
|
||||
|
||||
- **items** - The permission items to add/update. Items that are omitted from the list will be removed.
|
||||
|
||||
**Example response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=UTF-8
|
||||
Content-Length: 35
|
||||
|
||||
{"message":"Folder permissions updated"}
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** - Ok
|
||||
- **401** - Unauthorized
|
||||
- **403** - Access denied
|
||||
- **404** - Dashboard not found
|
@ -21,6 +21,10 @@ dashboards, creating users and updating data sources.
|
||||
* [Authentication API]({{< relref "/http_api/auth.md" >}})
|
||||
* [Dashboard API]({{< relref "/http_api/dashboard.md" >}})
|
||||
* [Dashboard Versions API]({{< relref "http_api/dashboard_versions.md" >}})
|
||||
* [Dashboard Permissions API]({{< relref "http_api/dashboard_permissions.md" >}})
|
||||
* [Folder API]({{< relref "/http_api/folder.md" >}})
|
||||
* [Folder Permissions API]({{< relref "http_api/folder_permissions.md" >}})
|
||||
* [Folder/dashboard search API]({{< relref "/http_api/folder_dashboard_search.md" >}})
|
||||
* [Data Source API]({{< relref "http_api/data_source.md" >}})
|
||||
* [Organisation API]({{< relref "http_api/org.md" >}})
|
||||
* [Snapshot API]({{< relref "http_api/snapshot.md" >}})
|
||||
|
316
docs/sources/http_api/team.md
Normal file
316
docs/sources/http_api/team.md
Normal file
@ -0,0 +1,316 @@
|
||||
+++
|
||||
title = "Team HTTP API "
|
||||
description = "Grafana Team HTTP API"
|
||||
keywords = ["grafana", "http", "documentation", "api", "team", "teams", "group"]
|
||||
aliases = ["/http_api/team/"]
|
||||
type = "docs"
|
||||
[menu.docs]
|
||||
name = "Teams"
|
||||
parent = "http_api"
|
||||
+++
|
||||
|
||||
# Team API
|
||||
|
||||
This API can be used to create/update/delete Teams and to add/remove users to Teams. All actions require that the user has the Admin role for the organization.
|
||||
|
||||
## Team Search With Paging
|
||||
|
||||
`GET /api/teams/search?perpage=50&page=1&query=mytea`
|
||||
|
||||
or
|
||||
|
||||
`GET /api/teams/search?name=myteam`
|
||||
|
||||
```http
|
||||
GET /api/teams/search?perpage=10&page=1&query=myteam HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Basic YWRtaW46YWRtaW4=
|
||||
```
|
||||
|
||||
### Using the query parameter
|
||||
|
||||
Default value for the `perpage` parameter is `1000` and for the `page` parameter is `1`.
|
||||
|
||||
The `totalCount` field in the response can be used for pagination of the teams list E.g. if `totalCount` is equal to 100 teams and the `perpage` parameter is set to 10 then there are 10 pages of teams.
|
||||
|
||||
The `query` parameter is optional and it will return results where the query value is contained in the `name` field. Query values with spaces need to be url encoded e.g. `query=my%20team`.
|
||||
|
||||
### Using the name parameter
|
||||
|
||||
The `name` parameter returns a single team if the parameter matches the `name` field.
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
"totalCount": 1,
|
||||
"teams": [
|
||||
{
|
||||
"id": 1,
|
||||
"orgId": 1,
|
||||
"name": "MyTestTeam",
|
||||
"email": "",
|
||||
"avatarUrl": "\/avatar\/3f49c15916554246daa714b9bd0ee398",
|
||||
"memberCount": 1
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"perPage": 1000
|
||||
}
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** - Ok
|
||||
- **401** - Unauthorized
|
||||
- **403** - Permission denied
|
||||
- **404** - Team not found (if searching by name)
|
||||
|
||||
## Get Team By Id
|
||||
|
||||
`GET /api/teams/:id`
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```http
|
||||
GET /api/teams/1 HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Basic YWRtaW46YWRtaW4=
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"orgId": 1,
|
||||
"name": "MyTestTeam",
|
||||
"email": "",
|
||||
"created": "2017-12-15T10:40:45+01:00",
|
||||
"updated": "2017-12-15T10:40:45+01:00"
|
||||
}
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** - Ok
|
||||
- **401** - Unauthorized
|
||||
- **403** - Permission denied
|
||||
- **404** - Team not found
|
||||
|
||||
## Add Team
|
||||
|
||||
The Team `name` needs to be unique. `name` is required and `email` is optional.
|
||||
|
||||
`POST /api/teams`
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```http
|
||||
POST /api/teams HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Basic YWRtaW46YWRtaW4=
|
||||
|
||||
{
|
||||
"name": "MyTestTeam",
|
||||
"email": "email@test.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
{"message":"Team created","teamId":2}
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** - Ok
|
||||
- **401** - Unauthorized
|
||||
- **403** - Permission denied
|
||||
- **409** - Team name is taken
|
||||
|
||||
## Update Team
|
||||
|
||||
There are two fields that can be updated for a team: `name` and `email`.
|
||||
|
||||
`PUT /api/teams/:id`
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```http
|
||||
PUT /api/teams/2 HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Basic YWRtaW46YWRtaW4=
|
||||
|
||||
{
|
||||
"name": "MyTestTeam",
|
||||
"email": "email@test.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
{"message":"Team updated"}
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** - Ok
|
||||
- **401** - Unauthorized
|
||||
- **403** - Permission denied
|
||||
- **404** - Team not found
|
||||
- **409** - Team name is taken
|
||||
|
||||
## Delete Team By Id
|
||||
|
||||
`DELETE /api/teams/:id`
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```http
|
||||
DELETE /api/teams/2 HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Basic YWRtaW46YWRtaW4=
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
{"message":"Team deleted"}
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** - Ok
|
||||
- **401** - Unauthorized
|
||||
- **403** - Permission denied
|
||||
- **404** - Failed to delete Team. ID not found
|
||||
|
||||
## Get Team Members
|
||||
|
||||
`GET /api/teams/:teamId/members`
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```http
|
||||
GET /api/teams/1/members HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Basic YWRtaW46YWRtaW4=
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
[
|
||||
{
|
||||
"orgId": 1,
|
||||
"teamId": 1,
|
||||
"userId": 3,
|
||||
"email": "user1@email.com",
|
||||
"login": "user1",
|
||||
"avatarUrl": "\/avatar\/1b3c32f6386b0185c40d359cdc733a79"
|
||||
},
|
||||
{
|
||||
"orgId": 1,
|
||||
"teamId": 1,
|
||||
"userId": 2,
|
||||
"email": "user2@email.com",
|
||||
"login": "user2",
|
||||
"avatarUrl": "\/avatar\/cad3c68da76e45d10269e8ef02f8e73e"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** - Ok
|
||||
- **401** - Unauthorized
|
||||
- **403** - Permission denied
|
||||
|
||||
## Add Team Member
|
||||
|
||||
`POST /api/teams/:teamId/members`
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```http
|
||||
POST /api/teams/1/members HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Basic YWRtaW46YWRtaW4=
|
||||
|
||||
{
|
||||
"userId": 2
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
{"message":"Member added to Team"}
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** - Ok
|
||||
- **400** - User is already added to this team
|
||||
- **401** - Unauthorized
|
||||
- **403** - Permission denied
|
||||
- **404** - Team not found
|
||||
|
||||
## Remove Member From Team
|
||||
|
||||
`DELETE /api/teams/:teamId/members/:userId`
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```http
|
||||
DELETE /api/teams/2/members/3 HTTP/1.1
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Basic YWRtaW46YWRtaW4=
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
Content-Type: application/json
|
||||
|
||||
{"message":"Team Member removed"}
|
||||
```
|
||||
|
||||
Status Codes:
|
||||
|
||||
- **200** - Ok
|
||||
- **401** - Unauthorized
|
||||
- **403** - Permission denied
|
||||
- **404** - Team not found/Team member not found
|
@ -296,7 +296,7 @@ options are `Admin` and `Editor`. e.g. :
|
||||
|
||||
`auto_assign_org_role = Viewer`
|
||||
|
||||
### viewers can edit
|
||||
### viewers_can_edit
|
||||
|
||||
Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard.
|
||||
Defaults to `false`.
|
||||
@ -354,7 +354,7 @@ enabled = true
|
||||
allow_sign_up = true
|
||||
client_id = YOUR_GITHUB_APP_CLIENT_ID
|
||||
client_secret = YOUR_GITHUB_APP_CLIENT_SECRET
|
||||
scopes = user:email
|
||||
scopes = user:email,read:org
|
||||
auth_url = https://github.com/login/oauth/authorize
|
||||
token_url = https://github.com/login/oauth/access_token
|
||||
api_url = https://api.github.com/user
|
||||
@ -387,6 +387,7 @@ scopes = user:email,read:org
|
||||
team_ids = 150,300
|
||||
auth_url = https://github.com/login/oauth/authorize
|
||||
token_url = https://github.com/login/oauth/access_token
|
||||
api_url = https://api.github.com/user
|
||||
allow_sign_up = true
|
||||
```
|
||||
|
||||
@ -405,6 +406,7 @@ client_secret = YOUR_GITHUB_APP_CLIENT_SECRET
|
||||
scopes = user:email,read:org
|
||||
auth_url = https://github.com/login/oauth/authorize
|
||||
token_url = https://github.com/login/oauth/access_token
|
||||
api_url = https://api.github.com/user
|
||||
allow_sign_up = true
|
||||
# space-delimited organization names
|
||||
allowed_organizations = github google
|
||||
@ -875,6 +877,4 @@ Defaults to true. Set to false to disable alerting engine and hide Alerting from
|
||||
|
||||
### execute_alerts
|
||||
|
||||
### execute_alerts = true
|
||||
|
||||
Makes it possible to turn off alert rule execution.
|
||||
|
@ -15,8 +15,7 @@ weight = 1
|
||||
|
||||
Description | Download
|
||||
------------ | -------------
|
||||
Stable for Debian-based Linux | [grafana_4.6.3_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.6.3_amd64.deb)
|
||||
Beta for Debian-based Linux | [grafana_5.0.0-beta4_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta4_amd64.deb)
|
||||
Stable for Debian-based Linux | [grafana_5.0.0_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0_amd64.deb)
|
||||
|
||||
Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
|
||||
installation.
|
||||
@ -25,19 +24,11 @@ installation.
|
||||
|
||||
|
||||
```bash
|
||||
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.6.3_amd64.deb
|
||||
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0_amd64.deb
|
||||
sudo apt-get install -y adduser libfontconfig
|
||||
sudo dpkg -i grafana_4.6.3_amd64.deb
|
||||
sudo dpkg -i grafana_5.0.0_amd64.deb
|
||||
```
|
||||
|
||||
## Install Latest Beta
|
||||
|
||||
```bash
|
||||
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta4_amd64.deb
|
||||
sudo apt-get install -y adduser libfontconfig
|
||||
sudo dpkg -i grafana_5.0.0-beta4_amd64.deb
|
||||
|
||||
```
|
||||
## APT Repository
|
||||
|
||||
Add the following line to your `/etc/apt/sources.list` file.
|
||||
|
@ -15,8 +15,8 @@ weight = 2
|
||||
|
||||
Description | Download
|
||||
------------ | -------------
|
||||
Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.6.3 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3-1.x86_64.rpm)
|
||||
Latest Beta for CentOS / Fedora / OpenSuse / Redhat Linux | [5.0.0-beta4 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta4.x86_64.rpm)
|
||||
Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [5.0.0 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-1.x86_64.rpm)
|
||||
|
||||
|
||||
Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
|
||||
installation.
|
||||
@ -26,13 +26,7 @@ installation.
|
||||
You can install Grafana using Yum directly.
|
||||
|
||||
```bash
|
||||
$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3-1.x86_64.rpm
|
||||
```
|
||||
|
||||
## Install Beta
|
||||
|
||||
```bash
|
||||
$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta4.x86_64.rpm
|
||||
$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-1.x86_64.rpm
|
||||
```
|
||||
|
||||
Or install manually using `rpm`.
|
||||
@ -40,15 +34,15 @@ Or install manually using `rpm`.
|
||||
#### On CentOS / Fedora / Redhat:
|
||||
|
||||
```bash
|
||||
$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3-1.x86_64.rpm
|
||||
$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-1.x86_64.rpm
|
||||
$ sudo yum install initscripts fontconfig
|
||||
$ sudo rpm -Uvh grafana-4.6.3-1.x86_64.rpm
|
||||
$ sudo rpm -Uvh grafana-5.0.0-1.x86_64.rpm
|
||||
```
|
||||
|
||||
#### On OpenSuse:
|
||||
|
||||
```bash
|
||||
$ sudo rpm -i --nodeps grafana-4.6.3-1.x86_64.rpm
|
||||
$ sudo rpm -i --nodeps grafana-5.0.0-1.x86_64.rpm
|
||||
```
|
||||
|
||||
## Install via YUM Repository
|
||||
|
@ -105,4 +105,7 @@ We are not aware of any issues upgrading directly from 2.x to 4.x but to be on t
|
||||
## Upgrading to v5.0
|
||||
|
||||
The dashboard grid layout engine has changed. All dashboards will be automatically upgraded to new
|
||||
positioning system when you load them in v5. Dashboards saved in v5 will not work in older versions of Grafana.
|
||||
positioning system when you load them in v5. Dashboards saved in v5 will not work in older versions of Grafana. Some
|
||||
external panel plugins might need to be updated to work properly.
|
||||
|
||||
For more details on the new panel positioning system, [click here]({{< relref "reference/dashboard.md#panel-size-position" >}})
|
||||
|
@ -13,8 +13,7 @@ weight = 3
|
||||
|
||||
Description | Download
|
||||
------------ | -------------
|
||||
Latest stable package for Windows | [grafana.4.6.3.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.6.3.windows-x64.zip)
|
||||
Latest beta package for Windows | [grafana.5.0.0-beta4.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta4.windows-x64.zip)
|
||||
Latest stable package for Windows | [grafana-5.0.0.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0.windows-x64.zip)
|
||||
|
||||
Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
|
||||
installation.
|
||||
@ -31,9 +30,9 @@ on windows. Edit `custom.ini` and uncomment the `http_port`
|
||||
configuration option (`;` is the comment character in ini files) and change it to something like `8080` or similar.
|
||||
That port should not require extra Windows privileges.
|
||||
|
||||
Start Grafana by executing `grafana-server.exe`, preferably from the
|
||||
Start Grafana by executing `grafana-server.exe`, located in the `bin` directory, preferably from the
|
||||
command line. If you want to run Grafana as windows service, download
|
||||
[NSSM](https://nssm.cc/). It is very easy add Grafana as a Windows
|
||||
[NSSM](https://nssm.cc/). It is very easy to add Grafana as a Windows
|
||||
service using that tool.
|
||||
|
||||
Read more about the [configuration options]({{< relref "configuration.md" >}}).
|
||||
@ -43,7 +42,3 @@ Read more about the [configuration options]({{< relref "configuration.md" >}}).
|
||||
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).
|
||||
|
||||
Copy `conf/sample.ini` to a file named `conf/custom.ini` and change the
|
||||
web server port to something like 8080. The default Grafana port, 3000,
|
||||
requires special privileges on Windows.
|
||||
|
@ -54,7 +54,8 @@ Annotation events are fetched via annotation queries. To add a new annotation qu
|
||||
open the dashboard settings menu, then select `Annotations`. This will open the dashboard annotations
|
||||
settings view. To create a new annotation query hit the `New` button.
|
||||
|
||||

|
||||
<!---->
|
||||
{{< docs-imagebox img="/img/docs/v50/annotation_new_query.png" max-width="600px" >}}
|
||||
|
||||
Specify a name for the annotation query. This name is given to the toggle (checkbox) that will allow
|
||||
you to enable/disable showing annotation events from this query. For example you might have two
|
||||
|
@ -10,7 +10,7 @@ weight = 100
|
||||
|
||||
# Dashboard JSON
|
||||
|
||||
A dashboard in Grafana is represented by a JSON object, which stores metadata of its dashboard. Dashboard metadata includes dashboard properties, metadata from rows, panels, template variables, panel queries, etc.
|
||||
A dashboard in Grafana is represented by a JSON object, which stores metadata of its dashboard. Dashboard metadata includes dashboard properties, metadata from panels, template variables, panel queries, etc.
|
||||
|
||||
To view the JSON of a dashboard, follow the steps mentioned below:
|
||||
|
||||
@ -27,6 +27,7 @@ When a user creates a new dashboard, a new dashboard JSON object is initialized
|
||||
```json
|
||||
{
|
||||
"id": null,
|
||||
"uid": "cLV5GDCkz",
|
||||
"title": "New dashboard",
|
||||
"tags": [],
|
||||
"style": "dark",
|
||||
@ -34,7 +35,7 @@ When a user creates a new dashboard, a new dashboard JSON object is initialized
|
||||
"editable": true,
|
||||
"hideControls": false,
|
||||
"graphTooltip": 1,
|
||||
"rows": [],
|
||||
"panels": [],
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
@ -49,7 +50,7 @@ When a user creates a new dashboard, a new dashboard JSON object is initialized
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"schemaVersion": 7,
|
||||
"schemaVersion": 16,
|
||||
"version": 0,
|
||||
"links": []
|
||||
}
|
||||
@ -58,224 +59,56 @@ Each field in the dashboard JSON is explained below with its usage:
|
||||
|
||||
| Name | Usage |
|
||||
| ---- | ----- |
|
||||
| **id** | unique dashboard id, an integer |
|
||||
| **id** | unique numeric identifier for the dashboard. (generated by the db) |
|
||||
| **uid** | unique dashboard identifier that can be generated by anyone. string (8-40) |
|
||||
| **title** | current title of dashboard |
|
||||
| **tags** | tags associated with dashboard, an array of strings |
|
||||
| **style** | theme of dashboard, i.e. `dark` or `light` |
|
||||
| **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** | 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 |
|
||||
| **templating** | templating metadata, see [templating section](#templating) for details |
|
||||
| **annotations** | annotations metadata, see [annotations section](#annotations) for details |
|
||||
| **schemaVersion** | version of the JSON schema (integer), incremented each time a Grafana update brings changes to the said schema |
|
||||
| **version** | version of the dashboard (integer), incremented each time the dashboard is updated |
|
||||
| **links** | TODO |
|
||||
| **panels** | panels array, see below for detail. |
|
||||
|
||||
### rows
|
||||
## Panels
|
||||
|
||||
`rows` field consists of an array of JSON object representing each row in a dashboard, such as shown below:
|
||||
|
||||
```json
|
||||
"rows": [
|
||||
{
|
||||
"collapse": false,
|
||||
"editable": true,
|
||||
"height": "200px",
|
||||
"panels": [],
|
||||
"title": "New row"
|
||||
},
|
||||
{
|
||||
"collapse": true,
|
||||
"editable": true,
|
||||
"height": "300px",
|
||||
"panels": [],
|
||||
"title": "New row"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Usage of the fields is explained below:
|
||||
|
||||
| Name | Usage |
|
||||
| ---- | ----- |
|
||||
| **collapse** | whether row is collapsed or not |
|
||||
| **editable** | whether a row is editable or not |
|
||||
| **height** | height of the row in pixels |
|
||||
| **panels** | panels metadata, see [panels section](#panels) for details |
|
||||
| **title** | title of row |
|
||||
|
||||
#### panels
|
||||
|
||||
Panels are the building blocks a dashboard. It consists of datasource queries, type of graphs, aliases, etc. Panel JSON consists of an array of JSON objects, each representing a different panel in a row. Most of the fields are common for all panels but some fields depends on the panel type. Following is an example of panel JSON representing a `graph` panel type:
|
||||
Panels are the building blocks a dashboard. It consists of datasource queries, type of graphs, aliases, etc. Panel JSON consists of an array of JSON objects, each representing a different panel. Most of the fields are common for all panels but some fields depends on the panel type. Following is an example of panel JSON of a text panel.
|
||||
|
||||
```json
|
||||
"panels": [
|
||||
{
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"datasource": null,
|
||||
"editable": true,
|
||||
"error": false,
|
||||
"fill": 0,
|
||||
"grid": {
|
||||
"leftLogBase": 1,
|
||||
"leftMax": null,
|
||||
"leftMin": null,
|
||||
"rightLogBase": 1,
|
||||
"rightMax": null,
|
||||
"rightMin": null,
|
||||
"threshold1": null,
|
||||
"threshold1Color": "rgba(216, 200, 27, 0.27)",
|
||||
"threshold2": null,
|
||||
"threshold2Color": "rgba(234, 112, 112, 0.22)"
|
||||
},
|
||||
"id": 1,
|
||||
"legend": {
|
||||
"avg": false,
|
||||
"current": false,
|
||||
"max": false,
|
||||
"min": false,
|
||||
"show": true,
|
||||
"total": false,
|
||||
"values": false
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 1,
|
||||
"links": [],
|
||||
"nullPointMode": "connected",
|
||||
"percentage": false,
|
||||
"pointradius": 5,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"span": 4,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"aggregator": "max",
|
||||
"alias": "$tag_instance_id",
|
||||
"currentTagKey": "",
|
||||
"currentTagValue": "",
|
||||
"downsampleAggregator": "avg",
|
||||
"downsampleInterval": "",
|
||||
"errors": {},
|
||||
"metric": "memory.percent-used",
|
||||
"refId": "A",
|
||||
"shouldComputeRate": false,
|
||||
"tags": {
|
||||
"app": "$app",
|
||||
"env": "stage",
|
||||
"instance_id": "*"
|
||||
}
|
||||
}
|
||||
],
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Memory Utilization",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"value_type": "cumulative"
|
||||
},
|
||||
"type": "graph",
|
||||
"x-axis": true,
|
||||
"y-axis": true,
|
||||
"y_formats": [
|
||||
"percent",
|
||||
"short"
|
||||
]
|
||||
},
|
||||
{
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"datasource": null,
|
||||
"editable": true,
|
||||
"error": false,
|
||||
"fill": 0,
|
||||
"grid": {
|
||||
"leftLogBase": 1,
|
||||
"leftMax": null,
|
||||
"leftMin": null,
|
||||
"rightLogBase": 1,
|
||||
"rightMax": null,
|
||||
"rightMin": null,
|
||||
"threshold1": null,
|
||||
"threshold1Color": "rgba(216, 200, 27, 0.27)",
|
||||
"threshold2": null,
|
||||
"threshold2Color": "rgba(234, 112, 112, 0.22)"
|
||||
},
|
||||
"id": 2,
|
||||
"legend": {
|
||||
"avg": false,
|
||||
"current": false,
|
||||
"max": false,
|
||||
"min": false,
|
||||
"show": true,
|
||||
"total": false,
|
||||
"values": false
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 1,
|
||||
"links": [],
|
||||
"nullPointMode": "connected",
|
||||
"percentage": false,
|
||||
"pointradius": 5,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"span": 4,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"aggregator": "avg",
|
||||
"alias": "$tag_instance_id",
|
||||
"currentTagKey": "",
|
||||
"currentTagValue": "",
|
||||
"downsampleAggregator": "avg",
|
||||
"downsampleInterval": "",
|
||||
"errors": {},
|
||||
"metric": "memory.percent-cached",
|
||||
"refId": "A",
|
||||
"shouldComputeRate": false,
|
||||
"tags": {
|
||||
"app": "$app",
|
||||
"env": "prod",
|
||||
"instance_id": "*"
|
||||
}
|
||||
}
|
||||
],
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Memory Cached",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"value_type": "cumulative"
|
||||
},
|
||||
"type": "graph",
|
||||
"x-axis": true,
|
||||
"y-axis": true,
|
||||
"y_formats": [
|
||||
"short",
|
||||
"short"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"title": "Panel Title",
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 12,
|
||||
"h": 9
|
||||
},
|
||||
"id": 4,
|
||||
"mode": "markdown",
|
||||
"content": "# title"
|
||||
}
|
||||
```
|
||||
|
||||
Usage of each field is explained below:
|
||||
### Panel size & position
|
||||
|
||||
| Name | Usage |
|
||||
| ---- | ----- |
|
||||
| TODO | TODO |
|
||||
The gridPos property describes the panel size and position in grid coordinates.
|
||||
|
||||
- `w` 1-24 (the width of the dashboard is divided into 24 columns)
|
||||
- `h` In grid height units, each represents 30 pixels.
|
||||
- `x` The x position, in same unit as `w`.
|
||||
- `y` The y position, in same unit as `h`.
|
||||
|
||||
The grid has a negative gravity that moves panels up if there i empty space above a panel.
|
||||
|
||||
### timepicker
|
||||
|
||||
Description: TODO
|
||||
|
||||
```json
|
||||
"timepicker": {
|
||||
"collapse": false,
|
||||
@ -416,7 +249,3 @@ Usage of the above mentioned fields in the templating section is explained below
|
||||
| **refresh** | TODO |
|
||||
| **regex** | TODO |
|
||||
| **type** | type of variable, i.e. `custom`, `query` or `interval` |
|
||||
|
||||
### annotations
|
||||
|
||||
TODO
|
||||
|
52
docs/sources/reference/dashboard_folders.md
Normal file
52
docs/sources/reference/dashboard_folders.md
Normal file
@ -0,0 +1,52 @@
|
||||
+++
|
||||
title = "Dashboard Folders"
|
||||
keywords = ["grafana", "dashboard", "dashboard folders", "folder", "folders", "documentation", "guide"]
|
||||
type = "docs"
|
||||
[menu.docs]
|
||||
name = "Folders"
|
||||
parent = "dashboard_features"
|
||||
weight = 3
|
||||
+++
|
||||
|
||||
# Dashboard Folders
|
||||
|
||||
Folders are a way to organize and group dashboards - very useful if you have a lot of dashboards or multiple teams using the same Grafana instance.
|
||||
|
||||
## How To Create A Folder
|
||||
|
||||
- Create a folder by using the Create Folder link in the side menu (under the create menu (+ icon))
|
||||
- Use the create Folder button on the Manage Dashboards page.
|
||||
- When saving a dashboard, you can either choose a folder for the dashboard to be saved in or create a new folder
|
||||
|
||||
On the Create Folder page, fill in a unique name for the folder and press Create.
|
||||
|
||||
## Manage Dashboards
|
||||
|
||||
{{< docs-imagebox img="/img/docs/v50/manage_dashboard_menu.png" max-width="300px" class="docs-image--right" >}}
|
||||
|
||||
There is a new Manage Dashboards page where you can carry out a variety of tasks:
|
||||
|
||||
- create a folder
|
||||
- create a dashboard
|
||||
- move dashboards into folders
|
||||
- delete multiple dashboards
|
||||
- navigate to a folder page (where you can set permissions for a folder and/or its dashboards)
|
||||
|
||||
## Dashboard Folder Page
|
||||
|
||||
You reach the dashboard folder page by clicking on the cog icon that appears when you hover
|
||||
over a folder in the dashboard list in the search result or on the Manage dashboards page.
|
||||
|
||||
The Dashboard Folder Page is similar to the Manage Dashboards page and is where you can carry out the following tasks:
|
||||
|
||||
- Allows you to move or delete dashboards in a folder.
|
||||
- Rename a folder (under the Settings tab).
|
||||
- Set permissions for the folder (inherited by dashboards in the folder).
|
||||
|
||||
## Permissions
|
||||
|
||||
Permissions can assigned to a folder and inherited by the containing dashboards. An Access Control List (ACL) is used where
|
||||
**Organization Role**, **Team** and Individual **User** can be assigned permissions. Read the
|
||||
[Dashboard & Folder Permissions]({{< relref "administration/permissions.md#dashboard-folder-permissions" >}}) docs for more detail
|
||||
on the permission system.
|
||||
|
@ -15,9 +15,9 @@ Grafana Dashboards can easily be exported and imported, either from the UI or fr
|
||||
|
||||
Dashboards are exported in Grafana JSON format, and contain everything you need (layout, variables, styles, data sources, queries, etc)to import the dashboard at a later time.
|
||||
|
||||
The export feature is accessed from the share menu.
|
||||
The export feature is accessed in the share window which you open by clicking the share button in the dashboard menu.
|
||||
|
||||
<img src="/img/docs/v31/export_menu.png">
|
||||
{{< docs-imagebox img="/img/docs/v50/export_modal.png" max-width="700px" >}}
|
||||
|
||||
### Making a dashboard portable
|
||||
|
||||
@ -31,12 +31,12 @@ the dashboard, and will also be added as an required input when the dashboard is
|
||||
|
||||
To import a dashboard open dashboard search and then hit the import button.
|
||||
|
||||
<img src="/img/docs/v31/import_step1.png">
|
||||
{{< docs-imagebox img="/img/docs/v50/import_step1.png" max-width="700px" >}}
|
||||
|
||||
From here you can upload a dashboard json file, paste a [Grafana.com](https://grafana.com) dashboard
|
||||
url or paste dashboard json text directly into the text area.
|
||||
|
||||
<img src="/img/docs/v31/import_step2.png">
|
||||
{{< docs-imagebox img="/img/docs/v50/import_step2.png" max-width="700px" >}}
|
||||
|
||||
In step 2 of the import process Grafana will let you change the name of the dashboard, pick what
|
||||
data source you want the dashboard to use and specify any metric prefixes (if the dashboard use any).
|
||||
@ -45,7 +45,7 @@ data source you want the dashboard to use and specify any metric prefixes (if th
|
||||
|
||||
Find dashboards for common server applications at [Grafana.com/dashboards](https://grafana.com/dashboards).
|
||||
|
||||
<img src="/img/docs/v31/gnet_dashboards_list.png">
|
||||
{{< docs-imagebox img="/img/docs/v50/gcom_dashboard_list.png" max-width="700px" >}}
|
||||
|
||||
## Import & Sharing with Grafana 2.x or 3.0
|
||||
|
||||
|
@ -16,7 +16,7 @@ Since Grafana automatically scales Dashboards to any resolution they're perfect
|
||||
|
||||
## Creating a Playlist
|
||||
|
||||
{{< docs-imagebox img="/img/docs/v3/playlist.png" max-width="25rem" class="docs-image--right">}}
|
||||
{{< docs-imagebox img="/img/docs/v50/playlist.png" max-width="25rem" class="docs-image--right">}}
|
||||
|
||||
The Playlist feature can be accessed from Grafana's sidemenu, in the Dashboard submenu.
|
||||
|
||||
|
@ -10,22 +10,22 @@ weight = 5
|
||||
|
||||
# Dashboard Search
|
||||
|
||||
Dashboards can be searched by the dashboard name, filtered by one (or many) tags or filtered by starred status. The dashboard search is accessed through the dashboard picker, available in the dashboard top nav area.
|
||||
Dashboards can be searched by the dashboard name, filtered by one (or many) tags or filtered by starred status. The dashboard search is accessed through the dashboard picker, available in the dashboard top nav area. The dashboard search can also be opened by using the shortcut `F`.
|
||||
|
||||
<img class="no-shadow" src="/img/docs/v2/dashboard_search.png">
|
||||
<img class="no-shadow" src="/img/docs/v50/dashboard_search_annotated.png" width="700px">
|
||||
|
||||
1. `Dashboard Picker`: The Dashboard Picker is your primary navigation tool to move between dashboards. It is present on all dashboards, and open the Dashboard Search. The dashboard picker also doubles as the title of the current dashboard.
|
||||
2. `Search Bar`: The search bar allows you to enter any string and search both database and file based dashboards in real-time.
|
||||
3. `Starred`: The starred link allows you to filter the list to display only starred dashboards.
|
||||
4. `Tags`: The tags filter allows you to filter the list by dashboard tags.
|
||||
1. `Search Bar`: The search bar allows you to enter any string and search both database and file based dashboards in real-time.
|
||||
2. `Starred`: Here you find all your starred dashboards.
|
||||
3. `Recent`: Here you find the latest created dashboards.
|
||||
4. `Folders`: The tags filter allows you to filter the list by dashboard tags.
|
||||
5. `Root`: The root contains all dashboards that are not placed in a folder.
|
||||
6. `Tags`: The tags filter allows you to filter the list by dashboard tags.
|
||||
|
||||
When using only a keyboard, you can use your keyboard arrow keys to navigate the results, hit enter to open the selected dashboard.
|
||||
|
||||
## Find by dashboard name
|
||||
|
||||
<img class="no-shadow" src="/img/docs/v2/dashboard_search_text.gif">
|
||||
|
||||
To search and load dashboards click the open folder icon in the header or use the shortcut `CTRL`+`F`. Begin typing any part of the desired dashboard names. Search will return results for for any partial string match in real-time, as you type.
|
||||
Begin typing any part of the desired dashboard names in the search bar. Search will return results for for any partial string match in real-time, as you type.
|
||||
|
||||
Dashboard search is:
|
||||
- Real-time
|
||||
@ -38,21 +38,8 @@ Tags are a great way to organize your dashboards, especially as the number of da
|
||||
|
||||
To filter the dashboard list by tag, click on any tag appearing in the right column. The list may be further filtered by clicking on additional tags:
|
||||
|
||||
<img class="no-shadow" src="/img/docs/v2/dashboard_search_tag_filtering.gif">
|
||||
|
||||
Alternately, to see a list of all available tags, click the tags link in the search bar. All tags will be shown, and when a tag is selected, the dashboard search will be instantly filtered:
|
||||
|
||||
<img class="no-shadow" src="/img/docs/v2/dashboard_search_tags_all_filtering.gif">
|
||||
Alternately, to see a list of all available tags, click the tags dropdown menu. All tags will be shown, and when a tag is selected, the dashboard search will be instantly filtered:
|
||||
|
||||
When using only a keyboard: `tab` to focus on the *tags* link, `▼` down arrow key to find a tag and select with the `Enter` key.
|
||||
|
||||
**Note**: When multiple tags are selected, Grafana will show dashboards that include **all**.
|
||||
|
||||
|
||||
## Filter by Starred
|
||||
|
||||
Starring is a great way to organize and find commonly used dashboards. To show only starred dashboards in the list, click the *starred* link in the search bar:
|
||||
|
||||
<img class="no-shadow" src="/img/docs/v2/dashboard_search_starred_filtering.gif">
|
||||
|
||||
When using only a keyboard: `tab` to focus on the *stars* link, `▼` down arrow key to find a tag and select with the `Enter` key.
|
||||
**Note**: When multiple tags are selected, Grafana will show dashboards that include **all**.
|
@ -24,7 +24,7 @@ A dashboard snapshot is an instant way to share an interactive dashboard publicl
|
||||
(metric, template and annotation) and panel links, leaving only the visible metric data and series names embedded into your dashboard. Dashboard
|
||||
snapshots can be accessed by anyone who has the link and can reach the URL.
|
||||
|
||||

|
||||
{{< docs-imagebox img="/img/docs/v50/share_panel_modal.png" max-width="700px" >}}
|
||||
|
||||
### Publish snapshots
|
||||
|
||||
@ -70,9 +70,9 @@ Below there should be an interactive Grafana graph embedded in an iframe:
|
||||
|
||||
### Export Panel Data
|
||||
|
||||

|
||||
{{< docs-imagebox img="/img/docs/v50/export_panel_data.png" max-width="500px" >}}
|
||||
|
||||
The submenu for a panel can be found by clicking on the title of a panel and then on the hamburger (three horizontal lines) submenu on the left of the context menu.
|
||||
The submenu for a panel can be found by clicking on the title of a panel and then on the More submenu.
|
||||
|
||||
This menu contains two options for exporting data:
|
||||
|
||||
|
@ -1,20 +1,20 @@
|
||||
+++
|
||||
title = "Templating"
|
||||
title = "Variables"
|
||||
keywords = ["grafana", "templating", "documentation", "guide"]
|
||||
type = "docs"
|
||||
[menu.docs]
|
||||
name = "Templating"
|
||||
name = "Variables"
|
||||
parent = "dashboard_features"
|
||||
weight = 1
|
||||
+++
|
||||
|
||||
# Templating
|
||||
# Variables
|
||||
|
||||
Templating allows for more interactive and dynamic dashboards. Instead of hard-coding things like server, application
|
||||
Variables allows for more interactive and dynamic dashboards. Instead of hard-coding things like server, application
|
||||
and sensor name in you metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the top of
|
||||
the dashboard. These dropdowns make it easy to change the data being displayed in your dashboard.
|
||||
|
||||
<img class="no-shadow" src="/img/docs/v4/templated_dash.png">
|
||||
{{< docs-imagebox img="/img/docs/v50/variables_dashboard.png" >}}
|
||||
|
||||
## What is a variable?
|
||||
|
||||
@ -43,7 +43,7 @@ is the set of values you can choose from.
|
||||
|
||||
## Adding a variable
|
||||
|
||||
<img class="no-shadow" src="/img/docs/v4/templating_var_list.png">
|
||||
{{< docs-imagebox img="/img/docs/v50/variables_var_list.png" max-width="800px" >}}
|
||||
|
||||
You add variables via Dashboard cogs menu > Templating. This opens up a list of variables and a `New` button to create a new variable.
|
||||
|
||||
@ -133,7 +133,7 @@ Option | Description
|
||||
*Tags query* | Data source query that should return a list of tags
|
||||
*Tag values query* | Data source query that should return a list of values for a specified tag key. Use `$tag` in the query to refer the currently selected tag.
|
||||
|
||||

|
||||
{{< docs-imagebox img="/img/docs/v50/variable_dropdown_tags.png" max-width="300px" >}}
|
||||
|
||||
### Interval variables
|
||||
|
||||
|
@ -13,7 +13,7 @@ weight = 7
|
||||
|
||||
Grafana provides numerous ways to manage the time ranges of the data being visualized, both at the Dashboard-level and the Panel-level.
|
||||
|
||||
<img class="no-shadow" src="/img/docs/whatsnew_2_5/timepicker.png">
|
||||
<img class="no-shadow" src="/img/docs/v50/timepicker.png" width="700px">
|
||||
|
||||
In the top right, you have the master Dashboard time picker (it's in between the 'Zoom out' and the 'Refresh' links).
|
||||
|
||||
@ -39,11 +39,11 @@ Week to date | `now/w` | `now`
|
||||
Previous Month | `now-1M/M` | `now-1M/M`
|
||||
|
||||
|
||||
## Dashboard-Level Time Picker Settings
|
||||
## Dashboard Time Options
|
||||
|
||||
There are two settings available from the Dashboard Settings area, allowing customization of the auto-refresh intervals and the definition of `now`.
|
||||
There are two settings available in the Dashboard Settings General tab, allowing customization of the auto-refresh intervals and the definition of `now`.
|
||||
|
||||
<img class="no-shadow" src="/img/docs/v2/TimePicker-TimeOptions.png">
|
||||
<img class="no-shadow" src="/img/docs/v50/time_options.png" width="500px">
|
||||
|
||||
### Auto-Refresh Options
|
||||
|
||||
@ -59,11 +59,11 @@ Users often ask, [when will then be now](https://www.youtube.com/watch?v=VeZ9HhH
|
||||
|
||||
You can override the relative time range for individual panels, causing them to be different than what is selected in the Dashboard time picker in the upper right. This allows you to show metrics from different time periods or days at the same time.
|
||||
|
||||
<img class="no-shadow" src="/img/docs/v2/panel_time_override.jpg">
|
||||
{{< docs-imagebox img="/img/docs/v50/panel_time_override.png" max-width="500px" >}}
|
||||
|
||||
You control these overrides in panel editor mode and the tab `Time Range`.
|
||||
|
||||
<img class="no-shadow" src="/img/docs/v2/time_range_tab.jpg">
|
||||
{{< docs-imagebox img="/img/docs/v50/time_range_tab.png" max-width="500px" >}}
|
||||
|
||||
When you zoom or change the Dashboard time to a custom absolute time range, all panel overrides will be disabled. The panel relative time override is only active when the dashboard time is also relative. The panel timeshift override is always active, even when the dashboard time is absolute.
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
[
|
||||
{ "version": "v5.0", "path": "/v5.0", "archived": false },
|
||||
{ "version": "v4.6", "path": "/", "archived": false, "current": true },
|
||||
{ "version": "v5.0", "path": "/", "archived": false, "current": true },
|
||||
{ "version": "v4.6", "path": "/v4.6", "archived": true },
|
||||
{ "version": "v4.5", "path": "/v4.5", "archived": true },
|
||||
{ "version": "v4.4", "path": "/v4.4", "archived": true },
|
||||
{ "version": "v4.3", "path": "/v4.3", "archived": true },
|
||||
|
@ -1,4 +1,4 @@
|
||||
{
|
||||
"stable": "4.6.2",
|
||||
"testing": "4.6.2"
|
||||
"stable": "5.0.0",
|
||||
"testing": "5.0.0"
|
||||
}
|
||||
|
@ -4,7 +4,7 @@
|
||||
"company": "Grafana Labs"
|
||||
},
|
||||
"name": "grafana",
|
||||
"version": "5.0.0-beta4",
|
||||
"version": "5.0.1-pre1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "http://github.com/grafana/grafana.git"
|
||||
@ -155,7 +155,7 @@
|
||||
"prop-types": "^15.6.0",
|
||||
"react": "^16.2.0",
|
||||
"react-dom": "^16.2.0",
|
||||
"react-grid-layout": "^0.16.2",
|
||||
"react-grid-layout-grafana": "0.16.0",
|
||||
"react-highlight-words": "^0.10.0",
|
||||
"react-popper": "^0.7.5",
|
||||
"react-select": "^1.1.0",
|
||||
|
@ -1,6 +1,6 @@
|
||||
#! /usr/bin/env bash
|
||||
deb_ver=5.0.0-beta4
|
||||
rpm_ver=5.0.0-beta4
|
||||
deb_ver=5.0.0-beta5
|
||||
rpm_ver=5.0.0-beta5
|
||||
|
||||
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${deb_ver}_amd64.deb
|
||||
|
||||
|
@ -411,6 +411,18 @@ func GetDashboardVersion(c *middleware.Context) Response {
|
||||
// POST /api/dashboards/calculate-diff performs diffs on two dashboards
|
||||
func CalculateDashboardDiff(c *middleware.Context, apiOptions dtos.CalculateDiffOptions) Response {
|
||||
|
||||
guardianBase := guardian.New(apiOptions.Base.DashboardId, c.OrgId, c.SignedInUser)
|
||||
if canSave, err := guardianBase.CanSave(); err != nil || !canSave {
|
||||
return dashboardGuardianResponse(err)
|
||||
}
|
||||
|
||||
if apiOptions.Base.DashboardId != apiOptions.New.DashboardId {
|
||||
guardianNew := guardian.New(apiOptions.New.DashboardId, c.OrgId, c.SignedInUser)
|
||||
if canSave, err := guardianNew.CanSave(); err != nil || !canSave {
|
||||
return dashboardGuardianResponse(err)
|
||||
}
|
||||
}
|
||||
|
||||
options := dashdiffs.Options{
|
||||
OrgId: c.OrgId,
|
||||
DiffType: dashdiffs.ParseDiffType(apiOptions.DiffType),
|
||||
@ -436,9 +448,9 @@ func CalculateDashboardDiff(c *middleware.Context, apiOptions dtos.CalculateDiff
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
return Respond(200, result.Delta).Header("Content-Type", "text/html")
|
||||
}
|
||||
|
||||
// RestoreDashboardVersion restores a dashboard to the given version.
|
||||
|
@ -18,13 +18,13 @@ func GetDashboardPermissionList(c *middleware.Context) Response {
|
||||
return rsp
|
||||
}
|
||||
|
||||
guardian := guardian.New(dashId, c.OrgId, c.SignedInUser)
|
||||
g := guardian.New(dashId, c.OrgId, c.SignedInUser)
|
||||
|
||||
if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin {
|
||||
if canAdmin, err := g.CanAdmin(); err != nil || !canAdmin {
|
||||
return dashboardGuardianResponse(err)
|
||||
}
|
||||
|
||||
acl, err := guardian.GetAcl()
|
||||
acl, err := g.GetAcl()
|
||||
if err != nil {
|
||||
return ApiError(500, "Failed to get dashboard permissions", err)
|
||||
}
|
||||
@ -46,8 +46,8 @@ func UpdateDashboardPermissions(c *middleware.Context, apiCmd dtos.UpdateDashboa
|
||||
return rsp
|
||||
}
|
||||
|
||||
guardian := guardian.New(dashId, c.OrgId, c.SignedInUser)
|
||||
if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin {
|
||||
g := guardian.New(dashId, c.OrgId, c.SignedInUser)
|
||||
if canAdmin, err := g.CanAdmin(); err != nil || !canAdmin {
|
||||
return dashboardGuardianResponse(err)
|
||||
}
|
||||
|
||||
@ -67,8 +67,13 @@ func UpdateDashboardPermissions(c *middleware.Context, apiCmd dtos.UpdateDashboa
|
||||
})
|
||||
}
|
||||
|
||||
if okToUpdate, err := guardian.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, cmd.Items); err != nil || !okToUpdate {
|
||||
if okToUpdate, err := g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, cmd.Items); err != nil || !okToUpdate {
|
||||
if err != nil {
|
||||
if err == guardian.ErrGuardianPermissionExists ||
|
||||
err == guardian.ErrGuardianOverride {
|
||||
return ApiError(400, err.Error(), err)
|
||||
}
|
||||
|
||||
return ApiError(500, "Error while checking dashboard permissions", err)
|
||||
}
|
||||
|
||||
|
@ -8,183 +8,180 @@ import (
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/middleware"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/guardian"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestDashboardPermissionApiEndpoint(t *testing.T) {
|
||||
Convey("Given a dashboard with permissions", t, func() {
|
||||
mockResult := []*m.DashboardAclInfoDTO{
|
||||
{OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW},
|
||||
{OrgId: 1, DashboardId: 1, UserId: 3, Permission: m.PERMISSION_EDIT},
|
||||
{OrgId: 1, DashboardId: 1, UserId: 4, Permission: m.PERMISSION_ADMIN},
|
||||
{OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW},
|
||||
{OrgId: 1, DashboardId: 1, TeamId: 2, Permission: m.PERMISSION_ADMIN},
|
||||
}
|
||||
dtoRes := transformDashboardAclsToDTOs(mockResult)
|
||||
|
||||
getDashboardQueryResult := m.NewDashboard("Dash")
|
||||
var getDashboardNotFoundError error
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
|
||||
query.Result = getDashboardQueryResult
|
||||
return getDashboardNotFoundError
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
|
||||
query.Result = dtoRes
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
|
||||
query.Result = mockResult
|
||||
return nil
|
||||
})
|
||||
|
||||
teamResp := []*m.Team{}
|
||||
bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
|
||||
query.Result = teamResp
|
||||
return nil
|
||||
})
|
||||
|
||||
// This tests four scenarios:
|
||||
// 1. user is an org admin
|
||||
// 2. user is an org editor AND has been granted admin permission for the dashboard
|
||||
// 3. user is an org viewer AND has been granted edit permission for the dashboard
|
||||
// 4. user is an org editor AND has no permissions for the dashboard
|
||||
|
||||
Convey("When user is org admin", func() {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardsId/permissions", m.ROLE_ADMIN, func(sc *scenarioContext) {
|
||||
Convey("Should be able to access ACL", func() {
|
||||
sc.handlerFunc = GetDashboardPermissionList
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
|
||||
respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
|
||||
So(err, ShouldBeNil)
|
||||
So(len(respJSON.MustArray()), ShouldEqual, 5)
|
||||
So(respJSON.GetIndex(0).Get("userId").MustInt(), ShouldEqual, 2)
|
||||
So(respJSON.GetIndex(0).Get("permission").MustInt(), ShouldEqual, m.PERMISSION_VIEW)
|
||||
})
|
||||
Convey("Dashboard permissions test", t, func() {
|
||||
Convey("Given dashboard not exists", func() {
|
||||
bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
|
||||
return m.ErrDashboardNotFound
|
||||
})
|
||||
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/permissions", "/api/dashboards/id/:dashboardId/permissions", m.ROLE_ADMIN, func(sc *scenarioContext) {
|
||||
getDashboardNotFoundError = m.ErrDashboardNotFound
|
||||
sc.handlerFunc = GetDashboardPermissionList
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||
|
||||
Convey("Should not be able to access ACL", func() {
|
||||
So(sc.resp.Code, ShouldEqual, 404)
|
||||
})
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
callGetDashboardPermissions(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 404)
|
||||
})
|
||||
|
||||
Convey("Should not be able to update permissions for non-existing dashboard", func() {
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
Items: []dtos.DashboardAclUpdateItem{
|
||||
{UserId: 1000, Permission: m.PERMISSION_ADMIN},
|
||||
},
|
||||
}
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
Items: []dtos.DashboardAclUpdateItem{
|
||||
{UserId: 1000, Permission: m.PERMISSION_ADMIN},
|
||||
},
|
||||
}
|
||||
|
||||
postAclScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardId/permissions", m.ROLE_ADMIN, cmd, func(sc *scenarioContext) {
|
||||
getDashboardNotFoundError = m.ErrDashboardNotFound
|
||||
CallPostAcl(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 404)
|
||||
})
|
||||
updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) {
|
||||
callUpdateDashboardPermissions(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 404)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When user is org editor and has admin permission in the ACL", func() {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardId/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
|
||||
Convey("Given user has no admin permissions", func() {
|
||||
origNewGuardian := guardian.New
|
||||
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanAdminValue: false})
|
||||
|
||||
Convey("Should be able to access ACL", func() {
|
||||
sc.handlerFunc = GetDashboardPermissionList
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
})
|
||||
getDashboardQueryResult := m.NewDashboard("Dash")
|
||||
bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
|
||||
query.Result = getDashboardQueryResult
|
||||
return nil
|
||||
})
|
||||
|
||||
Convey("Should not be able to downgrade their own Admin permission", func() {
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
Items: []dtos.DashboardAclUpdateItem{
|
||||
{UserId: TestUserID, Permission: m.PERMISSION_EDIT},
|
||||
},
|
||||
}
|
||||
|
||||
postAclScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardId/permissions", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) {
|
||||
mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
|
||||
|
||||
CallPostAcl(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
})
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
callGetDashboardPermissions(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
})
|
||||
|
||||
Convey("Should be able to update permissions", func() {
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
Items: []dtos.DashboardAclUpdateItem{
|
||||
{UserId: TestUserID, Permission: m.PERMISSION_ADMIN},
|
||||
{UserId: 2, Permission: m.PERMISSION_EDIT},
|
||||
},
|
||||
}
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
Items: []dtos.DashboardAclUpdateItem{
|
||||
{UserId: 1000, Permission: m.PERMISSION_ADMIN},
|
||||
},
|
||||
}
|
||||
|
||||
postAclScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardId/permissions", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) {
|
||||
mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
|
||||
|
||||
CallPostAcl(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
})
|
||||
updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) {
|
||||
callUpdateDashboardPermissions(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
Convey("When user is org viewer and has edit permission in the ACL", func() {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardId/permissions", m.ROLE_VIEWER, func(sc *scenarioContext) {
|
||||
mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT})
|
||||
|
||||
// Getting the permissions is an Admin permission
|
||||
Convey("Should not be able to get list of permissions from ACL", func() {
|
||||
sc.handlerFunc = GetDashboardPermissionList
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
})
|
||||
Reset(func() {
|
||||
guardian.New = origNewGuardian
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When user is org editor and not in the ACL", func() {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardsId/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
Convey("Given user has admin permissions and permissions to update", func() {
|
||||
origNewGuardian := guardian.New
|
||||
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
|
||||
CanAdminValue: true,
|
||||
CheckPermissionBeforeUpdateValue: true,
|
||||
GetAclValue: []*m.DashboardAclInfoDTO{
|
||||
{OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW},
|
||||
{OrgId: 1, DashboardId: 1, UserId: 3, Permission: m.PERMISSION_EDIT},
|
||||
{OrgId: 1, DashboardId: 1, UserId: 4, Permission: m.PERMISSION_ADMIN},
|
||||
{OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW},
|
||||
{OrgId: 1, DashboardId: 1, TeamId: 2, Permission: m.PERMISSION_ADMIN},
|
||||
},
|
||||
})
|
||||
|
||||
Convey("Should not be able to access ACL", func() {
|
||||
sc.handlerFunc = GetDashboardPermissionList
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||
getDashboardQueryResult := m.NewDashboard("Dash")
|
||||
bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
|
||||
query.Result = getDashboardQueryResult
|
||||
return nil
|
||||
})
|
||||
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
})
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", m.ROLE_ADMIN, func(sc *scenarioContext) {
|
||||
callGetDashboardPermissions(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
|
||||
So(err, ShouldBeNil)
|
||||
So(len(respJSON.MustArray()), ShouldEqual, 5)
|
||||
So(respJSON.GetIndex(0).Get("userId").MustInt(), ShouldEqual, 2)
|
||||
So(respJSON.GetIndex(0).Get("permission").MustInt(), ShouldEqual, m.PERMISSION_VIEW)
|
||||
})
|
||||
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
Items: []dtos.DashboardAclUpdateItem{
|
||||
{UserId: 1000, Permission: m.PERMISSION_ADMIN},
|
||||
},
|
||||
}
|
||||
|
||||
updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) {
|
||||
callUpdateDashboardPermissions(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
guardian.New = origNewGuardian
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When trying to update permissions with duplicate permissions", func() {
|
||||
origNewGuardian := guardian.New
|
||||
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
|
||||
CanAdminValue: true,
|
||||
CheckPermissionBeforeUpdateValue: false,
|
||||
CheckPermissionBeforeUpdateError: guardian.ErrGuardianPermissionExists,
|
||||
})
|
||||
|
||||
getDashboardQueryResult := m.NewDashboard("Dash")
|
||||
bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
|
||||
query.Result = getDashboardQueryResult
|
||||
return nil
|
||||
})
|
||||
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
Items: []dtos.DashboardAclUpdateItem{
|
||||
{UserId: 1000, Permission: m.PERMISSION_ADMIN},
|
||||
},
|
||||
}
|
||||
|
||||
updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) {
|
||||
callUpdateDashboardPermissions(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 400)
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
guardian.New = origNewGuardian
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When trying to override inherited permissions with lower presedence", func() {
|
||||
origNewGuardian := guardian.New
|
||||
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
|
||||
CanAdminValue: true,
|
||||
CheckPermissionBeforeUpdateValue: false,
|
||||
CheckPermissionBeforeUpdateError: guardian.ErrGuardianOverride},
|
||||
)
|
||||
|
||||
getDashboardQueryResult := m.NewDashboard("Dash")
|
||||
bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
|
||||
query.Result = getDashboardQueryResult
|
||||
return nil
|
||||
})
|
||||
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
Items: []dtos.DashboardAclUpdateItem{
|
||||
{UserId: 1000, Permission: m.PERMISSION_ADMIN},
|
||||
},
|
||||
}
|
||||
|
||||
updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) {
|
||||
callUpdateDashboardPermissions(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 400)
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
guardian.New = origNewGuardian
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func transformDashboardAclsToDTOs(acls []*m.DashboardAclInfoDTO) []*m.DashboardAclInfoDTO {
|
||||
dtos := make([]*m.DashboardAclInfoDTO, 0)
|
||||
|
||||
for _, acl := range acls {
|
||||
dto := &m.DashboardAclInfoDTO{
|
||||
OrgId: acl.OrgId,
|
||||
DashboardId: acl.DashboardId,
|
||||
Permission: acl.Permission,
|
||||
UserId: acl.UserId,
|
||||
TeamId: acl.TeamId,
|
||||
}
|
||||
dtos = append(dtos, dto)
|
||||
}
|
||||
|
||||
return dtos
|
||||
func callGetDashboardPermissions(sc *scenarioContext) {
|
||||
sc.handlerFunc = GetDashboardPermissionList
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||
}
|
||||
|
||||
func CallPostAcl(sc *scenarioContext) {
|
||||
func callUpdateDashboardPermissions(sc *scenarioContext) {
|
||||
bus.AddHandler("test", func(cmd *m.UpdateDashboardAclCommand) error {
|
||||
return nil
|
||||
})
|
||||
@ -192,7 +189,7 @@ func CallPostAcl(sc *scenarioContext) {
|
||||
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||
}
|
||||
|
||||
func postAclScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.UpdateDashboardAclCommand, fn scenarioFunc) {
|
||||
func updateDashboardPermissionScenario(desc string, url string, routePattern string, cmd dtos.UpdateDashboardAclCommand, fn scenarioFunc) {
|
||||
Convey(desc+" "+url, func() {
|
||||
defer bus.ClearBusHandlers()
|
||||
|
||||
@ -200,9 +197,8 @@ func postAclScenario(desc string, url string, routePattern string, role m.RoleTy
|
||||
|
||||
sc.defaultHandler = wrap(func(c *middleware.Context) Response {
|
||||
sc.context = c
|
||||
sc.context.UserId = TestUserID
|
||||
sc.context.OrgId = TestOrgID
|
||||
sc.context.OrgRole = role
|
||||
sc.context.UserId = TestUserID
|
||||
|
||||
return UpdateDashboardPermissions(c, cmd)
|
||||
})
|
||||
|
@ -743,6 +743,53 @@ func TestDashboardApiEndpoint(t *testing.T) {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given two dashboards being compared", t, func() {
|
||||
mockResult := []*m.DashboardAclInfoDTO{}
|
||||
bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
|
||||
query.Result = mockResult
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardVersionQuery) error {
|
||||
query.Result = &m.DashboardVersion{
|
||||
Data: simplejson.NewFromAny(map[string]interface{}{
|
||||
"title": "Dash" + string(query.DashboardId),
|
||||
}),
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
cmd := dtos.CalculateDiffOptions{
|
||||
Base: dtos.CalculateDiffTarget{
|
||||
DashboardId: 1,
|
||||
Version: 1,
|
||||
},
|
||||
New: dtos.CalculateDiffTarget{
|
||||
DashboardId: 2,
|
||||
Version: 2,
|
||||
},
|
||||
DiffType: "basic",
|
||||
}
|
||||
|
||||
Convey("when user does not have permission", func() {
|
||||
role := m.ROLE_VIEWER
|
||||
|
||||
postDiffScenario("When calling POST on", "/api/dashboards/calculate-diff", "/api/dashboards/calculate-diff", cmd, role, func(sc *scenarioContext) {
|
||||
CallPostDashboard(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 403)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("when user does have permission", func() {
|
||||
role := m.ROLE_ADMIN
|
||||
|
||||
postDiffScenario("When calling POST on", "/api/dashboards/calculate-diff", "/api/dashboards/calculate-diff", cmd, role, func(sc *scenarioContext) {
|
||||
CallPostDashboard(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta {
|
||||
@ -835,6 +882,28 @@ func postDashboardScenario(desc string, url string, routePattern string, mock *d
|
||||
})
|
||||
}
|
||||
|
||||
func postDiffScenario(desc string, url string, routePattern string, cmd dtos.CalculateDiffOptions, role m.RoleType, fn scenarioFunc) {
|
||||
Convey(desc+" "+url, func() {
|
||||
defer bus.ClearBusHandlers()
|
||||
|
||||
sc := setupScenarioContext(url)
|
||||
sc.defaultHandler = wrap(func(c *middleware.Context) Response {
|
||||
sc.context = c
|
||||
sc.context.SignedInUser = &m.SignedInUser{
|
||||
OrgId: TestOrgID,
|
||||
UserId: TestUserID,
|
||||
}
|
||||
sc.context.OrgRole = role
|
||||
|
||||
return CalculateDashboardDiff(c, cmd)
|
||||
})
|
||||
|
||||
sc.m.Post(routePattern, sc.defaultHandler)
|
||||
|
||||
fn(sc)
|
||||
})
|
||||
}
|
||||
|
||||
func (sc *scenarioContext) ToJson() *simplejson.Json {
|
||||
var result *simplejson.Json
|
||||
err := json.NewDecoder(sc.resp.Body).Decode(&result)
|
||||
|
@ -19,13 +19,13 @@ func GetFolderPermissionList(c *middleware.Context) Response {
|
||||
return toFolderError(err)
|
||||
}
|
||||
|
||||
guardian := guardian.New(folder.Id, c.OrgId, c.SignedInUser)
|
||||
g := guardian.New(folder.Id, c.OrgId, c.SignedInUser)
|
||||
|
||||
if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin {
|
||||
if canAdmin, err := g.CanAdmin(); err != nil || !canAdmin {
|
||||
return toFolderError(m.ErrFolderAccessDenied)
|
||||
}
|
||||
|
||||
acl, err := guardian.GetAcl()
|
||||
acl, err := g.GetAcl()
|
||||
if err != nil {
|
||||
return ApiError(500, "Failed to get folder permissions", err)
|
||||
}
|
||||
@ -50,8 +50,8 @@ func UpdateFolderPermissions(c *middleware.Context, apiCmd dtos.UpdateDashboardA
|
||||
return toFolderError(err)
|
||||
}
|
||||
|
||||
guardian := guardian.New(folder.Id, c.OrgId, c.SignedInUser)
|
||||
canAdmin, err := guardian.CanAdmin()
|
||||
g := guardian.New(folder.Id, c.OrgId, c.SignedInUser)
|
||||
canAdmin, err := g.CanAdmin()
|
||||
if err != nil {
|
||||
return toFolderError(err)
|
||||
}
|
||||
@ -76,8 +76,13 @@ func UpdateFolderPermissions(c *middleware.Context, apiCmd dtos.UpdateDashboardA
|
||||
})
|
||||
}
|
||||
|
||||
if okToUpdate, err := guardian.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, cmd.Items); err != nil || !okToUpdate {
|
||||
if okToUpdate, err := g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, cmd.Items); err != nil || !okToUpdate {
|
||||
if err != nil {
|
||||
if err == guardian.ErrGuardianPermissionExists ||
|
||||
err == guardian.ErrGuardianOverride {
|
||||
return ApiError(400, err.Error(), err)
|
||||
}
|
||||
|
||||
return ApiError(500, "Error while checking folder permissions", err)
|
||||
}
|
||||
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/middleware"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
@ -15,6 +16,35 @@ import (
|
||||
|
||||
func TestFolderPermissionApiEndpoint(t *testing.T) {
|
||||
Convey("Folder permissions test", t, func() {
|
||||
Convey("Given folder not exists", func() {
|
||||
mock := &fakeFolderService{
|
||||
GetFolderByUidError: m.ErrFolderNotFound,
|
||||
}
|
||||
|
||||
origNewFolderService := dashboards.NewFolderService
|
||||
mockFolderService(mock)
|
||||
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
callGetFolderPermissions(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 404)
|
||||
})
|
||||
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
Items: []dtos.DashboardAclUpdateItem{
|
||||
{UserId: 1000, Permission: m.PERMISSION_ADMIN},
|
||||
},
|
||||
}
|
||||
|
||||
updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) {
|
||||
callUpdateFolderPermissions(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 404)
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
dashboards.NewFolderService = origNewFolderService
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given user has no admin permissions", func() {
|
||||
origNewGuardian := guardian.New
|
||||
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanAdminValue: false})
|
||||
@ -54,7 +84,17 @@ func TestFolderPermissionApiEndpoint(t *testing.T) {
|
||||
|
||||
Convey("Given user has admin permissions and permissions to update", func() {
|
||||
origNewGuardian := guardian.New
|
||||
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanAdminValue: true, CheckPermissionBeforeUpdateValue: true})
|
||||
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
|
||||
CanAdminValue: true,
|
||||
CheckPermissionBeforeUpdateValue: true,
|
||||
GetAclValue: []*m.DashboardAclInfoDTO{
|
||||
{OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW},
|
||||
{OrgId: 1, DashboardId: 1, UserId: 3, Permission: m.PERMISSION_EDIT},
|
||||
{OrgId: 1, DashboardId: 1, UserId: 4, Permission: m.PERMISSION_ADMIN},
|
||||
{OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW},
|
||||
{OrgId: 1, DashboardId: 1, TeamId: 2, Permission: m.PERMISSION_ADMIN},
|
||||
},
|
||||
})
|
||||
|
||||
mock := &fakeFolderService{
|
||||
GetFolderByUidResult: &m.Folder{
|
||||
@ -70,6 +110,11 @@ func TestFolderPermissionApiEndpoint(t *testing.T) {
|
||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", m.ROLE_ADMIN, func(sc *scenarioContext) {
|
||||
callGetFolderPermissions(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 200)
|
||||
respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
|
||||
So(err, ShouldBeNil)
|
||||
So(len(respJSON.MustArray()), ShouldEqual, 5)
|
||||
So(respJSON.GetIndex(0).Get("userId").MustInt(), ShouldEqual, 2)
|
||||
So(respJSON.GetIndex(0).Get("permission").MustInt(), ShouldEqual, m.PERMISSION_VIEW)
|
||||
})
|
||||
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
@ -88,6 +133,78 @@ func TestFolderPermissionApiEndpoint(t *testing.T) {
|
||||
dashboards.NewFolderService = origNewFolderService
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When trying to update permissions with duplicate permissions", func() {
|
||||
origNewGuardian := guardian.New
|
||||
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
|
||||
CanAdminValue: true,
|
||||
CheckPermissionBeforeUpdateValue: false,
|
||||
CheckPermissionBeforeUpdateError: guardian.ErrGuardianPermissionExists,
|
||||
})
|
||||
|
||||
mock := &fakeFolderService{
|
||||
GetFolderByUidResult: &m.Folder{
|
||||
Id: 1,
|
||||
Uid: "uid",
|
||||
Title: "Folder",
|
||||
},
|
||||
}
|
||||
|
||||
origNewFolderService := dashboards.NewFolderService
|
||||
mockFolderService(mock)
|
||||
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
Items: []dtos.DashboardAclUpdateItem{
|
||||
{UserId: 1000, Permission: m.PERMISSION_ADMIN},
|
||||
},
|
||||
}
|
||||
|
||||
updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) {
|
||||
callUpdateFolderPermissions(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 400)
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
guardian.New = origNewGuardian
|
||||
dashboards.NewFolderService = origNewFolderService
|
||||
})
|
||||
})
|
||||
|
||||
Convey("When trying to override inherited permissions with lower presedence", func() {
|
||||
origNewGuardian := guardian.New
|
||||
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
|
||||
CanAdminValue: true,
|
||||
CheckPermissionBeforeUpdateValue: false,
|
||||
CheckPermissionBeforeUpdateError: guardian.ErrGuardianOverride},
|
||||
)
|
||||
|
||||
mock := &fakeFolderService{
|
||||
GetFolderByUidResult: &m.Folder{
|
||||
Id: 1,
|
||||
Uid: "uid",
|
||||
Title: "Folder",
|
||||
},
|
||||
}
|
||||
|
||||
origNewFolderService := dashboards.NewFolderService
|
||||
mockFolderService(mock)
|
||||
|
||||
cmd := dtos.UpdateDashboardAclCommand{
|
||||
Items: []dtos.DashboardAclUpdateItem{
|
||||
{UserId: 1000, Permission: m.PERMISSION_ADMIN},
|
||||
},
|
||||
}
|
||||
|
||||
updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) {
|
||||
callUpdateFolderPermissions(sc)
|
||||
So(sc.resp.Code, ShouldEqual, 400)
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
guardian.New = origNewGuardian
|
||||
dashboards.NewFolderService = origNewFolderService
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -68,6 +68,27 @@ type DashboardAclInfoDTO struct {
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
func (dto *DashboardAclInfoDTO) hasSameRoleAs(other *DashboardAclInfoDTO) bool {
|
||||
if dto.Role == nil || other.Role == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return dto.UserId <= 0 && dto.TeamId <= 0 && dto.UserId == other.UserId && dto.TeamId == other.TeamId && *dto.Role == *other.Role
|
||||
}
|
||||
|
||||
func (dto *DashboardAclInfoDTO) hasSameUserAs(other *DashboardAclInfoDTO) bool {
|
||||
return dto.UserId > 0 && dto.UserId == other.UserId
|
||||
}
|
||||
|
||||
func (dto *DashboardAclInfoDTO) hasSameTeamAs(other *DashboardAclInfoDTO) bool {
|
||||
return dto.TeamId > 0 && dto.TeamId == other.TeamId
|
||||
}
|
||||
|
||||
// IsDuplicateOf returns true if other item has same role, same user or same team
|
||||
func (dto *DashboardAclInfoDTO) IsDuplicateOf(other *DashboardAclInfoDTO) bool {
|
||||
return dto.hasSameRoleAs(other) || dto.hasSameUserAs(other) || dto.hasSameTeamAs(other)
|
||||
}
|
||||
|
||||
//
|
||||
// COMMANDS
|
||||
//
|
||||
|
@ -82,6 +82,8 @@ func (this *TeamsNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
message := this.Mention
|
||||
if evalContext.Rule.State != m.AlertStateOK { //dont add message when going back to alert state ok.
|
||||
message += " " + evalContext.Rule.Message
|
||||
} else {
|
||||
message += " " // summary must not be empty
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
|
@ -1,12 +1,19 @@
|
||||
package guardian
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrGuardianPermissionExists = errors.New("Permission already exists")
|
||||
ErrGuardianOverride = errors.New("You can only override a permission to be higher")
|
||||
)
|
||||
|
||||
// DashboardGuardian to be used for guard against operations without access on dashboard and acl
|
||||
type DashboardGuardian interface {
|
||||
CanSave() (bool, error)
|
||||
@ -119,14 +126,51 @@ func (g *dashboardGuardianImpl) checkAcl(permission m.PermissionType, acl []*m.D
|
||||
}
|
||||
|
||||
func (g *dashboardGuardianImpl) CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) {
|
||||
if g.user.OrgRole == m.ROLE_ADMIN {
|
||||
return true, nil
|
||||
acl := []*m.DashboardAclInfoDTO{}
|
||||
adminRole := m.ROLE_ADMIN
|
||||
everyoneWithAdminRole := &m.DashboardAclInfoDTO{DashboardId: g.dashId, UserId: 0, TeamId: 0, Role: &adminRole, Permission: m.PERMISSION_ADMIN}
|
||||
|
||||
// validate that duplicate permissions don't exists
|
||||
for _, p := range updatePermissions {
|
||||
aclItem := &m.DashboardAclInfoDTO{DashboardId: p.DashboardId, UserId: p.UserId, TeamId: p.TeamId, Role: p.Role, Permission: p.Permission}
|
||||
if aclItem.IsDuplicateOf(everyoneWithAdminRole) {
|
||||
return false, ErrGuardianPermissionExists
|
||||
}
|
||||
|
||||
for _, a := range acl {
|
||||
if a.IsDuplicateOf(aclItem) {
|
||||
return false, ErrGuardianPermissionExists
|
||||
}
|
||||
}
|
||||
|
||||
acl = append(acl, aclItem)
|
||||
}
|
||||
|
||||
acl := []*m.DashboardAclInfoDTO{}
|
||||
existingPermissions, err := g.GetAcl()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, p := range updatePermissions {
|
||||
acl = append(acl, &m.DashboardAclInfoDTO{UserId: p.UserId, TeamId: p.TeamId, Role: p.Role, Permission: p.Permission})
|
||||
// validate overridden permissions to be higher
|
||||
for _, a := range acl {
|
||||
for _, existingPerm := range existingPermissions {
|
||||
// handle default permissions
|
||||
if existingPerm.DashboardId == -1 {
|
||||
existingPerm.DashboardId = g.dashId
|
||||
}
|
||||
|
||||
if a.DashboardId == existingPerm.DashboardId {
|
||||
continue
|
||||
}
|
||||
|
||||
if a.IsDuplicateOf(existingPerm) && a.Permission <= existingPerm.Permission {
|
||||
return false, ErrGuardianOverride
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if g.user.OrgRole == m.ROLE_ADMIN {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return g.checkAcl(permission, acl)
|
||||
@ -143,6 +187,13 @@ func (g *dashboardGuardianImpl) GetAcl() ([]*m.DashboardAclInfoDTO, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, a := range query.Result {
|
||||
// handle default permissions
|
||||
if a.DashboardId == -1 {
|
||||
a.DashboardId = g.dashId
|
||||
}
|
||||
}
|
||||
|
||||
g.acl = query.Result
|
||||
return g.acl, nil
|
||||
}
|
||||
@ -169,6 +220,8 @@ type FakeDashboardGuardian struct {
|
||||
CanAdminValue bool
|
||||
HasPermissionValue bool
|
||||
CheckPermissionBeforeUpdateValue bool
|
||||
CheckPermissionBeforeUpdateError error
|
||||
GetAclValue []*m.DashboardAclInfoDTO
|
||||
}
|
||||
|
||||
func (g *FakeDashboardGuardian) CanSave() (bool, error) {
|
||||
@ -192,11 +245,11 @@ func (g *FakeDashboardGuardian) HasPermission(permission m.PermissionType) (bool
|
||||
}
|
||||
|
||||
func (g *FakeDashboardGuardian) CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) {
|
||||
return g.CheckPermissionBeforeUpdateValue, nil
|
||||
return g.CheckPermissionBeforeUpdateValue, g.CheckPermissionBeforeUpdateError
|
||||
}
|
||||
|
||||
func (g *FakeDashboardGuardian) GetAcl() ([]*m.DashboardAclInfoDTO, error) {
|
||||
return nil, nil
|
||||
return g.GetAclValue, nil
|
||||
}
|
||||
|
||||
func MockDashboardGuardian(mock *FakeDashboardGuardian) {
|
||||
|
711
pkg/services/guardian/guardian_test.go
Normal file
711
pkg/services/guardian/guardian_test.go
Normal file
@ -0,0 +1,711 @@
|
||||
package guardian
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestGuardian(t *testing.T) {
|
||||
Convey("Guardian permission tests", t, func() {
|
||||
orgRoleScenario("Given user has admin org role", m.ROLE_ADMIN, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeTrue)
|
||||
So(canEdit, ShouldBeTrue)
|
||||
So(canSave, ShouldBeTrue)
|
||||
So(canView, ShouldBeTrue)
|
||||
|
||||
Convey("When trying to update permissions", func() {
|
||||
Convey("With duplicate user permissions should return error", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_VIEW},
|
||||
{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianPermissionExists)
|
||||
})
|
||||
|
||||
Convey("With duplicate team permissions should return error", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_VIEW},
|
||||
{OrgId: 1, DashboardId: 1, TeamId: 1, Permission: m.PERMISSION_ADMIN},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianPermissionExists)
|
||||
})
|
||||
|
||||
Convey("With duplicate everyone with editor role permission should return error", func() {
|
||||
r := m.ROLE_EDITOR
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_VIEW},
|
||||
{OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_ADMIN},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianPermissionExists)
|
||||
})
|
||||
|
||||
Convey("With duplicate everyone with viewer role permission should return error", func() {
|
||||
r := m.ROLE_VIEWER
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_VIEW},
|
||||
{OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_ADMIN},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianPermissionExists)
|
||||
})
|
||||
|
||||
Convey("With everyone with admin role permission should return error", func() {
|
||||
r := m.ROLE_ADMIN
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_ADMIN},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianPermissionExists)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given default permissions", func() {
|
||||
editor := m.ROLE_EDITOR
|
||||
viewer := m.ROLE_VIEWER
|
||||
existingPermissions := []*m.DashboardAclInfoDTO{
|
||||
{OrgId: 1, DashboardId: -1, Role: &editor, Permission: m.PERMISSION_EDIT},
|
||||
{OrgId: 1, DashboardId: -1, Role: &viewer, Permission: m.PERMISSION_VIEW},
|
||||
}
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
|
||||
query.Result = existingPermissions
|
||||
return nil
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions without everyone with role editor can edit should be allowed", func() {
|
||||
r := m.ROLE_VIEWER
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_VIEW},
|
||||
}
|
||||
ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(ok, ShouldBeTrue)
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions without everyone with role viewer can view should be allowed", func() {
|
||||
r := m.ROLE_EDITOR
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 1, Role: &r, Permission: m.PERMISSION_EDIT},
|
||||
}
|
||||
ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(ok, ShouldBeTrue)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given parent folder has user admin permission", func() {
|
||||
existingPermissions := []*m.DashboardAclInfoDTO{
|
||||
{OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_ADMIN},
|
||||
}
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
|
||||
query.Result = existingPermissions
|
||||
return nil
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with admin user permission should return error", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_ADMIN},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianOverride)
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with edit user permission should return error", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_EDIT},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianOverride)
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with view user permission should return error", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_VIEW},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianOverride)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given parent folder has user edit permission", func() {
|
||||
existingPermissions := []*m.DashboardAclInfoDTO{
|
||||
{OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_EDIT},
|
||||
}
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
|
||||
query.Result = existingPermissions
|
||||
return nil
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with admin user permission should be allowed", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_ADMIN},
|
||||
}
|
||||
ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(ok, ShouldBeTrue)
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with edit user permission should return error", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_EDIT},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianOverride)
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with view user permission should return error", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_VIEW},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianOverride)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given parent folder has user view permission", func() {
|
||||
existingPermissions := []*m.DashboardAclInfoDTO{
|
||||
{OrgId: 1, DashboardId: 2, UserId: 1, Permission: m.PERMISSION_VIEW},
|
||||
}
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
|
||||
query.Result = existingPermissions
|
||||
return nil
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with admin user permission should be allowed", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_ADMIN},
|
||||
}
|
||||
ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(ok, ShouldBeTrue)
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with edit user permission should be allowed", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_EDIT},
|
||||
}
|
||||
ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(ok, ShouldBeTrue)
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with view user permission should return error", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, UserId: 1, Permission: m.PERMISSION_VIEW},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianOverride)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given parent folder has team admin permission", func() {
|
||||
existingPermissions := []*m.DashboardAclInfoDTO{
|
||||
{OrgId: 1, DashboardId: 2, TeamId: 1, Permission: m.PERMISSION_ADMIN},
|
||||
}
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
|
||||
query.Result = existingPermissions
|
||||
return nil
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with admin team permission should return error", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_ADMIN},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianOverride)
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with edit team permission should return error", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_EDIT},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianOverride)
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with view team permission should return error", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_VIEW},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianOverride)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given parent folder has team edit permission", func() {
|
||||
existingPermissions := []*m.DashboardAclInfoDTO{
|
||||
{OrgId: 1, DashboardId: 2, TeamId: 1, Permission: m.PERMISSION_EDIT},
|
||||
}
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
|
||||
query.Result = existingPermissions
|
||||
return nil
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with admin team permission should be allowed", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_ADMIN},
|
||||
}
|
||||
ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(ok, ShouldBeTrue)
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with edit team permission should return error", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_EDIT},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianOverride)
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with view team permission should return error", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_VIEW},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianOverride)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given parent folder has team view permission", func() {
|
||||
existingPermissions := []*m.DashboardAclInfoDTO{
|
||||
{OrgId: 1, DashboardId: 2, TeamId: 1, Permission: m.PERMISSION_VIEW},
|
||||
}
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
|
||||
query.Result = existingPermissions
|
||||
return nil
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with admin team permission should be allowed", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_ADMIN},
|
||||
}
|
||||
ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(ok, ShouldBeTrue)
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with edit team permission should be allowed", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_EDIT},
|
||||
}
|
||||
ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(ok, ShouldBeTrue)
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with view team permission should return error", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, TeamId: 1, Permission: m.PERMISSION_VIEW},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianOverride)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given parent folder has editor role with edit permission", func() {
|
||||
r := m.ROLE_EDITOR
|
||||
existingPermissions := []*m.DashboardAclInfoDTO{
|
||||
{OrgId: 1, DashboardId: 2, Role: &r, Permission: m.PERMISSION_EDIT},
|
||||
}
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
|
||||
query.Result = existingPermissions
|
||||
return nil
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with everyone with editor role can admin permission should be allowed", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_ADMIN},
|
||||
}
|
||||
ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(ok, ShouldBeTrue)
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with everyone with editor role can edit permission should return error", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_EDIT},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianOverride)
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with everyone with editor role can view permission should return error", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_VIEW},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianOverride)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Given parent folder has editor role with view permission", func() {
|
||||
r := m.ROLE_EDITOR
|
||||
existingPermissions := []*m.DashboardAclInfoDTO{
|
||||
{OrgId: 1, DashboardId: 2, Role: &r, Permission: m.PERMISSION_VIEW},
|
||||
}
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
|
||||
query.Result = existingPermissions
|
||||
return nil
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with everyone with viewer role can admin permission should be allowed", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_ADMIN},
|
||||
}
|
||||
ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(ok, ShouldBeTrue)
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with everyone with viewer role can edit permission should be allowed", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_EDIT},
|
||||
}
|
||||
ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(ok, ShouldBeTrue)
|
||||
})
|
||||
|
||||
Convey("When trying to update dashboard permissions with everyone with viewer role can view permission should return error", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 3, Role: &r, Permission: m.PERMISSION_VIEW},
|
||||
}
|
||||
_, err := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(err, ShouldEqual, ErrGuardianOverride)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
orgRoleScenario("Given user has editor org role", m.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeTrue)
|
||||
So(canEdit, ShouldBeTrue)
|
||||
So(canSave, ShouldBeTrue)
|
||||
So(canView, ShouldBeTrue)
|
||||
})
|
||||
|
||||
everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_EDIT, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeFalse)
|
||||
So(canEdit, ShouldBeTrue)
|
||||
So(canSave, ShouldBeTrue)
|
||||
So(canView, ShouldBeTrue)
|
||||
})
|
||||
|
||||
everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_VIEW, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeFalse)
|
||||
So(canEdit, ShouldBeFalse)
|
||||
So(canSave, ShouldBeFalse)
|
||||
So(canView, ShouldBeTrue)
|
||||
})
|
||||
|
||||
everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeFalse)
|
||||
So(canEdit, ShouldBeFalse)
|
||||
So(canSave, ShouldBeFalse)
|
||||
So(canView, ShouldBeFalse)
|
||||
})
|
||||
|
||||
everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_EDIT, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeFalse)
|
||||
So(canEdit, ShouldBeFalse)
|
||||
So(canSave, ShouldBeFalse)
|
||||
So(canView, ShouldBeFalse)
|
||||
})
|
||||
|
||||
everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_VIEW, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeFalse)
|
||||
So(canEdit, ShouldBeFalse)
|
||||
So(canSave, ShouldBeFalse)
|
||||
So(canView, ShouldBeFalse)
|
||||
})
|
||||
|
||||
userWithPermissionScenario(m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeTrue)
|
||||
So(canEdit, ShouldBeTrue)
|
||||
So(canSave, ShouldBeTrue)
|
||||
So(canView, ShouldBeTrue)
|
||||
})
|
||||
|
||||
userWithPermissionScenario(m.PERMISSION_EDIT, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeFalse)
|
||||
So(canEdit, ShouldBeTrue)
|
||||
So(canSave, ShouldBeTrue)
|
||||
So(canView, ShouldBeTrue)
|
||||
})
|
||||
|
||||
userWithPermissionScenario(m.PERMISSION_VIEW, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeFalse)
|
||||
So(canEdit, ShouldBeFalse)
|
||||
So(canSave, ShouldBeFalse)
|
||||
So(canView, ShouldBeTrue)
|
||||
})
|
||||
|
||||
teamWithPermissionScenario(m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeTrue)
|
||||
So(canEdit, ShouldBeTrue)
|
||||
So(canSave, ShouldBeTrue)
|
||||
So(canView, ShouldBeTrue)
|
||||
})
|
||||
|
||||
teamWithPermissionScenario(m.PERMISSION_EDIT, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeFalse)
|
||||
So(canEdit, ShouldBeTrue)
|
||||
So(canSave, ShouldBeTrue)
|
||||
So(canView, ShouldBeTrue)
|
||||
})
|
||||
|
||||
teamWithPermissionScenario(m.PERMISSION_VIEW, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeFalse)
|
||||
So(canEdit, ShouldBeFalse)
|
||||
So(canSave, ShouldBeFalse)
|
||||
So(canView, ShouldBeTrue)
|
||||
})
|
||||
|
||||
Convey("When trying to update permissions should return false", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_VIEW},
|
||||
{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN},
|
||||
}
|
||||
ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(ok, ShouldBeFalse)
|
||||
})
|
||||
})
|
||||
|
||||
orgRoleScenario("Given user has viewer org role", m.ROLE_VIEWER, func(sc *scenarioContext) {
|
||||
everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeFalse)
|
||||
So(canEdit, ShouldBeFalse)
|
||||
So(canSave, ShouldBeFalse)
|
||||
So(canView, ShouldBeFalse)
|
||||
})
|
||||
|
||||
everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_EDIT, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeFalse)
|
||||
So(canEdit, ShouldBeFalse)
|
||||
So(canSave, ShouldBeFalse)
|
||||
So(canView, ShouldBeFalse)
|
||||
})
|
||||
|
||||
everyoneWithRoleScenario(m.ROLE_EDITOR, m.PERMISSION_VIEW, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeFalse)
|
||||
So(canEdit, ShouldBeFalse)
|
||||
So(canSave, ShouldBeFalse)
|
||||
So(canView, ShouldBeFalse)
|
||||
})
|
||||
|
||||
everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeTrue)
|
||||
So(canEdit, ShouldBeTrue)
|
||||
So(canSave, ShouldBeTrue)
|
||||
So(canView, ShouldBeTrue)
|
||||
})
|
||||
|
||||
everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_EDIT, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeFalse)
|
||||
So(canEdit, ShouldBeTrue)
|
||||
So(canSave, ShouldBeTrue)
|
||||
So(canView, ShouldBeTrue)
|
||||
})
|
||||
|
||||
everyoneWithRoleScenario(m.ROLE_VIEWER, m.PERMISSION_VIEW, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeFalse)
|
||||
So(canEdit, ShouldBeFalse)
|
||||
So(canSave, ShouldBeFalse)
|
||||
So(canView, ShouldBeTrue)
|
||||
})
|
||||
|
||||
userWithPermissionScenario(m.PERMISSION_ADMIN, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeTrue)
|
||||
So(canEdit, ShouldBeTrue)
|
||||
So(canSave, ShouldBeTrue)
|
||||
So(canView, ShouldBeTrue)
|
||||
})
|
||||
|
||||
userWithPermissionScenario(m.PERMISSION_EDIT, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeFalse)
|
||||
So(canEdit, ShouldBeTrue)
|
||||
So(canSave, ShouldBeTrue)
|
||||
So(canView, ShouldBeTrue)
|
||||
})
|
||||
|
||||
userWithPermissionScenario(m.PERMISSION_VIEW, sc, func(sc *scenarioContext) {
|
||||
canAdmin, _ := sc.g.CanAdmin()
|
||||
canEdit, _ := sc.g.CanEdit()
|
||||
canSave, _ := sc.g.CanSave()
|
||||
canView, _ := sc.g.CanView()
|
||||
So(canAdmin, ShouldBeFalse)
|
||||
So(canEdit, ShouldBeFalse)
|
||||
So(canSave, ShouldBeFalse)
|
||||
So(canView, ShouldBeTrue)
|
||||
})
|
||||
|
||||
Convey("When trying to update permissions should return false", func() {
|
||||
p := []*m.DashboardAcl{
|
||||
{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_VIEW},
|
||||
{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN},
|
||||
}
|
||||
ok, _ := sc.g.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, p)
|
||||
So(ok, ShouldBeFalse)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type scenarioContext struct {
|
||||
g DashboardGuardian
|
||||
}
|
||||
|
||||
type scenarioFunc func(c *scenarioContext)
|
||||
|
||||
func orgRoleScenario(desc string, role m.RoleType, fn scenarioFunc) {
|
||||
user := &m.SignedInUser{
|
||||
UserId: 1,
|
||||
OrgId: 1,
|
||||
OrgRole: role,
|
||||
}
|
||||
guard := New(1, 1, user)
|
||||
sc := &scenarioContext{
|
||||
g: guard,
|
||||
}
|
||||
|
||||
Convey(desc, func() {
|
||||
fn(sc)
|
||||
})
|
||||
}
|
||||
|
||||
func permissionScenario(desc string, sc *scenarioContext, permissions []*m.DashboardAclInfoDTO, fn scenarioFunc) {
|
||||
bus.ClearBusHandlers()
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
|
||||
query.Result = permissions
|
||||
return nil
|
||||
})
|
||||
|
||||
teams := []*m.Team{}
|
||||
|
||||
for _, p := range permissions {
|
||||
if p.TeamId > 0 {
|
||||
teams = append(teams, &m.Team{Id: p.TeamId})
|
||||
}
|
||||
}
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
|
||||
query.Result = teams
|
||||
return nil
|
||||
})
|
||||
|
||||
Convey(desc, func() {
|
||||
fn(sc)
|
||||
})
|
||||
}
|
||||
|
||||
func userWithPermissionScenario(permission m.PermissionType, sc *scenarioContext, fn scenarioFunc) {
|
||||
p := []*m.DashboardAclInfoDTO{
|
||||
{OrgId: 1, DashboardId: 1, UserId: 1, Permission: permission},
|
||||
}
|
||||
permissionScenario(fmt.Sprintf("and user has permission to %s item", permission), sc, p, fn)
|
||||
}
|
||||
|
||||
func teamWithPermissionScenario(permission m.PermissionType, sc *scenarioContext, fn scenarioFunc) {
|
||||
p := []*m.DashboardAclInfoDTO{
|
||||
{OrgId: 1, DashboardId: 1, TeamId: 1, Permission: permission},
|
||||
}
|
||||
permissionScenario(fmt.Sprintf("and team has permission to %s item", permission), sc, p, fn)
|
||||
}
|
||||
|
||||
func everyoneWithRoleScenario(role m.RoleType, permission m.PermissionType, sc *scenarioContext, fn scenarioFunc) {
|
||||
p := []*m.DashboardAclInfoDTO{
|
||||
{OrgId: 1, DashboardId: 1, UserId: -1, Role: &role, Permission: permission},
|
||||
}
|
||||
permissionScenario(fmt.Sprintf("and everyone with %s role can %s item", role, permission), sc, p, fn)
|
||||
}
|
@ -3,4 +3,4 @@
|
||||
# folder: ''
|
||||
# type: file
|
||||
# options:
|
||||
# folder: /var/lib/grafana/dashboards
|
||||
# path: /var/lib/grafana/dashboards
|
||||
|
@ -195,10 +195,9 @@ func (s *SocialGithub) FetchOrganizations(client *http.Client, organizationsUrl
|
||||
func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
|
||||
|
||||
var data struct {
|
||||
Id int `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Email string `json:"email"`
|
||||
OrganizationsUrl string `json:"organizations_url"`
|
||||
Id int `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
response, err := HttpGet(client, s.apiUrl)
|
||||
@ -217,11 +216,13 @@ func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*Basi
|
||||
Email: data.Email,
|
||||
}
|
||||
|
||||
organizationsUrl := fmt.Sprintf(s.apiUrl + "/orgs")
|
||||
|
||||
if !s.IsTeamMember(client) {
|
||||
return nil, ErrMissingTeamMembership
|
||||
}
|
||||
|
||||
if !s.IsOrganizationMember(client, data.OrganizationsUrl) {
|
||||
if !s.IsOrganizationMember(client, organizationsUrl) {
|
||||
return nil, ErrMissingOrganizationMembership
|
||||
}
|
||||
|
||||
|
@ -17,7 +17,7 @@ describe('AddPermissions', () => {
|
||||
])
|
||||
);
|
||||
|
||||
backendSrv.post = jest.fn();
|
||||
backendSrv.post = jest.fn(() => Promise.resolve({}));
|
||||
|
||||
store = RootStore.create(
|
||||
{},
|
||||
|
@ -135,14 +135,6 @@ class AddPermissions extends Component<IProps, any> {
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{permissions.error ? (
|
||||
<div className="gf-form width-17">
|
||||
<span ng-if="ctrl.error" className="text-error p-l-1">
|
||||
<i className="fa fa-warning" />
|
||||
{permissions.error}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import ReactGridLayout from 'react-grid-layout';
|
||||
import ReactGridLayout from 'react-grid-layout-grafana';
|
||||
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
|
||||
import { DashboardPanel } from './DashboardPanel';
|
||||
import { DashboardModel } from '../dashboard_model';
|
||||
@ -50,7 +50,8 @@ function GridWrapper({
|
||||
onResize={onResize}
|
||||
onResizeStop={onResizeStop}
|
||||
onDragStop={onDragStop}
|
||||
onLayoutChange={onLayoutChange}>
|
||||
onLayoutChange={onLayoutChange}
|
||||
>
|
||||
{children}
|
||||
</ReactGridLayout>
|
||||
);
|
||||
@ -178,7 +179,7 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
|
||||
panelElements.push(
|
||||
<div key={panel.id.toString()} className={panelClasses}>
|
||||
<DashboardPanel panel={panel} getPanelContainer={this.props.getPanelContainer} />
|
||||
</div>,
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -196,7 +197,8 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
|
||||
onWidthChange={this.onWidthChange}
|
||||
onDragStop={this.onDragStop}
|
||||
onResize={this.onResize}
|
||||
onResizeStop={this.onResizeStop}>
|
||||
onResizeStop={this.onResizeStop}
|
||||
>
|
||||
{this.renderPanels()}
|
||||
</SizedReactLayoutGrid>
|
||||
);
|
||||
|
@ -31,12 +31,40 @@ describe('templateSrv', function() {
|
||||
expect(target).toBe('this.mupp.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test} with scoped value', function() {
|
||||
var target = _templateSrv.replace('this.${test}.filters', {
|
||||
test: { value: 'mupp', text: 'asd' },
|
||||
});
|
||||
expect(target).toBe('this.mupp.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test:glob} with scoped value', function() {
|
||||
var target = _templateSrv.replace('this.${test:glob}.filters', {
|
||||
test: { value: 'mupp', text: 'asd' },
|
||||
});
|
||||
expect(target).toBe('this.mupp.filters');
|
||||
});
|
||||
|
||||
it('should replace $test with scoped text', function() {
|
||||
var target = _templateSrv.replaceWithText('this.$test.filters', {
|
||||
test: { value: 'mupp', text: 'asd' },
|
||||
});
|
||||
expect(target).toBe('this.asd.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test} with scoped text', function() {
|
||||
var target = _templateSrv.replaceWithText('this.${test}.filters', {
|
||||
test: { value: 'mupp', text: 'asd' },
|
||||
});
|
||||
expect(target).toBe('this.asd.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test:glob} with scoped text', function() {
|
||||
var target = _templateSrv.replaceWithText('this.${test:glob}.filters', {
|
||||
test: { value: 'mupp', text: 'asd' },
|
||||
});
|
||||
expect(target).toBe('this.asd.filters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAdhocFilters', function() {
|
||||
@ -79,18 +107,34 @@ describe('templateSrv', function() {
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should replace $test with globbed value', function() {
|
||||
var target = _templateSrv.replace('this.$test.filters', {}, 'glob');
|
||||
expect(target).toBe('this.{value1,value2}.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test} with globbed value', function() {
|
||||
var target = _templateSrv.replace('this.${test}.filters', {}, 'glob');
|
||||
expect(target).toBe('this.{value1,value2}.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test:glob} with globbed value', function() {
|
||||
var target = _templateSrv.replace('this.${test:glob}.filters', {});
|
||||
expect(target).toBe('this.{value1,value2}.filters');
|
||||
});
|
||||
|
||||
it('should replace $test with piped value', function() {
|
||||
var target = _templateSrv.replace('this=$test', {}, 'pipe');
|
||||
expect(target).toBe('this=value1|value2');
|
||||
});
|
||||
|
||||
it('should replace $test with piped value', function() {
|
||||
var target = _templateSrv.replace('this=$test', {}, 'pipe');
|
||||
it('should replace ${test} with piped value', function() {
|
||||
var target = _templateSrv.replace('this=${test}', {}, 'pipe');
|
||||
expect(target).toBe('this=value1|value2');
|
||||
});
|
||||
|
||||
it('should replace ${test:pipe} with piped value', function() {
|
||||
var target = _templateSrv.replace('this=${test:pipe}', {});
|
||||
expect(target).toBe('this=value1|value2');
|
||||
});
|
||||
});
|
||||
@ -111,6 +155,16 @@ describe('templateSrv', function() {
|
||||
var target = _templateSrv.replace('this.$test.filters', {}, 'glob');
|
||||
expect(target).toBe('this.{value1,value2}.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test} with formatted all value', function() {
|
||||
var target = _templateSrv.replace('this.${test}.filters', {}, 'glob');
|
||||
expect(target).toBe('this.{value1,value2}.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test:glob} with formatted all value', function() {
|
||||
var target = _templateSrv.replace('this.${test:glob}.filters', {});
|
||||
expect(target).toBe('this.{value1,value2}.filters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('variable with all option and custom value', function() {
|
||||
@ -131,6 +185,16 @@ describe('templateSrv', function() {
|
||||
expect(target).toBe('this.*.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test} with formatted all value', function() {
|
||||
var target = _templateSrv.replace('this.${test}.filters', {}, 'glob');
|
||||
expect(target).toBe('this.*.filters');
|
||||
});
|
||||
|
||||
it('should replace ${test:glob} with formatted all value', function() {
|
||||
var target = _templateSrv.replace('this.${test:glob}.filters', {});
|
||||
expect(target).toBe('this.*.filters');
|
||||
});
|
||||
|
||||
it('should not escape custom all value', function() {
|
||||
var target = _templateSrv.replace('this.$test', {}, 'regex');
|
||||
expect(target).toBe('this.*');
|
||||
@ -143,6 +207,18 @@ describe('templateSrv', function() {
|
||||
var target = _templateSrv.replace('this:$test', {}, 'lucene');
|
||||
expect(target).toBe('this:value\\/4');
|
||||
});
|
||||
|
||||
it('should properly escape ${test} with lucene escape sequences', function() {
|
||||
initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]);
|
||||
var target = _templateSrv.replace('this:${test}', {}, 'lucene');
|
||||
expect(target).toBe('this:value\\/4');
|
||||
});
|
||||
|
||||
it('should properly escape ${test:lucene} with lucene escape sequences', function() {
|
||||
initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]);
|
||||
var target = _templateSrv.replace('this:${test:lucene}', {});
|
||||
expect(target).toBe('this:value\\/4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('format variable to string values', function() {
|
||||
|
@ -8,7 +8,13 @@ function luceneEscape(value) {
|
||||
export class TemplateSrv {
|
||||
variables: any[];
|
||||
|
||||
private regex = /\$(\w+)|\[\[([\s\S]+?)\]\]/g;
|
||||
/*
|
||||
* This regex matches 3 types of variable reference with an optional format specifier
|
||||
* \$(\w+) $var1
|
||||
* \[\[([\s\S]+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]]
|
||||
* \${(\w+)(?::(\w+))?} ${var3} or ${var3:fmt3}
|
||||
*/
|
||||
private regex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?::(\w+))?}/g;
|
||||
private index = {};
|
||||
private grafanaVariables = {};
|
||||
private builtIns = {};
|
||||
@ -89,6 +95,9 @@ export class TemplateSrv {
|
||||
}
|
||||
|
||||
var escapedValues = _.map(value, kbn.regexEscape);
|
||||
if (escapedValues.length === 1) {
|
||||
return escapedValues[0];
|
||||
}
|
||||
return '(' + escapedValues.join('|') + ')';
|
||||
}
|
||||
case 'lucene': {
|
||||
@ -140,8 +149,8 @@ export class TemplateSrv {
|
||||
|
||||
str = _.escape(str);
|
||||
this.regex.lastIndex = 0;
|
||||
return str.replace(this.regex, (match, g1, g2) => {
|
||||
if (this.index[g1 || g2] || this.builtIns[g1 || g2]) {
|
||||
return str.replace(this.regex, (match, var1, var2, fmt2, var3) => {
|
||||
if (this.index[var1 || var2 || var3] || this.builtIns[var1 || var2 || var3]) {
|
||||
return '<span class="template-variable">' + match + '</span>';
|
||||
}
|
||||
return match;
|
||||
@ -167,11 +176,11 @@ export class TemplateSrv {
|
||||
var variable, systemValue, value;
|
||||
this.regex.lastIndex = 0;
|
||||
|
||||
return target.replace(this.regex, (match, g1, g2) => {
|
||||
variable = this.index[g1 || g2];
|
||||
|
||||
return target.replace(this.regex, (match, var1, var2, fmt2, var3, fmt3) => {
|
||||
variable = this.index[var1 || var2 || var3];
|
||||
format = fmt2 || fmt3 || format;
|
||||
if (scopedVars) {
|
||||
value = scopedVars[g1 || g2];
|
||||
value = scopedVars[var1 || var2 || var3];
|
||||
if (value) {
|
||||
return this.formatValue(value.value, format, variable);
|
||||
}
|
||||
@ -212,15 +221,15 @@ export class TemplateSrv {
|
||||
var variable;
|
||||
this.regex.lastIndex = 0;
|
||||
|
||||
return target.replace(this.regex, (match, g1, g2) => {
|
||||
return target.replace(this.regex, (match, var1, var2, fmt2, var3) => {
|
||||
if (scopedVars) {
|
||||
var option = scopedVars[g1 || g2];
|
||||
var option = scopedVars[var1 || var2 || var3];
|
||||
if (option) {
|
||||
return option.text;
|
||||
}
|
||||
}
|
||||
|
||||
variable = this.index[g1 || g2];
|
||||
variable = this.index[var1 || var2 || var3];
|
||||
if (!variable) {
|
||||
return match;
|
||||
}
|
||||
|
@ -59,7 +59,7 @@
|
||||
Sign in with {{oauth.generic_oauth.name}}
|
||||
</a>
|
||||
</div>
|
||||
<div class="login-signup-box">
|
||||
<div class="login-signup-box" ng-show="!disableUserSignUp">
|
||||
<div class="login-signup-title p-r-1">
|
||||
New to Grafana?
|
||||
</div>
|
||||
|
@ -348,6 +348,10 @@ export class GraphiteQueryCtrl extends QueryCtrl {
|
||||
let tagKey = tag.key;
|
||||
return this.datasource.getTagValuesAutoComplete(tagExpressions, tagKey, valuePrefix).then(values => {
|
||||
let altValues = _.map(values, 'text');
|
||||
// Add template variables as additional values
|
||||
_.eachRight(this.templateSrv.variables, variable => {
|
||||
altValues.push('${' + variable.name + ':regex}');
|
||||
});
|
||||
return mapToDropdownOptions(altValues);
|
||||
});
|
||||
}
|
||||
|
@ -1,19 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="115.333px" height="114px" viewBox="0 0 115.333 114" enable-background="new 0 0 115.333 114" xml:space="preserve">
|
||||
<g id="Layer_2">
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#FFFFFF" d="M56.667,0.667C25.372,0.667,0,26.036,0,57.332c0,31.295,25.372,56.666,56.667,56.666
|
||||
s56.666-25.371,56.666-56.666C113.333,26.036,87.961,0.667,56.667,0.667z M56.667,106.722c-8.904,0-16.123-5.948-16.123-13.283
|
||||
H72.79C72.79,100.773,65.571,106.722,56.667,106.722z M83.297,89.04H30.034v-9.658h53.264V89.04z M83.106,74.411h-52.92
|
||||
c-0.176-0.203-0.356-0.403-0.526-0.609c-5.452-6.62-6.736-10.076-7.983-13.598c-0.021-0.116,6.611,1.355,11.314,2.413
|
||||
c0,0,2.42,0.56,5.958,1.205c-3.397-3.982-5.414-9.044-5.414-14.218c0-11.359,8.712-21.285,5.569-29.308
|
||||
c3.059,0.249,6.331,6.456,6.552,16.161c3.252-4.494,4.613-12.701,4.613-17.733c0-5.21,3.433-11.262,6.867-11.469
|
||||
c-3.061,5.045,0.793,9.37,4.219,20.099c1.285,4.03,1.121,10.812,2.113,15.113C63.797,33.534,65.333,20.5,71,16
|
||||
c-2.5,5.667,0.37,12.758,2.333,16.167c3.167,5.5,5.087,9.667,5.087,17.548c0,5.284-1.951,10.259-5.242,14.148
|
||||
c3.742-0.702,6.326-1.335,6.326-1.335l12.152-2.371C91.657,60.156,89.891,67.418,83.106,74.411z"/>
|
||||
</g>
|
||||
</svg>
|
||||
<svg width="2490" height="2500" viewBox="0 0 256 257" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M128.001.667C57.311.667 0 57.971 0 128.664c0 70.69 57.311 127.998 128.001 127.998S256 199.354 256 128.664C256 57.97 198.689.667 128.001.667zm0 239.56c-20.112 0-36.419-13.435-36.419-30.004h72.838c0 16.566-16.306 30.004-36.419 30.004zm60.153-39.94H67.842V178.47h120.314v21.816h-.002zm-.432-33.045H68.185c-.398-.458-.804-.91-1.188-1.375-12.315-14.954-15.216-22.76-18.032-30.716-.048-.262 14.933 3.06 25.556 5.45 0 0 5.466 1.265 13.458 2.722-7.673-8.994-12.23-20.428-12.23-32.116 0-25.658 19.68-48.079 12.58-66.201 6.91.562 14.3 14.583 14.8 36.505 7.346-10.152 10.42-28.69 10.42-40.056 0-11.769 7.755-25.44 15.512-25.907-6.915 11.396 1.79 21.165 9.53 45.4 2.902 9.103 2.532 24.423 4.772 34.138.744-20.178 4.213-49.62 17.014-59.784-5.647 12.8.836 28.818 5.27 36.518 7.154 12.424 11.49 21.836 11.49 39.638 0 11.936-4.407 23.173-11.84 31.958 8.452-1.586 14.289-3.016 14.289-3.016l27.45-5.355c.002-.002-3.987 16.401-19.314 32.197z" fill="#DA4E31"/></svg>
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.0 KiB |
@ -1,10 +1,10 @@
|
||||
import { PermissionsStore, aclTypeValues } from './PermissionsStore';
|
||||
import { PermissionsStore } from './PermissionsStore';
|
||||
import { backendSrv } from 'test/mocks/common';
|
||||
|
||||
describe('PermissionsStore', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
backendSrv.get.mockReturnValue(
|
||||
Promise.resolve([
|
||||
{ id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
|
||||
@ -20,7 +20,7 @@ describe('PermissionsStore', () => {
|
||||
])
|
||||
);
|
||||
|
||||
backendSrv.post = jest.fn();
|
||||
backendSrv.post = jest.fn(() => Promise.resolve({}));
|
||||
|
||||
store = PermissionsStore.create(
|
||||
{
|
||||
@ -32,14 +32,14 @@ describe('PermissionsStore', () => {
|
||||
}
|
||||
);
|
||||
|
||||
return store.load(1, false, false);
|
||||
await store.load(1, false, false);
|
||||
});
|
||||
|
||||
it('should save update on permission change', () => {
|
||||
it('should save update on permission change', async () => {
|
||||
expect(store.items[0].permission).toBe(1);
|
||||
expect(store.items[0].permissionName).toBe('View');
|
||||
|
||||
store.updatePermissionOnIndex(0, 2, 'Edit');
|
||||
await store.updatePermissionOnIndex(0, 2, 'Edit');
|
||||
|
||||
expect(store.items[0].permission).toBe(2);
|
||||
expect(store.items[0].permissionName).toBe('Edit');
|
||||
@ -47,69 +47,18 @@ describe('PermissionsStore', () => {
|
||||
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions');
|
||||
});
|
||||
|
||||
it('should save removed permissions automatically', () => {
|
||||
it('should save removed permissions automatically', async () => {
|
||||
expect(store.items.length).toBe(3);
|
||||
|
||||
store.removeStoreItem(2);
|
||||
await store.removeStoreItem(2);
|
||||
|
||||
expect(store.items.length).toBe(2);
|
||||
expect(backendSrv.post.mock.calls.length).toBe(1);
|
||||
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions');
|
||||
});
|
||||
|
||||
describe('when duplicate team permissions are added', () => {
|
||||
beforeEach(() => {
|
||||
const newItem = {
|
||||
teamId: 10,
|
||||
team: 'tester-team',
|
||||
permission: 1,
|
||||
dashboardId: 1,
|
||||
};
|
||||
store.resetNewType();
|
||||
store.newItem.setTeam(newItem.teamId, newItem.team);
|
||||
store.newItem.setPermission(newItem.permission);
|
||||
store.addStoreItem();
|
||||
|
||||
store.newItem.setTeam(newItem.teamId, newItem.team);
|
||||
store.newItem.setPermission(newItem.permission);
|
||||
store.addStoreItem();
|
||||
});
|
||||
|
||||
it('should return a validation error', () => {
|
||||
expect(store.items.length).toBe(4);
|
||||
expect(store.error).toBe('This permission exists already.');
|
||||
expect(backendSrv.post.mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when duplicate user permissions are added', () => {
|
||||
beforeEach(() => {
|
||||
expect(store.items.length).toBe(3);
|
||||
const newItem = {
|
||||
userId: 10,
|
||||
userLogin: 'tester1',
|
||||
permission: 1,
|
||||
dashboardId: 1,
|
||||
};
|
||||
store.setNewType(aclTypeValues.USER.value);
|
||||
store.newItem.setUser(newItem.userId, newItem.userLogin);
|
||||
store.newItem.setPermission(newItem.permission);
|
||||
store.addStoreItem();
|
||||
store.setNewType(aclTypeValues.USER.value);
|
||||
store.newItem.setUser(newItem.userId, newItem.userLogin);
|
||||
store.newItem.setPermission(newItem.permission);
|
||||
store.addStoreItem();
|
||||
});
|
||||
|
||||
it('should return a validation error', () => {
|
||||
expect(store.items.length).toBe(4);
|
||||
expect(store.error).toBe('This permission exists already.');
|
||||
expect(backendSrv.post.mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when one inherited and one not inherited team permission are added', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
const overridingItemForChildDashboard = {
|
||||
team: 'MyTestTeam',
|
||||
dashboardId: 1,
|
||||
@ -120,11 +69,7 @@ describe('PermissionsStore', () => {
|
||||
store.resetNewType();
|
||||
store.newItem.setTeam(overridingItemForChildDashboard.teamId, overridingItemForChildDashboard.team);
|
||||
store.newItem.setPermission(overridingItemForChildDashboard.permission);
|
||||
store.addStoreItem();
|
||||
});
|
||||
|
||||
it('should allowing overriding the inherited permission and not throw a validation error', () => {
|
||||
expect(store.error).toBe(null);
|
||||
await store.addStoreItem();
|
||||
});
|
||||
|
||||
it('should add new overriding permission', () => {
|
||||
|
@ -1,8 +1,6 @@
|
||||
import { types, getEnv, flow } from 'mobx-state-tree';
|
||||
import { PermissionsStoreItem } from './PermissionsStoreItem';
|
||||
|
||||
const duplicateError = 'This permission exists already.';
|
||||
|
||||
export const permissionOptions = [
|
||||
{ value: 1, label: 'View', description: 'Can view dashboards.' },
|
||||
{ value: 2, label: 'Edit', description: 'Can add, edit and delete dashboards.' },
|
||||
@ -75,7 +73,6 @@ export const PermissionsStore = types
|
||||
isFolder: types.maybe(types.boolean),
|
||||
dashboardId: types.maybe(types.number),
|
||||
items: types.optional(types.array(PermissionsStoreItem), []),
|
||||
error: types.maybe(types.string),
|
||||
originalItems: types.optional(types.array(PermissionsStoreItem), []),
|
||||
newType: types.optional(types.string, defaultNewType),
|
||||
newItem: types.maybe(NewPermissionsItem),
|
||||
@ -88,7 +85,6 @@ export const PermissionsStore = types
|
||||
return isDuplicate(it, item);
|
||||
});
|
||||
if (dupe) {
|
||||
self.error = duplicateError;
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -96,8 +92,7 @@ export const PermissionsStore = types
|
||||
},
|
||||
}))
|
||||
.actions(self => {
|
||||
const resetNewType = () => {
|
||||
self.error = null;
|
||||
const resetNewTypeInternal = () => {
|
||||
self.newItem = NewPermissionsItem.create();
|
||||
};
|
||||
|
||||
@ -115,11 +110,9 @@ export const PermissionsStore = types
|
||||
self.items = items;
|
||||
self.originalItems = items;
|
||||
self.fetching = false;
|
||||
self.error = null;
|
||||
}),
|
||||
|
||||
addStoreItem: flow(function* addStoreItem() {
|
||||
self.error = null;
|
||||
let item = {
|
||||
type: self.newItem.type,
|
||||
permission: self.newItem.permission,
|
||||
@ -147,19 +140,21 @@ export const PermissionsStore = types
|
||||
throw Error('Unknown type: ' + self.newItem.type);
|
||||
}
|
||||
|
||||
if (!self.isValid(item)) {
|
||||
return undefined;
|
||||
}
|
||||
const updatedItems = self.items.peek();
|
||||
const newItem = prepareItem(item, self.dashboardId, self.isFolder, self.isInRoot);
|
||||
updatedItems.push(newItem);
|
||||
|
||||
self.items.push(prepareItem(item, self.dashboardId, self.isFolder, self.isInRoot));
|
||||
resetNewType();
|
||||
return updateItems(self);
|
||||
try {
|
||||
yield updateItems(self, updatedItems);
|
||||
self.items.push(newItem);
|
||||
resetNewTypeInternal();
|
||||
} catch {}
|
||||
yield Promise.resolve();
|
||||
}),
|
||||
|
||||
removeStoreItem: flow(function* removeStoreItem(idx: number) {
|
||||
self.error = null;
|
||||
self.items.splice(idx, 1);
|
||||
return updateItems(self);
|
||||
yield updateItems(self, self.items.peek());
|
||||
}),
|
||||
|
||||
updatePermissionOnIndex: flow(function* updatePermissionOnIndex(
|
||||
@ -167,9 +162,8 @@ export const PermissionsStore = types
|
||||
permission: number,
|
||||
permissionName: string
|
||||
) {
|
||||
self.error = null;
|
||||
self.items[idx].updatePermission(permission, permissionName);
|
||||
return updateItems(self);
|
||||
yield updateItems(self, self.items.peek());
|
||||
}),
|
||||
|
||||
setNewType(newType: string) {
|
||||
@ -177,7 +171,7 @@ export const PermissionsStore = types
|
||||
},
|
||||
|
||||
resetNewType() {
|
||||
resetNewType();
|
||||
resetNewTypeInternal();
|
||||
},
|
||||
|
||||
toggleAddPermissions() {
|
||||
@ -190,12 +184,10 @@ export const PermissionsStore = types
|
||||
};
|
||||
});
|
||||
|
||||
const updateItems = self => {
|
||||
self.error = null;
|
||||
|
||||
const updateItems = (self, items) => {
|
||||
const backendSrv = getEnv(self).backendSrv;
|
||||
const updated = [];
|
||||
for (let item of self.items) {
|
||||
for (let item of items) {
|
||||
if (item.inherited) {
|
||||
continue;
|
||||
}
|
||||
@ -208,16 +200,9 @@ const updateItems = self => {
|
||||
});
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = backendSrv.post(`/api/dashboards/id/${self.dashboardId}/permissions`, {
|
||||
items: updated,
|
||||
});
|
||||
} catch (error) {
|
||||
self.error = error;
|
||||
}
|
||||
|
||||
return res;
|
||||
return backendSrv.post(`/api/dashboards/id/${self.dashboardId}/permissions`, {
|
||||
items: updated,
|
||||
});
|
||||
};
|
||||
|
||||
const prepareServerResponse = (response, dashboardId: number, isFolder: boolean, isInRoot: boolean) => {
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import '~react-grid-layout/css/styles.css';
|
||||
@import '~react-grid-layout-grafana/css/styles.css';
|
||||
@import '~react-resizable/css/styles.css';
|
||||
|
||||
.panel-in-fullscreen {
|
||||
|
@ -3,6 +3,9 @@
|
||||
.navbar {
|
||||
display: none;
|
||||
}
|
||||
.scroll-canvas--dashboard {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-active,
|
||||
|
15
yarn.lock
15
yarn.lock
@ -8275,16 +8275,23 @@ react-dom@^16.2.0:
|
||||
object-assign "^4.1.1"
|
||||
prop-types "^15.6.0"
|
||||
|
||||
"react-draggable@^2.2.6 || ^3.0.3", react-draggable@^3.0.3:
|
||||
"react-draggable@^2.2.6 || ^3.0.3":
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-3.0.3.tgz#a6f9b3a7171981b76dadecf238316925cb9eacf4"
|
||||
dependencies:
|
||||
classnames "^2.2.5"
|
||||
prop-types "^15.5.10"
|
||||
|
||||
react-grid-layout@^0.16.2:
|
||||
version "0.16.2"
|
||||
resolved "https://registry.yarnpkg.com/react-grid-layout/-/react-grid-layout-0.16.2.tgz#ef09b0b6db4a9635799663658277ee2d26fa2994"
|
||||
react-draggable@^3.0.3:
|
||||
version "3.0.5"
|
||||
resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-3.0.5.tgz#c031e0ed4313531f9409d6cd84c8ebcec0ddfe2d"
|
||||
dependencies:
|
||||
classnames "^2.2.5"
|
||||
prop-types "^15.6.0"
|
||||
|
||||
react-grid-layout-grafana@0.16.0:
|
||||
version "0.16.0"
|
||||
resolved "https://registry.yarnpkg.com/react-grid-layout-grafana/-/react-grid-layout-grafana-0.16.0.tgz#12242153fcd0bb80a26af8e41694bc2fde788b3a"
|
||||
dependencies:
|
||||
classnames "2.x"
|
||||
lodash.isequal "^4.0.0"
|
||||
|
Loading…
Reference in New Issue
Block a user