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

This commit is contained in:
Sven Klemm 2018-02-07 15:01:00 +01:00
commit 09efcbc205
75 changed files with 520 additions and 220 deletions

View File

@ -1,6 +1,8 @@
# 5.0.0 (unreleased / master branch)
# 5.0.0-beta2 (unrelased)
Grafana v5.0 is going to be the biggest and most foundational release Grafana has ever had, coming with a ton of UX improvements, a new dashboard grid engine, dashboard folders, user teams and permissions. Checkout out this [video preview](https://www.youtube.com/watch?v=BC_YRNpqj5k) of Grafana v5.
# 5.0.0-beta1 (2018-02-05)
Grafana v5.0 is going to be the biggest and most foundational release Grafana has ever had, coming with a ton of UX improvements, a new dashboard grid engine, dashboard folders, user teams and permissions. Checkout out this [video preview](https://www.youtube.com/watch?v=Izr0IBgoTZQ) of Grafana v5.
### New Major Features
- **Dashboards** Dashboard folders, [#1611](https://github.com/grafana/grafana/issues/1611)
@ -9,6 +11,7 @@ Grafana v5.0 is going to be the biggest and most foundational release Grafana ha
- **Templating**: Vertical repeat direction for panel repeats.
- **UX**: Major update to page header and navigation
- **Dashboard settings**: Combine dashboard settings views into one with side menu, [#9750](https://github.com/grafana/grafana/issues/9750)
- **Persistent dashboard url's**: New url's for dashboards that allows renaming dashboards without breaking links. [#7883](https://github.com/grafana/grafana/issues/7883)
## Breaking changes
@ -18,6 +21,9 @@ From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when
* **Pagerduty** The notifier now defaults to not auto resolve incidents. More details at [#10222](https://github.com/grafana/grafana/issues/10222)
* **HTTP API**
- `GET /api/alerts` property dashboardUri renamed to url and is now the full url (that is including app sub url).
## New Dashboard Grid
The new grid engine is a major upgrade for how you can position and move panels. It enables new layouts and a much easier dashboard building experience. The change is backward compatible. So you can upgrade your current version to 5.0 without breaking dashboards, but you cannot downgrade from 5.0 to previous versions. Grafana will automatically upgrade your dashboards to the new schema and position panels to match your existing layout. There might be minor differences in panel height. If you upgrade to 5.0 and for some reason want to rollback to the previous version you can restore dashboards to previous versions using dashboard history. But that should only be seen as an emergency solution.
@ -58,10 +64,22 @@ Dashboard panels and rows are positioned using a gridPos object `{x: 0, y: 0, w:
* **Singlestat**: suppress error when result contains no datapoints [#9636](https://github.com/grafana/grafana/issues/9636), thx [@utkarshcmu](https://github.com/utkarshcmu)
* **Postgres/MySQL**: Control quoting in SQL-queries when using template variables [#9030](https://github.com/grafana/grafana/issues/9030), thanks [@svenklemm](https://github.com/svenklemm)
* **Pagerduty**: Pagerduty dont auto resolve incidents by default anymore. [#10222](https://github.com/grafana/grafana/issues/10222)
* **Cloudwatch**: Fix for multi-valued templated queries. [#9903](https://github.com/grafana/grafana/issues/9903)
## Tech
* **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645)
## Deprecation notes
### HTTP API
The following operations have been deprecated and will be removed in a future release:
- `GET /api/dashboards/db/:slug` -> Use `GET /api/dashboards/uid/:uid` instead
- `DELETE /api/dashboards/db/:slug` -> Use `DELETE /api/dashboards/uid/:uid` instead
The following properties have been deprecated and will be removed in a future release:
- `uri` property in `GET /api/search` -> Use new `url` or `uid` property instead
- `meta.slug` property in `GET /api/dashboards/uid/:uid` and `GET /api/dashboards/db/:slug` -> Use new `meta.url` or `dashboard.uid` property instead
# 4.6.3 (2017-12-14)
## Fixes

View File

@ -28,7 +28,7 @@ in that organization.
Can do everything scoped to the organization. For example:
- Add & Edit data data sources.
- Add & Edit data sources.
- Add & Edit organization users & teams.
- Configure App plugins & set org settings.
@ -73,4 +73,13 @@ The highest permission always wins so if you for example want to hide a folder o
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.
- 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.
### Data source permissions
Permissions on dashboards and folders **do not** include permissions on data sources. A user with `Viewer` role
can still issue any possible query to a data source, not just those queries that exist on dashboards he/she has access to.
We hope to add permissions on data sources in a future release. Until then **do not** view dashboard permissions as a secure
way to restrict user data access. Dashboard permissions only limits what dashboards & folders a user can view & edit not which
data sources a user can access nor what queries a user can issue.

View File

@ -12,6 +12,8 @@ weight = -6
# What's New in Grafana v5.0
> Out in beta: [Download now!](https://grafana.com/grafana/download/5.0.0-beta1)
This is the most substantial update that Grafana has ever seen. This article will detail the major new features and enhancements.
- [New Dashboard Layout Engine]({{< relref "#new-dashboard-layout-engine" >}}) enables a much easier drag, drop and resize experience and new types of layouts.
@ -22,10 +24,12 @@ This is the most substantial update that Grafana has ever seen. This article wil
- [Group users into teams]({{< relref "#teams" >}}) and use them in the new permission system.
- [Datasource provisioning]({{< relref "#data-sources" >}}) makes it possible to setup datasources via config files.
- [Dashboard provisioning]({{< relref "#dashboards" >}}) makes it possible to setup dashboards via config files.
- [Persistent dashboard url's]({{< relref "#dashboard-model-persistent-url-s-and-api-changes" >}}) makes it possible to rename dashboards without breaking links.
- [Graphite Tags & Integrated Function Docs]({{< relref "#graphite-tags-integrated-function-docs" >}}).
### Video showing new features
<iframe height="215" src="https://www.youtube.com/embed/BC_YRNpqj5k?rel=0&amp;showinfo=0" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
<iframe width="450" height="270" src="https://www.youtube.com/embed/Izr0IBgoTZQ?rel=0&amp;" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
<br />
## New Dashboard Layout Engine
@ -36,7 +40,7 @@ The new dashboard layout engine allows for much easier movement and sizing of pa
a very intuitive way. Panels are sized independently, so rows are no longer necessary to create layouts. This opens
up many new types of layouts where panels of different heights can be aligned easily. Checkout the new grid in the video
above or on the [play site](http://play.grafana.org). All your existing dashboards will automatically migrate to the
new position system and look close to identical. The new panel position makes dashboards saved in v5.0 not compatible
new position system and look close to identical. The new panel position makes dashboards saved in v5.0 incompatible
with older versions of Grafana.
<div class="clearfix"></div>
@ -49,7 +53,7 @@ Almost every page has seen significant UX improvements. All pages (except dashbo
<div class="clearfix"></div>
### Dashboard Settings
## Dashboard Settings
{{< docs-imagebox img="/img/docs/v50/dashboard_settings.png" max-width="1000px" class="docs-image--right" >}}
Dashboard pages have a new header toolbar where buttons and actions are now all moved to the right. All the dashboard
@ -61,7 +65,7 @@ settings views have been combined with a side nav which allows you to easily mov
{{< docs-imagebox img="/img/docs/v50/new_white_theme.png" max-width="1000px" class="docs-image--right" >}}
This theme has not seen a lot of love in recent years and we felt it was time to rework it and give it a major overhaul. We are very happy with the result.
This theme has not seen a lot of love in recent years and we felt it was time to give it a major overhaul. We are very happy with the result.
<div class="clearfix"></div>
@ -78,22 +82,26 @@ which is very useful if you have a lot of dashboards or multiple teams.
## Teams
A team is a new concept in Grafana v5. They are simply a group of users that can be then be used in the new permission system for dashboards and folders. Only an admin can create teams.
A team is a new concept in Grafana v5. They are simply a group of users that can be used in the new permission system for dashboards and folders. Only an admin can create teams.
We hope to do more with teams in future releases like integration with LDAP and a team landing page.
## Permissions
{{< docs-imagebox img="/img/docs/v50/folder_permissions.png" max-width="1000px" class="docs-image--right" >}}
You can assign permissions to folders and dashboards. The default user role-based permissions can be removed and replaced with specific teams or users enabling more control over what a user can see and edit.
You can assign permissions to folders and dashboards. The default user role-based permissions can be removed and
replaced with specific teams or users enabling more control over what a user can see and edit.
Dashboard permissions only limits what dashboards & folders a user can view & edit not which
data sources a user can access nor what queries a user can issue.
<div class="clearfix"></div>
# Provisioning from configuration
## Provisioning from configuration
In previous versions of Grafana, you could only use the API for provisioning data sources and dashboards.
But that required the service to be running before you started creating dashboards and you also needed to
set up credentials for the HTTP API. In 5.0 we decided to improve this experience by adding a new active
set up credentials for the HTTP API. In v5.0 we decided to improve this experience by adding a new active
provisioning system that uses config files. This will make GitOps more natural as data sources and dashboards can
be defined via files that can be version controlled. We hope to extend this system to later add support for users, orgs
and alerts as well.
@ -111,10 +119,36 @@ in sync with dashboards in Grafana's database. The dashboard provisioner has mul
which makes it possible to star them, use one as the home dashboard, set permissions and other features in Grafana that
expects the dashboards to exist in the database. More info in the [dashboard provisioning docs](/administration/provisioning/#dashboards)
# Dashboard model & API
We are introducing a new identifier (`uid`) in the dashboard JSON model. The new identifier will be a 9-12 character long unique id.
We are also changing the route for getting dashboards to use this `uid` instead of the slug that the current route and API are using.
We will keep supporting the old route for backward compatibility. This will make it possible to change the title on dashboards without breaking links.
Sharing dashboards between instances becomes much easier since the uid is unique (unique enough). This might seem like a small change,
but we are incredibly excited about it since it will make it much easier to manage, collaborate and navigate between dashboards.
## Graphite Tags & Integrated Function Docs
{{< docs-imagebox img="/img/docs/v50/graphite_tags.png" max-width="1000px" class="docs-image--right" >}}
The Graphite query editor has been updated to support the latest Graphite version (v1.2) that adds
many new functions and support for querying by tags. You can now also view function documentation right in the query editor!
Read more on [Graphite Tag Support](http://graphite.readthedocs.io/en/latest/tags.html?highlight=tags).
<div class="clearfix"></div>
## Dashboard model, persistent url's and API changes
We are introducing a new unique identifier (`uid`) in the dashboard JSON model. It's automatically
generated if not provided when creating a dashboard and will have a length of 9-12 characters.
The unique identifier allows having persistent URL's for accessing dashboards, sharing them
between instances and when using [dashboard provisioning](#dashboards). This means that dashboard can
be renamed without breaking any links. We're changing the url format for dashboards
from `/dashboard/db/:slug` to `/d/:uid/:slug`. We'll keep supporting the old slug-based url's for dashboards
and redirects to the new one for backward compatibility. Please note that the old slug-based url's
have been deprecated and will be removed in a future release.
Sharing dashboards between instances becomes much easier since the `uid` is unique (unique enough).
This might seem like a small change, but we are incredibly excited about it since it will make it
much easier to manage, collaborate and navigate between dashboards.
### API changes
New uid-based routes in the dashboard API have been introduced to retrieve and delete dashboards.
The corresponding slug-based routes have been deprecated and will be removed in a future release.

View File

@ -62,7 +62,7 @@ Content-Type: application/json
}
"newStateDate": "2016-12-25",
"executionError": "",
"dashboardUri": "http://grafana.com/dashboard/db/sensors"
"url": "http://grafana.com/dashboard/db/sensors"
}
]
```
@ -94,7 +94,7 @@ Content-Type: application/json
"state": "alerting",
"newStateDate": "2016-12-25",
"executionError": "",
"dashboardUri": "http://grafana.com/dashboard/db/sensors"
"url": "http://grafana.com/dashboard/db/sensors"
}
```
@ -196,7 +196,7 @@ Content-Type: application/json
## Create alert notification
You can find the full list of [supported notifers](/alerting/notifications/#all-supported-notifier) at the alert notifiers page.
You can find the full list of [supported notifers](/alerting/notifications/#all-supported-notifier) at the alert notifiers page.
`POST /api/alert-notifications`
@ -294,4 +294,4 @@ Content-Type: application/json
{
"message": "Notification deleted"
}
```
```

View File

@ -18,12 +18,15 @@ dashboards, creating users and updating data sources.
## Supported HTTP APIs:
* [Authentication API]({{< relref "auth.md" >}})
* [Dashboard API]({{< relref "dashboard.md" >}})
* [Data Source API]({{< relref "data_source.md" >}})
* [Organisation API]({{< relref "org.md" >}})
* [User API]({{< relref "user.md" >}})
* [Admin API]({{< relref "admin.md" >}})
* [Snapshot API]({{< relref "snapshot.md" >}})
* [Preferences API]({{< relref "preferences.md" >}})
* [Other API]({{< relref "other.md" >}})
* [Authentication API]({{< relref "/http_api/auth.md" >}})
* [Dashboard API]({{< relref "/http_api/dashboard.md" >}})
* [Dashboard Versions API]({{< relref "http_api/dashboard_versions.md" >}})
* [Data Source API]({{< relref "http_api/data_source.md" >}})
* [Organisation API]({{< relref "http_api/org.md" >}})
* [Snapshot API]({{< relref "http_api/snapshot.md" >}})
* [Annotations API]({{< relref "http_api/annotations.md" >}})
* [Alerting API]({{< relref "http_api/alerting.md" >}})
* [User API]({{< relref "http_api/user.md" >}})
* [Admin API]({{< relref "http_api/admin.md" >}})
* [Preferences API]({{< relref "http_api/preferences.md" >}})
* [Other API]({{< relref "http_api/other.md" >}})

View File

@ -16,6 +16,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-beta1_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta1_amd64.deb)
Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
installation.
@ -27,6 +28,15 @@ installation.
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.6.3_amd64.deb
sudo apt-get install -y adduser libfontconfig
sudo dpkg -i grafana_4.6.3_amd64.deb
```
## Install Latest Beta
```bash
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.0.0-beta1_amd64.deb
sudo apt-get install -y adduser libfontconfig
sudo dpkg -i grafana_5.0.0-beta1_amd64.deb
```
## APT Repository

View File

@ -16,6 +16,7 @@ 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-beta1 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta1.x86_64.rpm)
Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
installation.
@ -28,6 +29,12 @@ You can install Grafana using Yum directly.
$ 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-beta1.x86_64.rpm
```
Or install manually using `rpm`.
#### On CentOS / Fedora / Redhat:

View File

@ -101,3 +101,8 @@ as this will make upgrades easier without risking losing your config changes.
## Upgrading from 2.x
We are not aware of any issues upgrading directly from 2.x to 4.x but to be on the safe side go via 3.x => 4.x.
## 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.

View File

@ -14,6 +14,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-beta1.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta1.windows-x64.zip)
Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
installation.

View File

@ -4,7 +4,7 @@
"company": "Grafana Labs"
},
"name": "grafana",
"version": "5.0.0-pre1",
"version": "5.0.0-beta1",
"repository": {
"type": "git",
"url": "http://github.com/grafana/grafana.git"

View File

@ -1,6 +1,6 @@
#! /usr/bin/env bash
deb_ver=4.6.0-beta1
rpm_ver=4.6.0-beta1
deb_ver=5.0.0-beta1
rpm_ver=5.0.0-beta1
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${deb_ver}_amd64.deb

View File

@ -105,7 +105,7 @@ func transformToDTOs(alerts []*models.Alert, c *middleware.Context) ([]*dtos.Ale
for _, alert := range alertDTOs {
for _, dash := range dashboardsQuery.Result {
if alert.DashboardId == dash.Id {
alert.DashbboardUri = dash.GenerateUrl()
alert.Url = dash.GenerateUrl()
break
}
}

View File

@ -99,7 +99,7 @@ func GetDashboard(c *middleware.Context) Response {
return ApiError(500, "Dashboard folder could not be read", err)
}
meta.FolderTitle = query.Result.Title
meta.FolderSlug = query.Result.Slug
meta.FolderUrl = query.Result.GetUrl()
}
// make sure db version is in sync with json model version
@ -293,10 +293,11 @@ func GetHomeDashboard(c *middleware.Context) Response {
}
if prefsQuery.Result.HomeDashboardId != 0 {
slugQuery := m.GetDashboardSlugByIdQuery{Id: prefsQuery.Result.HomeDashboardId}
slugQuery := m.GetDashboardRefByIdQuery{Id: prefsQuery.Result.HomeDashboardId}
err := bus.Dispatch(&slugQuery)
if err == nil {
dashRedirect := dtos.DashboardRedirect{RedirectUri: "db/" + slugQuery.Result}
url := m.GetDashboardUrl(slugQuery.Result.Uid, slugQuery.Result.Slug)
dashRedirect := dtos.DashboardRedirect{RedirectUri: url}
return Json(200, &dashRedirect)
} else {
log.Warn("Failed to get slug from database, %s", err.Error())

View File

@ -24,6 +24,12 @@ func GetDashboardAclList(c *middleware.Context) Response {
return ApiError(500, "Failed to get dashboard acl", err)
}
for _, perm := range acl {
if perm.Slug != "" {
perm.Url = m.GetDashboardFolderUrl(perm.IsFolder, perm.Uid, perm.Slug)
}
}
return Json(200, acl)
}

View File

@ -19,7 +19,7 @@ type AlertRule struct {
EvalDate time.Time `json:"evalDate"`
EvalData *simplejson.Json `json:"evalData"`
ExecutionError string `json:"executionError"`
DashbboardUri string `json:"dashboardUri"`
Url string `json:"url"`
CanEdit bool `json:"canEdit"`
}

View File

@ -27,7 +27,7 @@ type DashboardMeta struct {
IsFolder bool `json:"isFolder"`
FolderId int64 `json:"folderId"`
FolderTitle string `json:"folderTitle"`
FolderSlug string `json:"folderSlug"`
FolderUrl string `json:"folderUrl"`
}
type DashboardFullWithMeta struct {

View File

@ -40,14 +40,14 @@ func GenStateString() string {
func OAuthLogin(ctx *middleware.Context) {
if setting.OAuthService == nil {
ctx.Handle(404, "login.OAuthLogin(oauth service not enabled)", nil)
ctx.Handle(404, "OAuth not enabled", nil)
return
}
name := ctx.Params(":name")
connect, ok := social.SocialMap[name]
if !ok {
ctx.Handle(404, "login.OAuthLogin(social login not enabled)", errors.New(name))
ctx.Handle(404, fmt.Sprintf("No OAuth with name %s configured", name), nil)
return
}

View File

@ -42,8 +42,7 @@ func accessForbidden(c *Context) {
return
}
c.SetCookie("redirect_to", url.QueryEscape(setting.AppSubUrl+c.Req.RequestURI), 0, setting.AppSubUrl+"/")
c.Redirect(setting.AppSubUrl + "/login")
c.Redirect(setting.AppSubUrl + "/")
}
func notAuthorized(c *Context) {

View File

@ -1,6 +1,7 @@
package middleware
import (
"fmt"
"strings"
"github.com/grafana/grafana/pkg/bus"
@ -24,6 +25,7 @@ func RedirectFromLegacyDashboardUrl() macaron.Handler {
if slug != "" {
if url, err := getDashboardUrlBySlug(c.OrgId, slug); err == nil {
url = fmt.Sprintf("%s?%s", url, c.Req.URL.RawQuery)
c.Redirect(url, 301)
return
}
@ -38,6 +40,7 @@ func RedirectFromLegacyDashboardSoloUrl() macaron.Handler {
if slug != "" {
if url, err := getDashboardUrlBySlug(c.OrgId, slug); err == nil {
url = strings.Replace(url, "/d/", "/d-solo/", 1)
url = fmt.Sprintf("%s?%s", url, c.Req.URL.RawQuery)
c.Redirect(url, 301)
return
}

View File

@ -30,19 +30,20 @@ func TestMiddlewareDashboardRedirect(t *testing.T) {
middlewareScenario("GET dashboard by legacy url", func(sc *scenarioContext) {
sc.m.Get("/dashboard/db/:slug", redirectFromLegacyDashboardUrl, sc.defaultHandler)
sc.fakeReqWithParams("GET", "/dashboard/db/dash", map[string]string{}).exec()
sc.fakeReqWithParams("GET", "/dashboard/db/dash?orgId=1&panelId=2", map[string]string{}).exec()
Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() {
So(sc.resp.Code, ShouldEqual, 301)
redirectUrl, _ := sc.resp.Result().Location()
So(redirectUrl.Path, ShouldEqual, m.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug))
So(len(redirectUrl.Query()), ShouldEqual, 2)
})
})
middlewareScenario("GET dashboard solo by legacy url", func(sc *scenarioContext) {
sc.m.Get("/dashboard-solo/db/:slug", redirectFromLegacyDashboardSoloUrl, sc.defaultHandler)
sc.fakeReqWithParams("GET", "/dashboard-solo/db/dash", map[string]string{}).exec()
sc.fakeReqWithParams("GET", "/dashboard-solo/db/dash?orgId=1&panelId=2", map[string]string{}).exec()
Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() {
So(sc.resp.Code, ShouldEqual, 301)
@ -50,6 +51,7 @@ func TestMiddlewareDashboardRedirect(t *testing.T) {
expectedUrl := m.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug)
expectedUrl = strings.Replace(expectedUrl, "/d/", "/d-solo/", 1)
So(redirectUrl.Path, ShouldEqual, expectedUrl)
So(len(redirectUrl.Query()), ShouldEqual, 2)
})
})
})

View File

@ -206,7 +206,9 @@ func (ctx *Context) Handle(status int, title string, err error) {
ctx.Data["Title"] = title
ctx.Data["AppSubUrl"] = setting.AppSubUrl
ctx.HTML(status, strconv.Itoa(status))
ctx.Data["Theme"] = "dark"
ctx.HTML(status, "error")
}
func (ctx *Context) JsonOK(message string) {

View File

@ -137,7 +137,7 @@ func Recovery() macaron.Handler {
c.JSON(500, resp)
} else {
c.HTML(500, "500")
c.HTML(500, "error")
}
}
}()

View File

@ -59,6 +59,11 @@ type DashboardAclInfoDTO struct {
Role *RoleType `json:"role,omitempty"`
Permission PermissionType `json:"permission"`
PermissionName string `json:"permissionName"`
Uid string `json:"uid"`
Title string `json:"title"`
Slug string `json:"slug"`
IsFolder bool `json:"isFolder"`
Url string `json:"url"`
}
//

View File

@ -293,7 +293,7 @@ type DashboardRef struct {
Slug string
}
type GetDashboardUIDByIdQuery struct {
type GetDashboardRefByIdQuery struct {
Id int64
Result *DashboardRef
}

View File

@ -90,7 +90,7 @@ func (c *EvalContext) GetDashboardUID() (*m.DashboardRef, error) {
return c.dashboardRef, nil
}
uidQuery := &m.GetDashboardUIDByIdQuery{Id: c.Rule.DashboardId}
uidQuery := &m.GetDashboardRefByIdQuery{Id: c.Rule.DashboardId}
if err := bus.Dispatch(uidQuery); err != nil {
return nil, err
}

View File

@ -21,8 +21,9 @@ type Hit struct {
Tags []string `json:"tags"`
IsStarred bool `json:"isStarred"`
FolderId int64 `json:"folderId,omitempty"`
FolderUid string `json:"folderUid,omitempty"`
FolderTitle string `json:"folderTitle,omitempty"`
FolderSlug string `json:"folderSlug,omitempty"`
FolderUrl string `json:"folderUrl,omitempty"`
}
type HitList []*Hit

View File

@ -245,6 +245,7 @@ type DashboardSearchProjection struct {
Term string
IsFolder bool
FolderId int64
FolderUid string
FolderSlug string
FolderTitle string
}
@ -323,11 +324,15 @@ func makeQueryResult(query *search.FindPersistedDashboardsQuery, res []Dashboard
Url: m.GetDashboardFolderUrl(item.IsFolder, item.Uid, item.Slug),
Type: getHitType(item),
FolderId: item.FolderId,
FolderUid: item.FolderUid,
FolderTitle: item.FolderTitle,
FolderSlug: item.FolderSlug,
Tags: []string{},
}
if item.FolderId > 0 {
hit.FolderUrl = m.GetFolderUrl(item.FolderUid, item.FolderSlug)
}
query.Result = append(query.Result, hit)
hits[item.Id] = hit
}
@ -569,7 +574,7 @@ func GetDashboardsBySlug(query *m.GetDashboardsBySlugQuery) error {
return nil
}
func GetDashboardUIDById(query *m.GetDashboardUIDByIdQuery) error {
func GetDashboardUIDById(query *m.GetDashboardRefByIdQuery) error {
var rawSql = `SELECT uid, slug from dashboard WHERE Id=?`
us := &m.DashboardRef{}

View File

@ -113,6 +113,7 @@ func SetDashboardAcl(cmd *m.SetDashboardAclCommand) error {
})
}
// RemoveDashboardAcl removes a specified permission from the dashboard acl
func RemoveDashboardAcl(cmd *m.RemoveDashboardAclCommand) error {
return inTransaction(func(sess *DBSession) error {
var rawSQL = "DELETE FROM " + dialect.Quote("dashboard_acl") + " WHERE org_id =? and id=?"
@ -125,6 +126,11 @@ func RemoveDashboardAcl(cmd *m.RemoveDashboardAclCommand) error {
})
}
// GetDashboardAclInfoList returns a list of permissions for a dashboard. They can be fetched from three
// different places.
// 1) Permissions for the dashboard
// 2) permissions for its parent folder
// 3) if no specific permissions have been set for the dashboard or its parent folder then get the default permissions
func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
var err error
@ -141,7 +147,11 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
da.updated,
'' as user_login,
'' as user_email,
'' as team
'' as team,
'' as title,
'' as slug,
'' as uid,` +
dialect.BooleanStr(false) + ` AS is_folder
FROM dashboard_acl as da
WHERE da.dashboard_id = -1`
query.Result = make([]*m.DashboardAclInfoDTO, 0)
@ -155,6 +165,7 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
)`, query.DashboardId, query.DashboardId)
rawSQL := `
-- get permissions for the dashboard and its parent folder
SELECT
da.id,
da.org_id,
@ -167,13 +178,18 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
da.updated,
u.login AS user_login,
u.email AS user_email,
ug.name AS team
ug.name AS team,
d.title,
d.slug,
d.uid,
d.is_folder
FROM` + dialect.Quote("dashboard_acl") + ` as da
LEFT OUTER JOIN ` + dialect.Quote("user") + ` AS u ON u.id = da.user_id
LEFT OUTER JOIN team ug on ug.id = da.team_id
LEFT OUTER JOIN dashboard d on da.dashboard_id = d.id
WHERE dashboard_id ` + dashboardFilter + ` AND da.org_id = ?
-- Also include default permission if has_acl = 0
-- Also include default permissions if folder or dashboard field "has_acl" is false
UNION
SELECT
@ -188,10 +204,14 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
da.updated,
'' as user_login,
'' as user_email,
'' as team
FROM dashboard_acl as da,
'' as team,
folder.title,
folder.slug,
folder.uid,
folder.is_folder
FROM dashboard_acl as da,
dashboard as dash
LEFT JOIN dashboard folder on dash.folder_id = folder.id
LEFT OUTER JOIN dashboard folder on dash.folder_id = folder.id
WHERE
dash.id = ? AND (
dash.has_acl = ` + dialect.BooleanStr(false) + ` or

View File

@ -147,6 +147,7 @@ func TestDashboardDataAccess(t *testing.T) {
hit := query.Result[0]
So(hit.Type, ShouldEqual, search.DashHitFolder)
So(hit.Url, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug))
So(hit.FolderTitle, ShouldEqual, "")
})
Convey("Should be able to search for a dashboard folder's children", func() {
@ -163,6 +164,10 @@ func TestDashboardDataAccess(t *testing.T) {
hit := query.Result[0]
So(hit.Id, ShouldEqual, savedDash.Id)
So(hit.Url, ShouldEqual, fmt.Sprintf("/d/%s/%s", savedDash.Uid, savedDash.Slug))
So(hit.FolderId, ShouldEqual, savedFolder.Id)
So(hit.FolderUid, ShouldEqual, savedFolder.Uid)
So(hit.FolderTitle, ShouldEqual, savedFolder.Title)
So(hit.FolderUrl, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug))
})
Convey("Should be able to search for dashboard by dashboard ids", func() {

View File

@ -167,4 +167,13 @@ func addDashboardMigration(mg *Migrator) {
mg.AddMigration("Remove unique index org_id_slug", NewDropIndexMigration(dashboardV2, &Index{
Cols: []string{"org_id", "slug"}, Type: UniqueIndex,
}))
mg.AddMigration("Update dashboard title length", NewTableCharsetMigration("dashboard", []*Column{
{Name: "title", Type: DB_NVarchar, Length: 189, Nullable: false},
}))
mg.AddMigration("Add unique index for dashboard_org_id_title_folder_id", NewAddIndexMigration(dashboardV2, &Index{
Cols: []string{"org_id", "folder_id", "title"}, Type: UniqueIndex,
}))
}

View File

@ -107,6 +107,7 @@ func (sb *SearchBuilder) buildSelect() {
dashboard_tag.term,
dashboard.is_folder,
dashboard.folder_id,
folder.uid as folder_uid,
folder.slug as folder_slug,
folder.title as folder_title
FROM `)

View File

@ -23,7 +23,7 @@ describe('AlertRuleList', () => {
.format(),
evalData: {},
executionError: '',
dashboardUri: 'd/ufkcofof/my-goal',
url: 'd/ufkcofof/my-goal',
canEdit: true,
},
])

View File

@ -137,7 +137,7 @@ export class AlertRuleItem extends React.Component<AlertRuleItemProps, any> {
'fa-pause': !rule.isPaused,
});
let ruleUrl = `${rule.dashboardUri}?panelId=${rule.panelId}&fullscreen=true&edit=true&tab=alert`;
let ruleUrl = `${rule.url}?panelId=${rule.panelId}&fullscreen=true&edit=true&tab=alert`;
return (
<li className="alert-rule-item">

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react';
import React, { Component } from 'react';
import { inject, observer } from 'mobx-react';
import { toJS } from 'mobx';
import IContainerProps from 'app/containers/IContainerProps';
@ -8,6 +8,7 @@ import Tooltip from 'app/core/components/Tooltip/Tooltip';
import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
import AddPermissions from 'app/core/components/Permissions/AddPermissions';
import SlideDown from 'app/core/components/Animations/SlideDown';
@inject('nav', 'folder', 'view', 'permissions')
@observer
export class FolderPermissions extends Component<IContainerProps, any> {
@ -17,6 +18,11 @@ export class FolderPermissions extends Component<IContainerProps, any> {
this.loadStore();
}
componentWillUnmount() {
const { permissions } = this.props;
permissions.hideAddPermissions();
}
loadStore() {
const { nav, folder, view } = this.props;
return folder.load(view.routeParams.get('uid') as string).then(res => {
@ -58,7 +64,7 @@ export class FolderPermissions extends Component<IContainerProps, any> {
</button>
</div>
<SlideDown in={permissions.isAddPermissionsVisible}>
<AddPermissions permissions={permissions} backendSrv={backendSrv} dashboardId={dashboardId} />
<AddPermissions permissions={permissions} backendSrv={backendSrv} />
</SlideDown>
<Permissions permissions={permissions} isFolder={true} dashboardId={dashboardId} backendSrv={backendSrv} />
</div>

View File

@ -14,6 +14,7 @@ describe('FolderSettings', () => {
dashboard: {
id: 1,
title: 'Folder Name',
uid: 'uid-str',
},
meta: {
url: '/dashboards/f/uid/folder-name',
@ -23,19 +24,27 @@ describe('FolderSettings', () => {
);
const store = RootStore.create(
{},
{
view: {
path: 'asd',
query: {},
routeParams: {
uid: 'uid-str',
},
},
},
{
backendSrv: backendSrv,
}
);
wrapper = shallow(<FolderSettings backendSrv={backendSrv} {...store} />);
return wrapper
.dive()
page = wrapper.dive();
return page
.instance()
.loadStore()
.then(() => {
page = wrapper.dive();
page.update();
});
});

View File

@ -20,11 +20,5 @@ export function registerAngularDirectives() {
['tagOptions', { watchDepth: 'reference' }],
]);
react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'handlePicked']);
react2AngularDirective('dashboardPermissions', DashboardPermissions, [
'backendSrv',
'dashboardId',
'folderTitle',
'folderSlug',
'folderId',
]);
react2AngularDirective('dashboardPermissions', DashboardPermissions, ['backendSrv', 'dashboardId', 'folder']);
}

View File

@ -26,7 +26,7 @@ describe('AddPermissions', () => {
}
);
wrapper = shallow(<AddPermissions permissions={store.permissions} backendSrv={backendSrv} dashboardId={1} />);
wrapper = shallow(<AddPermissions permissions={store.permissions} backendSrv={backendSrv} />);
instance = wrapper.instance();
return store.permissions.load(1, true, false);
});

View File

@ -9,7 +9,6 @@ import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore'
export interface IProps {
permissions: any;
backendSrv: any;
dashboardId: any;
}
@observer
class AddPermissions extends Component<IProps, any> {
@ -31,12 +30,6 @@ class AddPermissions extends Component<IProps, any> {
const { value } = evt.target;
const { permissions } = this.props;
// if (value === 'Viewer' || value === 'Editor') {
// // permissions.addStoreItem({ permission: 1, role: value, dashboardId: dashboardId }, dashboardId);
// // this.resetNewType();
// return;
// }
permissions.setNewType(value);
}

View File

@ -6,12 +6,11 @@ import Tooltip from 'app/core/components/Tooltip/Tooltip';
import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
import AddPermissions from 'app/core/components/Permissions/AddPermissions';
import SlideDown from 'app/core/components/Animations/SlideDown';
import { FolderInfo } from './FolderInfo';
export interface IProps {
dashboardId: number;
folderId: number;
folderTitle: string;
folderSlug: string;
folder?: FolderInfo;
backendSrv: any;
}
@observer
@ -28,8 +27,12 @@ class DashboardPermissions extends Component<IProps, any> {
this.permissions.toggleAddPermissions();
}
componentWillUnmount() {
this.permissions.hideAddPermissions();
}
render() {
const { dashboardId, folderTitle, folderSlug, folderId, backendSrv } = this.props;
const { dashboardId, folder, backendSrv } = this.props;
return (
<div>
@ -50,13 +53,13 @@ class DashboardPermissions extends Component<IProps, any> {
</div>
</div>
<SlideDown in={this.permissions.isAddPermissionsVisible}>
<AddPermissions permissions={this.permissions} backendSrv={backendSrv} dashboardId={dashboardId} />
<AddPermissions permissions={this.permissions} backendSrv={backendSrv} />
</SlideDown>
<Permissions
permissions={this.permissions}
isFolder={false}
dashboardId={dashboardId}
folderInfo={{ title: folderTitle, slug: folderSlug, id: folderId }}
folderInfo={folder}
backendSrv={backendSrv}
/>
</div>

View File

@ -1,5 +1,5 @@
export interface FolderInfo {
title: string;
id: number;
slug: string;
title: string;
url: string;
}

View File

@ -30,7 +30,7 @@ export default observer(({ item, removeItem, permissionChanged, itemIndex, folde
folderInfo && (
<em className="muted no-wrap">
Inherited from folder{' '}
<a className="text-link" href={`dashboards/folder/${folderInfo.id}/${folderInfo.slug}/permissions`}>
<a className="text-link" href={`${folderInfo.url}/permissions`}>
{folderInfo.title}
</a>{' '}
</em>

View File

@ -7,7 +7,6 @@ export interface Props {
}
export default class ScrollBar extends React.Component<Props, any> {
private container: any;
private ps: PerfectScrollbar;
@ -16,7 +15,9 @@ export default class ScrollBar extends React.Component<Props, any> {
}
componentDidMount() {
this.ps = new PerfectScrollbar(this.container);
this.ps = new PerfectScrollbar(this.container, {
wheelPropagation: true,
});
}
componentDidUpdate() {

View File

@ -83,6 +83,10 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
body.toggleClass('sidemenu-hidden');
});
scope.$watch(() => playlistSrv.isPlaying, function(newValue) {
elem.toggleClass('playlist-active', newValue === true);
});
// tooltip removal fix
// manage page classes
var pageClass;

View File

@ -19,7 +19,6 @@ export class HelpCtrl {
],
Dashboard: [
{ keys: ['mod+s'], description: 'Save dashboard' },
{ keys: ['mod+h'], description: 'Hide row controls' },
{ keys: ['d', 'r'], description: 'Refresh all panels' },
{ keys: ['d', 's'], description: 'Dashboard settings' },
{ keys: ['d', 'v'], description: 'Toggle in-active / view mode' },

View File

@ -1,5 +1,6 @@
import coreModule from 'app/core/core_module';
import { contextSrv } from 'app/core/services/context_srv';
import config from 'app/core/config';
const template = `
<div class="modal-body">
@ -60,16 +61,11 @@ export class OrgSwitchCtrl {
setUsingOrg(org) {
return this.backendSrv.post('/api/user/using/' + org.orgId).then(() => {
const re = /orgId=\d+/gi;
this.setWindowLocationHref(this.getWindowLocationHref().replace(re, 'orgId=' + org.orgId));
this.setWindowLocation(config.appSubUrl + (config.appSubUrl.endsWith('/') ? '' : '/') + '?orgId=' + org.orgId);
});
}
getWindowLocationHref() {
return window.location.href;
}
setWindowLocationHref(href: string) {
setWindowLocation(href: string) {
window.location.href = href;
}
}

View File

@ -6,7 +6,9 @@ export function geminiScrollbar() {
return {
restrict: 'A',
link: function(scope, elem, attrs) {
let scrollbar = new PerfectScrollbar(elem[0]);
let scrollbar = new PerfectScrollbar(elem[0], {
wheelPropagation: true,
});
let lastPos = 0;
appEvents.on(

View File

@ -18,10 +18,6 @@ function (_, $, coreModule) {
elem.toggleClass('panel-in-fullscreen', false);
});
$scope.$watch('ctrl.playlistSrv.isPlaying', function(newValue) {
elem.toggleClass('playlist-active', newValue === true);
});
$scope.$watch('ctrl.dashboardViewState.state.editview', function(newValue) {
if (newValue) {
elem.toggleClass('dashboard-page--settings-opening', _.isString(newValue));

View File

@ -150,9 +150,9 @@ export class SearchSrv {
if (hit.folderId) {
section = {
id: hit.folderId,
uid: hit.uid,
uid: hit.folderUid,
title: hit.folderTitle,
url: hit.url,
url: hit.folderUrl,
items: [],
icon: 'fa fa-folder-open',
toggle: this.toggleFolder.bind(this),

View File

@ -0,0 +1,64 @@
import * as fileExport from '../utils/file_export';
import { beforeEach, expect } from 'test/lib/common';
describe('file_export', () => {
let ctx: any = {};
beforeEach(() => {
ctx.seriesList = [
{
alias: 'series_1',
datapoints: [
[1, 1500026100000],
[2, 1500026200000],
[null, 1500026300000],
[null, 1500026400000],
[null, 1500026500000],
[6, 1500026600000],
],
},
{
alias: 'series_2',
datapoints: [[11, 1500026100000], [12, 1500026200000], [13, 1500026300000], [15, 1500026500000]],
},
];
ctx.timeFormat = 'X'; // Unix timestamp (seconds)
});
describe('when exporting series as rows', () => {
it('should export points in proper order', () => {
let text = fileExport.convertSeriesListToCsv(ctx.seriesList, ctx.timeFormat);
const expectedText =
'Series;Time;Value\n' +
'series_1;1500026100;1\n' +
'series_1;1500026200;2\n' +
'series_1;1500026300;null\n' +
'series_1;1500026400;null\n' +
'series_1;1500026500;null\n' +
'series_1;1500026600;6\n' +
'series_2;1500026100;11\n' +
'series_2;1500026200;12\n' +
'series_2;1500026300;13\n' +
'series_2;1500026500;15\n';
expect(text).toBe(expectedText);
});
});
describe('when exporting series as columns', () => {
it('should export points in proper order', () => {
let text = fileExport.convertSeriesListToCsvColumns(ctx.seriesList, ctx.timeFormat);
const expectedText =
'Time;series_1;series_2\n' +
'1500026100;1;11\n' +
'1500026200;2;12\n' +
'1500026300;null;13\n' +
'1500026400;null;null\n' +
'1500026500;null;15\n' +
'1500026600;6;null\n';
expect(text).toBe(expectedText);
});
});
});

View File

@ -20,9 +20,6 @@ describe('ManageDashboards', () => {
icon: 'fa fa-folder',
tags: [],
isStarred: false,
folderId: 410,
folderTitle: 'afolder',
folderSlug: 'afolder',
},
],
tags: [],
@ -77,9 +74,6 @@ describe('ManageDashboards', () => {
icon: 'fa fa-folder',
tags: [],
isStarred: false,
folderId: 410,
folderTitle: 'afolder',
folderSlug: 'afolder',
},
],
tags: [],
@ -112,8 +106,9 @@ describe('ManageDashboards', () => {
tags: [],
isStarred: false,
folderId: 410,
folderTitle: 'afolder',
folderSlug: 'afolder',
folderUid: 'uid',
folderTitle: 'Folder',
folderUrl: '/dashboards/f/uid/folder',
},
{
id: 500,

View File

@ -7,6 +7,12 @@ jest.mock('app/core/services/context_srv', () => ({
},
}));
jest.mock('app/core/config', () => {
return {
appSubUrl: '/subUrl',
};
});
describe('OrgSwitcher', () => {
describe('when switching org', () => {
let expectedHref;
@ -25,8 +31,7 @@ describe('OrgSwitcher', () => {
const orgSwitcherCtrl = new OrgSwitchCtrl(backendSrvStub);
orgSwitcherCtrl.getWindowLocationHref = () => 'http://localhost:3000?orgId=1&from=now-3h&to=now';
orgSwitcherCtrl.setWindowLocationHref = href => (expectedHref = href);
orgSwitcherCtrl.setWindowLocation = href => (expectedHref = href);
return orgSwitcherCtrl.setUsingOrg({ orgId: 2 });
});
@ -35,8 +40,8 @@ describe('OrgSwitcher', () => {
expect(expectedUsingUrl).toBe('/api/user/using/2');
});
it('should switch orgId in url', () => {
expect(expectedHref).toBe('http://localhost:3000?orgId=2&from=now-3h&to=now');
it('should switch orgId in url and redirect to home page', () => {
expect(expectedHref).toBe('/subUrl/?orgId=2');
});
});
});

View File

@ -190,7 +190,9 @@ describe('SearchSrv', () => {
title: 'dash in folder1 1',
type: 'dash-db',
folderId: 1,
folderUid: 'uid',
folderTitle: 'folder1',
folderUrl: '/dashboards/f/uid/folder1',
},
])
);
@ -206,6 +208,11 @@ describe('SearchSrv', () => {
it('should group results by folder', () => {
expect(results).toHaveLength(2);
expect(results[0].id).toEqual(0);
expect(results[1].id).toEqual(1);
expect(results[1].uid).toEqual('uid');
expect(results[1].title).toEqual('folder1');
expect(results[1].url).toEqual('/dashboards/f/uid/folder1');
});
});

View File

@ -3,19 +3,27 @@ import moment from 'moment';
import { saveAs } from 'file-saver';
const DEFAULT_DATETIME_FORMAT = 'YYYY-MM-DDTHH:mm:ssZ';
const POINT_TIME_INDEX = 1;
const POINT_VALUE_INDEX = 0;
export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
export function convertSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
var text = (excel ? 'sep=;\n' : '') + 'Series;Time;Value\n';
_.each(seriesList, function(series) {
_.each(series.datapoints, function(dp) {
text += series.alias + ';' + moment(dp[1]).format(dateTimeFormat) + ';' + dp[0] + '\n';
text +=
series.alias + ';' + moment(dp[POINT_TIME_INDEX]).format(dateTimeFormat) + ';' + dp[POINT_VALUE_INDEX] + '\n';
});
});
return text;
}
export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
var text = convertSeriesListToCsv(seriesList, dateTimeFormat, excel);
saveSaveBlob(text, 'grafana_data_export.csv');
}
export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
var text = (excel ? 'sep=;\n' : '') + 'Time;';
export function convertSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
let text = (excel ? 'sep=;\n' : '') + 'Time;';
// add header
_.each(seriesList, function(series) {
text += series.alias + ';';
@ -24,14 +32,15 @@ export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAUL
text += '\n';
// process data
seriesList = mergeSeriesByTime(seriesList);
var dataArr = [[]];
var sIndex = 1;
_.each(seriesList, function(series) {
var cIndex = 0;
dataArr.push([]);
_.each(series.datapoints, function(dp) {
dataArr[0][cIndex] = moment(dp[1]).format(dateTimeFormat);
dataArr[sIndex][cIndex] = dp[0];
dataArr[0][cIndex] = moment(dp[POINT_TIME_INDEX]).format(dateTimeFormat);
dataArr[sIndex][cIndex] = dp[POINT_VALUE_INDEX];
cIndex++;
});
sIndex++;
@ -46,6 +55,44 @@ export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAUL
text = text.substring(0, text.length - 1);
text += '\n';
}
return text;
}
/**
* Collect all unique timestamps from series list and use it to fill
* missing points by null.
*/
function mergeSeriesByTime(seriesList) {
let timestamps = [];
for (let i = 0; i < seriesList.length; i++) {
let seriesPoints = seriesList[i].datapoints;
for (let j = 0; j < seriesPoints.length; j++) {
timestamps.push(seriesPoints[j][POINT_TIME_INDEX]);
}
}
timestamps = _.sortedUniq(timestamps.sort());
for (let i = 0; i < seriesList.length; i++) {
let seriesPoints = seriesList[i].datapoints;
let seriesTimestamps = _.map(seriesPoints, p => p[POINT_TIME_INDEX]);
let extendedSeries = [];
let pointIndex;
for (let j = 0; j < timestamps.length; j++) {
pointIndex = _.sortedIndexOf(seriesTimestamps, timestamps[j]);
if (pointIndex !== -1) {
extendedSeries.push(seriesPoints[pointIndex]);
} else {
extendedSeries.push([null, timestamps[j]]);
}
}
seriesList[i].datapoints = extendedSeries;
}
return seriesList;
}
export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
let text = convertSeriesListToCsvColumns(seriesList, dateTimeFormat, excel);
saveSaveBlob(text, 'grafana_data_export.csv');
}

View File

@ -1,5 +1,6 @@
import coreModule from 'app/core/core_module';
import { DashboardModel } from './dashboard_model';
import locationUtil from 'app/core/utils/location_util';
export class DashboardSrv {
dash: any;
@ -74,7 +75,7 @@ export class DashboardSrv {
this.dash.version = data.version;
if (data.url !== this.$location.path()) {
this.$location.url(data.url);
this.$location.url(locationUtil.stripBaseFromUrl(data.url)).replace();
}
this.$rootScope.appEvent('dashboard-saved', this.dash);

View File

@ -93,7 +93,6 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
}
renderPanelItem(panel, index) {
console.log('render panel', index);
return (
<div key={index} className="add-panel__item" onClick={() => this.onAddPanel(panel)} title={panel.name}>
<img className="add-panel__item-img" src={panel.info.logos.small} />

View File

@ -4,6 +4,7 @@ import _ from 'lodash';
import angular from 'angular';
import moment from 'moment';
import locationUtil from 'app/core/utils/location_util';
import { DashboardModel } from '../dashboard_model';
import { HistoryListOpts, RevisionsModel, CalculateDiffOptions, HistorySrv } from './history_srv';
@ -185,7 +186,7 @@ export class HistoryListCtrl {
return this.historySrv
.restoreDashboard(this.dashboard, version)
.then(response => {
this.$location.path('dashboard/db/' + response.slug);
this.$location.url(locationUtil.stripBaseFromUrl(response.url)).replace();
this.$route.reload();
this.$rootScope.appEvent('alert-success', ['Dashboard restored', 'Restored from version ' + version]);
})

View File

@ -96,13 +96,14 @@
</div>
<div class="dashboard-settings__content" ng-if="ctrl.viewId === 'permissions'" >
<dashboard-permissions ng-if="ctrl.dashboard"
<dashboard-permissions ng-if="ctrl.dashboard && !ctrl.hasUnsavedFolderChange"
dashboardId="ctrl.dashboard.id"
backendSrv="ctrl.backendSrv"
folderTitle="ctrl.dashboard.meta.folderTitle"
folderSlug="ctrl.dashboard.meta.folderSlug"
folderId="ctrl.dashboard.meta.folderId"
folder="ctrl.getFolder()"
/>
<div ng-if="ctrl.hasUnsavedFolderChange">
<h5>You have changed folder, please save to view permissions.</h5>
</div>
</div>
<div class="dashboard-settings__content" ng-if="ctrl.viewId === '404'">

View File

@ -14,6 +14,7 @@ export class SettingsCtrl {
canSave: boolean;
canDelete: boolean;
sections: any[];
hasUnsavedFolderChange: boolean;
/** @ngInject */
constructor(private $scope, private $location, private $rootScope, private backendSrv, private dashboardSrv) {
@ -38,6 +39,7 @@ export class SettingsCtrl {
this.$rootScope.onAppEvent('$routeUpdate', this.onRouteUpdated.bind(this), $scope);
this.$rootScope.appEvent('dash-scroll', { animate: false, pos: 0 });
this.$rootScope.onAppEvent('dashboard-saved', this.onPostSave.bind(this), $scope);
}
buildSectionList() {
@ -135,6 +137,10 @@ export class SettingsCtrl {
this.dashboardSrv.saveDashboard();
}
onPostSave() {
this.hasUnsavedFolderChange = false;
}
hideSettings() {
var urlParams = this.$location.search();
delete urlParams.editview;
@ -195,7 +201,15 @@ export class SettingsCtrl {
onFolderChange(folder) {
this.dashboard.meta.folderId = folder.id;
this.dashboard.meta.folderTitle = folder.title;
this.dashboard.meta.folderSlug = folder.slug;
this.hasUnsavedFolderChange = true;
}
getFolder() {
return {
id: this.dashboard.meta.folderId,
title: this.dashboard.meta.folderTitle,
url: this.dashboard.meta.folderUrl,
};
}
}

View File

@ -100,7 +100,9 @@ module.directive('grafanaPanel', function($rootScope, $document, $timeout) {
// update scrollbar after mounting
ctrl.events.on('component-did-mount', () => {
if (ctrl.__proto__.constructor.scrollable) {
panelScrollbar = new PerfectScrollbar(panelContent[0]);
panelScrollbar = new PerfectScrollbar(panelContent[0], {
wheelPropagation: true,
});
}
});

View File

@ -9,7 +9,7 @@ export class SoloPanelCtrl {
$scope.init = function() {
contextSrv.sidemenu = false;
appEvents.emit('toggle-sidemenu');
appEvents.emit('toggle-sidemenu-hidden');
var params = $location.search();
panelId = parseInt(params.panelId);

View File

@ -52,7 +52,7 @@
<empty-list-cta model="{
title: 'There are no data sources defined yet',
buttonIcon: 'gicon gicon-add-datasources',
buttonLink: '/datasources/new',
buttonLink: 'datasources/new',
buttonTitle: 'Add data source',
proTip: 'You can also define data sources through configuration files.',
proTipLink: 'http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list',

View File

@ -12,7 +12,7 @@
<div class="alert-rule-item__body">
<div class="alert-rule-item__header">
<p class="alert-rule-item__name">
<a href="dashboard/{{alert.dashboardUri}}?panelId={{alert.panelId}}&fullscreen&edit&tab=alert">
<a href="{{alert.url}}?panelId={{alert.panelId}}&fullscreen&edit&tab=alert">
{{alert.name}}
</a>
</p>

View File

@ -246,6 +246,7 @@ module.directive('graphLegend', function(popoverSrv, $timeout) {
// Number of pixels the content height can surpass the container height without enabling the scroll bar.
scrollYMarginOffset: 2,
suppressScrollX: true,
wheelPropagation: true,
};
if (!legendScrollbar) {

View File

@ -153,8 +153,12 @@ export class HeatmapTooltip {
getXBucketIndex(offsetX, data) {
let x = this.scope.xScale.invert(offsetX - this.scope.yAxisWidth).valueOf();
let xBucketIndex = getValueBucketBound(x, data.xBucketSize, 1);
return xBucketIndex;
// First try to find X bucket by checking x pos is in the
// [bucket.x, bucket.x + xBucketSize] interval
let xBucket = _.find(data.buckets, bucket => {
return x > bucket.x && x - bucket.x <= data.xBucketSize;
});
return xBucket ? xBucket.x : getValueBucketBound(x, data.xBucketSize, 1);
}
getYBucketIndex(offsetY, data) {

View File

@ -9,7 +9,7 @@ export class LoadDashboardCtrl {
if (!$routeParams.uid && !$routeParams.slug) {
backendSrv.get('/api/dashboards/home').then(function(homeDash) {
if (homeDash.redirectUri) {
$location.path('dashboard/' + homeDash.redirectUri);
$location.path(homeDash.redirectUri);
} else {
var meta = homeDash.meta;
meta.canSave = meta.canShare = meta.canStar = false;
@ -23,18 +23,19 @@ export class LoadDashboardCtrl {
if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {
backendSrv.get(`/api/dashboards/db/${$routeParams.slug}`).then(res => {
if (res) {
const url = locationUtil.stripBaseFromUrl(res.meta.url);
$location.path(url).replace();
$location.path(locationUtil.stripBaseFromUrl(res.meta.url)).replace();
}
});
return;
}
dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug, $routeParams.uid).then(function(result) {
const url = locationUtil.stripBaseFromUrl(result.meta.url);
if (result.meta.url) {
const url = locationUtil.stripBaseFromUrl(result.meta.url);
if (url !== $location.path()) {
$location.path(url).replace();
if (url !== $location.path()) {
$location.path(url).replace();
}
}
if ($routeParams.keepRows) {

View File

@ -14,7 +14,7 @@ function getRule(name, state, info) {
.format(),
evalData: {},
executionError: '',
dashboardUri: 'db/mygool',
url: 'db/mygool',
stateText: state,
stateIcon: 'fa',
stateClass: 'asd',

View File

@ -13,7 +13,7 @@ export const AlertRule = types
stateClass: types.string,
stateAge: types.string,
info: types.optional(types.string, ''),
dashboardUri: types.string,
url: types.string,
canEdit: types.boolean,
})
.views(self => ({

View File

@ -5,6 +5,7 @@ export const Folder = types.model('Folder', {
title: types.string,
url: types.string,
canSave: types.boolean,
uid: types.string,
hasChanged: types.boolean,
});
@ -14,15 +15,23 @@ export const FolderStore = types
})
.actions(self => ({
load: flow(function* load(uid: string) {
// clear folder state
if (self.folder && self.folder.uid !== uid) {
self.folder = null;
}
const backendSrv = getEnv(self).backendSrv;
const res = yield backendSrv.getDashboardByUid(uid);
self.folder = Folder.create({
id: res.dashboard.id,
title: res.dashboard.title,
url: res.meta.url,
uid: res.dashboard.uid,
canSave: res.meta.canSave,
hasChanged: false,
});
return res;
}),

View File

@ -115,6 +115,7 @@ export const PermissionsStore = types
self.fetching = false;
self.error = null;
}),
addStoreItem: flow(function* addStoreItem() {
self.error = null;
let item = {
@ -152,11 +153,13 @@ export const PermissionsStore = types
resetNewType();
return updateItems(self);
}),
removeStoreItem: flow(function* removeStoreItem(idx: number) {
self.error = null;
self.items.splice(idx, 1);
return updateItems(self);
}),
updatePermissionOnIndex: flow(function* updatePermissionOnIndex(
idx: number,
permission: number,
@ -166,18 +169,19 @@ export const PermissionsStore = types
self.items[idx].updatePermission(permission, permissionName);
return updateItems(self);
}),
setNewType(newType: string) {
self.newItem = NewPermissionsItem.create({ type: newType });
},
resetNewType() {
resetNewType();
},
toggleAddPermissions() {
self.isAddPermissionsVisible = !self.isAddPermissionsVisible;
},
showAddPermissions() {
self.isAddPermissionsVisible = true;
},
hideAddPermissions() {
self.isAddPermissionsVisible = false;
},

View File

@ -255,7 +255,7 @@
}
// Caret to indicate there is a submenu
.dropdown-submenu > a::before {
.dropdown-submenu > a::after {
display: block;
content: ' ';
float: right;

View File

@ -5,6 +5,7 @@
.dashlist-section {
margin-bottom: $spacer;
padding-top: 3px;
}
.dashlist-link {

View File

@ -1,5 +1,6 @@
.page-kiosk-mode {
dashnav {
.sidemenu,
.navbar {
display: none;
}
}
@ -31,6 +32,10 @@
}
}
.sidemenu {
display: none;
}
.gf-timepicker-nav-btn {
transform: translate3d(40px, 0, 0);
}

View File

@ -1,28 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width">
<title>Grafana</title>
<base href="[[.AppSubUrl]]/" />
<link rel="stylesheet" href="public/build/grafana.dark.min.css" title="Dark">
<link rel="icon" type="image/png" href="public/img/fav32.png">
</head>
<body>
<div class="gf-box" style="margin: 200px auto 0 auto; width: 500px;">
<div class="gf-box-header">
<span class="gf-box-title">
Proxy authentication required
</span>
</div>
<div class="gf-box-body">
<h4>Proxy authenticaion required</h4>
</div>
</div>
</body>
</html>

View File

@ -1,39 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width">
<title>Grafana - Error</title>
<base href="[[.AppSubUrl]]/" />
<link href='public/css/fonts.min.css' rel='stylesheet' type='text/css'>
<link rel="stylesheet" href="public/build/grafana.dark.min.css">
<link rel="icon" type="image/png" href="public/img/fav32.png">
</head>
<body>
<div class="page-container">
<div class="page-header">
<h1>Server side error :(</h1>
</div>
<div class="panel-container" style="padding: 2rem">
<div class="alert">
<div class="alert-icon"><i class="fa fa-exclamation-triangle"></i></div>
<div class="alert-body">
<div class="alert-title">[[.Title]]</div>
<div class="alert-text">
[[if .ErrorMsg]]
<pre>[[.ErrorMsg]]</pre>
[[end]]
</div>
</div>
</div>
<div style="padding: 2rem 0 0">
<p>Check the Grafana server logs for the detailed error message.</p>
</div>
</div>
</div>
</body>
</html>

57
public/views/error.html Normal file
View File

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width">
<meta name="theme-color" content="#000">
<title>Grafana - Error</title>
<base href="[[.AppSubUrl]]/" />
<link rel="stylesheet" href="public/build/grafana.[[ .Theme ]].css?v[[ .BuildVersion ]]">
<link rel="icon" type="image/png" href="public/img/fav32.png">
<link rel="mask-icon" href="public/img/grafana_mask_icon.svg" color="#F05A28">
</head>
<body class="theme-[[ .Theme ]]">
<div class="main-view">
<div class="page-container">
<div class="page-header">
<div class="page-header__inner">
<span class="page-header__logo">
<i class="page-header__icon fa fa-frown-o"></i>
</span>
<div class="page-header__info-block">
<h1 class="page-header__title">
<a class="text-link" href="login">Grafana</a><span> / Server Error</span><span></span>
</h1>
<div class="page-header__sub-title">Sadly something went wrong</div>
</div>
</div>
</div>
</div>
<div class="page-container page-body ng-scope" style="padding: 2rem">
<div class="alert">
<div class="alert-icon"><i class="fa fa-exclamation-triangle"></i></div>
<div class="alert-body">
<div class="alert-title">[[.Title]]</div>
</div>
</div>
<br />
[[if .ErrorMsg]]
<h4 class="page-heading">Error details</h4>
<div class="alert-text">
<pre>[[.ErrorMsg]]</pre>
</div>
[[end]]
<div style="padding: 2rem 0 0">
<p>Check the Grafana server logs for the detailed error message.</p>
</div>
</div>
</div>
</body>
</html>