diff --git a/CHANGELOG.md b/CHANGELOG.md index 62e0ce1173c..ef6c3ffc273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ * **Prometheus**: Adds /metrics endpoint for exposing Grafana metrics. [#9187](https://github.com/grafana/grafana/pull/9187) * **Graph**: Add support for local formating in axis. [#1395](https://github.com/grafana/grafana/issues/1395), thx [@m0nhawk](https://github.com/m0nhawk) * **Jaeger**: Add support for open tracing using jaeger in Grafana. [#9213](https://github.com/grafana/grafana/pull/9213) -* **Unit types**: New date & time unit types added, useful in singlestat to show dates & times. [#3678](https://github.com/grafana/grafana/issues/3678), [#6710](https://github.com/grafana/grafana/issues/6710), [#2764](https://github.com/grafana/grafana/issues/6710) +* **Unit types**: New date & time unit types added, useful in singlestat to show dates & times. [#3678](https://github.com/grafana/grafana/issues/3678), [#6710](https://github.com/grafana/grafana/issues/6710), [#2764](https://github.com/grafana/grafana/issues/2764) * **CLI**: Make it possible to install plugins from any url [#5873](https://github.com/grafana/grafana/issues/5873) * **Prometheus**: Add support for instant queries [#5765](https://github.com/grafana/grafana/issues/5765), thx [@mtanda](https://github.com/mtanda) * **Cloudwatch**: Add support for alerting using the cloudwatch datasource [#8050](https://github.com/grafana/grafana/pull/8050), thx [@mtanda](https://github.com/mtanda) @@ -23,6 +23,8 @@ * **Prometheus**: Align $__interval with the step parameters. [#9226](https://github.com/grafana/grafana/pull/9226), thx [@alin-amana](https://github.com/alin-amana) * **Prometheus**: Autocomplete for label name and label value [#9208](https://github.com/grafana/grafana/pull/9208), thx [@mtanda](https://github.com/mtanda) * **Postgres**: New Postgres data source [#9209](https://github.com/grafana/grafana/pull/9209), thx [@svenklemm](https://github.com/svenklemm) +* **Datasources**: Make datasource HTTP requests verify TLS by default. closes [#9371](https://github.com/grafana/grafana/issues/9371), [#5334](https://github.com/grafana/grafana/issues/5334), [#8812](https://github.com/grafana/grafana/issues/8812), thx [@mattbostock](https://github.com/mattbostock) +* **OAuth**: Verify TLS during OAuth callback [#9373](https://github.com/grafana/grafana/issues/9373), thx [@mattbostock](https://github.com/mattbostock) ## Minor * **SMTP**: Make it possible to set specific EHLO for smtp client. [#9319](https://github.com/grafana/grafana/issues/9319) @@ -33,9 +35,11 @@ * **Opsgenie**: Use their latest API instead of old version [#9399](https://github.com/grafana/grafana/pull/9399), thx [@cglrkn](https://github.com/cglrkn) * **Table**: Add support for displaying the timestamp with milliseconds [#9429](https://github.com/grafana/grafana/pull/9429), thx [@s1061123](https://github.com/s1061123) * **Hipchat**: Add metrics, message and image to hipchat notifications [#9110](https://github.com/grafana/grafana/issues/9110), thx [@eloo](https://github.com/eloo) +* **Kafka**: Add support for sending alert notifications to kafka [#7104](https://github.com/grafana/grafana/issues/7104), thx [@utkarshcmu](https://github.com/utkarshcmu) ## Tech * **Go**: Grafana is now built using golang 1.9 +* **Webpack**: Changed from systemjs to webpack (see readme or building from source guide for new build instructions). Systemjs is still used to load plugins but now plugins can only import a limited set of dependencies. See [PLUGIN_DEV.md](https://github.com/grafana/grafana/blob/master/PLUGIN_DEV.md) for more details on how this can effect some plugins. # 4.5.2 (2017-09-22) diff --git a/PLUGIN_DEV.md b/PLUGIN_DEV.md new file mode 100644 index 00000000000..1f672aa5a3d --- /dev/null +++ b/PLUGIN_DEV.md @@ -0,0 +1,28 @@ +# Plugin Development + +This document is not meant as complete guide for developing plugins but more as a changelog for changes in +Grafana that can impact plugin development. When ever you as plugin author encounter an issue with your plugin after +upgrading Grafana please check here before creating an issue. + +## Links + +- [Datasource plugin written in typescript](https://github.com/grafana/typescript-template-datasource) +- [Simple json dataource plugin](https://github.com/grafana/simple-json-datasource) +- [Plugin development guide](http://docs.grafana.org/plugins/developing/development/) + +## Changes in v4.6 + +This version of Grafana has big changes that will impact a limited set of plugins. We moved from systemjs to webpack +for built-in plugins & everything internal. External plugins still use systemjs but now with a limited +set of Grafana components they can import. Plugins can depend on libs like lodash & moment and internal components +like before using the same import paths. However since everything in Grafana is no longer accessible, a few plugins could encounter issues when importing a Grafana dependency. + +[List of exposed components plugins can import/require](https://github.com/grafana/grafana/blob/master/public/app/features/plugins/plugin_loader.ts#L48) + +If you think we missed exposing a crucial lib or Grafana component let us know by opening an issue. + +### Deprecated components + +The angular directive `` is no deprecated (will still work for a version more) but we recommend plugin authors +to upgrade to new `` + diff --git a/README.md b/README.md index 40ec43e1c16..28f8bfc1ba2 100644 --- a/README.md +++ b/README.md @@ -82,10 +82,17 @@ You only need to add the options you want to override. Config files are applied In your custom.ini uncomment (remove the leading `;`) sign. And set `app_mode = development`. ## Contribute + If you have any idea for an improvement or found a bug do not hesitate to open an issue. And if you have time clone this repo and submit a pull request and help me make Grafana the kickass metrics & devops dashboard we all dream about! +## Plugin development + +Checkout the [Plugin Development Guide](http://docs.grafana.org/plugins/developing/development/) and checkout the [PLUGIN_DEV.md](https://github.com/grafana/grafana/blob/master/PLUGIN_DEV.md) file for changes in Grafana that relate to +plugin development. + ## License + Grafana is distributed under Apache 2.0 License. -Work in progress Grafana 2.0 (with included Grafana backend) + diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index de9e5abd472..102134f34dd 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -115,6 +115,17 @@ In DingTalk PC Client: Dingtalk supports the following "message type": `text`, `link` and `markdown`. Only the `text` message type is supported. +### Kafka + +Notifications can be sent to a Kafka topic from Grafana using [Kafka REST Proxy](https://docs.confluent.io/1.0/kafka-rest/docs/index.html). +There are couple of configurations options which need to be set in Grafana UI under Kafka Settings: + +1. Kafka REST Proxy endpoint. + +2. Kafka Topic. + +Once these two properties are set, you can send the alerts to Kafka for further processing or throttling them. + ### Other Supported Notification Channels Grafana also supports the following Notification Channels: diff --git a/docs/sources/archive.md b/docs/sources/archive.md index 1d656880286..aa36e1ee620 100644 --- a/docs/sources/archive.md +++ b/docs/sources/archive.md @@ -13,6 +13,7 @@ Here you can find links to older versions of the documentation that might be bet of Grafana. - [Latest](http://docs.grafana.org) +- [Version 4.5](http://docs.grafana.org/v4.5) - [Version 4.4](http://docs.grafana.org/v4.4) - [Version 4.3](http://docs.grafana.org/v4.3) - [Version 4.2](http://docs.grafana.org/v4.2) diff --git a/docs/sources/features/datasources/elasticsearch.md b/docs/sources/features/datasources/elasticsearch.md index be4e3e78e49..6ce17113a9b 100644 --- a/docs/sources/features/datasources/elasticsearch.md +++ b/docs/sources/features/datasources/elasticsearch.md @@ -135,6 +135,5 @@ Name | Description ------------ | ------------- Query | You can leave the search query blank or specify a lucene query Time | The name of the time field, needs to be date field. -Title | The name of the field to use for the event title. +Text | Event description field. Tags | Optional field name to use for event tags (can be an array or a CSV string). -Text | Optional field name to use event text body. diff --git a/docs/sources/features/panels/alertlist.md b/docs/sources/features/panels/alertlist.md index 8d235c20669..9307bb71391 100644 --- a/docs/sources/features/panels/alertlist.md +++ b/docs/sources/features/panels/alertlist.md @@ -18,7 +18,7 @@ The alert list panel allows you to display your dashbords alerts. The list can b ## Alert List Options -{{< docs-imagebox img="/img/docs/v45/alert-list-options.png" max-width="600px" class="docs-image--no-shadow docs-image--right">}} +{{< docs-imagebox img="/img/docs/v45/alert-list-options.png" max-width="600px" class="docs-image--no-shadow docs-image--right" >}} 1. **Show**: Lets you choose between current state or recent state changes. 2. **Max Items**: Max items set the maximum of items in a list. diff --git a/docs/sources/guides/whats-new-in-v4-6.md b/docs/sources/guides/whats-new-in-v4-6.md new file mode 100644 index 00000000000..3bc80799afc --- /dev/null +++ b/docs/sources/guides/whats-new-in-v4-6.md @@ -0,0 +1,74 @@ ++++ +title = "What's New in Grafana v4.6" +description = "Feature & improvement highlights for Grafana v4.6" +keywords = ["grafana", "new", "documentation", "4.6"] +type = "docs" +[menu.docs] +name = "Version 4.6" +identifier = "v4.6" +parent = "whatsnew" +weight = -5 ++++ + +# What's New in Grafana v4.6 + +Grafana v4.6 brings many enhancements to Annotations, Cloudwatch & Prometheus. It also adds support for Postgres as metric & table data source! + +### Annotations + +{{< docs-imagebox img="/img/docs/v46/add_annotation_region.png" max-width= "800px" >}} + +You can now add annotation events and regions right from the graph panel! Just hold CTRL/CMD + click or drag region to open the **Add Annotation** view. The +[Annotations]({{< relref "reference/annotations.md" >}}) documentation is updated to include details on this new exciting feature. + +### Cloudwatch + +Cloudwatch now supports alerting. Setup alert rules for any Cloudwatch metric! + +{{< docs-imagebox img="/img/docs/v46/cloudwatch_alerting.png" max-width= "800px" >}} + +### Postgres + +Grafana v4.6 now ships with a built-in datasource plugin for Postgres. Have logs or metric data in Postgres? You can now visualize that data and +define alert rules on it like any of our other data sources. + +{{< docs-imagebox img="/img/docs/v46/postgres_table_query.png" max-width= "800px" >}} + +### Prometheus + +New enhancements include support for **instant queries** and improvements to query editor in the form of autocomplete for label names and label values. +This makes exploring and filtering Prometheus data much easier. + +## Changelog + +### New Features + +* **GCS**: Adds support for Google Cloud Storage [#8370](https://github.com/grafana/grafana/issues/8370) thx [@chuhlomin](https://github.com/chuhlomin) +* **Prometheus**: Adds /metrics endpoint for exposing Grafana metrics. [#9187](https://github.com/grafana/grafana/pull/9187) +* **Graph**: Add support for local formating in axis. [#1395](https://github.com/grafana/grafana/issues/1395), thx [@m0nhawk](https://github.com/m0nhawk) +* **Jaeger**: Add support for open tracing using jaeger in Grafana. [#9213](https://github.com/grafana/grafana/pull/9213) +* **Unit types**: New date & time unit types added, useful in singlestat to show dates & times. [#3678](https://github.com/grafana/grafana/issues/3678), [#6710](https://github.com/grafana/grafana/issues/6710), [#2764](https://github.com/grafana/grafana/issues/2764) +* **CLI**: Make it possible to install plugins from any url [#5873](https://github.com/grafana/grafana/issues/5873) +* **Prometheus**: Add support for instant queries [#5765](https://github.com/grafana/grafana/issues/5765), thx [@mtanda](https://github.com/mtanda) +* **Cloudwatch**: Add support for alerting using the cloudwatch datasource [#8050](https://github.com/grafana/grafana/pull/8050), thx [@mtanda](https://github.com/mtanda) +* **Pagerduty**: Include triggering series in pagerduty notification [#8479](https://github.com/grafana/grafana/issues/8479), thx [@rickymoorhouse](https://github.com/rickymoorhouse) +* **Timezone**: Time ranges like Today & Yesterday now work correctly when timezone setting is set to UTC [#8916](https://github.com/grafana/grafana/issues/8916), thx [@ctide](https://github.com/ctide) +* **Prometheus**: Align $__interval with the step parameters. [#9226](https://github.com/grafana/grafana/pull/9226), thx [@alin-amana](https://github.com/alin-amana) +* **Prometheus**: Autocomplete for label name and label value [#9208](https://github.com/grafana/grafana/pull/9208), thx [@mtanda](https://github.com/mtanda) +* **Postgres**: New Postgres data source [#9209](https://github.com/grafana/grafana/pull/9209), thx [@svenklemm](https://github.com/svenklemm) +* **Datasources**: closes [#9371](https://github.com/grafana/grafana/issues/9371), [#5334](https://github.com/grafana/grafana/issues/5334), [#8812](https://github.com/grafana/grafana/issues/8812), thx [@mattbostock](https://github.com/mattbostock) + +### Minor Changes + +* **SMTP**: Make it possible to set specific EHLO for smtp client. [#9319](https://github.com/grafana/grafana/issues/9319) +* **Dataproxy**: Allow grafan to renegotiate tls connection [#9250](https://github.com/grafana/grafana/issues/9250) +* **HTTP**: set net.Dialer.DualStack to true for all http clients [#9367](https://github.com/grafana/grafana/pull/9367) +* **Alerting**: Add diff and percent diff as series reducers [#9386](https://github.com/grafana/grafana/pull/9386), thx [@shanhuhai5739](https://github.com/shanhuhai5739) +* **Slack**: Allow images to be uploaded to slack when Token is precent [#7175](https://github.com/grafana/grafana/issues/7175), thx [@xginn8](https://github.com/xginn8) +* **Opsgenie**: Use their latest API instead of old version [#9399](https://github.com/grafana/grafana/pull/9399), thx [@cglrkn](https://github.com/cglrkn) +* **Table**: Add support for displaying the timestamp with milliseconds [#9429](https://github.com/grafana/grafana/pull/9429), thx [@s1061123](https://github.com/s1061123) +* **Hipchat**: Add metrics, message and image to hipchat notifications [#9110](https://github.com/grafana/grafana/issues/9110), thx [@eloo](https://github.com/eloo) + +### Tech +* **Go**: Grafana is now built using golang 1.9 + diff --git a/docs/sources/installation/rpm.md b/docs/sources/installation/rpm.md index b72e5c78d2f..cf08173f3e9 100644 --- a/docs/sources/installation/rpm.md +++ b/docs/sources/installation/rpm.md @@ -57,8 +57,7 @@ baseurl=https://packagecloud.io/grafana/stable/el/6/$basearch repo_gpgcheck=1 enabled=1 gpgcheck=1 -gpgkey=https://packagecloud.io/gpg.key -https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana +gpgkey=https://packagecloud.io/gpg.key https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana sslverify=1 sslcacert=/etc/pki/tls/certs/ca-bundle.crt ``` diff --git a/docs/sources/plugins/developing/development.md b/docs/sources/plugins/developing/development.md index 7d67dd22323..41cef41b653 100644 --- a/docs/sources/plugins/developing/development.md +++ b/docs/sources/plugins/developing/development.md @@ -10,26 +10,42 @@ weight = 1 # Developer Guide -From grafana 3.0 it's very easy to develop your own plugins and share them with other grafana users. - -There are two blog posts about authoring a plugin that might also be of interest to any plugin authors, [Timing is Everything. Writing the Clock Panel Plugin for Grafana 3.0- part 1](https://grafana.com/blog/2016/04/08/timing-is-everything.-writing-the-clock-panel-plugin-for-grafana-3.0/) and [Timing is Everything. Editor Mode in Grafana 3.0 for the Clock Panel Plugin](https://grafana.com/blog/2016/04/15/timing-is-everything.-editor-mode-in-grafana-3.0-for-the-clock-panel-plugin/). +You can extend Grafana by writing your own plugins and then share then with other users in [our plugin repository](https://grafana.com/plugins). ## Short version 1. [Setup grafana](http://docs.grafana.org/project/building_from_source/) 2. Clone an example plugin into ```/var/lib/grafana/plugins``` or `data/plugins` (relative to grafana git repo if you're running development version from source dir) -3. Code away! +3. You one of our example plugins as starting point + +Example plugins + +- [Typescript data source example](https://github.com/grafana/typescript-template-datasource) +- [Simple json data source](https://github.com/grafana/simple-json-datasource) +- [Clock panel](https://github.com/grafana/clock-panel) +- [Pie chart panel](https://github.com/grafana/piechart-panel) + +There are two blog posts about authoring a plugin that might also be of interest to any plugin authors. + +- [Timing is Everything. Writing the Clock Panel Plugin for Grafana](https://grafana.com/blog/2016/04/08/timing-is-everything.-writing-the-clock-panel-plugin-for-grafana-3.0/) +- [Timing is Everything. Editor Mode in Grafana for the Clock Panel Plugin](https://grafana.com/blog/2016/04/15/timing-is-everything.-editor-mode-in-grafana-3.0-for-the-clock-panel-plugin/). ## What languages? -Since everything turns into javascript it's up to you to choose which language you want. That said it's probably a good idea to choose es6 or typescript since we use es6 classes in Grafana. So it's easier to get inspiration from the Grafana repo is you choose one of those languages. +Since everything turns into javascript it's up to you to choose which language you want. That said it's probably a good idea to choose es6 or typescript since +we use es6 classes in Grafana. So it's easier to get inspiration from the Grafana repo is you choose one of those languages. ## Buildscript -You can use any build system you like that support systemjs. All the built content should end up in a folder named ```dist``` and committed to the repository.By committing the dist folder the person who installs your plugin does not have to run any buildscript. - +You can use any build system you like that support systemjs. All the built content should end up in a folder named ```dist``` and committed to the repository. +By committing the dist folder the person who installs your plugin does not have to run any buildscript. All our example plugins have build scripted configured. +## Keep your plugin up to date + +New versions of Grafana can sometimes cause plugins to break. Checkout our [PLUGIN_DEV.md](https://github.com/grafana/grafana/blob/master/PLUGIN_DEV.md) doc for changes in +Grafana that can impact your plugin. + ## Metadata See the [coding styleguide]({{< relref "code-styleguide.md" >}}) for details on the metadata. diff --git a/docs/sources/reference/annotations.md b/docs/sources/reference/annotations.md index fd30e520d07..3852c2c727b 100644 --- a/docs/sources/reference/annotations.md +++ b/docs/sources/reference/annotations.md @@ -10,34 +10,37 @@ weight = 2 # Annotations +{{< docs-imagebox img="/img/docs/v46/annotations.png" max-width="800px" >}} + Annotations provide a way to mark points on the graph with rich events. When you hover over an annotation you can get event description and event tags. The text field can include links to other systems with more detail. -![](/img/docs/annotations/toggles.png) - ## Native annotations -Grafana v4.6+ comes with a native annotation store and the ability to add annotation events directly from the graph panel or via the [HTTP API]({{< relref "http_api/annotations.md" >}}) +Grafana v4.6+ comes with a native annotation store and the ability to add annotation events directly from the graph panel or via the [HTTP API]({{< relref "http_api/annotations.md" >}}). ## Adding annotations -by holding down CTRL/CMD + mouse click. Add tags to the annotation will make it searchable from other dashboards. +By holding down **CTRL** or **CMD** + Click. Add tags to the annotation will make it searchable from other dashboards. - +{{< docs-imagebox img="/img/docs/annotations/annotation-still.png" +max-width="600px" animated-gif="/img/docs/annotations/annotation.gif" >}} ### Adding regions events -You can also hold down CTRL/CMD and select region to create a region annotation. +You can also hold down **CTRL** or **CMD** and select region to create a region annotation. - +{{< docs-imagebox img="/img/docs/annotations/region-annotation-still.png" +max-width="600px" animated-gif="/img/docs/annotations/region-annotation.gif" >}} ### Built in query -After you added an an annotation they will be still be visible. This is due to the built in annotation query that exists on all dashboards. This annotation query will +After you added an annotation they will still be visible. This is due to the built in annotation query that exists on all dashboards. This annotation query will fetch all annotation events that originate from the current dashboard and show them on the panel where they where created. This includes alert state history annotations. You can stop annotations from being fetched & drawn by opening the **Annotations** settings (via Dashboard cogs menu) and modifying the query named `Annotations & Alerts (Built-in)`. - +When you copy a dashboard using the **Save As** feature it will get a new dashboard id so annotations created on source dashboard will no longer be visible on the copy. You +can still show them if you add a new **Annotation Query** and filter by tags. But this only works if the annotations on the source dashboard had tags to filter by. ### Query by tag diff --git a/package.json b/package.json index b34ceb1bce3..910928c43a3 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "company": "Grafana Labs" }, "name": "grafana", - "version": "4.6.0-pre1", + "version": "4.6.0-beta1", "repository": { "type": "git", "url": "http://github.com/grafana/grafana.git" diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index 300fa7f2cdc..be069f2b07e 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -1,6 +1,10 @@ package api import ( + "fmt" + "strings" + "time" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/middleware" @@ -78,6 +82,60 @@ func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response return ApiSuccess("Annotation added") } +type GraphiteAnnotationError struct { + message string +} + +func (e *GraphiteAnnotationError) Error() string { + return e.message +} + +func formatGraphiteAnnotation(what string, data string) string { + return fmt.Sprintf("%s\n%s", what, data) +} + +func PostGraphiteAnnotation(c *middleware.Context, cmd dtos.PostGraphiteAnnotationsCmd) Response { + repo := annotations.GetRepository() + + if cmd.When == 0 { + cmd.When = time.Now().Unix() + } + text := formatGraphiteAnnotation(cmd.What, cmd.Data) + + // Support tags in prior to Graphite 0.10.0 format (string of tags separated by space) + var tagsArray []string + switch tags := cmd.Tags.(type) { + case string: + tagsArray = strings.Split(tags, " ") + case []interface{}: + for _, t := range tags { + if tagStr, ok := t.(string); ok { + tagsArray = append(tagsArray, tagStr) + } else { + err := &GraphiteAnnotationError{"tag should be a string"} + return ApiError(500, "Failed to save Graphite annotation", err) + } + } + default: + err := &GraphiteAnnotationError{"unsupported tags format"} + return ApiError(500, "Failed to save Graphite annotation", err) + } + + item := annotations.Item{ + OrgId: c.OrgId, + UserId: c.UserId, + Epoch: cmd.When, + Text: text, + Tags: tagsArray, + } + + if err := repo.Save(&item); err != nil { + return ApiError(500, "Failed to save Graphite annotation", err) + } + + return ApiSuccess("Graphite Annotation added") +} + func UpdateAnnotation(c *middleware.Context, cmd dtos.UpdateAnnotationsCmd) Response { annotationId := c.ParamsInt64(":annotationId") diff --git a/pkg/api/api.go b/pkg/api/api.go index 2def4adef97..fa5854b5333 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -240,16 +240,24 @@ func (hs *HttpServer) registerRoutes() { dashboardRoute.Get("/db/:slug", wrap(GetDashboard)) dashboardRoute.Delete("/db/:slug", reqEditorRole, wrap(DeleteDashboard)) - dashboardRoute.Get("/id/:dashboardId/versions", wrap(GetDashboardVersions)) - dashboardRoute.Get("/id/:dashboardId/versions/:id", wrap(GetDashboardVersion)) - dashboardRoute.Post("/id/:dashboardId/restore", reqEditorRole, bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion)) - dashboardRoute.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), wrap(CalculateDashboardDiff)) dashboardRoute.Post("/db", reqEditorRole, bind(m.SaveDashboardCommand{}), wrap(PostDashboard)) dashboardRoute.Get("/home", wrap(GetHomeDashboard)) dashboardRoute.Get("/tags", GetDashboardTags) dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), wrap(ImportDashboard)) + + dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute RouteRegister) { + dashIdRoute.Get("/id/:dashboardId/versions", wrap(GetDashboardVersions)) + dashIdRoute.Get("/id/:dashboardId/versions/:id", wrap(GetDashboardVersion)) + dashIdRoute.Post("/id/:dashboardId/restore", reqEditorRole, bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion)) + + dashIdRoute.Group("/acl", func(aclRoute RouteRegister) { + aclRoute.Get("/", wrap(GetDashboardAclList)) + aclRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardAcl)) + aclRoute.Delete("/:aclId", wrap(DeleteDashboardAcl)) + }) + }) }) // Dashboard snapshots @@ -304,6 +312,7 @@ func (hs *HttpServer) registerRoutes() { annotationsRoute.Delete("/:annotationId", wrap(DeleteAnnotationById)) annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), wrap(UpdateAnnotation)) annotationsRoute.Delete("/region/:regionId", wrap(DeleteAnnotationRegion)) + annotationsRoute.Post("/graphite", bind(dtos.PostGraphiteAnnotationsCmd{}), wrap(PostGraphiteAnnotation)) }, reqEditorRole) // error test diff --git a/pkg/api/app_routes.go b/pkg/api/app_routes.go index 8992f8f66d6..0440c880979 100644 --- a/pkg/api/app_routes.go +++ b/pkg/api/app_routes.go @@ -6,31 +6,33 @@ import ( "net/http" "time" - "gopkg.in/macaron.v1" - "github.com/grafana/grafana/pkg/api/pluginproxy" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" + macaron "gopkg.in/macaron.v1" ) -var pluginProxyTransport = &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - Renegotiation: tls.RenegotiateFreelyAsClient, - }, - Proxy: http.ProxyFromEnvironment, - Dial: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - DualStack: true, - }).Dial, - TLSHandshakeTimeout: 10 * time.Second, -} +var pluginProxyTransport *http.Transport func InitAppPluginRoutes(r *macaron.Macaron) { + pluginProxyTransport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: setting.PluginAppsSkipVerifyTLS, + Renegotiation: tls.RenegotiateFreelyAsClient, + }, + Proxy: http.ProxyFromEnvironment, + Dial: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + }).Dial, + TLSHandshakeTimeout: 10 * time.Second, + } + for _, plugin := range plugins.Apps { for _, route := range plugin.Routes { url := util.JoinUrlFragments("/api/plugin-proxy/"+plugin.Id, route.Path) diff --git a/pkg/api/dtos/annotations.go b/pkg/api/dtos/annotations.go index ee5f6915b66..c917b0d9feb 100644 --- a/pkg/api/dtos/annotations.go +++ b/pkg/api/dtos/annotations.go @@ -29,3 +29,10 @@ type DeleteAnnotationsCmd struct { AnnotationId int64 `json:"annotationId"` RegionId int64 `json:"regionId"` } + +type PostGraphiteAnnotationsCmd struct { + When int64 `json:"when"` + What string `json:"what"` + Data string `json:"data"` + Tags interface{} `json:"tags"` +} diff --git a/pkg/api/grafana_com_proxy.go b/pkg/api/grafana_com_proxy.go index 015f690adda..a2a446b48eb 100644 --- a/pkg/api/grafana_com_proxy.go +++ b/pkg/api/grafana_com_proxy.go @@ -1,7 +1,6 @@ package api import ( - "crypto/tls" "net" "net/http" "net/http/httputil" @@ -14,8 +13,7 @@ import ( ) var grafanaComProxyTransport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, - Proxy: http.ProxyFromEnvironment, + Proxy: http.ProxyFromEnvironment, Dial: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index b43d55b2a8f..3107dcf7e4b 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -11,6 +11,8 @@ import ( "path" "time" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" gocache "github.com/patrickmn/go-cache" @@ -19,7 +21,6 @@ import ( "github.com/grafana/grafana/pkg/api/live" httpstatic "github.com/grafana/grafana/pkg/api/static" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/middleware" @@ -153,7 +154,7 @@ func (hs *HttpServer) newMacaron() *macaron.Macaron { for _, route := range plugins.StaticRoutes { pluginRoute := path.Join("/public/plugins/", route.PluginId) - logger.Debug("Plugins: Adding route", "route", pluginRoute, "dir", route.Directory) + hs.log.Debug("Plugins: Adding route", "route", pluginRoute, "dir", route.Directory) hs.mapStatic(m, route.Directory, "", pluginRoute) } @@ -187,7 +188,9 @@ func (hs *HttpServer) metricsEndpoint(ctx *macaron.Context) { return } - promhttp.Handler().ServeHTTP(ctx.Resp, ctx.Req.Request) + promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{ + DisableCompression: true, + }).ServeHTTP(ctx.Resp, ctx.Req.Request) } func (hs *HttpServer) healthHandler(ctx *macaron.Context) { diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 4be49915fd9..847f09f0eb8 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "io/ioutil" - "log" "net/http" "net/url" @@ -16,6 +15,7 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" @@ -29,6 +29,7 @@ var ( ErrSignUpNotAllowed = errors.New("Signup is not allowed for this adapter") ErrUsersQuotaReached = errors.New("Users quota reached") ErrNoEmail = errors.New("Login provider didn't return an email address") + oauthLogger = log.New("oauth.login") ) func GenStateString() string { @@ -50,10 +51,11 @@ func OAuthLogin(ctx *middleware.Context) { return } - error := ctx.Query("error") - if error != "" { + errorParam := ctx.Query("error") + if errorParam != "" { errorDesc := ctx.Query("error_description") - redirectWithError(ctx, ErrProviderDeniedRequest, "error", error, "errorDesc", errorDesc) + oauthLogger.Error("failed to login ", "error", errorParam, "errorDesc", errorDesc) + redirectWithError(ctx, ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc) return } @@ -69,8 +71,12 @@ func OAuthLogin(ctx *middleware.Context) { return } - // verify state string - savedState := ctx.Session.Get(middleware.SESS_KEY_OAUTH_STATE).(string) + savedState, ok := ctx.Session.Get(middleware.SESS_KEY_OAUTH_STATE).(string) + if !ok { + ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil) + return + } + queryState := ctx.Query("state") if savedState != queryState { ctx.Handle(500, "login.OAuthLogin(state mismatch)", nil) @@ -78,36 +84,37 @@ func OAuthLogin(ctx *middleware.Context) { } // handle call back + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: setting.OAuthService.OAuthInfos[name].TlsSkipVerify, + }, + } + oauthClient := &http.Client{ + Transport: tr, + } - // initialize oauth2 context - oauthCtx := oauth2.NoContext - if setting.OAuthService.OAuthInfos[name].TlsClientCert != "" { + if setting.OAuthService.OAuthInfos[name].TlsClientCert != "" || setting.OAuthService.OAuthInfos[name].TlsClientKey != "" { cert, err := tls.LoadX509KeyPair(setting.OAuthService.OAuthInfos[name].TlsClientCert, setting.OAuthService.OAuthInfos[name].TlsClientKey) if err != nil { - log.Fatal(err) + log.Fatal(1, "Failed to setup TlsClientCert", "oauth provider", name, "error", err) } - // Load CA cert + tr.TLSClientConfig.Certificates = append(tr.TLSClientConfig.Certificates, cert) + } + + if setting.OAuthService.OAuthInfos[name].TlsClientCa != "" { caCert, err := ioutil.ReadFile(setting.OAuthService.OAuthInfos[name].TlsClientCa) if err != nil { - log.Fatal(err) + log.Fatal(1, "Failed to setup TlsClientCa", "oauth provider", name, "error", err) } caCertPool := x509.NewCertPool() caCertPool.AppendCertsFromPEM(caCert) - tr := &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - Certificates: []tls.Certificate{cert}, - RootCAs: caCertPool, - }, - } - sslcli := &http.Client{Transport: tr} - - oauthCtx = context.Background() - oauthCtx = context.WithValue(oauthCtx, oauth2.HTTPClient, sslcli) + tr.TLSClientConfig.RootCAs = caCertPool } + oauthCtx := context.WithValue(context.Background(), oauth2.HTTPClient, oauthClient) + // get token from provider token, err := connect.Exchange(oauthCtx, code) if err != nil { diff --git a/pkg/cmd/grafana-cli/main.go b/pkg/cmd/grafana-cli/main.go index 73548c3b159..86eb6bc271a 100644 --- a/pkg/cmd/grafana-cli/main.go +++ b/pkg/cmd/grafana-cli/main.go @@ -17,8 +17,6 @@ var version = "master" func main() { setupLogging() - services.Init(version) - app := cli.NewApp() app.Name = "Grafana cli" app.Usage = "" @@ -44,12 +42,20 @@ func main() { Value: "", EnvVar: "GF_PLUGIN_URL", }, + cli.BoolFlag{ + Name: "insecure", + Usage: "Skip TLS verification (insecure)", + }, cli.BoolFlag{ Name: "debug, d", Usage: "enable debug logging", }, } + app.Before = func(c *cli.Context) error { + services.Init(version, c.GlobalBool("insecure")) + return nil + } app.Commands = commands.Commands app.CommandNotFound = cmdNotFound diff --git a/pkg/cmd/grafana-cli/services/services.go b/pkg/cmd/grafana-cli/services/services.go index d3a05430944..d13e90d6a2f 100644 --- a/pkg/cmd/grafana-cli/services/services.go +++ b/pkg/cmd/grafana-cli/services/services.go @@ -22,7 +22,7 @@ var ( grafanaVersion string ) -func Init(version string) { +func Init(version string, skipTLSVerify bool) { grafanaVersion = version tr := &http.Transport{ @@ -36,8 +36,9 @@ func Init(version string) { IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, - - TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: skipTLSVerify, + }, } HttpClient = http.Client{ diff --git a/pkg/models/datasource_cache.go b/pkg/models/datasource_cache.go index 158018b0f0a..b4a4e7f8a4d 100644 --- a/pkg/models/datasource_cache.go +++ b/pkg/models/datasource_cache.go @@ -3,6 +3,7 @@ package models import ( "crypto/tls" "crypto/x509" + "errors" "net" "net/http" "sync" @@ -45,9 +46,16 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) { return t.Transport, nil } + var tlsSkipVerify, tlsClientAuth, tlsAuthWithCACert bool + if ds.JsonData != nil { + tlsClientAuth = ds.JsonData.Get("tlsAuth").MustBool(false) + tlsAuthWithCACert = ds.JsonData.Get("tlsAuthWithCACert").MustBool(false) + tlsSkipVerify = ds.JsonData.Get("tlsSkipVerify").MustBool(false) + } + transport := &http.Transport{ TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, + InsecureSkipVerify: tlsSkipVerify, Renegotiation: tls.RenegotiateFreelyAsClient, }, Proxy: http.ProxyFromEnvironment, @@ -62,30 +70,24 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) { IdleConnTimeout: 90 * time.Second, } - var tlsAuth, tlsAuthWithCACert bool - if ds.JsonData != nil { - tlsAuth = ds.JsonData.Get("tlsAuth").MustBool(false) - tlsAuthWithCACert = ds.JsonData.Get("tlsAuthWithCACert").MustBool(false) - } - - if tlsAuth { - transport.TLSClientConfig.InsecureSkipVerify = false - + if tlsClientAuth || tlsAuthWithCACert { decrypted := ds.SecureJsonData.Decrypt() - if tlsAuthWithCACert && len(decrypted["tlsCACert"]) > 0 { caPool := x509.NewCertPool() ok := caPool.AppendCertsFromPEM([]byte(decrypted["tlsCACert"])) - if ok { - transport.TLSClientConfig.RootCAs = caPool + if !ok { + return nil, errors.New("Failed to parse TLS CA PEM certificate") } + transport.TLSClientConfig.RootCAs = caPool } - cert, err := tls.X509KeyPair([]byte(decrypted["tlsClientCert"]), []byte(decrypted["tlsClientKey"])) - if err != nil { - return nil, err + if tlsClientAuth { + cert, err := tls.X509KeyPair([]byte(decrypted["tlsClientCert"]), []byte(decrypted["tlsClientKey"])) + if err != nil { + return nil, err + } + transport.TLSClientConfig.Certificates = []tls.Certificate{cert} } - transport.TLSClientConfig.Certificates = []tls.Certificate{cert} } ptc.cache[ds.Id] = cachedTransport{ diff --git a/pkg/models/datasource_cache_test.go b/pkg/models/datasource_cache_test.go index 5e821ea28c4..85ece0bbdcc 100644 --- a/pkg/models/datasource_cache_test.go +++ b/pkg/models/datasource_cache_test.go @@ -29,61 +29,140 @@ func TestDataSourceCache(t *testing.T) { Convey("Should be using the cached proxy", func() { So(t2, ShouldEqual, t1) }) + Convey("Should verify TLS by default", func() { + So(t1.TLSClientConfig.InsecureSkipVerify, ShouldEqual, false) + }) + Convey("Should have no TLS client certificate configured", func() { + So(len(t1.TLSClientConfig.Certificates), ShouldEqual, 0) + }) + Convey("Should have no user-supplied TLS CA onfigured", func() { + So(t1.TLSClientConfig.RootCAs, ShouldBeNil) + }) }) - Convey("When getting kubernetes datasource proxy", t, func() { + Convey("When caching a datasource proxy then updating it", t, func() { + clearCache() + setting.SecretKey = "password" + + json := simplejson.New() + json.Set("tlsAuthWithCACert", true) + + tlsCaCert, err := util.Encrypt([]byte(caCert), "password") + So(err, ShouldBeNil) + ds := DataSource{ + Id: 1, + Url: "http://k8s:8001", + Type: "Kubernetes", + SecureJsonData: map[string][]byte{"tlsCACert": tlsCaCert}, + Updated: time.Now().Add(-2 * time.Minute), + } + + t1, err := ds.GetHttpTransport() + So(err, ShouldBeNil) + + Convey("Should verify TLS by default", func() { + So(t1.TLSClientConfig.InsecureSkipVerify, ShouldEqual, false) + }) + Convey("Should have no TLS client certificate configured", func() { + So(len(t1.TLSClientConfig.Certificates), ShouldEqual, 0) + }) + Convey("Should have no user-supplied TLS CA configured", func() { + So(t1.TLSClientConfig.RootCAs, ShouldBeNil) + }) + + ds.JsonData = nil + ds.SecureJsonData = map[string][]byte{} + ds.Updated = time.Now() + + t2, err := ds.GetHttpTransport() + So(err, ShouldBeNil) + + Convey("Should have no user-supplied TLS CA configured after the update", func() { + So(t2.TLSClientConfig.RootCAs, ShouldBeNil) + }) + }) + + Convey("When caching a datasource proxy with TLS client authentication enabled", t, func() { clearCache() setting.SecretKey = "password" json := simplejson.New() json.Set("tlsAuth", true) + + tlsClientCert, err := util.Encrypt([]byte(clientCert), "password") + So(err, ShouldBeNil) + tlsClientKey, err := util.Encrypt([]byte(clientKey), "password") + So(err, ShouldBeNil) + + ds := DataSource{ + Id: 1, + Url: "http://k8s:8001", + Type: "Kubernetes", + JsonData: json, + SecureJsonData: map[string][]byte{ + "tlsClientCert": tlsClientCert, + "tlsClientKey": tlsClientKey, + }, + } + + tr, err := ds.GetHttpTransport() + So(err, ShouldBeNil) + + Convey("Should verify TLS by default", func() { + So(tr.TLSClientConfig.InsecureSkipVerify, ShouldEqual, false) + }) + Convey("Should have a TLS client certificate configured", func() { + So(len(tr.TLSClientConfig.Certificates), ShouldEqual, 1) + }) + }) + + Convey("When caching a datasource proxy with a user-supplied TLS CA", t, func() { + clearCache() + setting.SecretKey = "password" + + json := simplejson.New() json.Set("tlsAuthWithCACert", true) - t := time.Now() + tlsCaCert, err := util.Encrypt([]byte(caCert), "password") + So(err, ShouldBeNil) + ds := DataSource{ - Url: "http://k8s:8001", - Type: "Kubernetes", - Updated: t.Add(-2 * time.Minute), + Id: 1, + Url: "http://k8s:8001", + Type: "Kubernetes", + JsonData: json, + SecureJsonData: map[string][]byte{"tlsCACert": tlsCaCert}, } - transport, err := ds.GetHttpTransport() + tr, err := ds.GetHttpTransport() So(err, ShouldBeNil) - Convey("Should have no cert", func() { - So(transport.TLSClientConfig.InsecureSkipVerify, ShouldEqual, true) + Convey("Should verify TLS by default", func() { + So(tr.TLSClientConfig.InsecureSkipVerify, ShouldEqual, false) }) + Convey("Should have a TLS CA configured", func() { + So(len(tr.TLSClientConfig.RootCAs.Subjects()), ShouldEqual, 1) + }) + }) - ds.JsonData = json + Convey("When caching a datasource proxy when user skips TLS verification", t, func() { + clearCache() - tlsCaCert, _ := util.Encrypt([]byte(caCert), "password") - tlsClientCert, _ := util.Encrypt([]byte(clientCert), "password") - tlsClientKey, _ := util.Encrypt([]byte(clientKey), "password") + json := simplejson.New() + json.Set("tlsSkipVerify", true) - ds.SecureJsonData = map[string][]byte{ - "tlsCACert": tlsCaCert, - "tlsClientCert": tlsClientCert, - "tlsClientKey": tlsClientKey, + ds := DataSource{ + Id: 1, + Url: "http://k8s:8001", + Type: "Kubernetes", + JsonData: json, } - ds.Updated = t.Add(-1 * time.Minute) - transport, err = ds.GetHttpTransport() + tr, err := ds.GetHttpTransport() So(err, ShouldBeNil) - Convey("Should add cert", func() { - So(transport.TLSClientConfig.InsecureSkipVerify, ShouldEqual, false) - So(len(transport.TLSClientConfig.Certificates), ShouldEqual, 1) - }) - - ds.JsonData = nil - ds.SecureJsonData = map[string][]byte{} - ds.Updated = t - - transport, err = ds.GetHttpTransport() - So(err, ShouldBeNil) - - Convey("Should remove cert", func() { - So(transport.TLSClientConfig.InsecureSkipVerify, ShouldEqual, true) - So(len(transport.TLSClientConfig.Certificates), ShouldEqual, 0) + Convey("Should skip TLS verification", func() { + So(tr.TLSClientConfig.InsecureSkipVerify, ShouldEqual, true) }) }) } @@ -115,7 +194,8 @@ FHoXIyGOdq1chmRVocdGBCF8fUoGIbuF14r53rpvcbEKtKnnP8+96luKAZLq0a4n 3lb92xM= -----END CERTIFICATE-----` -const clientCert string = `-----BEGIN CERTIFICATE----- +const clientCert string = ` +-----BEGIN CERTIFICATE----- MIICsjCCAZoCCQCcd8sOfstQLzANBgkqhkiG9w0BAQsFADAXMRUwEwYDVQQDDAxj YS1rOHMtc3RobG0wHhcNMTYxMTAyMDkyNTE1WhcNMTcxMTAyMDkyNTE1WjAfMR0w GwYDVQQDDBRhZG0tZGFuaWVsLWs4cy1zdGhsbTCCASIwDQYJKoZIhvcNAQEBBQAD diff --git a/pkg/services/alerting/notifiers/kafka.go b/pkg/services/alerting/notifiers/kafka.go new file mode 100644 index 00000000000..92f6489106b --- /dev/null +++ b/pkg/services/alerting/notifiers/kafka.go @@ -0,0 +1,120 @@ +package notifiers + +import ( + "strconv" + + "fmt" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/log" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" +) + +func init() { + alerting.RegisterNotifier(&alerting.NotifierPlugin{ + Type: "kafka", + Name: "Kafka REST Proxy", + Description: "Sends notifications to Kafka Rest Proxy", + Factory: NewKafkaNotifier, + OptionsTemplate: ` +

Kafka settings

+
+ Kafka REST Proxy + +
+
+ Topic + +
+ `, + }) +} + +func NewKafkaNotifier(model *m.AlertNotification) (alerting.Notifier, error) { + endpoint := model.Settings.Get("kafkaRestProxy").MustString() + if endpoint == "" { + return nil, alerting.ValidationError{Reason: "Could not find kafka rest proxy endpoint property in settings"} + } + topic := model.Settings.Get("kafkaTopic").MustString() + if topic == "" { + return nil, alerting.ValidationError{Reason: "Could not find kafka topic property in settings"} + } + + return &KafkaNotifier{ + NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings), + Endpoint: endpoint, + Topic: topic, + log: log.New("alerting.notifier.kafka"), + }, nil +} + +type KafkaNotifier struct { + NotifierBase + Endpoint string + Topic string + log log.Logger +} + +func (this *KafkaNotifier) Notify(evalContext *alerting.EvalContext) error { + + state := evalContext.Rule.State + + customData := "Triggered metrics:\n\n" + for _, evt := range evalContext.EvalMatches { + customData = customData + fmt.Sprintf("%s: %v\n", evt.Metric, evt.Value) + } + + this.log.Info("Notifying Kafka", "alert_state", state) + + recordJSON := simplejson.New() + records := make([]interface{}, 1) + + bodyJSON := simplejson.New() + bodyJSON.Set("description", evalContext.Rule.Name+" - "+evalContext.Rule.Message) + bodyJSON.Set("client", "Grafana") + bodyJSON.Set("details", customData) + bodyJSON.Set("incident_key", "alertId-"+strconv.FormatInt(evalContext.Rule.Id, 10)) + + ruleUrl, err := evalContext.GetRuleUrl() + if err != nil { + this.log.Error("Failed get rule link", "error", err) + return err + } + bodyJSON.Set("client_url", ruleUrl) + + if evalContext.ImagePublicUrl != "" { + contexts := make([]interface{}, 1) + imageJSON := simplejson.New() + imageJSON.Set("type", "image") + imageJSON.Set("src", evalContext.ImagePublicUrl) + contexts[0] = imageJSON + bodyJSON.Set("contexts", contexts) + } + + valueJSON := simplejson.New() + valueJSON.Set("value", bodyJSON) + records[0] = valueJSON + recordJSON.Set("records", records) + body, _ := recordJSON.MarshalJSON() + + topicUrl := this.Endpoint + "/topics/" + this.Topic + + cmd := &m.SendWebhookSync{ + Url: topicUrl, + Body: string(body), + HttpMethod: "POST", + HttpHeader: map[string]string{ + "Content-Type": "application/vnd.kafka.json.v2+json", + "Accept": "application/vnd.kafka.v2+json", + }, + } + + if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { + this.log.Error("Failed to send notification to Kafka", "error", err, "body", string(body)) + return err + } + + return nil +} diff --git a/pkg/services/alerting/notifiers/kafka_test.go b/pkg/services/alerting/notifiers/kafka_test.go new file mode 100644 index 00000000000..045976cb14b --- /dev/null +++ b/pkg/services/alerting/notifiers/kafka_test.go @@ -0,0 +1,55 @@ +package notifiers + +import ( + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestKafkaNotifier(t *testing.T) { + Convey("Kafka notifier tests", t, func() { + + Convey("Parsing alert notification from settings", func() { + Convey("empty settings should return error", func() { + json := `{ }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "kafka_testing", + Type: "kafka", + Settings: settingsJSON, + } + + _, err := NewKafkaNotifier(model) + So(err, ShouldNotBeNil) + }) + + Convey("settings should send an event to kafka", func() { + json := ` + { + "kafkaRestProxy": "http://localhost:8082", + "kafkaTopic": "topic1" + }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "kafka_testing", + Type: "kafka", + Settings: settingsJSON, + } + + not, err := NewKafkaNotifier(model) + kafkaNotifier := not.(*KafkaNotifier) + + So(err, ShouldBeNil) + So(kafkaNotifier.Name, ShouldEqual, "kafka_testing") + So(kafkaNotifier.Type, ShouldEqual, "kafka") + So(kafkaNotifier.Endpoint, ShouldEqual, "http://localhost:8082") + So(kafkaNotifier.Topic, ShouldEqual, "topic1") + }) + + }) + }) +} diff --git a/pkg/services/sqlstore/alert.go b/pkg/services/sqlstore/alert.go index bc589f89c14..33a4cae53c2 100644 --- a/pkg/services/sqlstore/alert.go +++ b/pkg/services/sqlstore/alert.go @@ -100,13 +100,13 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error { sql.WriteString(")") } + sql.WriteString(" ORDER BY name ASC") + if query.Limit != 0 { sql.WriteString(" LIMIT ?") params = append(params, query.Limit) } - sql.WriteString(" ORDER BY name ASC") - alerts := make([]*m.Alert, 0) if err := x.Sql(sql.String(), params...).Find(&alerts); err != nil { return err diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index f2ba16fa675..ca65fe581af 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -122,6 +122,9 @@ var ( // Basic Auth BasicAuthEnabled bool + // Plugin settings + PluginAppsSkipVerifyTLS bool + // Session settings. SessionOptions session.Options @@ -560,6 +563,9 @@ func NewConfigContext(args *CommandLineArgs) error { authBasic := Cfg.Section("auth.basic") BasicAuthEnabled = authBasic.Key("enabled").MustBool(true) + // global plugin settings + PluginAppsSkipVerifyTLS = Cfg.Section("plugins").Key("app_tls_skip_verify_insecure").MustBool(false) + // PhantomJS rendering ImagesDir = filepath.Join(DataPath, "png") PhantomDir = filepath.Join(HomePath, "vendor/phantomjs") diff --git a/pkg/setting/setting_oauth.go b/pkg/setting/setting_oauth.go index bc52d2336c3..ee2e812415b 100644 --- a/pkg/setting/setting_oauth.go +++ b/pkg/setting/setting_oauth.go @@ -13,6 +13,7 @@ type OAuthInfo struct { TlsClientCert string TlsClientKey string TlsClientCa string + TlsSkipVerify bool } type OAuther struct { diff --git a/pkg/social/social.go b/pkg/social/social.go index 9d2a53946c7..d40c0a0c965 100644 --- a/pkg/social/social.go +++ b/pkg/social/social.go @@ -66,6 +66,7 @@ func NewOAuthService() { TlsClientCert: sec.Key("tls_client_cert").String(), TlsClientKey: sec.Key("tls_client_key").String(), TlsClientCa: sec.Key("tls_client_ca").String(), + TlsSkipVerify: sec.Key("tls_skip_verify_insecure").MustBool(), } if !info.Enabled { diff --git a/public/app/core/components/colorpicker/spectrum_picker.ts b/public/app/core/components/colorpicker/spectrum_picker.ts new file mode 100644 index 00000000000..c262eaac326 --- /dev/null +++ b/public/app/core/components/colorpicker/spectrum_picker.ts @@ -0,0 +1,23 @@ +/** + * Wrapper for the new ngReact directive for backward compatibility. + * Allows remaining untouched in outdated plugins. + * Technically, it's just a wrapper for react component with two-way data binding support. + */ +import coreModule from '../../core_module'; + +export function spectrumPicker() { + return { + restrict: 'E', + require: 'ngModel', + scope: true, + replace: true, + template: '', + link: function(scope, element, attrs, ngModel) { + scope.ngModel = ngModel; + scope.onColorChange = (color) => { + ngModel.$setViewValue(color); + }; + } + }; +} +coreModule.directive('spectrumPicker', spectrumPicker); diff --git a/public/app/core/constants.ts b/public/app/core/constants.ts new file mode 100644 index 00000000000..93bff05689e --- /dev/null +++ b/public/app/core/constants.ts @@ -0,0 +1,7 @@ + +export const GRID_CELL_HEIGHT = 20; +export const GRID_CELL_VMARGIN = 10; +export const GRID_COLUMN_COUNT = 24; +export const REPEAT_DIR_VERTICAL = 'v'; + + diff --git a/public/app/core/controllers/error_ctrl.js b/public/app/core/controllers/error_ctrl.js index cc711f07a22..fd4081186be 100644 --- a/public/app/core/controllers/error_ctrl.js +++ b/public/app/core/controllers/error_ctrl.js @@ -1,19 +1,21 @@ define([ 'angular', + 'app/core/config', '../core_module', ], -function (angular, coreModule) { +function (angular, config, coreModule) { 'use strict'; coreModule.default.controller('ErrorCtrl', function($scope, contextSrv, navModelSrv) { $scope.navModel = navModelSrv.getNotFoundNav(); + $scope.appSubUrl = config.appSubUrl; var showSideMenu = contextSrv.sidemenu; contextSrv.sidemenu = false; $scope.$on('$destroy', function() { - $scope.contextSrv.sidemenu = showSideMenu; + contextSrv.sidemenu = showSideMenu; }); }); diff --git a/public/app/core/core.ts b/public/app/core/core.ts index 4cab5dcfaf5..ed845599991 100644 --- a/public/app/core/core.ts +++ b/public/app/core/core.ts @@ -17,6 +17,7 @@ import './components/code_editor/code_editor'; import './utils/outline'; import './components/colorpicker/ColorPicker'; import './components/colorpicker/SeriesColorPicker'; +import './components/colorpicker/spectrum_picker'; import {grafanaAppDirective} from './components/grafana_app'; import {sideMenuDirective} from './components/sidemenu/sidemenu'; @@ -50,8 +51,10 @@ import {userGroupPicker} from './components/user_group_picker'; import {geminiScrollbar} from './components/scroll/scroll'; import {gfPageDirective} from './components/gf_page'; import {orgSwitcher} from './components/org_switcher'; +import {profiler} from './profiler'; export { + profiler, arrayJoin, coreModule, grafanaAppDirective, diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index 1e351054b6d..e80e1b5c357 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -256,7 +256,7 @@ export class BackendSrv { gridPos: { x: 0, y: 0, - w: 4, + w: 8, h: 10 } }, @@ -268,7 +268,7 @@ export class BackendSrv { gridPos: { x: 4, y: 0, - w: 4, + w: 8, h: 10 } }, @@ -280,7 +280,7 @@ export class BackendSrv { gridPos: { x: 8, y: 0, - w: 4, + w: 8, h: 10 } } diff --git a/public/app/features/alerting/alert_tab_ctrl.ts b/public/app/features/alerting/alert_tab_ctrl.ts index 677eec31060..25c23580ed7 100644 --- a/public/app/features/alerting/alert_tab_ctrl.ts +++ b/public/app/features/alerting/alert_tab_ctrl.ts @@ -94,6 +94,7 @@ export class AlertTabCtrl { case "opsgenie": return "fa fa-bell"; case "hipchat": return "fa fa-mail-forward"; case "pushover": return "fa fa-mobile"; + case "kafka": return "fa fa-random"; } return 'fa fa-bell'; } diff --git a/public/app/features/alerting/partials/notification_edit.html b/public/app/features/alerting/partials/notification_edit.html index ec692586d7f..2cd0c7984e1 100644 --- a/public/app/features/alerting/partials/notification_edit.html +++ b/public/app/features/alerting/partials/notification_edit.html @@ -39,13 +39,11 @@
-
- +
+
-
-
- -
+
+
diff --git a/public/app/features/annotations/annotation_tooltip.ts b/public/app/features/annotations/annotation_tooltip.ts index c8c95b38392..c950d3edd55 100644 --- a/public/app/features/annotations/annotation_tooltip.ts +++ b/public/app/features/annotations/annotation_tooltip.ts @@ -66,7 +66,7 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, contextSrv, tooltip += '
'; if (text) { - tooltip += '
' + sanitizeString(text).replace(/\n/g, '
') + '
'; + tooltip += '
' + sanitizeString(text.replace(/\n/g, '
')) + '
'; } var tags = event.tags; diff --git a/public/app/features/dashboard/DashboardModel.ts b/public/app/features/dashboard/DashboardModel.ts deleted file mode 100644 index e86fa9d462e..00000000000 --- a/public/app/features/dashboard/DashboardModel.ts +++ /dev/null @@ -1,722 +0,0 @@ -import angular from 'angular'; -import moment from 'moment'; -import _ from 'lodash'; -import $ from 'jquery'; - -import {DEFAULT_ANNOTATION_COLOR} from 'app/core/utils/colors'; -import {Emitter, contextSrv, appEvents} from 'app/core/core'; -import {DashboardRow} from './row/row_model'; -import {PanelModel} from './PanelModel'; -import sortByKeys from 'app/core/utils/sort_by_keys'; - -export const CELL_HEIGHT = 30; -export const CELL_VMARGIN = 10; - -export class DashboardModel { - id: any; - title: any; - autoUpdate: any; - description: any; - tags: any; - style: any; - timezone: any; - editable: any; - graphTooltip: any; - rows: DashboardRow[]; - time: any; - timepicker: any; - hideControls: any; - templating: any; - annotations: any; - refresh: any; - snapshot: any; - schemaVersion: number; - version: number; - revision: number; - links: any; - gnetId: any; - meta: any; - events: any; - editMode: boolean; - folderId: number; - panels: PanelModel[]; - - constructor(data, meta?) { - if (!data) { - data = {}; - } - - this.events = new Emitter(); - this.id = data.id || null; - this.revision = data.revision; - this.title = data.title || 'No Title'; - this.autoUpdate = data.autoUpdate; - this.description = data.description; - this.tags = data.tags || []; - this.style = data.style || "dark"; - this.timezone = data.timezone || ''; - this.editable = data.editable !== false; - this.graphTooltip = data.graphTooltip || 0; - this.hideControls = data.hideControls || false; - this.time = data.time || { from: 'now-6h', to: 'now' }; - this.timepicker = data.timepicker || {}; - this.templating = this.ensureListExist(data.templating); - this.annotations = this.ensureListExist(data.annotations); - this.refresh = data.refresh; - this.snapshot = data.snapshot; - this.schemaVersion = data.schemaVersion || 0; - this.version = data.version || 0; - this.links = data.links || []; - this.gnetId = data.gnetId || null; - this.folderId = data.folderId || null; - this.panels = _.map(data.panels || [], panelData => new PanelModel(panelData)); - - this.addBuiltInAnnotationQuery(); - this.initMeta(meta); - this.updateSchema(data); - } - - addBuiltInAnnotationQuery() { - let found = false; - for (let item of this.annotations.list) { - if (item.builtIn === 1) { - found = true; - break; - } - } - - if (found) { - return; - } - - this.annotations.list.unshift({ - datasource: '-- Grafana --', - name: 'Annotations & Alerts', - type: 'dashboard', - iconColor: DEFAULT_ANNOTATION_COLOR, - enable: true, - hide: true, - builtIn: 1, - }); - } - - private initMeta(meta) { - meta = meta || {}; - - meta.canShare = meta.canShare !== false; - meta.canSave = meta.canSave !== false; - meta.canStar = meta.canStar !== false; - meta.canEdit = meta.canEdit !== false; - - if (!this.editable) { - meta.canEdit = false; - meta.canDelete = false; - meta.canSave = false; - } - - this.meta = meta; - } - - // cleans meta data and other non peristent state - getSaveModelClone() { - // temp remove stuff - var events = this.events; - var meta = this.meta; - var variables = this.templating.list; - var panels = this.panels; - - delete this.events; - delete this.meta; - delete this.panels; - - // prepare save model - this.templating.list = _.map(variables, variable => variable.getSaveModel ? variable.getSaveModel() : variable); - this.panels = _.map(panels, panel => panel.getSaveModel()); - - // make clone - var copy = $.extend(true, {}, this); - // sort clone - copy = sortByKeys(copy); - console.log(copy.panels); - - // restore properties - this.events = events; - this.meta = meta; - this.templating.list = variables; - this.panels = panels; - - return copy; - } - - setViewMode(panel: PanelModel, fullscreen: boolean, isEditing: boolean) { - this.meta.fullscreen = fullscreen; - this.meta.isEditing = isEditing && this.meta.canEdit; - - panel.setViewMode(fullscreen, this.meta.isEditing); - - this.events.emit('view-mode-changed', panel); - } - - private ensureListExist(data) { - if (!data) { data = {}; } - if (!data.list) { data.list = []; } - return data; - } - - getNextPanelId() { - var j, panel, max = 0; - for (j = 0; j < this.panels.length; j++) { - panel = this.panels[j]; - if (panel.id > max) { max = panel.id; } - } - return max + 1; - } - - forEachPanel(callback) { - for (let i = 0; i < this.panels.length; i++) { - callback(this.panels[i], i); - } - } - - getPanelById(id) { - for (let panel of this.panels) { - if (panel.id === id) { - return panel; - } - } - return null; - } - - addPanel(panel) { - panel.id = this.getNextPanelId(); - this.panels.unshift(new PanelModel(panel)); - this.events.emit('panel-added', panel); - } - - removePanel(panel, ask?) { - // confirm deletion - if (ask !== false) { - var text2, confirmText; - if (panel.alert) { - text2 = "Panel includes an alert rule, removing panel will also remove alert rule"; - confirmText = "YES"; - } - - appEvents.emit('confirm-modal', { - title: 'Remove Panel', - text: 'Are you sure you want to remove this panel?', - text2: text2, - icon: 'fa-trash', - confirmText: confirmText, - yesText: 'Remove', - onConfirm: () => { - this.removePanel(panel, false); - } - }); - return; - } - - var index = _.indexOf(this.panels, panel); - this.panels.splice(index, 1); - this.events.emit('panel-removed', panel); - } - - setPanelFocus(id) { - this.meta.focusPanelId = id; - } - - updateSubmenuVisibility() { - this.meta.submenuEnabled = (() => { - if (this.links.length > 0) { return true; } - - var visibleVars = _.filter(this.templating.list, variable => variable.hide !== 2); - if (visibleVars.length > 0) { return true; } - - var visibleAnnotations = _.filter(this.annotations.list, annotation => annotation.hide !== true); - if (visibleAnnotations.length > 0) { return true; } - - return false; - })(); - } - - getPanelInfoById(panelId) { - var result: any = {}; - _.each(this.rows, function(row) { - _.each(row.panels, function(panel, index) { - if (panel.id === panelId) { - result.panel = panel; - result.row = row; - result.index = index; - } - }); - }); - - if (!result.panel) { - return null; - } - - return result; - } - - duplicatePanel(panel, row) { - var newPanel = angular.copy(panel); - newPanel.id = this.getNextPanelId(); - - delete newPanel.repeat; - delete newPanel.repeatIteration; - delete newPanel.repeatPanelId; - delete newPanel.scopedVars; - if (newPanel.alert) { - delete newPanel.thresholds; - } - delete newPanel.alert; - - row.addPanel(newPanel); - return newPanel; - } - - formatDate(date, format?) { - date = moment.isMoment(date) ? date : moment(date); - format = format || 'YYYY-MM-DD HH:mm:ss'; - let timezone = this.getTimezone(); - - return timezone === 'browser' ? - moment(date).format(format) : - moment.utc(date).format(format); - } - - destroy() { - this.events.removeAllListeners(); - for (let row of this.rows) { - row.destroy(); - } - } - - on(eventName, callback) { - this.events.on(eventName, callback); - } - - off(eventName, callback?) { - this.events.off(eventName, callback); - } - - cycleGraphTooltip() { - this.graphTooltip = (this.graphTooltip + 1) % 3; - } - - sharedTooltipModeEnabled() { - return this.graphTooltip > 0; - } - - sharedCrosshairModeOnly() { - return this.graphTooltip === 1; - } - - getRelativeTime(date) { - date = moment.isMoment(date) ? date : moment(date); - - return this.timezone === 'browser' ? - moment(date).fromNow() : - moment.utc(date).fromNow(); - } - - getNextQueryLetter(panel) { - var letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - - return _.find(letters, function(refId) { - return _.every(panel.targets, function(other) { - return other.refId !== refId; - }); - }); - } - - isTimezoneUtc() { - return this.getTimezone() === 'utc'; - } - - getTimezone() { - return this.timezone ? this.timezone : contextSrv.user.timezone; - } - - private updateSchema(old) { - var i, j, k; - var oldVersion = this.schemaVersion; - var panelUpgrades = []; - this.schemaVersion = 16; - - if (oldVersion === this.schemaVersion) { - return; - } - - // version 2 schema changes - if (oldVersion < 2) { - - if (old.services) { - if (old.services.filter) { - this.time = old.services.filter.time; - this.templating.list = old.services.filter.list || []; - } - } - - panelUpgrades.push(function(panel) { - // rename panel type - if (panel.type === 'graphite') { - panel.type = 'graph'; - } - - if (panel.type !== 'graph') { - return; - } - - if (_.isBoolean(panel.legend)) { panel.legend = { show: panel.legend }; } - - if (panel.grid) { - if (panel.grid.min) { - panel.grid.leftMin = panel.grid.min; - delete panel.grid.min; - } - - if (panel.grid.max) { - panel.grid.leftMax = panel.grid.max; - delete panel.grid.max; - } - } - - if (panel.y_format) { - panel.y_formats[0] = panel.y_format; - delete panel.y_format; - } - - if (panel.y2_format) { - panel.y_formats[1] = panel.y2_format; - delete panel.y2_format; - } - }); - } - - // schema version 3 changes - if (oldVersion < 3) { - // ensure panel ids - var maxId = this.getNextPanelId(); - panelUpgrades.push(function(panel) { - if (!panel.id) { - panel.id = maxId; - maxId += 1; - } - }); - } - - // schema version 4 changes - if (oldVersion < 4) { - // move aliasYAxis changes - panelUpgrades.push(function(panel) { - if (panel.type !== 'graph') { return; } - _.each(panel.aliasYAxis, function(value, key) { - panel.seriesOverrides = [{ alias: key, yaxis: value }]; - }); - delete panel.aliasYAxis; - }); - } - - if (oldVersion < 6) { - // move pulldowns to new schema - var annotations = _.find(old.pulldowns, { type: 'annotations' }); - - if (annotations) { - this.annotations = { - list: annotations.annotations || [], - }; - } - - // update template variables - for (i = 0 ; i < this.templating.list.length; i++) { - var variable = this.templating.list[i]; - if (variable.datasource === void 0) { variable.datasource = null; } - if (variable.type === 'filter') { variable.type = 'query'; } - if (variable.type === void 0) { variable.type = 'query'; } - if (variable.allFormat === void 0) { variable.allFormat = 'glob'; } - } - } - - if (oldVersion < 7) { - if (old.nav && old.nav.length) { - this.timepicker = old.nav[0]; - } - - // ensure query refIds - panelUpgrades.push(function(panel) { - _.each(panel.targets, function(target) { - if (!target.refId) { - target.refId = this.getNextQueryLetter(panel); - } - }.bind(this)); - }); - } - - if (oldVersion < 8) { - panelUpgrades.push(function(panel) { - _.each(panel.targets, function(target) { - // update old influxdb query schema - if (target.fields && target.tags && target.groupBy) { - if (target.rawQuery) { - delete target.fields; - delete target.fill; - } else { - target.select = _.map(target.fields, function(field) { - var parts = []; - parts.push({type: 'field', params: [field.name]}); - parts.push({type: field.func, params: []}); - if (field.mathExpr) { - parts.push({type: 'math', params: [field.mathExpr]}); - } - if (field.asExpr) { - parts.push({type: 'alias', params: [field.asExpr]}); - } - return parts; - }); - delete target.fields; - _.each(target.groupBy, function(part) { - if (part.type === 'time' && part.interval) { - part.params = [part.interval]; - delete part.interval; - } - if (part.type === 'tag' && part.key) { - part.params = [part.key]; - delete part.key; - } - }); - - if (target.fill) { - target.groupBy.push({type: 'fill', params: [target.fill]}); - delete target.fill; - } - } - } - }); - }); - } - - // schema version 9 changes - if (oldVersion < 9) { - // move aliasYAxis changes - panelUpgrades.push(function(panel) { - if (panel.type !== 'singlestat' && panel.thresholds !== "") { return; } - - if (panel.thresholds) { - var k = panel.thresholds.split(","); - - if (k.length >= 3) { - k.shift(); - panel.thresholds = k.join(","); - } - } - }); - } - - // schema version 10 changes - if (oldVersion < 10) { - // move aliasYAxis changes - panelUpgrades.push(function(panel) { - if (panel.type !== 'table') { return; } - - _.each(panel.styles, function(style) { - if (style.thresholds && style.thresholds.length >= 3) { - var k = style.thresholds; - k.shift(); - style.thresholds = k; - } - }); - }); - } - - if (oldVersion < 12) { - // update template variables - _.each(this.templating.list, function(templateVariable) { - if (templateVariable.refresh) { templateVariable.refresh = 1; } - if (!templateVariable.refresh) { templateVariable.refresh = 0; } - if (templateVariable.hideVariable) { - templateVariable.hide = 2; - } else if (templateVariable.hideLabel) { - templateVariable.hide = 1; - } - }); - } - - if (oldVersion < 12) { - // update graph yaxes changes - panelUpgrades.push(function(panel) { - if (panel.type !== 'graph') { return; } - if (!panel.grid) { return; } - - if (!panel.yaxes) { - panel.yaxes = [ - { - show: panel['y-axis'], - min: panel.grid.leftMin, - max: panel.grid.leftMax, - logBase: panel.grid.leftLogBase, - format: panel.y_formats[0], - label: panel.leftYAxisLabel, - }, - { - show: panel['y-axis'], - min: panel.grid.rightMin, - max: panel.grid.rightMax, - logBase: panel.grid.rightLogBase, - format: panel.y_formats[1], - label: panel.rightYAxisLabel, - } - ]; - - panel.xaxis = { - show: panel['x-axis'], - }; - - delete panel.grid.leftMin; - delete panel.grid.leftMax; - delete panel.grid.leftLogBase; - delete panel.grid.rightMin; - delete panel.grid.rightMax; - delete panel.grid.rightLogBase; - delete panel.y_formats; - delete panel.leftYAxisLabel; - delete panel.rightYAxisLabel; - delete panel['y-axis']; - delete panel['x-axis']; - } - }); - } - - if (oldVersion < 13) { - // update graph yaxes changes - panelUpgrades.push(function(panel) { - if (panel.type !== 'graph') { return; } - if (!panel.grid) { return; } - - panel.thresholds = []; - var t1: any = {}, t2: any = {}; - - if (panel.grid.threshold1 !== null) { - t1.value = panel.grid.threshold1; - if (panel.grid.thresholdLine) { - t1.line = true; - t1.lineColor = panel.grid.threshold1Color; - t1.colorMode = 'custom'; - } else { - t1.fill = true; - t1.fillColor = panel.grid.threshold1Color; - t1.colorMode = 'custom'; - } - } - - if (panel.grid.threshold2 !== null) { - t2.value = panel.grid.threshold2; - if (panel.grid.thresholdLine) { - t2.line = true; - t2.lineColor = panel.grid.threshold2Color; - t2.colorMode = 'custom'; - } else { - t2.fill = true; - t2.fillColor = panel.grid.threshold2Color; - t2.colorMode = 'custom'; - } - } - - if (_.isNumber(t1.value)) { - if (_.isNumber(t2.value)) { - if (t1.value > t2.value) { - t1.op = t2.op = 'lt'; - panel.thresholds.push(t1); - panel.thresholds.push(t2); - } else { - t1.op = t2.op = 'gt'; - panel.thresholds.push(t1); - panel.thresholds.push(t2); - } - } else { - t1.op = 'gt'; - panel.thresholds.push(t1); - } - } - - delete panel.grid.threshold1; - delete panel.grid.threshold1Color; - delete panel.grid.threshold2; - delete panel.grid.threshold2Color; - delete panel.grid.thresholdLine; - }); - } - - if (oldVersion < 14) { - this.graphTooltip = old.sharedCrosshair ? 1 : 0; - } - - if (oldVersion < 16) { - this.upgradeToGridLayout(old); - } - - if (panelUpgrades.length === 0) { - return; - } - - for (j = 0; j < this.panels.length; j++) { - for (k = 0; k < panelUpgrades.length; k++) { - panelUpgrades[k].call(this, this.panels[j]); - } - } - } - - upgradeToGridLayout(old) { - let yPos = 0; - //let rowIds = 1000; - // - - if (!old.rows) { - return; - } - - for (let row of old.rows) { - let xPos = 0; - let height: any = row.height || 250; - - // if (this.meta.keepRows) { - // this.panels.push({ - // id: rowIds++, - // type: 'row', - // title: row.title, - // x: 0, - // y: yPos, - // height: 1, - // width: 12 - // }); - // - // yPos += 1; - // } - - if (_.isString(height)) { - height = parseInt(height.replace('px', ''), 10); - } - - const rowGridHeight = Math.ceil(height / CELL_HEIGHT); - - for (let panel of row.panels) { - // should wrap to next row? - if (xPos + panel.span >= 12) { - yPos += rowGridHeight; - } - - panel.gridPos = { x: xPos, y: yPos, w: panel.span, h: rowGridHeight }; - - delete panel.span; - - xPos += panel.gridPos.w; - - this.panels.push(new PanelModel(panel)); - } - - yPos += rowGridHeight; - } - - console.log('panels', this.panels); - } -} diff --git a/public/app/features/dashboard/dashboard_ctrl.ts b/public/app/features/dashboard/dashboard_ctrl.ts index b457f6d33c0..0e19270723d 100644 --- a/public/app/features/dashboard/dashboard_ctrl.ts +++ b/public/app/features/dashboard/dashboard_ctrl.ts @@ -2,7 +2,7 @@ import config from 'app/core/config'; import coreModule from 'app/core/core_module'; import {PanelContainer} from './dashgrid/PanelContainer'; -import {DashboardModel} from './DashboardModel'; +import {DashboardModel} from './dashboard_model'; export class DashboardCtrl implements PanelContainer { dashboard: DashboardModel; @@ -20,7 +20,6 @@ export class DashboardCtrl implements PanelContainer { private alertingSrv, private dashboardSrv, private unsavedChangesSrv, - private dynamicDashboardSrv, private dashboardViewStateSrv, private panelLoader) { // temp hack due to way dashboards are loaded @@ -57,10 +56,9 @@ export class DashboardCtrl implements PanelContainer { .catch(this.onInitFailed.bind(this, 'Templating init failed', false)) // continue .finally(() => { - this.dashboard = dashboard; - this.dynamicDashboardSrv.init(dashboard); - this.dynamicDashboardSrv.process(); + this.dashboard = dashboard; + this.dashboard.processRepeats(); this.unsavedChangesSrv.init(dashboard, this.$scope); @@ -97,7 +95,7 @@ export class DashboardCtrl implements PanelContainer { } templateVariableUpdated() { - this.dynamicDashboardSrv.process(); + this.dashboard.processRepeats(); } setWindowTitleAndTheme() { @@ -135,8 +133,8 @@ export class DashboardCtrl implements PanelContainer { } init(dashboard) { - this.$scope.onAppEvent('show-json-editor', this.$scope.showJsonEditor); - this.$scope.onAppEvent('template-variable-value-updated', this.$scope.templateVariableUpdated); + this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.bind(this)); + this.$scope.onAppEvent('template-variable-value-updated', this.templateVariableUpdated.bind(this)); this.setupDashboard(dashboard); } } diff --git a/public/app/features/dashboard/dashboard_model.ts b/public/app/features/dashboard/dashboard_model.ts new file mode 100644 index 00000000000..97c018d700c --- /dev/null +++ b/public/app/features/dashboard/dashboard_model.ts @@ -0,0 +1,860 @@ +import moment from 'moment'; +import _ from 'lodash'; + +import {GRID_COLUMN_COUNT, GRID_CELL_HEIGHT, REPEAT_DIR_VERTICAL} from 'app/core/constants'; +import {DEFAULT_ANNOTATION_COLOR} from 'app/core/utils/colors'; +import {Emitter, contextSrv} from 'app/core/core'; +import sortByKeys from 'app/core/utils/sort_by_keys'; + +import {DashboardRow} from './row/row_model'; +import {PanelModel} from './panel_model'; + +export class DashboardModel { + id: any; + title: any; + autoUpdate: any; + description: any; + tags: any; + style: any; + timezone: any; + editable: any; + graphTooltip: any; + rows: DashboardRow[]; + time: any; + timepicker: any; + hideControls: any; + templating: any; + annotations: any; + refresh: any; + snapshot: any; + schemaVersion: number; + version: number; + revision: number; + links: any; + gnetId: any; + editMode: boolean; + folderId: number; + panels: PanelModel[]; + + // ------------------ + // not persisted + // ------------------ + + // repeat process cycles + iteration: number; + meta: any; + events: Emitter; + + static nonPersistedProperties: {[str: string]: boolean} = { + events: true, + meta: true, + panels: true, // needs special handling + templating: true, // needs special handling + }; + + constructor(data, meta?) { + if (!data) { + data = {}; + } + + this.events = new Emitter(); + this.id = data.id || null; + this.revision = data.revision; + this.title = data.title || 'No Title'; + this.autoUpdate = data.autoUpdate; + this.description = data.description; + this.tags = data.tags || []; + this.style = data.style || 'dark'; + this.timezone = data.timezone || ''; + this.editable = data.editable !== false; + this.graphTooltip = data.graphTooltip || 0; + this.hideControls = data.hideControls || false; + this.time = data.time || {from: 'now-6h', to: 'now'}; + this.timepicker = data.timepicker || {}; + this.templating = this.ensureListExist(data.templating); + this.annotations = this.ensureListExist(data.annotations); + this.refresh = data.refresh; + this.snapshot = data.snapshot; + this.schemaVersion = data.schemaVersion || 0; + this.version = data.version || 0; + this.links = data.links || []; + this.gnetId = data.gnetId || null; + this.folderId = data.folderId || null; + this.panels = _.map(data.panels || [], panelData => new PanelModel(panelData)); + + this.addBuiltInAnnotationQuery(); + this.initMeta(meta); + this.updateSchema(data); + } + + addBuiltInAnnotationQuery() { + let found = false; + for (let item of this.annotations.list) { + if (item.builtIn === 1) { + found = true; + break; + } + } + + if (found) { + return; + } + + this.annotations.list.unshift({ + datasource: '-- Grafana --', + name: 'Annotations & Alerts', + type: 'dashboard', + iconColor: DEFAULT_ANNOTATION_COLOR, + enable: true, + hide: true, + builtIn: 1, + }); + } + + private initMeta(meta) { + meta = meta || {}; + + meta.canShare = meta.canShare !== false; + meta.canSave = meta.canSave !== false; + meta.canStar = meta.canStar !== false; + meta.canEdit = meta.canEdit !== false; + + if (!this.editable) { + meta.canEdit = false; + meta.canDelete = false; + meta.canSave = false; + } + + this.meta = meta; + } + + // cleans meta data and other non peristent state + getSaveModelClone() { + // make clone + var copy: any = {}; + for (var property in this) { + if (DashboardModel.nonPersistedProperties[property] || !this.hasOwnProperty(property)) { + continue; + } + + copy[property] = _.cloneDeep(this[property]); + } + + // get variable save models + copy.templating = { + list: _.map(this.templating.list, variable => (variable.getSaveModel ? variable.getSaveModel() : variable)), + }; + + // get panel save models + copy.panels = _.map(this.panels, panel => panel.getSaveModel()); + + // sort by keys + copy = sortByKeys(copy); + + return copy; + } + + setViewMode(panel: PanelModel, fullscreen: boolean, isEditing: boolean) { + this.meta.fullscreen = fullscreen; + this.meta.isEditing = isEditing && this.meta.canEdit; + + panel.setViewMode(fullscreen, this.meta.isEditing); + + this.events.emit('view-mode-changed', panel); + } + + private ensureListExist(data) { + if (!data) { + data = {}; + } + if (!data.list) { + data.list = []; + } + return data; + } + + getNextPanelId() { + let max = 0; + + for (let panel of this.panels) { + if (panel.id > max) { + max = panel.id; + } + } + + return max + 1; + } + + forEachPanel(callback) { + for (let i = 0; i < this.panels.length; i++) { + callback(this.panels[i], i); + } + } + + getPanelById(id) { + for (let panel of this.panels) { + if (panel.id === id) { + return panel; + } + } + return null; + } + + addPanel(panel) { + panel.id = this.getNextPanelId(); + + this.panels.unshift(new PanelModel(panel)); + + this.sortPanelsByGridPos(); + + this.events.emit('panel-added', panel); + } + + private sortPanelsByGridPos() { + this.panels.sort(function(panelA, panelB) { + if (panelA.gridPos.y === panelB.gridPos.y) { + return panelA.gridPos.x - panelB.gridPos.x; + } else { + return panelA.gridPos.y - panelB.gridPos.y; + } + }); + } + + cleanUpRepeats() { + this.processRepeats(true); + } + + processRepeats(cleanUpOnly?: boolean) { + if (this.snapshot || this.templating.list.length === 0) { + return; + } + + this.iteration = (this.iteration || new Date().getTime()) + 1; + let panelsToRemove = []; + + // cleanup scopedVars + for (let panel of this.panels) { + delete panel.scopedVars; + } + + for (let panel of this.panels) { + if (panel.repeat) { + if (!cleanUpOnly) { + this.repeatPanel(panel); + } + } else if (panel.repeatPanelId && panel.repeatIteration !== this.iteration) { + panelsToRemove.push(panel); + } + } + + // remove panels + _.pull(this.panels, ...panelsToRemove); + + this.sortPanelsByGridPos(); + this.events.emit('repeats-processed'); + } + + getRepeatClone(sourcePanel, index) { + // if first clone return source + if (index === 0) { + return sourcePanel; + } + + var clone = new PanelModel(sourcePanel.getSaveModel()); + clone.id = this.getNextPanelId(); + this.panels.push(clone); + + clone.repeatIteration = this.iteration; + clone.repeatPanelId = sourcePanel.id; + clone.repeat = null; + return clone; + } + + repeatPanel(panel: PanelModel) { + var variable = _.find(this.templating.list, {name: panel.repeat}); + if (!variable) { + return; + } + + var selected; + if (variable.current.text === 'All') { + selected = variable.options.slice(1, variable.options.length); + } else { + selected = _.filter(variable.options, {selected: true}); + } + + let minWidth = panel.minSpan || 6; + let xIndex = 0; + + for (let index = 0; index < selected.length; index++) { + var option = selected[index]; + var copy = this.getRepeatClone(panel, index); + + copy.scopedVars = {}; + copy.scopedVars[variable.name] = option; + + if (panel.repeatDirection === REPEAT_DIR_VERTICAL) { + if (index === 0) { + continue; + } + + copy.gridPos.y = panel.gridPos.y + panel.gridPos.h * index; + } else { + // set width based on how many are selected + // assumed the repeated panels should take up full row width + + copy.gridPos.w = Math.max(GRID_COLUMN_COUNT / selected.length, minWidth); + copy.gridPos.x = copy.gridPos.w * xIndex; + + // handle overflow by pushing down one row + if (copy.gridPos.x + copy.gridPos.w > GRID_COLUMN_COUNT) { + copy.gridPos.x = 0; + xIndex = 0; + } else { + xIndex += 1; + } + } + } + } + + removePanel(panel: PanelModel) { + var index = _.indexOf(this.panels, panel); + this.panels.splice(index, 1); + this.events.emit('panel-removed', panel); + } + + setPanelFocus(id) { + this.meta.focusPanelId = id; + } + + updateSubmenuVisibility() { + this.meta.submenuEnabled = (() => { + if (this.links.length > 0) { + return true; + } + + var visibleVars = _.filter(this.templating.list, variable => variable.hide !== 2); + if (visibleVars.length > 0) { + return true; + } + + var visibleAnnotations = _.filter(this.annotations.list, annotation => annotation.hide !== true); + if (visibleAnnotations.length > 0) { + return true; + } + + return false; + })(); + } + + getPanelInfoById(panelId) { + for (let i = 0; i < this.panels.length; i++) { + if (this.panels[i].id === panelId) { + return { + panel: this.panels[i], + index: i, + }; + } + } + + return null; + } + + duplicatePanel(panel) { + const newPanel = panel.getSaveModel(); + newPanel.id = this.getNextPanelId(); + + delete newPanel.repeat; + delete newPanel.repeatIteration; + delete newPanel.repeatPanelId; + delete newPanel.scopedVars; + if (newPanel.alert) { + delete newPanel.thresholds; + } + delete newPanel.alert; + + // does it fit to the right? + if (panel.gridPos.x + panel.gridPos.w * 2 <= GRID_COLUMN_COUNT) { + newPanel.gridPos.x += panel.gridPos.w; + } else { + // add bellow + newPanel.gridPos.y += panel.gridPos.h; + } + + this.addPanel(newPanel); + return newPanel; + } + + formatDate(date, format?) { + date = moment.isMoment(date) ? date : moment(date); + format = format || 'YYYY-MM-DD HH:mm:ss'; + let timezone = this.getTimezone(); + + return timezone === 'browser' ? moment(date).format(format) : moment.utc(date).format(format); + } + + destroy() { + this.events.removeAllListeners(); + for (let row of this.rows) { + row.destroy(); + } + } + + on(eventName, callback) { + this.events.on(eventName, callback); + } + + off(eventName, callback?) { + this.events.off(eventName, callback); + } + + cycleGraphTooltip() { + this.graphTooltip = (this.graphTooltip + 1) % 3; + } + + sharedTooltipModeEnabled() { + return this.graphTooltip > 0; + } + + sharedCrosshairModeOnly() { + return this.graphTooltip === 1; + } + + getRelativeTime(date) { + date = moment.isMoment(date) ? date : moment(date); + + return this.timezone === 'browser' ? moment(date).fromNow() : moment.utc(date).fromNow(); + } + + getNextQueryLetter(panel) { + var letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + + return _.find(letters, function(refId) { + return _.every(panel.targets, function(other) { + return other.refId !== refId; + }); + }); + } + + isTimezoneUtc() { + return this.getTimezone() === 'utc'; + } + + getTimezone() { + return this.timezone ? this.timezone : contextSrv.user.timezone; + } + + private updateSchema(old) { + var i, j, k; + var oldVersion = this.schemaVersion; + var panelUpgrades = []; + this.schemaVersion = 16; + + if (oldVersion === this.schemaVersion) { + return; + } + + // version 2 schema changes + if (oldVersion < 2) { + if (old.services) { + if (old.services.filter) { + this.time = old.services.filter.time; + this.templating.list = old.services.filter.list || []; + } + } + + panelUpgrades.push(function(panel) { + // rename panel type + if (panel.type === 'graphite') { + panel.type = 'graph'; + } + + if (panel.type !== 'graph') { + return; + } + + if (_.isBoolean(panel.legend)) { + panel.legend = {show: panel.legend}; + } + + if (panel.grid) { + if (panel.grid.min) { + panel.grid.leftMin = panel.grid.min; + delete panel.grid.min; + } + + if (panel.grid.max) { + panel.grid.leftMax = panel.grid.max; + delete panel.grid.max; + } + } + + if (panel.y_format) { + panel.y_formats[0] = panel.y_format; + delete panel.y_format; + } + + if (panel.y2_format) { + panel.y_formats[1] = panel.y2_format; + delete panel.y2_format; + } + }); + } + + // schema version 3 changes + if (oldVersion < 3) { + // ensure panel ids + var maxId = this.getNextPanelId(); + panelUpgrades.push(function(panel) { + if (!panel.id) { + panel.id = maxId; + maxId += 1; + } + }); + } + + // schema version 4 changes + if (oldVersion < 4) { + // move aliasYAxis changes + panelUpgrades.push(function(panel) { + if (panel.type !== 'graph') { + return; + } + _.each(panel.aliasYAxis, function(value, key) { + panel.seriesOverrides = [{alias: key, yaxis: value}]; + }); + delete panel.aliasYAxis; + }); + } + + if (oldVersion < 6) { + // move pulldowns to new schema + var annotations = _.find(old.pulldowns, {type: 'annotations'}); + + if (annotations) { + this.annotations = { + list: annotations.annotations || [], + }; + } + + // update template variables + for (i = 0; i < this.templating.list.length; i++) { + var variable = this.templating.list[i]; + if (variable.datasource === void 0) { + variable.datasource = null; + } + if (variable.type === 'filter') { + variable.type = 'query'; + } + if (variable.type === void 0) { + variable.type = 'query'; + } + if (variable.allFormat === void 0) { + variable.allFormat = 'glob'; + } + } + } + + if (oldVersion < 7) { + if (old.nav && old.nav.length) { + this.timepicker = old.nav[0]; + } + + // ensure query refIds + panelUpgrades.push(function(panel) { + _.each( + panel.targets, + function(target) { + if (!target.refId) { + target.refId = this.getNextQueryLetter(panel); + } + }.bind(this), + ); + }); + } + + if (oldVersion < 8) { + panelUpgrades.push(function(panel) { + _.each(panel.targets, function(target) { + // update old influxdb query schema + if (target.fields && target.tags && target.groupBy) { + if (target.rawQuery) { + delete target.fields; + delete target.fill; + } else { + target.select = _.map(target.fields, function(field) { + var parts = []; + parts.push({type: 'field', params: [field.name]}); + parts.push({type: field.func, params: []}); + if (field.mathExpr) { + parts.push({type: 'math', params: [field.mathExpr]}); + } + if (field.asExpr) { + parts.push({type: 'alias', params: [field.asExpr]}); + } + return parts; + }); + delete target.fields; + _.each(target.groupBy, function(part) { + if (part.type === 'time' && part.interval) { + part.params = [part.interval]; + delete part.interval; + } + if (part.type === 'tag' && part.key) { + part.params = [part.key]; + delete part.key; + } + }); + + if (target.fill) { + target.groupBy.push({type: 'fill', params: [target.fill]}); + delete target.fill; + } + } + } + }); + }); + } + + // schema version 9 changes + if (oldVersion < 9) { + // move aliasYAxis changes + panelUpgrades.push(function(panel) { + if (panel.type !== 'singlestat' && panel.thresholds !== '') { + return; + } + + if (panel.thresholds) { + var k = panel.thresholds.split(','); + + if (k.length >= 3) { + k.shift(); + panel.thresholds = k.join(','); + } + } + }); + } + + // schema version 10 changes + if (oldVersion < 10) { + // move aliasYAxis changes + panelUpgrades.push(function(panel) { + if (panel.type !== 'table') { + return; + } + + _.each(panel.styles, function(style) { + if (style.thresholds && style.thresholds.length >= 3) { + var k = style.thresholds; + k.shift(); + style.thresholds = k; + } + }); + }); + } + + if (oldVersion < 12) { + // update template variables + _.each(this.templating.list, function(templateVariable) { + if (templateVariable.refresh) { + templateVariable.refresh = 1; + } + if (!templateVariable.refresh) { + templateVariable.refresh = 0; + } + if (templateVariable.hideVariable) { + templateVariable.hide = 2; + } else if (templateVariable.hideLabel) { + templateVariable.hide = 1; + } + }); + } + + if (oldVersion < 12) { + // update graph yaxes changes + panelUpgrades.push(function(panel) { + if (panel.type !== 'graph') { + return; + } + if (!panel.grid) { + return; + } + + if (!panel.yaxes) { + panel.yaxes = [ + { + show: panel['y-axis'], + min: panel.grid.leftMin, + max: panel.grid.leftMax, + logBase: panel.grid.leftLogBase, + format: panel.y_formats[0], + label: panel.leftYAxisLabel, + }, + { + show: panel['y-axis'], + min: panel.grid.rightMin, + max: panel.grid.rightMax, + logBase: panel.grid.rightLogBase, + format: panel.y_formats[1], + label: panel.rightYAxisLabel, + }, + ]; + + panel.xaxis = { + show: panel['x-axis'], + }; + + delete panel.grid.leftMin; + delete panel.grid.leftMax; + delete panel.grid.leftLogBase; + delete panel.grid.rightMin; + delete panel.grid.rightMax; + delete panel.grid.rightLogBase; + delete panel.y_formats; + delete panel.leftYAxisLabel; + delete panel.rightYAxisLabel; + delete panel['y-axis']; + delete panel['x-axis']; + } + }); + } + + if (oldVersion < 13) { + // update graph yaxes changes + panelUpgrades.push(function(panel) { + if (panel.type !== 'graph') { + return; + } + if (!panel.grid) { + return; + } + + panel.thresholds = []; + var t1: any = {}, + t2: any = {}; + + if (panel.grid.threshold1 !== null) { + t1.value = panel.grid.threshold1; + if (panel.grid.thresholdLine) { + t1.line = true; + t1.lineColor = panel.grid.threshold1Color; + t1.colorMode = 'custom'; + } else { + t1.fill = true; + t1.fillColor = panel.grid.threshold1Color; + t1.colorMode = 'custom'; + } + } + + if (panel.grid.threshold2 !== null) { + t2.value = panel.grid.threshold2; + if (panel.grid.thresholdLine) { + t2.line = true; + t2.lineColor = panel.grid.threshold2Color; + t2.colorMode = 'custom'; + } else { + t2.fill = true; + t2.fillColor = panel.grid.threshold2Color; + t2.colorMode = 'custom'; + } + } + + if (_.isNumber(t1.value)) { + if (_.isNumber(t2.value)) { + if (t1.value > t2.value) { + t1.op = t2.op = 'lt'; + panel.thresholds.push(t1); + panel.thresholds.push(t2); + } else { + t1.op = t2.op = 'gt'; + panel.thresholds.push(t1); + panel.thresholds.push(t2); + } + } else { + t1.op = 'gt'; + panel.thresholds.push(t1); + } + } + + delete panel.grid.threshold1; + delete panel.grid.threshold1Color; + delete panel.grid.threshold2; + delete panel.grid.threshold2Color; + delete panel.grid.thresholdLine; + }); + } + + if (oldVersion < 14) { + this.graphTooltip = old.sharedCrosshair ? 1 : 0; + } + + if (oldVersion < 16) { + this.upgradeToGridLayout(old); + } + + if (panelUpgrades.length === 0) { + return; + } + + for (j = 0; j < this.panels.length; j++) { + for (k = 0; k < panelUpgrades.length; k++) { + panelUpgrades[k].call(this, this.panels[j]); + } + } + } + + upgradeToGridLayout(old) { + let yPos = 0; + let widthFactor = GRID_COLUMN_COUNT / 12; + //let rowIds = 1000; + // + + if (!old.rows) { + return; + } + + for (let row of old.rows) { + let xPos = 0; + let height: any = row.height || 250; + + // if (this.meta.keepRows) { + // this.panels.push({ + // id: rowIds++, + // type: 'row', + // title: row.title, + // x: 0, + // y: yPos, + // height: 1, + // width: 12 + // }); + // + // yPos += 1; + // } + + if (_.isString(height)) { + height = parseInt(height.replace('px', ''), 10); + } + + const rowGridHeight = Math.ceil(height / GRID_CELL_HEIGHT); + + for (let panel of row.panels) { + const panelWidth = Math.floor(panel.span) * widthFactor; + + // should wrap to next row? + if (xPos + panelWidth >= GRID_COLUMN_COUNT) { + yPos += rowGridHeight; + } + + panel.gridPos = {x: xPos, y: yPos, w: panelWidth, h: rowGridHeight}; + + delete panel.span; + + xPos += panel.gridPos.w; + + this.panels.push(new PanelModel(panel)); + } + + yPos += rowGridHeight; + } + } +} diff --git a/public/app/features/dashboard/dashboard_srv.ts b/public/app/features/dashboard/dashboard_srv.ts index 4502d4cdc6e..7c1af20716b 100644 --- a/public/app/features/dashboard/dashboard_srv.ts +++ b/public/app/features/dashboard/dashboard_srv.ts @@ -1,5 +1,5 @@ import coreModule from 'app/core/core_module'; -import {DashboardModel} from './DashboardModel'; +import {DashboardModel} from './dashboard_model'; export class DashboardSrv { dash: any; diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index dfd1a8e4274..725f66e9798 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -1,15 +1,14 @@ import React from 'react'; import coreModule from 'app/core/core_module'; import ReactGridLayout from 'react-grid-layout'; -import {CELL_HEIGHT, CELL_VMARGIN} from '../DashboardModel'; +import {GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT} from 'app/core/constants'; import {DashboardPanel} from './DashboardPanel'; -import {DashboardModel} from '../DashboardModel'; +import {DashboardModel} from '../dashboard_model'; import {PanelContainer} from './PanelContainer'; -import {PanelModel} from '../PanelModel'; +import {PanelModel} from '../panel_model'; import classNames from 'classnames'; import sizeMe from 'react-sizeme'; -const COLUMN_COUNT = 12; let lastGridWidth = 1200; function GridWrapper({size, layout, onLayoutChange, children, onResize, onResizeStop, onWidthChange}) { @@ -31,10 +30,10 @@ function GridWrapper({size, layout, onLayoutChange, children, onResize, onResize isResizable={true} measureBeforeMount={false} containerPadding={[0, 0]} - useCSSTransforms={true} - margin={[CELL_VMARGIN, CELL_VMARGIN]} - cols={COLUMN_COUNT} - rowHeight={CELL_HEIGHT} + useCSSTransforms={false} + margin={[GRID_CELL_VMARGIN, GRID_CELL_VMARGIN]} + cols={GRID_COLUMN_COUNT} + rowHeight={GRID_CELL_HEIGHT} draggableHandle=".grid-drag-handle" layout={layout} onResize={onResize} @@ -68,6 +67,8 @@ export class DashboardGrid extends React.Component { // subscribe to dashboard events this.dashboard = this.panelContainer.getDashboard(); this.dashboard.on('panel-added', this.triggerForceUpdate.bind(this)); + this.dashboard.on('panel-removed', this.triggerForceUpdate.bind(this)); + this.dashboard.on('repeats-processed', this.triggerForceUpdate.bind(this)); this.dashboard.on('view-mode-changed', this.triggerForceUpdate.bind(this)); } diff --git a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx index 5c7f2178c96..11f06113617 100644 --- a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {PanelModel} from '../PanelModel'; +import {PanelModel} from '../panel_model'; import {PanelContainer} from './PanelContainer'; import {AttachedPanel} from './PanelLoader'; @@ -31,6 +31,8 @@ export class DashboardPanel extends React.Component { } } + + render() { return (
this.element = element} /> diff --git a/public/app/features/dashboard/dashgrid/DashboardRow.tsx b/public/app/features/dashboard/dashgrid/DashboardRow.tsx new file mode 100644 index 00000000000..8b6aaf45722 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardRow.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import {PanelModel} from '../panel_model'; + +export interface DashboardRowProps { + panel: PanelModel; +} + +export class DashboardPanel extends React.Component { + constructor(props) { + super(props); + + this.toggle = this.toggle.bind(this); + this.openSettings = this.openSettings.bind(this); + } + + toggle() {} + + openSettings() {} + + render() { + return ( +
+ +
(0 hidden panels)
+
+
+ ); + } +} diff --git a/public/app/features/dashboard/dashgrid/PanelContainer.ts b/public/app/features/dashboard/dashgrid/PanelContainer.ts index e2e524ed8da..66ec2a7c6f3 100644 --- a/public/app/features/dashboard/dashgrid/PanelContainer.ts +++ b/public/app/features/dashboard/dashgrid/PanelContainer.ts @@ -1,4 +1,4 @@ -import {DashboardModel}  from '../DashboardModel'; +import {DashboardModel}  from '../dashboard_model'; import {PanelLoader} from './PanelLoader'; export interface PanelContainer { diff --git a/public/app/features/dashboard/dashnav/dashnav.ts b/public/app/features/dashboard/dashnav/dashnav.ts index 4b2587d8ed3..6aa8c1a0c7e 100644 --- a/public/app/features/dashboard/dashnav/dashnav.ts +++ b/public/app/features/dashboard/dashnav/dashnav.ts @@ -4,7 +4,7 @@ import _ from 'lodash'; import moment from 'moment'; import angular from 'angular'; import {appEvents, NavModel} from 'app/core/core'; -import {DashboardModel} from '../DashboardModel'; +import {DashboardModel} from '../dashboard_model'; export class DashNavCtrl { dashboard: DashboardModel; @@ -148,7 +148,7 @@ export class DashNavCtrl { addPanel() { this.dashboard.addPanel({ type: 'graph', - gridPos: {x: 0, y: 0, w: 6, h: 5}, + gridPos: {x: 0, y: 0, w: 12, h: 9}, title: 'New Graph', }); } diff --git a/public/app/features/dashboard/dynamic_dashboard_srv.ts b/public/app/features/dashboard/dynamic_dashboard_srv.ts deleted file mode 100644 index 3958836d8d5..00000000000 --- a/public/app/features/dashboard/dynamic_dashboard_srv.ts +++ /dev/null @@ -1,192 +0,0 @@ -/// - -import angular from 'angular'; -import _ from 'lodash'; - -import coreModule from 'app/core/core_module'; -import {DashboardRow} from './row/row_model'; - -export class DynamicDashboardSrv { - iteration: number; - dashboard: any; - variables: any; - - init(dashboard) { - this.dashboard = dashboard; - this.variables = dashboard.templating.list; - } - - process(options?) { - if (this.dashboard.snapshot || this.variables.length === 0) { - return; - } - - this.iteration = (this.iteration || new Date().getTime()) + 1; - - options = options || {}; - var cleanUpOnly = options.cleanUpOnly; - var i, j, row, panel; - - // cleanup scopedVars - for (i = 0; i < this.dashboard.rows.length; i++) { - row = this.dashboard.rows[i]; - delete row.scopedVars; - - for (j = 0; j < row.panels.length; j++) { - delete row.panels[j].scopedVars; - } - } - - for (i = 0; i < this.dashboard.rows.length; i++) { - row = this.dashboard.rows[i]; - - // handle row repeats - if (row.repeat) { - if (!cleanUpOnly) { - this.repeatRow(row, i); - } - } else if (row.repeatRowId && row.repeatIteration !== this.iteration) { - // clean up old left overs - this.dashboard.removeRow(row, true); - i = i - 1; - continue; - } - - // repeat panels - for (j = 0; j < row.panels.length; j++) { - panel = row.panels[j]; - if (panel.repeat) { - if (!cleanUpOnly) { - this.repeatPanel(panel, row); - } - } else if (panel.repeatPanelId && panel.repeatIteration !== this.iteration) { - // clean up old left overs - row.panels = _.without(row.panels, panel); - j = j - 1; - } - } - - row.panelSpanChanged(); - } - } - - // returns a new row clone or reuses a clone from previous iteration - getRowClone(sourceRow, repeatIndex, sourceRowIndex) { - if (repeatIndex === 0) { - return sourceRow; - } - - var i, panel, row, copy; - var sourceRowId = sourceRowIndex + 1; - - // look for row to reuse - for (i = 0; i < this.dashboard.rows.length; i++) { - row = this.dashboard.rows[i]; - if (row.repeatRowId === sourceRowId && row.repeatIteration !== this.iteration) { - copy = row; - copy.copyPropertiesFromRowSource(sourceRow); - break; - } - } - - if (!copy) { - var modelCopy = angular.copy(sourceRow.getSaveModel()); - copy = new DashboardRow(modelCopy); - this.dashboard.rows.splice(sourceRowIndex + repeatIndex, 0, copy); - - // set new panel ids - for (i = 0; i < copy.panels.length; i++) { - panel = copy.panels[i]; - panel.id = this.dashboard.getNextPanelId(); - } - } - - copy.repeat = null; - copy.repeatRowId = sourceRowId; - copy.repeatIteration = this.iteration; - return copy; - } - - // returns a new row clone or reuses a clone from previous iteration - repeatRow(row, rowIndex) { - var variable = _.find(this.variables, {name: row.repeat}); - if (!variable) { - return; - } - - var selected, copy, i, panel; - if (variable.current.text === 'All') { - selected = variable.options.slice(1, variable.options.length); - } else { - selected = _.filter(variable.options, {selected: true}); - } - - _.each(selected, (option, index) => { - copy = this.getRowClone(row, index, rowIndex); - copy.scopedVars = {}; - copy.scopedVars[variable.name] = option; - - for (i = 0; i < copy.panels.length; i++) { - panel = copy.panels[i]; - panel.scopedVars = {}; - panel.scopedVars[variable.name] = option; - } - }); - } - - getPanelClone(sourcePanel, row, index) { - // if first clone return source - if (index === 0) { - return sourcePanel; - } - - var i, tmpId, panel, clone; - - // first try finding an existing clone to use - for (i = 0; i < row.panels.length; i++) { - panel = row.panels[i]; - if (panel.repeatIteration !== this.iteration && panel.repeatPanelId === sourcePanel.id) { - clone = panel; - break; - } - } - - if (!clone) { - clone = { id: this.dashboard.getNextPanelId() }; - row.panels.push(clone); - } - - // save id - tmpId = clone.id; - // copy properties from source - angular.copy(sourcePanel, clone); - // restore id - clone.id = tmpId; - clone.repeatIteration = this.iteration; - clone.repeatPanelId = sourcePanel.id; - clone.repeat = null; - return clone; - } - - repeatPanel(panel, row) { - var variable = _.find(this.variables, {name: panel.repeat}); - if (!variable) { return; } - - var selected; - if (variable.current.text === 'All') { - selected = variable.options.slice(1, variable.options.length); - } else { - selected = _.filter(variable.options, {selected: true}); - } - - _.each(selected, (option, index) => { - var copy = this.getPanelClone(panel, row, index); - copy.span = Math.max(12 / selected.length, panel.minSpan || 4); - copy.scopedVars = copy.scopedVars || {}; - copy.scopedVars[variable.name] = option; - }); - } -} - -coreModule.service('dynamicDashboardSrv', DynamicDashboardSrv); - diff --git a/public/app/features/dashboard/export/exporter.ts b/public/app/features/dashboard/export/exporter.ts index 9bbcd8ab3dc..d78883fccad 100644 --- a/public/app/features/dashboard/export/exporter.ts +++ b/public/app/features/dashboard/export/exporter.ts @@ -1,29 +1,24 @@ -/// - import config from 'app/core/config'; import _ from 'lodash'; -import {DynamicDashboardSrv} from '../dynamic_dashboard_srv'; +import {DashboardModel} from '../dashboard_model'; export class DashboardExporter { constructor(private datasourceSrv) { } - makeExportable(dashboard) { - var dynSrv = new DynamicDashboardSrv(); - + makeExportable(dashboard: DashboardModel) { // clean up repeated rows and panels, // this is done on the live real dashboard instance, not on a clone // so we need to undo this // this is pretty hacky and needs to be changed - dynSrv.init(dashboard); - dynSrv.process({cleanUpOnly: true}); + dashboard.cleanUpRepeats(); var saveModel = dashboard.getSaveModelClone(); saveModel.id = null; // undo repeat cleanup - dynSrv.process(); + dashboard.processRepeats(); var inputs = []; var requires = {}; @@ -69,29 +64,27 @@ export class DashboardExporter { }; // check up panel data sources - for (let row of saveModel.rows) { - for (let panel of row.panels) { - if (panel.datasource !== undefined) { - templateizeDatasourceUsage(panel); - } + for (let panel of saveModel.panels) { + if (panel.datasource !== undefined) { + templateizeDatasourceUsage(panel); + } - if (panel.targets) { - for (let target of panel.targets) { - if (target.datasource !== undefined) { - templateizeDatasourceUsage(target); - } + if (panel.targets) { + for (let target of panel.targets) { + if (target.datasource !== undefined) { + templateizeDatasourceUsage(target); } } + } - var panelDef = config.panels[panel.type]; - if (panelDef) { - requires['panel' + panelDef.id] = { - type: 'panel', - id: panelDef.id, - name: panelDef.name, - version: panelDef.info.version, - }; - } + var panelDef = config.panels[panel.type]; + if (panelDef) { + requires['panel' + panelDef.id] = { + type: 'panel', + id: panelDef.id, + name: panelDef.name, + version: panelDef.info.version, + }; } } diff --git a/public/app/features/dashboard/folder_modal/folder.ts b/public/app/features/dashboard/folder_modal/folder.ts index be0efbc6f9b..c788d54351f 100644 --- a/public/app/features/dashboard/folder_modal/folder.ts +++ b/public/app/features/dashboard/folder_modal/folder.ts @@ -1,5 +1,3 @@ -/// - import coreModule from 'app/core/core_module'; import appEvents from 'app/core/app_events'; diff --git a/public/app/features/dashboard/history/history.ts b/public/app/features/dashboard/history/history.ts index 98c737e53c3..b0dee045a6a 100644 --- a/public/app/features/dashboard/history/history.ts +++ b/public/app/features/dashboard/history/history.ts @@ -6,7 +6,7 @@ import _ from 'lodash'; import angular from 'angular'; import moment from 'moment'; -import {DashboardModel} from '../DashboardModel'; +import {DashboardModel} from '../dashboard_model'; import {HistoryListOpts, RevisionsModel, CalculateDiffOptions, HistorySrv} from './history_srv'; export class HistoryListCtrl { diff --git a/public/app/features/dashboard/history/history_srv.ts b/public/app/features/dashboard/history/history_srv.ts index 7fd4705c823..4a9e85c1b9d 100644 --- a/public/app/features/dashboard/history/history_srv.ts +++ b/public/app/features/dashboard/history/history_srv.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; import coreModule from 'app/core/core_module'; -import {DashboardModel} from '../DashboardModel'; +import {DashboardModel} from '../dashboard_model'; export interface HistoryListOpts { limit: number; diff --git a/public/app/features/dashboard/PanelModel.ts b/public/app/features/dashboard/panel_model.ts similarity index 83% rename from public/app/features/dashboard/PanelModel.ts rename to public/app/features/dashboard/panel_model.ts index 44b67fd841a..f4df8a1d7a5 100644 --- a/public/app/features/dashboard/PanelModel.ts +++ b/public/app/features/dashboard/panel_model.ts @@ -1,4 +1,5 @@ import {Emitter} from 'app/core/core'; +import _ from 'lodash'; export interface GridPos { x: number; @@ -19,6 +20,12 @@ export class PanelModel { type: string; title: string; alert?: any; + scopedVars?: any; + repeat?: string; + repeatIteration?: number; + repeatPanelId?: number; + repeatDirection?: string; + minSpan?: number; // non persisted fullscreen: boolean; @@ -32,6 +39,10 @@ export class PanelModel { for (var property in model) { this[property] = model[property]; } + + if (!this.gridPos) { + this.gridPos = {x: 0, y: 0, h: 3, w: 6}; + } } getSaveModel() { @@ -41,7 +52,7 @@ export class PanelModel { continue; } - model[property] = this[property]; + model[property] = _.cloneDeep(this[property]); } return model; diff --git a/public/app/features/dashboard/repeat_option/repeat_option.ts b/public/app/features/dashboard/repeat_option/repeat_option.ts index 056cfded0b5..cd9a2c3700b 100644 --- a/public/app/features/dashboard/repeat_option/repeat_option.ts +++ b/public/app/features/dashboard/repeat_option/repeat_option.ts @@ -1,11 +1,9 @@ -/// - import {coreModule} from 'app/core/core'; var template = `
- +
`; @@ -29,6 +27,17 @@ function dashRepeatOptionDirective(variableSrv) { } scope.variables.unshift({text: 'Disabled', value: null}); + + // if repeat is set and no direction set to horizontal + if (scope.panel.repeat && !scope.panel.repeatDirection) { + scope.panel.repeatDirection = 'h'; + } + + scope.optionChanged = function() { + if (scope.panel.repeat) { + scope.panel.repeatDirection = 'h'; + } + }; } }; } diff --git a/public/app/features/dashboard/specs/dashboard_model_specs.ts b/public/app/features/dashboard/specs/dashboard_model_specs.ts index 940456cfba1..5ca8122e3bd 100644 --- a/public/app/features/dashboard/specs/dashboard_model_specs.ts +++ b/public/app/features/dashboard/specs/dashboard_model_specs.ts @@ -1,7 +1,8 @@ import {describe, beforeEach, it, expect} from 'test/lib/common'; import _ from 'lodash'; -import {DashboardModel} from '../DashboardModel'; +import {DashboardModel} from '../dashboard_model'; +import {PanelModel} from '../panel_model'; describe('DashboardModel', function() { @@ -22,7 +23,7 @@ describe('DashboardModel', function() { }); it('should have default properties', function() { - expect(model.rows.length).to.be(0); + expect(model.panels.length).to.be(0); }); }); @@ -31,7 +32,7 @@ describe('DashboardModel', function() { beforeEach(function() { model = new DashboardModel({ - rows: [{ panels: [{ id: 5 }]}] + panels: [{ id: 5 }] }); }); @@ -46,8 +47,8 @@ describe('DashboardModel', function() { var saveModel = model.getSaveModelClone(); var keys = _.keys(saveModel); - expect(keys[0]).to.be('addBuiltInAnnotationQuery'); - expect(keys[1]).to.be('addEmptyRow'); + expect(keys[0]).to.be('annotations'); + expect(keys[1]).to.be('autoUpdate'); }); }); @@ -58,39 +59,30 @@ describe('DashboardModel', function() { dashboard = new DashboardModel({}); }); - it('adding default should split span in half', function() { - dashboard.addEmptyRow(); - dashboard.rows[0].addPanel({span: 12}); - dashboard.rows[0].addPanel({span: 12}); + it('adding panel should new up panel model', function() { + dashboard.addPanel({type: 'test', title: 'test'}); - expect(dashboard.rows[0].panels[0].span).to.be(6); - expect(dashboard.rows[0].panels[1].span).to.be(6); + expect(dashboard.panels[0] instanceof PanelModel).to.be(true); }); - it('duplicate panel should try to add it to same row', function() { - var panel = { span: 4, attr: '123', id: 10 }; + it('duplicate panel should try to add to the right if there is space', function() { + var panel = {id: 10, gridPos: {x: 0, y: 0, w: 6, h: 2}}; - dashboard.addEmptyRow(); - dashboard.rows[0].addPanel(panel); - dashboard.duplicatePanel(panel, dashboard.rows[0]); + dashboard.addPanel(panel); + dashboard.duplicatePanel(dashboard.panels[0]); - expect(dashboard.rows[0].panels[0].span).to.be(4); - expect(dashboard.rows[0].panels[1].span).to.be(4); - expect(dashboard.rows[0].panels[1].attr).to.be('123'); - expect(dashboard.rows[0].panels[1].id).to.be(11); + expect(dashboard.panels[1].gridPos).to.eql({x: 6, y: 0, h: 2, w: 6}); }); it('duplicate panel should remove repeat data', function() { - var panel = { span: 4, attr: '123', id: 10, repeat: 'asd', scopedVars: { test: 'asd' }}; + var panel = {id: 10, gridPos: {x: 0, y: 0, w: 6, h: 2}, repeat: 'asd', scopedVars: {test: 'asd'}}; - dashboard.addEmptyRow(); - dashboard.rows[0].addPanel(panel); - dashboard.duplicatePanel(panel, dashboard.rows[0]); + dashboard.addPanel(panel); + dashboard.duplicatePanel(dashboard.panels[0]); - expect(dashboard.rows[0].panels[1].repeat).to.be(undefined); - expect(dashboard.rows[0].panels[1].scopedVars).to.be(undefined); + expect(dashboard.panels[1].repeat).to.be(undefined); + expect(dashboard.panels[1].scopedVars).to.be(undefined); }); - }); describe('when creating dashboard with old schema', function() { @@ -106,43 +98,39 @@ describe('DashboardModel', function() { {type: 'filtering', enable: true}, {type: 'annotations', enable: true, annotations: [{name: 'old'}]} ], - rows: [ + panels: [ { - panels: [ - { - type: 'graph', legend: true, aliasYAxis: { test: 2 }, - y_formats: ['kbyte', 'ms'], - grid: { - min: 1, - max: 10, - rightMin: 5, - rightMax: 15, - leftLogBase: 1, - rightLogBase: 2, - threshold1: 200, - threshold2: 400, - threshold1Color: 'yellow', - threshold2Color: 'red', - }, - leftYAxisLabel: 'left label', - targets: [{refId: 'A'}, {}], - }, - { - type: 'singlestat', legend: true, thresholds: '10,20,30', aliasYAxis: { test: 2 }, grid: { min: 1, max: 10 }, - targets: [{refId: 'A'}, {}], - }, - { - type: 'table', legend: true, styles: [{ thresholds: ["10", "20", "30"]}, { thresholds: ["100", "200", "300"]}], - targets: [{refId: 'A'}, {}], - } - ] + type: 'graph', legend: true, aliasYAxis: { test: 2 }, + y_formats: ['kbyte', 'ms'], + grid: { + min: 1, + max: 10, + rightMin: 5, + rightMax: 15, + leftLogBase: 1, + rightLogBase: 2, + threshold1: 200, + threshold2: 400, + threshold1Color: 'yellow', + threshold2Color: 'red', + }, + leftYAxisLabel: 'left label', + targets: [{refId: 'A'}, {}], + }, + { + type: 'singlestat', legend: true, thresholds: '10,20,30', aliasYAxis: { test: 2 }, grid: { min: 1, max: 10 }, + targets: [{refId: 'A'}, {}], + }, + { + type: 'table', legend: true, styles: [{ thresholds: ["10", "20", "30"]}, { thresholds: ["100", "200", "300"]}], + targets: [{refId: 'A'}, {}], } ] }); - graph = model.rows[0].panels[0]; - singlestat = model.rows[0].panels[1]; - table = model.rows[0].panels[2]; + graph = model.panels[0]; + singlestat = model.panels[1]; + table = model.panels[2]; }); it('should have title', function() { @@ -207,7 +195,7 @@ describe('DashboardModel', function() { }); it('dashboard schema version should be set to latest', function() { - expect(model.schemaVersion).to.be(14); + expect(model.schemaVersion).to.be(16); }); it('graph thresholds should be migrated', function() { @@ -244,52 +232,50 @@ describe('DashboardModel', function() { beforeEach(function() { model = new DashboardModel({ - rows: [{ - panels: [{ - type: 'graph', - grid: {}, - yaxes: [{}, {}], - targets: [{ - "alias": "$tag_datacenter $tag_source $col", - "column": "value", - "measurement": "logins.count", - "fields": [ - { - "func": "mean", - "name": "value", - "mathExpr": "*2", - "asExpr": "value" - }, - { - "name": "one-minute", - "func": "mean", - "mathExpr": "*3", - "asExpr": "one-minute" - } - ], - "tags": [], - "fill": "previous", - "function": "mean", - "groupBy": [ - { - "interval": "auto", - "type": "time" - }, - { - "key": "source", - "type": "tag" - }, - { - "type": "tag", - "key": "datacenter" - } - ], - }] + panels: [{ + type: 'graph', + grid: {}, + yaxes: [{}, {}], + targets: [{ + "alias": "$tag_datacenter $tag_source $col", + "column": "value", + "measurement": "logins.count", + "fields": [ + { + "func": "mean", + "name": "value", + "mathExpr": "*2", + "asExpr": "value" + }, + { + "name": "one-minute", + "func": "mean", + "mathExpr": "*3", + "asExpr": "one-minute" + } + ], + "tags": [], + "fill": "previous", + "function": "mean", + "groupBy": [ + { + "interval": "auto", + "type": "time" + }, + { + "key": "source", + "type": "tag" + }, + { + "type": "tag", + "key": "datacenter" + } + ], }] }] }); - target = model.rows[0].panels[0].targets[0]; + target = model.panels[0].targets[0]; }); it('should update query schema', function() { @@ -414,19 +400,163 @@ describe('DashboardModel', function() { }); describe('updateSubmenuVisibility with hidden annotation toggle', function() { - var model; + var dashboard; beforeEach(function() { - model = new DashboardModel({ + dashboard = new DashboardModel({ annotations: { list: [{hide: true}] } }); - model.updateSubmenuVisibility(); + dashboard.updateSubmenuVisibility(); }); it('should not enable submmenu', function() { - expect(model.meta.submenuEnabled).to.be(false); + expect(dashboard.meta.submenuEnabled).to.be(false); + }); + }); + + describe('given dashboard with panel repeat in horizontal direction', function(ctx) { + var dashboard; + + beforeEach(function() { + dashboard = new DashboardModel({ + panels: [{id: 2, repeat: 'apps', repeatDirection: 'h', gridPos: {x: 0, y: 0, h: 2, w: 24}}], + templating: { + list: [{ + name: 'apps', + current: { + text: 'se1, se2, se3', + value: ['se1', 'se2', 'se3'] + }, + options: [ + {text: 'se1', value: 'se1', selected: true}, + {text: 'se2', value: 'se2', selected: true}, + {text: 'se3', value: 'se3', selected: true}, + {text: 'se4', value: 'se4', selected: false} + ] + }] + } + }); + dashboard.processRepeats(); + }); + + it('should repeat panel 3 times', function() { + expect(dashboard.panels.length).to.be(3); + }); + + it('should mark panel repeated', function() { + expect(dashboard.panels[0].repeat).to.be('apps'); + expect(dashboard.panels[1].repeatPanelId).to.be(2); + }); + + it('should set scopedVars on panels', function() { + expect(dashboard.panels[0].scopedVars.apps.value).to.be('se1'); + expect(dashboard.panels[1].scopedVars.apps.value).to.be('se2'); + expect(dashboard.panels[2].scopedVars.apps.value).to.be('se3'); + }); + + it('should place on first row and adjust width so all fit', function() { + expect(dashboard.panels[0].gridPos).to.eql({x: 0, y: 0, h: 2, w: 8}); + expect(dashboard.panels[1].gridPos).to.eql({x: 8, y: 0, h: 2, w: 8}); + expect(dashboard.panels[2].gridPos).to.eql({x: 16, y: 0, h: 2, w: 8}); + }); + + describe('After a second iteration', function() { + var repeatedPanelAfterIteration1; + + beforeEach(function() { + repeatedPanelAfterIteration1 = dashboard.panels[1]; + dashboard.panels[0].fill = 10; + dashboard.processRepeats(); + }); + + it('reused panel should copy properties from source', function() { + expect(dashboard.panels[1].fill).to.be(10); + }); + + it('should have same panel count', function() { + expect(dashboard.panels.length).to.be(3); + }); + }); + + describe('After a second iteration with different variable', function() { + beforeEach(function() { + dashboard.templating.list.push({ + name: 'server', + current: { text: 'se1, se2, se3', value: ['se1']}, + options: [{text: 'se1', value: 'se1', selected: true}] + }); + dashboard.panels[0].repeat = "server"; + dashboard.processRepeats(); + }); + + it('should remove scopedVars value for last variable', function() { + expect(dashboard.panels[0].scopedVars.apps).to.be(undefined); + }); + + it('should have new variable value in scopedVars', function() { + expect(dashboard.panels[0].scopedVars.server.value).to.be("se1"); + }); + }); + + describe('After a second iteration and selected values reduced', function() { + beforeEach(function() { + dashboard.templating.list[0].options[1].selected = false; + dashboard.processRepeats(); + }); + + it('should clean up repeated panel', function() { + expect(dashboard.panels.length).to.be(2); + }); + }); + + describe('After a second iteration and panel repeat is turned off', function() { + beforeEach(function() { + dashboard.panels[0].repeat = null; + dashboard.processRepeats(); + }); + + it('should clean up repeated panel', function() { + expect(dashboard.panels.length).to.be(1); + }); + + it('should remove scoped vars from reused panel', function() { + expect(dashboard.panels[0].scopedVars).to.be(undefined); + }); + }); + + }); + + describe('given dashboard with panel repeat in vertical direction', function(ctx) { + var dashboard; + + beforeEach(function() { + dashboard = new DashboardModel({ + panels: [{id: 2, repeat: 'apps', repeatDirection: 'v', gridPos: {x: 5, y: 0, h: 2, w: 8}}], + templating: { + list: [{ + name: 'apps', + current: { + text: 'se1, se2, se3', + value: ['se1', 'se2', 'se3'] + }, + options: [ + {text: 'se1', value: 'se1', selected: true}, + {text: 'se2', value: 'se2', selected: true}, + {text: 'se3', value: 'se3', selected: true}, + {text: 'se4', value: 'se4', selected: false} + ] + }] + } + }); + dashboard.processRepeats(); + }); + + it('should place on items on top of each other and keep witdh', function() { + expect(dashboard.panels[0].gridPos).to.eql({x: 5, y: 0, h: 2, w: 8}); + expect(dashboard.panels[1].gridPos).to.eql({x: 5, y: 2, h: 2, w: 8}); + expect(dashboard.panels[2].gridPos).to.eql({x: 5, y: 4, h: 2, w: 8}); }); }); diff --git a/public/app/features/dashboard/specs/dynamic_dashboard_srv_specs.ts b/public/app/features/dashboard/specs/dynamic_dashboard_srv_specs.ts deleted file mode 100644 index cb7ea3bed46..00000000000 --- a/public/app/features/dashboard/specs/dynamic_dashboard_srv_specs.ts +++ /dev/null @@ -1,287 +0,0 @@ -import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common'; - -import '../dashboard_srv'; -import {DynamicDashboardSrv} from '../dynamic_dashboard_srv'; - -function dynamicDashScenario(desc, func) { - - describe(desc, function() { - var ctx: any = {}; - - ctx.setup = function (setupFunc) { - - beforeEach(angularMocks.module('grafana.core')); - beforeEach(angularMocks.module('grafana.services')); - beforeEach(angularMocks.module(function($provide) { - $provide.value('contextSrv', { - user: { timezone: 'utc'} - }); - })); - - beforeEach(angularMocks.inject(function(dashboardSrv) { - ctx.dashboardSrv = dashboardSrv; - - var model = { - rows: [], - templating: { list: [] } - }; - - setupFunc(model); - ctx.dash = ctx.dashboardSrv.create(model); - ctx.dynamicDashboardSrv = new DynamicDashboardSrv(); - ctx.dynamicDashboardSrv.init(ctx.dash); - ctx.dynamicDashboardSrv.process(); - ctx.rows = ctx.dash.rows; - })); - }; - - func(ctx); - }); -} - -dynamicDashScenario('given dashboard with panel repeat', function(ctx) { - ctx.setup(function(dash) { - dash.rows.push({ - panels: [{id: 2, repeat: 'apps'}] - }); - dash.templating.list.push({ - name: 'apps', - current: { - text: 'se1, se2, se3', - value: ['se1', 'se2', 'se3'] - }, - options: [ - {text: 'se1', value: 'se1', selected: true}, - {text: 'se2', value: 'se2', selected: true}, - {text: 'se3', value: 'se3', selected: true}, - {text: 'se4', value: 'se4', selected: false} - ] - }); - }); - - it('should repeat panel one time', function() { - expect(ctx.rows[0].panels.length).to.be(3); - }); - - it('should mark panel repeated', function() { - expect(ctx.rows[0].panels[0].repeat).to.be('apps'); - expect(ctx.rows[0].panels[1].repeatPanelId).to.be(2); - }); - - it('should set scopedVars on panels', function() { - expect(ctx.rows[0].panels[0].scopedVars.apps.value).to.be('se1'); - expect(ctx.rows[0].panels[1].scopedVars.apps.value).to.be('se2'); - expect(ctx.rows[0].panels[2].scopedVars.apps.value).to.be('se3'); - }); - - describe('After a second iteration', function() { - var repeatedPanelAfterIteration1; - - beforeEach(function() { - repeatedPanelAfterIteration1 = ctx.rows[0].panels[1]; - ctx.rows[0].panels[0].fill = 10; - ctx.dynamicDashboardSrv.process(); - }); - - it('should have reused same panel instances', function() { - expect(ctx.rows[0].panels[1]).to.be(repeatedPanelAfterIteration1); - }); - - it('reused panel should copy properties from source', function() { - expect(ctx.rows[0].panels[1].fill).to.be(10); - }); - - it('should have same panel count', function() { - expect(ctx.rows[0].panels.length).to.be(3); - }); - }); - - describe('After a second iteration with different variable', function() { - beforeEach(function() { - ctx.dash.templating.list.push({ - name: 'server', - current: { text: 'se1, se2, se3', value: ['se1']}, - options: [{text: 'se1', value: 'se1', selected: true}] - }); - ctx.rows[0].panels[0].repeat = "server"; - ctx.dynamicDashboardSrv.process(); - }); - - it('should remove scopedVars value for last variable', function() { - expect(ctx.rows[0].panels[0].scopedVars.apps).to.be(undefined); - }); - - it('should have new variable value in scopedVars', function() { - expect(ctx.rows[0].panels[0].scopedVars.server.value).to.be("se1"); - }); - }); - - describe('After a second iteration and selected values reduced', function() { - beforeEach(function() { - ctx.dash.templating.list[0].options[1].selected = false; - ctx.dynamicDashboardSrv.process(); - }); - - it('should clean up repeated panel', function() { - expect(ctx.rows[0].panels.length).to.be(2); - }); - }); - - describe('After a second iteration and panel repeat is turned off', function() { - beforeEach(function() { - ctx.rows[0].panels[0].repeat = null; - ctx.dynamicDashboardSrv.process(); - }); - - it('should clean up repeated panel', function() { - expect(ctx.rows[0].panels.length).to.be(1); - }); - - it('should remove scoped vars from reused panel', function() { - expect(ctx.rows[0].panels[0].scopedVars).to.be(undefined); - }); - }); - -}); - -dynamicDashScenario('given dashboard with row repeat', function(ctx) { - ctx.setup(function(dash) { - dash.rows.push({ - repeat: 'servers', - panels: [{id: 2}] - }); - dash.rows.push({panels: []}); - dash.templating.list.push({ - name: 'servers', - current: { - text: 'se1, se2', - value: ['se1', 'se2'] - }, - options: [ - {text: 'se1', value: 'se1', selected: true}, - {text: 'se2', value: 'se2', selected: true}, - ] - }); - }); - - it('should repeat row one time', function() { - expect(ctx.rows.length).to.be(3); - }); - - it('should keep panel ids on first row', function() { - expect(ctx.rows[0].panels[0].id).to.be(2); - }); - - it('should keep first row as repeat', function() { - expect(ctx.rows[0].repeat).to.be('servers'); - }); - - it('should clear repeat field on repeated row', function() { - expect(ctx.rows[1].repeat).to.be(null); - }); - - it('should add scopedVars to rows', function() { - expect(ctx.rows[0].scopedVars.servers.value).to.be('se1'); - expect(ctx.rows[1].scopedVars.servers.value).to.be('se2'); - }); - - it('should generate a repeartRowId based on repeat row index', function() { - expect(ctx.rows[1].repeatRowId).to.be(1); - expect(ctx.rows[1].repeatIteration).to.be(ctx.dynamicDashboardSrv.iteration); - }); - - it('should set scopedVars on row panels', function() { - expect(ctx.rows[0].panels[0].scopedVars.servers.value).to.be('se1'); - expect(ctx.rows[1].panels[0].scopedVars.servers.value).to.be('se2'); - }); - - describe('After a second iteration', function() { - var repeatedRowAfterFirstIteration; - - beforeEach(function() { - repeatedRowAfterFirstIteration = ctx.rows[1]; - ctx.rows[0].height = 500; - ctx.dynamicDashboardSrv.process(); - }); - - it('should still only have 2 rows', function() { - expect(ctx.rows.length).to.be(3); - }); - - it.skip('should have updated props from source', function() { - expect(ctx.rows[1].height).to.be(500); - }); - - it('should reuse row instance', function() { - expect(ctx.rows[1]).to.be(repeatedRowAfterFirstIteration); - }); - }); - - describe('After a second iteration and selected values reduced', function() { - beforeEach(function() { - ctx.dash.templating.list[0].options[1].selected = false; - ctx.dynamicDashboardSrv.process(); - }); - - it('should remove repeated second row', function() { - expect(ctx.rows.length).to.be(2); - }); - }); -}); - -dynamicDashScenario('given dashboard with row repeat and panel repeat', function(ctx) { - ctx.setup(function(dash) { - dash.rows.push({ - repeat: 'servers', - panels: [{id: 2, repeat: 'metric'}] - }); - dash.templating.list.push({ - name: 'servers', - current: { text: 'se1, se2', value: ['se1', 'se2'] }, - options: [ - {text: 'se1', value: 'se1', selected: true}, - {text: 'se2', value: 'se2', selected: true}, - ] - }); - dash.templating.list.push({ - name: 'metric', - current: { text: 'm1, m2', value: ['m1', 'm2'] }, - options: [ - {text: 'm1', value: 'm1', selected: true}, - {text: 'm2', value: 'm2', selected: true}, - ] - }); - }); - - it('should repeat row one time', function() { - expect(ctx.rows.length).to.be(2); - }); - - it('should repeat panel on both rows', function() { - expect(ctx.rows[0].panels.length).to.be(2); - expect(ctx.rows[1].panels.length).to.be(2); - }); - - it('should keep panel ids on first row', function() { - expect(ctx.rows[0].panels[0].id).to.be(2); - }); - - it('should mark second row as repeated', function() { - expect(ctx.rows[0].repeat).to.be('servers'); - }); - - it('should clear repeat field on repeated row', function() { - expect(ctx.rows[1].repeat).to.be(null); - }); - - it('should generate a repeartRowId based on repeat row index', function() { - expect(ctx.rows[1].repeatRowId).to.be(1); - }); - - it('should set scopedVars on row panels', function() { - expect(ctx.rows[0].panels[0].scopedVars.servers.value).to.be('se1'); - expect(ctx.rows[1].panels[0].scopedVars.servers.value).to.be('se2'); - }); - -}); - diff --git a/public/app/features/dashboard/specs/exporter_specs.ts b/public/app/features/dashboard/specs/exporter_specs.ts index 2c63ccb45f5..47fe64f387f 100644 --- a/public/app/features/dashboard/specs/exporter_specs.ts +++ b/public/app/features/dashboard/specs/exporter_specs.ts @@ -3,14 +3,13 @@ import {describe, beforeEach, it, sinon, expect} from 'test/lib/common'; import _ from 'lodash'; import config from 'app/core/config'; import {DashboardExporter} from '../export/exporter'; -import {DashboardModel} from '../DashboardModel'; +import {DashboardModel} from '../dashboard_model'; describe('given dashboard with repeated panels', function() { var dash, exported; beforeEach(done => { dash = { - rows: [], templating: { list: [] }, annotations: { list: [] }, }; @@ -47,25 +46,19 @@ describe('given dashboard with repeated panels', function() { datasource: 'gfdb', }); - dash.rows.push({ - repeat: 'test', - panels: [ - {id: 2, repeat: 'apps', datasource: 'gfdb', type: 'graph'}, - {id: 3, repeat: null, repeatPanelId: 2}, - { - id: 4, - datasource: '-- Mixed --', - targets: [{datasource: 'other'}], - }, - {id: 5, datasource: '$ds'}, - ] - }); + dash.panels = [ + {id: 6, datasource: 'gfdb', type: 'graph'}, + {id: 7}, + { + id: 8, + datasource: '-- Mixed --', + targets: [{datasource: 'other'}], + }, + {id: 9, datasource: '$ds'}, + ]; - dash.rows.push({ - repeat: null, - repeatRowId: 1, - panels: [], - }); + dash.panels.push({id: 2, repeat: 'apps', datasource: 'gfdb', type: 'graph'}); + dash.panels.push({id: 3, repeat: null, repeatPanelId: 2}); var datasourceSrvStub = {get: sinon.stub()}; datasourceSrvStub.get.withArgs('gfdb').returns(Promise.resolve({ @@ -99,16 +92,8 @@ describe('given dashboard with repeated panels', function() { }); }); - it('exported dashboard should not contain repeated panels', function() { - expect(exported.rows[0].panels.length).to.be(3); - }); - - it('exported dashboard should not contain repeated rows', function() { - expect(exported.rows.length).to.be(1); - }); - it('should replace datasource refs', function() { - var panel = exported.rows[0].panels[0]; + var panel = exported.panels[0]; expect(panel.datasource).to.be("${DS_GFDB}"); }); diff --git a/public/app/features/dashboard/specs/unsaved_changes_srv_specs.ts b/public/app/features/dashboard/specs/unsaved_changes_srv_specs.ts index 135a81c166e..50a5c3434c8 100644 --- a/public/app/features/dashboard/specs/unsaved_changes_srv_specs.ts +++ b/public/app/features/dashboard/specs/unsaved_changes_srv_specs.ts @@ -29,6 +29,7 @@ describe("unsavedChangesSrv", function() { beforeEach(function() { dash = _dashboardSrv.create({ refresh: false, + panels: [{ test: "asd", legend: { } }], rows: [ { panels: [{ test: "asd", legend: { } }] @@ -58,23 +59,23 @@ describe("unsavedChangesSrv", function() { expect(tracker.hasChanges()).to.be(false); }); - it('Should ignore row collapse change', function() { + it.skip('Should ignore row collapse change', function() { dash.rows[0].collapse = true; expect(tracker.hasChanges()).to.be(false); }); it('Should ignore panel legend changes', function() { - dash.rows[0].panels[0].legend.sortDesc = true; - dash.rows[0].panels[0].legend.sort = "avg"; + dash.panels[0].legend.sortDesc = true; + dash.panels[0].legend.sort = "avg"; expect(tracker.hasChanges()).to.be(false); }); - it('Should ignore panel repeats', function() { + it.skip('Should ignore panel repeats', function() { dash.rows[0].panels.push({repeatPanelId: 10}); expect(tracker.hasChanges()).to.be(false); }); - it('Should ignore row repeats', function() { + it.skip('Should ignore row repeats', function() { dash.addEmptyRow(); dash.rows[1].repeatRowId = 10; expect(tracker.hasChanges()).to.be(false); diff --git a/public/app/features/dashboard/unsavedChangesSrv.js b/public/app/features/dashboard/unsavedChangesSrv.js index 766ebce0b59..94eac569ea2 100644 --- a/public/app/features/dashboard/unsavedChangesSrv.js +++ b/public/app/features/dashboard/unsavedChangesSrv.js @@ -106,6 +106,23 @@ function(angular, _) { return true; }); + dash.panels = _.filter(dash.panels, function(panel) { + if (panel.repeatPanelId) { + return false; + } + + // remove scopedVars + panel.scopedVars = null; + + // ignore panel legend sort + if (panel.legend) { + delete panel.legend.sort; + delete panel.legend.sortDesc; + } + + return true; + }); + // ignore template variable values _.each(dash.templating.list, function(value) { value.current = null; diff --git a/public/app/features/dashboard/unsaved_changes_modal.ts b/public/app/features/dashboard/unsaved_changes_modal.ts index cacfbe0f045..9ab16ee077d 100644 --- a/public/app/features/dashboard/unsaved_changes_modal.ts +++ b/public/app/features/dashboard/unsaved_changes_modal.ts @@ -18,13 +18,13 @@ const template = `
diff --git a/public/app/features/dashboard/viewStateSrv.js b/public/app/features/dashboard/viewStateSrv.js index 6820de3189f..34451c25951 100644 --- a/public/app/features/dashboard/viewStateSrv.js +++ b/public/app/features/dashboard/viewStateSrv.js @@ -39,18 +39,8 @@ function (angular, _, $, config) { // dont want url changes like adding orgId to add browser history $location.replace(); this.update(this.getQueryStringState()); - this.expandRowForPanel(); } - DashboardViewState.prototype.expandRowForPanel = function() { - if (!this.state.panelId) { return; } - - var panelInfo = this.$scope.dashboard.getPanelInfoById(this.state.panelId); - if (panelInfo) { - panelInfo.row.collapse = false; - } - }; - DashboardViewState.prototype.needsSync = function(urlState) { return _.isEqual(this.state, urlState) === false; }; diff --git a/public/app/features/panel/metrics_tab.ts b/public/app/features/panel/metrics_tab.ts index b32e9fc21c1..e80a9a51ec1 100644 --- a/public/app/features/panel/metrics_tab.ts +++ b/public/app/features/panel/metrics_tab.ts @@ -1,4 +1,4 @@ -import {DashboardModel} from '../dashboard/DashboardModel'; +import {DashboardModel} from '../dashboard/dashboard_model'; import Remarkable from 'remarkable'; export class MetricsTabCtrl { diff --git a/public/app/features/panel/panel_ctrl.ts b/public/app/features/panel/panel_ctrl.ts index 83d43a280f7..fca9050b4ac 100644 --- a/public/app/features/panel/panel_ctrl.ts +++ b/public/app/features/panel/panel_ctrl.ts @@ -1,9 +1,9 @@ import config from 'app/core/config'; import _ from 'lodash'; import $ from 'jquery'; -import {profiler} from 'app/core/profiler'; +import {appEvents, profiler} from 'app/core/core'; import Remarkable from 'remarkable'; -import {CELL_HEIGHT, CELL_VMARGIN} from '../dashboard/DashboardModel'; +import {GRID_CELL_HEIGHT, GRID_CELL_VMARGIN} from 'app/core/constants'; const TITLE_HEIGHT = 25; const EMPTY_TITLE_HEIGHT = 9; @@ -163,7 +163,7 @@ export class PanelCtrl { var fullscreenHeight = Math.floor(docHeight * 0.8); this.containerHeight = this.editMode ? editHeight : fullscreenHeight; } else { - this.containerHeight = this.panel.gridPos.h * CELL_HEIGHT + ((this.panel.gridPos.h-1) * CELL_VMARGIN); + this.containerHeight = this.panel.gridPos.h * GRID_CELL_HEIGHT + ((this.panel.gridPos.h-1) * GRID_CELL_VMARGIN); } this.height = this.containerHeight - (PANEL_BORDER + PANEL_PADDING + (this.panel.title ? TITLE_HEIGHT : EMPTY_TITLE_HEIGHT)); @@ -188,13 +188,36 @@ export class PanelCtrl { }); } - removePanel() { + removePanel(ask: boolean) { + // confirm deletion + if (ask !== false) { + var text2, confirmText; + + if (this.panel.alert) { + text2 = "Panel includes an alert rule, removing panel will also remove alert rule"; + confirmText = "YES"; + } + + appEvents.emit('confirm-modal', { + title: 'Remove Panel', + text: 'Are you sure you want to remove this panel?', + text2: text2, + icon: 'fa-trash', + confirmText: confirmText, + yesText: 'Remove', + onConfirm: () => { + this.removePanel(false); + } + }); + return; + } + this.dashboard.removePanel(this.panel); } editPanelJson() { this.publishAppEvent('show-json-editor', { - object: this.panel, + object: this.panel.getSaveModel(), updateHandler: this.replacePanel.bind(this) }); } diff --git a/public/app/features/plugins/buit_in_plugins.ts b/public/app/features/plugins/buit_in_plugins.ts index 0603df492b0..aed997f2fb3 100644 --- a/public/app/features/plugins/buit_in_plugins.ts +++ b/public/app/features/plugins/buit_in_plugins.ts @@ -18,6 +18,8 @@ import * as heatmapPanel from 'app/plugins/panel/heatmap/module'; import * as tablePanel from 'app/plugins/panel/table/module'; import * as singlestatPanel from 'app/plugins/panel/singlestat/module'; import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module'; +import * as permissionListPlugin from 'app/plugins/panel/permissionlist/module'; + import * as testDataAppPlugin from 'app/plugins/app/testdata/module'; import * as testDataDSPlugin from 'app/plugins/app/testdata/datasource/module'; @@ -35,6 +37,7 @@ const builtInPlugins = { "app/plugins/app/testdata/module": testDataAppPlugin, "app/plugins/app/testdata/datasource/module": testDataDSPlugin, + "app/plugins/panel/permissionlist/module": permissionListPlugin, "app/plugins/panel/text/module": textPanel, "app/plugins/panel/graph/module": graphPanel, "app/plugins/panel/dashlist/module": dashListPanel, diff --git a/public/app/features/plugins/partials/ds_edit.html b/public/app/features/plugins/partials/ds_edit.html index 88804d874b9..451a9024a18 100644 --- a/public/app/features/plugins/partials/ds_edit.html +++ b/public/app/features/plugins/partials/ds_edit.html @@ -81,11 +81,6 @@
- -
- -
-
diff --git a/public/app/features/plugins/partials/ds_http_settings.html b/public/app/features/plugins/partials/ds_http_settings.html index 62c3e477446..39fc7296b32 100644 --- a/public/app/features/plugins/partials/ds_http_settings.html +++ b/public/app/features/plugins/partials/ds_http_settings.html @@ -1,23 +1,23 @@
-

Http settings

+

HTTP settings

- Url + URL -

Specify a complete HTTP url (for example http://your_server:8080)

+

Specify a complete HTTP URL (for example http://your_server:8080)

- Your access method is Direct, this means the url + Your access method is Direct, this means the URL needs to be accessible from the browser. - Your access method is currently Proxy, this means the url + Your access method is currently Proxy, this means the URL needs to be accessible from the grafana backend.
@@ -30,7 +30,7 @@
- Direct = url is used directly from browser
+ Direct = URL is used directly from browser
Proxy = Grafana backend will proxy the request
@@ -38,27 +38,21 @@
-

Http Auth

+

HTTP Auth

+
+
+ + +
+
+ + +
+
-
- - - - -
-
- - - - +
+ +
@@ -79,7 +73,7 @@
-
+
TLS Auth Details
TLS Certs are encrypted and stored in the Grafana database. @@ -90,7 +84,7 @@
- +
@@ -100,29 +94,31 @@
-
-
- +
+
+
+ +
+
+ +
+
+ + reset +
-
- -
-
- - reset -
-
-
-
- -
-
- -
-
- - reset +
+
+ +
+
+ +
+
+ + reset +
diff --git a/public/app/partials/error.html b/public/app/partials/error.html index 2f75581fca5..0750e860dd5 100644 --- a/public/app/partials/error.html +++ b/public/app/partials/error.html @@ -42,8 +42,8 @@

Sorry for the inconvenience

-

Please go back to your home dashboard and try again.

-

If the error persists, seek help on the community site.

+

Please go back to your home dashboard and try again.

+

If the error persists, seek help on the community site.

diff --git a/public/app/partials/panelgeneral.html b/public/app/partials/panelgeneral.html index be53968854b..5f17730f228 100644 --- a/public/app/partials/panelgeneral.html +++ b/public/app/partials/panelgeneral.html @@ -9,21 +9,27 @@ Description +
-
Options
- +
Repeat
- Repeat Panel + For each value of
-
- Min width +
+ Min width
+
+ Direction + +
diff --git a/public/app/plugins/panel/graph/axes_editor.html b/public/app/plugins/panel/graph/axes_editor.html index 1ce4c3174b4..6160ef01fec 100644 --- a/public/app/plugins/panel/graph/axes_editor.html +++ b/public/app/plugins/panel/graph/axes_editor.html @@ -31,7 +31,7 @@
- +
diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index 723a92ad19b..f378e52a111 100755 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -497,8 +497,8 @@ function graphDirective($rootScope, timeSrv, popoverSrv, contextSrv) { show: panel.yaxes[0].show, index: 1, logBase: panel.yaxes[0].logBase || 1, - min: panel.yaxes[0].min ? _.toNumber(panel.yaxes[0].min) : null, - max: panel.yaxes[0].max ? _.toNumber(panel.yaxes[0].max) : null, + min: parseNumber(panel.yaxes[0].min), + max: parseNumber(panel.yaxes[0].max), tickDecimals: panel.yaxes[0].decimals }; @@ -510,9 +510,9 @@ function graphDirective($rootScope, timeSrv, popoverSrv, contextSrv) { secondY.show = panel.yaxes[1].show; secondY.logBase = panel.yaxes[1].logBase || 1; secondY.position = 'right'; - secondY.min = panel.yaxes[1].min ? _.toNumber(panel.yaxes[1].min) : null; - secondY.max = panel.yaxes[1].max ? _.toNumber(panel.yaxes[1].max) : null; - secondY.tickDecimals = panel.yaxes[1].decimals !== null ? _.toNumber(panel.yaxes[1].decimals): null; + secondY.min = parseNumber(panel.yaxes[1].min); + secondY.max = parseNumber(panel.yaxes[1].max); + secondY.tickDecimals = panel.yaxes[1].decimals; options.yaxes.push(secondY); applyLogScale(options.yaxes[1], data); @@ -522,6 +522,14 @@ function graphDirective($rootScope, timeSrv, popoverSrv, contextSrv) { configureAxisMode(options.yaxes[0], panel.percentage && panel.stack ? "percent" : panel.yaxes[0].format); } + function parseNumber(value: any) { + if (value === null || typeof value === 'undefined') { + return null; + } + + return _.toNumber(value); + } + function applyLogScale(axis, data) { if (axis.logBase === 1) { return; diff --git a/public/dashboards/home.json b/public/dashboards/home.json index 91f5abec5eb..9e6d6dddb6d 100644 --- a/public/dashboards/home.json +++ b/public/dashboards/home.json @@ -21,8 +21,8 @@ "transparent": true, "type": "text", "gridPos": { - "w": 12, - "h": 2, + "w": 24, + "h": 3, "x": 0, "y": 0 } @@ -42,7 +42,7 @@ "transparent": false, "type": "dashlist", "gridPos": { - "w": 7, + "w": 12, "h": 17, "x": 0, "y": 6 @@ -57,9 +57,9 @@ "transparent": false, "type": "pluginlist", "gridPos": { - "w": 5, + "w": 12, "h": 17, - "x": 7, + "x": 12, "y": 6 } } diff --git a/public/sass/_variables.dark.scss b/public/sass/_variables.dark.scss index 7eea87b2009..3c015383103 100644 --- a/public/sass/_variables.dark.scss +++ b/public/sass/_variables.dark.scss @@ -105,7 +105,7 @@ $tight-form-bg: $dark-3; $tight-form-func-bg: #333; $tight-form-func-highlight-bg: #444; -$modal-backdrop-bg: $dark-3; +$modal-backdrop-bg: #3a4754; $code-tag-bg: $gray-1; $code-tag-border: lighten($code-tag-bg, 2%); diff --git a/public/sass/_variables.scss b/public/sass/_variables.scss index 12692690772..c3345661581 100644 --- a/public/sass/_variables.scss +++ b/public/sass/_variables.scss @@ -206,7 +206,7 @@ $zindex-modal: 1050; // $btn-padding-x: 1rem !default; -$btn-padding-y: .8rem !default; +$btn-padding-y: .7rem !default; $btn-line-height: 1 !default; $btn-font-weight: 500 !default; diff --git a/public/sass/components/_gf-form.scss b/public/sass/components/_gf-form.scss index 9cf0541e147..6ab35262922 100644 --- a/public/sass/components/_gf-form.scss +++ b/public/sass/components/_gf-form.scss @@ -1,4 +1,4 @@ -$gf-form-margin: 1px; +$gf-form-margin: 3px; .gf-form { margin-bottom: $gf-form-margin; @@ -55,6 +55,7 @@ $gf-form-margin: 1px; .gf-form-label { padding: $input-padding-y $input-padding-x; + margin-right: $gf-form-margin; flex-shrink: 0; font-weight: $font-weight-semi-bold; @@ -107,6 +108,7 @@ $gf-form-margin: 1px; display: block; width: 100%; padding: $input-padding-y $input-padding-x; + margin-right: $gf-form-margin; font-size: $font-size-base; line-height: $input-line-height; color: $input-color; @@ -114,8 +116,6 @@ $gf-form-margin: 1px; background-image: none; background-clip: padding-box; border: 1px solid $input-border-color; - border-bottom: none; - border-left: none; @include border-radius($input-border-radius-sm); @include box-shadow($input-box-shadow); white-space: nowrap; diff --git a/public/sass/components/_navbar.scss b/public/sass/components/_navbar.scss index 8456af7b152..ab1cba4fd23 100644 --- a/public/sass/components/_navbar.scss +++ b/public/sass/components/_navbar.scss @@ -62,40 +62,6 @@ background-color: $navbarLinkBackgroundActive; } -.navbar-page-btn { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - display: block; - margin: 0; - color: darken($link-color, 5%); - font-size: $font-size-lg; - padding: 1rem 1rem 0.75rem 1rem; - min-height:: $navbarHeight; - - .fa-caret-down { - font-size: 60%; - padding-left: 0.2rem; - } - - .icon-gf { - position: relative; - top: 2px; - font-size: 20px; - line-height: 8px; - margin-right: 0.5rem; - } - - > img { - max-width: 27px; - max-height: 27px; - } - - &--search { - padding: 1rem 1.5rem 0.75rem 1.5rem; - } -} - .navbar-page-btn { text-overflow: ellipsis; overflow: hidden; diff --git a/public/sass/components/_tabbed_view.scss b/public/sass/components/_tabbed_view.scss index d831f54a0c3..48ea259c36e 100644 --- a/public/sass/components/_tabbed_view.scss +++ b/public/sass/components/_tabbed_view.scss @@ -12,6 +12,9 @@ .gf-tabs-link.active { background-color: $panel-bg; } + .tabbed-view-body { + min-height: 200px; + } } } @@ -51,7 +54,6 @@ .tabbed-view-body { background-color: $panel-bg; padding: $spacer*2 $spacer; - min-height: 250px; &--small { min-height: 0px; diff --git a/public/sass/pages/_errorpage.scss b/public/sass/pages/_errorpage.scss index 63676417274..e18306ea05a 100644 --- a/public/sass/pages/_errorpage.scss +++ b/public/sass/pages/_errorpage.scss @@ -43,7 +43,7 @@ line-height: 1rem; } -.error-link {color: $yellow;} +.error-link {color: $orange;} .error-minus { color: #7eb26d; @@ -57,4 +57,4 @@ line-height: 1rem; } -.graph-text {margin: 0;} \ No newline at end of file +.graph-text {margin: 0;} diff --git a/public/test/specs/helpers.ts b/public/test/specs/helpers.ts index 709617a9465..8e83915362f 100644 --- a/public/test/specs/helpers.ts +++ b/public/test/specs/helpers.ts @@ -2,7 +2,7 @@ import _ from 'lodash'; import config from 'app/core/config'; import * as dateMath from 'app/core/utils/datemath'; import {angularMocks, sinon} from '../lib/common'; -import {PanelModel} from 'app/features/dashboard/PanelModel'; +import {PanelModel} from 'app/features/dashboard/panel_model'; export function ControllerTestContext() { var self = this; diff --git a/public/vendor/tagsinput/bootstrap-tagsinput.js b/public/vendor/tagsinput/bootstrap-tagsinput.js index 4bc29ecbbdb..06f02c10712 100644 --- a/public/vendor/tagsinput/bootstrap-tagsinput.js +++ b/public/vendor/tagsinput/bootstrap-tagsinput.js @@ -358,6 +358,8 @@ // Remove icon clicked self.$container.on('click', '[data-role=remove]', $.proxy(function(event) { self.remove($(event.target).closest('.tag').data('item')); + // Grafana mod, if tags input used in popover the click event will bubble up and hide popover + event.stopPropagation(); }, self)); // Only add existing value as tags when using strings as tags diff --git a/scripts/webpack/webpack.test.js b/scripts/webpack/webpack.test.js index 8983541a612..000fd292760 100644 --- a/scripts/webpack/webpack.test.js +++ b/scripts/webpack/webpack.test.js @@ -3,7 +3,7 @@ const merge = require('webpack-merge'); const common = require('./webpack.common.js'); config = merge(common, { - devtool: 'inline-source-map', + devtool: 'cheap-module-source-map', externals: { 'react/addons': true, 'react/lib/ExecutionEnvironment': true, @@ -13,10 +13,10 @@ config = merge(common, { fs: 'empty' }, plugins: [ - new webpack.SourceMapDevToolPlugin({ - filename: null, // if no value is provided the sourcemap is inlined - test: /\.(ts|js)($|\?)/i // process .js and .ts files only - }) + // new webpack.SourceMapDevToolPlugin({ + // filename: null, // if no value is provided the sourcemap is inlined + // test: /\.(ts|js)($|\?)/i // process .js and .ts files only + // }) ] }); diff --git a/tslint.json b/tslint.json index 2e93d80ad25..9e3a4f29747 100644 --- a/tslint.json +++ b/tslint.json @@ -2,8 +2,8 @@ "rules": { "no-string-throw": true, "no-unused-expression": true, - "no-duplicate-variable": true, "no-unused-variable": true, + "no-duplicate-variable": true, "curly": true, "class-name": true, "semicolon": [true, "always", "ignore-bound-class-methods"],