Merge branch 'develop' into develop-settings

This commit is contained in:
Torkel Ödegaard 2017-12-08 14:32:15 +01:00
commit 9369a87e93
152 changed files with 5429 additions and 1808 deletions

2
.gitignore vendored
View File

@ -39,6 +39,8 @@ conf/custom.ini
fig.yml
docker-compose.yml
docker-compose.yaml
/conf/dashboards/custom.yaml
/conf/datasources/custom.yaml
profile.cov
/grafana
.notouch

View File

@ -24,7 +24,7 @@
* **Cloudwatch**: Fixes broken query inspector for cloudwatch [#9661](https://github.com/grafana/grafana/issues/9661), thx [@mtanda](https://github.com/mtanda)
* **Dashboard**: Make it possible to start dashboards from search and dashboard list panel [#1871](https://github.com/grafana/grafana/issues/1871)
* **Annotations**: Posting annotations now return the id of the annotation [#9798](https://github.com/grafana/grafana/issues/9798)
* **Systemd**: Use systemd notification ready flag [#10024](https://github.com/grafana/grafana/issues/10024), thx [@jgrassler](https://github.com/jgrassler)
## Tech
* **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645)

View File

@ -1,14 +1,202 @@
Copyright 2014-2017 Torkel Ödegaard, Raintank Inc.
Licensed under the Apache License, Version 2.0 (the "License"); you
may not use this file except in compliance with the License. You may
obtain a copy of the License at
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
http://www.apache.org/licenses/LICENSE-2.0
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied. See the License for the specific language governing
permissions and limitations under the License.
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -1,16 +1,6 @@
This software is based on Kibana:
========================================
Copyright 2014-2017 Grafana Labs
This software is based on Kibana:
Copyright 2012-2013 Elasticsearch BV
Licensed under the Apache License, Version 2.0 (the "License"); you
may not use this file except in compliance with the License. You may
obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied. See the License for the specific language governing
permissions and limitations under the License.

View File

@ -95,9 +95,9 @@ func main() {
case "package":
grunt(gruntBuildArg("release")...)
if runtime.GOOS != "windows" {
createLinuxPackages()
}
if runtime.GOOS != "windows" {
createLinuxPackages()
}
case "pkg-rpm":
grunt(gruntBuildArg("release")...)

View File

@ -7,3 +7,4 @@
MYSQL_PASSWORD: password
ports:
- "3306:3306"
tmpfs: /var/lib/mysql:rw

View File

@ -5,3 +5,4 @@
POSTGRES_PASSWORD: grafanatest
ports:
- "5432:5432"
tmpfs: /var/lib/postgresql/data:rw

View File

@ -65,13 +65,14 @@ Currently we do not provide any scripts/manifests for configuring Grafana. Rathe
Tool | Project
-----|------------
Puppet | [https://forge.puppet.com/puppet/grafana](https://forge.puppet.com/puppet/grafana)
Ansible | [https://github.com/cloudalchemy/ansible-grafana](https://github.com/cloudalchemy/ansible-grafana)
Ansible | [https://github.com/picotrading/ansible-grafana](https://github.com/picotrading/ansible-grafana)
Chef | [https://github.com/JonathanTron/chef-grafana](https://github.com/JonathanTron/chef-grafana)
Saltstack | [https://github.com/salt-formulas/salt-formula-grafana](https://github.com/salt-formulas/salt-formula-grafana)
## Datasources
> This feature is available from v4.7
> This feature is available from v5.0
It's possible to manage datasources in Grafana by adding one or more yaml config files in the [`conf/datasources`](/installation/configuration/#datasources) directory. Each config file can contain a list of `datasources` that will be added or updated during start up. If the datasource already exists, Grafana will update it to match the configuration file. The config file can also contain a list of datasources that should be deleted. That list is called `delete_datasources`. Grafana will delete datasources listed in `delete_datasources` before inserting/updating those in the `datasource` list.

View File

@ -127,6 +127,12 @@ A query can returns multiple columns and Grafana will automatically create a lis
SELECT my_host.hostname, my_other_host.hostname2 FROM my_host JOIN my_other_host ON my_host.city = my_other_host.city
```
To use time range dependent macros like `$__timeFilter(column)` in your query the refresh mode of the template variable needs to be set to *On Time Range Change*.
```sql
SELECT event_name FROM event_log WHERE $__timeFilter(time_column)
```
Another option is a query that can create a key/value variable. The query should return two columns that are named `__text` and `__value`. The `__text` column value should be unique (if it is not unique then the first value is used). The options in the dropdown will have a text and value that allows you to have a friendly name as text and an id as the value. An example query with `hostname` as the text and `id` as the value:
```sql

View File

@ -139,6 +139,12 @@ A query can return multiple columns and Grafana will automatically create a list
SELECT host.hostname, other_host.hostname2 FROM host JOIN other_host ON host.city = other_host.city
```
To use time range dependent macros like `$__timeFilter(column)` in your query the refresh mode of the template variable needs to be set to *On Time Range Change*.
```sql
SELECT event_name FROM event_log WHERE $__timeFilter(time_column)
```
Another option is a query that can create a key/value variable. The query should return two columns that are named `__text` and `__value`. The `__text` column value should be unique (if it is not unique then the first value is used). The options in the dropdown will have a text and value that allows you to have a friendly name as text and an id as the value. An example query with `hostname` as the text and `id` as the value:
```sql

View File

@ -93,7 +93,10 @@ Directory where grafana will automatically scan and look for plugins
### datasources
Config files containing datasources that will be configured at startup
> This feature is available in 5.0+
Config files containing datasources that will be configured at startup.
You can read more about the config files at the [provisioning page](/administration/provisioning/#datasources).
## [server]

View File

@ -125,7 +125,6 @@
"lodash": "^4.17.4",
"moment": "^2.18.1",
"mousetrap": "^1.6.0",
"ngreact": "^0.4.1",
"perfect-scrollbar": "^1.2.0",
"prop-types": "^15.6.0",
"react": "^16.1.1",

View File

@ -9,7 +9,7 @@ After=postgresql.service mariadb.service mysql.service
EnvironmentFile=/etc/sysconfig/grafana-server
User=grafana
Group=grafana
Type=simple
Type=notify
Restart=on-failure
WorkingDirectory=/usr/share/grafana
RuntimeDirectory=grafana

View File

@ -90,12 +90,13 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
if c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR {
data.NavTree = append(data.NavTree, &dtos.NavLink{
Text: "Create",
Id: "create",
Icon: "fa fa-fw fa-plus",
Url: "#",
Children: []*dtos.NavLink{
{Text: "Dashboard", Icon: "gicon gicon-dashboard-new", Url: setting.AppSubUrl + "/dashboard/new"},
{Text: "Folder", Icon: "gicon gicon-folder-new", Url: setting.AppSubUrl + "/dashboard/new/?editview=new-folder"},
{Text: "Import", Icon: "gicon gicon-dashboard-import", Url: setting.AppSubUrl + "/dashboard/new/?editview=import"},
{Text: "Folder", SubTitle: "Create a new folder to organize your dashboards", Id: "folder", Icon: "gicon gicon-folder-new", Url: setting.AppSubUrl + "/dashboards/folder/new"},
{Text: "Import", SubTitle: "Import dashboard from file or Grafana.com", Id: "import", Icon: "gicon gicon-dashboard-import", Url: setting.AppSubUrl + "/dashboard/import"},
},
})
}
@ -103,7 +104,7 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
dashboardChildNavs := []*dtos.NavLink{
{Text: "Home", Url: setting.AppSubUrl + "/", Icon: "fa fa-fw fa-home", HideFromTabs: true},
{Divider: true, HideFromTabs: true},
{Text: "Manage", Id: "dashboards", Url: setting.AppSubUrl + "/dashboards", Icon: "fa fa-fw fa-sitemap"},
{Text: "Manage", Id: "manage-dashboards", Url: setting.AppSubUrl + "/dashboards", Icon: "fa fa-fw fa-sitemap"},
{Text: "Playlists", Id: "playlists", Url: setting.AppSubUrl + "/playlists", Icon: "fa fa-fw fa-film"},
{Text: "Snapshots", Id: "snapshots", Url: setting.AppSubUrl + "/dashboard/snapshots", Icon: "icon-gf icon-gf-fw icon-gf-snapshot"},
}
@ -117,6 +118,21 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
Children: dashboardChildNavs,
})
dashboardFolderChildNavs := []*dtos.NavLink{
{Text: "Dashboards", Id: "manage-folder-dashboards", Url: setting.AppSubUrl + "/dashboards", Icon: "fa fa-fw fa-th-large"},
{Text: "Permissions", Id: "manage-folder-permissions", Url: setting.AppSubUrl + "/dashboards?1", Icon: "fa fa-fw fa-lock"},
}
data.NavTree = append(data.NavTree, &dtos.NavLink{
Text: "Dashboards",
Id: "manage-folder",
SubTitle: "Manage folder dashboards & permissions",
Icon: "fa fa-folder-open",
Url: setting.AppSubUrl + "/",
HideFromMenu: true,
Children: dashboardFolderChildNavs,
})
if c.IsSignedIn {
profileNode := &dtos.NavLink{
Text: c.SignedInUser.Name,

View File

@ -10,7 +10,11 @@ import (
)
func RenderToPng(c *middleware.Context) {
queryReader := util.NewUrlQueryReader(c.Req.URL)
queryReader, err := util.NewUrlQueryReader(c.Req.URL)
if err != nil {
c.Handle(400, "Render parameters error", err)
return
}
queryParams := fmt.Sprintf("?%s", c.Req.URL.RawQuery)
renderOpts := &renderer.RenderOpts{

View File

@ -3,7 +3,9 @@ package main
import (
"context"
"flag"
"fmt"
"io/ioutil"
"net"
"os"
"path/filepath"
"strconv"
@ -96,6 +98,7 @@ func (g *GrafanaServerImpl) Start() {
return
}
SendSystemdNotification("READY=1")
g.startHttpServer()
}
@ -169,3 +172,28 @@ func (g *GrafanaServerImpl) writePIDFile() {
g.log.Info("Writing PID file", "path", *pidFile, "pid", pid)
}
func SendSystemdNotification(state string) error {
notifySocket := os.Getenv("NOTIFY_SOCKET")
if notifySocket == "" {
return fmt.Errorf("NOTIFY_SOCKET environment variable empty or unset.")
}
socketAddr := &net.UnixAddr{
Name: notifySocket,
Net: "unixgram",
}
conn, err := net.DialUnix(socketAddr.Net, nil, socketAddr)
if err != nil {
return err
}
_, err = conn.Write([]byte(state))
conn.Close()
return err
}

View File

@ -38,6 +38,7 @@ func TestLogFile(t *testing.T) {
So(fileLogWrite.maxlines_curlines, ShouldEqual, 3)
})
fileLogWrite.Close()
err = os.Remove(fileLogWrite.Filename)
So(err, ShouldBeNil)
})

View File

@ -366,7 +366,6 @@ func TestAlertRuleExtraction(t *testing.T) {
"steppedLine": false,
"targets": [
{
"dsType": "influxdb",
"groupBy": [
{
"params": [
@ -411,7 +410,6 @@ func TestAlertRuleExtraction(t *testing.T) {
"tags": []
},
{
"dsType": "influxdb",
"groupBy": [
{
"params": [

View File

@ -6,6 +6,7 @@ import (
"io"
"mime/multipart"
"os"
"path/filepath"
"time"
"github.com/grafana/grafana/pkg/bus"
@ -176,7 +177,7 @@ func (this *SlackNotifier) Notify(evalContext *alerting.EvalContext) error {
func SlackFileUpload(evalContext *alerting.EvalContext, log log.Logger, url string, recipient string, token string) error {
if evalContext.ImageOnDiskPath == "" {
evalContext.ImageOnDiskPath = "public/img/mixed_styles.png"
evalContext.ImageOnDiskPath = filepath.Join(setting.HomePath, "public/img/mixed_styles.png")
}
log.Info("Uploading to slack via file.upload API")
headers, uploadBody, err := GenerateSlackBody(evalContext.ImageOnDiskPath, token, recipient)

View File

@ -401,7 +401,7 @@ func SearchUsers(query *m.SearchUsersQuery) error {
}
if query.Query != "" {
whereConditions = append(whereConditions, "(email LIKE ? OR name LIKE ? OR login like ?)")
whereConditions = append(whereConditions, "(email "+dialect.LikeStr()+" ? OR name "+dialect.LikeStr()+" ? OR login "+dialect.LikeStr()+" ?)")
whereParams = append(whereParams, queryWithWildcards, queryWithWildcards, queryWithWildcards)
}

View File

@ -17,7 +17,7 @@ import (
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tsdb"
opentracing "github.com/opentracing/opentracing-go"
"github.com/opentracing/opentracing-go"
)
type GraphiteExecutor struct {
@ -158,7 +158,7 @@ func formatTimeRange(input string) string {
if input == "now" {
return input
}
return strings.Replace(strings.Replace(input, "m", "min", -1), "M", "mon", -1)
return strings.Replace(strings.Replace(strings.Replace(input, "now", "", -1), "m", "min", -1), "M", "mon", -1)
}
func fixIntervalFormat(target string) string {

View File

@ -18,14 +18,14 @@ func TestGraphiteFunctions(t *testing.T) {
Convey("formatting time range for now-1m", func() {
timeRange := formatTimeRange("now-1m")
So(timeRange, ShouldEqual, "now-1min")
So(timeRange, ShouldEqual, "-1min")
})
Convey("formatting time range for now-1M", func() {
timeRange := formatTimeRange("now-1M")
So(timeRange, ShouldEqual, "now-1mon")
So(timeRange, ShouldEqual, "-1mon")
})

View File

@ -20,7 +20,6 @@ func TestInfluxdbQueryParser(t *testing.T) {
Convey("can parse influxdb json model", func() {
json := `
{
"dsType": "influxdb",
"groupBy": [
{
"params": [
@ -123,7 +122,6 @@ func TestInfluxdbQueryParser(t *testing.T) {
Convey("can part raw query json model", func() {
json := `
{
"dsType": "influxdb",
"groupBy": [
{
"params": [

View File

@ -50,6 +50,7 @@ func (rp *ResponseParser) transformRows(rows []Row, queryResult *tsdb.QueryResul
result = append(result, &tsdb.TimeSeries{
Name: rp.formatSerieName(row, column, query),
Points: points,
Tags: row.Tags,
})
}
}

View File

@ -78,6 +78,15 @@ func (e PostgresQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Ro
rowLimit := 1000000
rowCount := 0
timeIndex := -1
// check if there is a column named time
for i, col := range columnNames {
switch col {
case "time":
timeIndex = i
}
}
for ; rows.Next(); rowCount++ {
if rowCount > rowLimit {
@ -89,6 +98,15 @@ func (e PostgresQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Ro
return err
}
// convert column named time to unix timestamp to make
// native datetime postgres types work in annotation queries
if timeIndex != -1 {
switch value := values[timeIndex].(type) {
case time.Time:
values[timeIndex] = float64(value.UnixNano() / 1e9)
}
}
table.Rows = append(table.Rows, values)
}
@ -142,8 +160,13 @@ func (e PostgresQueryEndpoint) getTypedRowData(rows *core.Rows) (tsdb.RowValues,
func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult) error {
pointsBySeries := make(map[string]*tsdb.TimeSeries)
seriesByQueryOrder := list.New()
columnNames, err := rows.Columns()
columnNames, err := rows.Columns()
if err != nil {
return err
}
columnTypes, err := rows.ColumnTypes()
if err != nil {
return err
}
@ -153,13 +176,21 @@ func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *co
timeIndex := -1
metricIndex := -1
// check columns of resultset
// check columns of resultset: a column named time is mandatory
// the first text column is treated as metric name unless a column named metric is present
for i, col := range columnNames {
switch col {
case "time":
timeIndex = i
case "metric":
metricIndex = i
default:
if metricIndex == -1 {
switch columnTypes[i].DatabaseTypeName() {
case "UNKNOWN", "TEXT", "VARCHAR", "CHAR":
metricIndex = i
}
}
}
}

View File

@ -9,10 +9,15 @@ type UrlQueryReader struct {
values url.Values
}
func NewUrlQueryReader(url *url.URL) *UrlQueryReader {
return &UrlQueryReader{
values: url.Query(),
func NewUrlQueryReader(urlInfo *url.URL) (*UrlQueryReader, error) {
u, err := url.ParseQuery(urlInfo.String())
if err != nil {
return nil, err
}
return &UrlQueryReader{
values: u,
}, nil
}
func (r *UrlQueryReader) Get(name string, def string) string {

View File

@ -9,7 +9,6 @@ import 'angular-native-dragdrop';
import 'angular-bindonce';
import 'react';
import 'react-dom';
import 'ngreact';
import 'vendor/bootstrap/bootstrap';
import 'vendor/angular-ui/ui-bootstrap-tpls';

View File

@ -1,10 +1,10 @@
import { react2AngularDirective } from 'app/core/utils/react2angular';
import { PasswordStrength } from './components/PasswordStrength';
import PageHeader from './components/PageHeader';
import PageHeader from './components/PageHeader/PageHeader';
import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
export function registerAngularDirectives() {
react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
react2AngularDirective('pageHeader', PageHeader, ['model', "noTabs"]);
react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']);
react2AngularDirective('emptyListCta', EmptyListCTA, ['model']);
}

View File

@ -0,0 +1,22 @@
import React from 'react';
import renderer from 'react-test-renderer';
import EmptyListCTA from './EmptyListCTA';
const model = {
title: 'Title',
buttonIcon: 'ga css class',
buttonLink: 'http://url/to/destination',
buttonTitle: 'Click me',
proTip: 'This is a tip',
proTipLink: 'http://url/to/tip/destination',
proTipLinkTitle: 'Learn more',
proTipTarget: '_blank'
};
describe('CollorPalette', () => {
it('renders correctly', () => {
const tree = renderer.create(<EmptyListCTA model={model} />).toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@ -0,0 +1,34 @@
import React, { Component } from 'react';
export interface IProps {
model: any;
}
class EmptyListCTA extends Component<IProps, any> {
render() {
const {
title,
buttonIcon,
buttonLink,
buttonTitle,
proTip,
proTipLink,
proTipLinkTitle,
proTipTarget
} = this.props.model;
return (
<div className="empty-list-cta p-t-2 p-b-1">
<div className="empty-list-cta__title">{title}</div>
<a href={buttonLink} className="empty-list-cta__button btn btn-xlarge btn-success"><i className={buttonIcon} />{buttonTitle}</a>
<div className="empty-list-cta__pro-tip">
<i className="fa fa-rocket" /> ProTip: {proTip}
<a className="text-link empty-list-cta__pro-tip-link"
href={proTipLink}
target={proTipTarget}>{proTipLinkTitle}</a>
</div>
</div>
);
}
}
export default EmptyListCTA;

View File

@ -0,0 +1,38 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CollorPalette renders correctly 1`] = `
<div
className="empty-list-cta p-t-2 p-b-1"
>
<div
className="empty-list-cta__title"
>
Title
</div>
<a
className="empty-list-cta__button btn btn-xlarge btn-success"
href="http://url/to/destination"
>
<i
className="ga css class"
/>
Click me
</a>
<div
className="empty-list-cta__pro-tip"
>
<i
className="fa fa-rocket"
/>
ProTip:
This is a tip
<a
className="text-link empty-list-cta__pro-tip-link"
href="http://url/to/tip/destination"
target="_blank"
>
Learn more
</a>
</div>
</div>
`;

View File

@ -1,72 +0,0 @@
import React from 'react';
import { NavModel, NavModelItem } from '../nav_model_srv';
import classNames from 'classnames';
export interface IProps {
model: NavModel;
}
function TabItem(tab: NavModelItem) {
if (tab.hideFromTabs) {
return (null);
}
let tabClasses = classNames({
'gf-tabs-link': true,
active: tab.active,
});
return (
<li className="gf-tabs-item" key={tab.url}>
<a className={tabClasses} href={tab.url}>
<i className={tab.icon} />
{tab.text}
</a>
</li>
);
}
function Tabs({main}: {main: NavModelItem}) {
return <ul className="gf-tabs">{main.children.map(TabItem)}</ul>;
}
export default class PageHeader extends React.Component<IProps, any> {
constructor(props) {
super(props);
}
renderHeaderTitle(main) {
return (
<div className="page-header__inner">
<span className="page-header__logo">
{main.icon && <i className={`page-header__icon ${main.icon}`} />}
{main.img && <img className="page-header__img" src={main.img} />}
</span>
<div className="page-header__info-block">
<h1 className="page-header__title">{main.text}</h1>
{main.subTitle && <div className="page-header__sub-title">{main.subTitle}</div>}
{main.subType && (
<div className="page-header__stamps">
<i className={main.subType.icon} />
{main.subType.text}
</div>
)}
</div>
</div>
);
}
render() {
return (
<div className="page-header-canvas">
<div className="page-container">
<div className="page-header">
{this.renderHeaderTitle(this.props.model.main)}
{this.props.model.main.children && <Tabs main={this.props.model.main} />}
</div>
</div>
</div>
);
}
}

View File

@ -0,0 +1,127 @@
import React from 'react';
import { NavModel, NavModelItem } from '../../nav_model_srv';
import classNames from 'classnames';
import appEvents from 'app/core/app_events';
export interface IProps {
model: NavModel;
}
function TabItem(tab: NavModelItem) {
if (tab.hideFromTabs) {
return (null);
}
let tabClasses = classNames({
'gf-tabs-link': true,
active: tab.active,
});
return (
<li className="gf-tabs-item" key={tab.url}>
<a className={tabClasses} href={tab.url}>
<i className={tab.icon} />
{tab.text}
</a>
</li>
);
}
function SelectOption(navItem: NavModelItem) {
if (navItem.hideFromTabs) { // TODO: Rename hideFromTabs => hideFromNav
return (null);
}
return (
<option key={navItem.url} value={navItem.url}>
{navItem.text}
</option>
);
}
function Navigation({main}: {main: NavModelItem}) {
return (<nav>
<SelectNav customCss="page-header__select_nav" main={main} />
<Tabs customCss="page-header__tabs" main={main} />
</nav>);
}
function SelectNav({main, customCss}: {main: NavModelItem, customCss: string}) {
const defaultSelectedItem = main.children.find(navItem => {
return navItem.active === true;
});
const gotoUrl = evt => {
var element = evt.target;
var url = element.options[element.selectedIndex].value;
appEvents.emit('location-change', {href: url});
};
return (<select
className={`gf-select-nav ${customCss}`}
defaultValue={defaultSelectedItem.url}
onChange={gotoUrl}>{main.children.map(SelectOption)}</select>);
}
function Tabs({main, customCss}: {main: NavModelItem, customCss: string}) {
return <ul className={`gf-tabs ${customCss}`}>{main.children.map(TabItem)}</ul>;
}
export default class PageHeader extends React.Component<IProps, any> {
constructor(props) {
super(props);
}
renderBreadcrumb(breadcrumbs) {
const breadcrumbsResult = [];
for (let i = 0; i < breadcrumbs.length; i++) {
const bc = breadcrumbs[i];
if (bc.uri) {
breadcrumbsResult.push(<a className="text-link" key={i} href={bc.uri}>{bc.title}</a>);
} else {
breadcrumbsResult.push(<span key={i}> / {bc.title}</span>);
}
}
return breadcrumbsResult;
}
renderHeaderTitle(main) {
return (
<div className="page-header__inner">
<span className="page-header__logo">
{main.icon && <i className={`page-header__icon ${main.icon}`} />}
{main.img && <img className="page-header__img" src={main.img} />}
</span>
<div className="page-header__info-block">
{main.text && <h1 className="page-header__title">{main.text}</h1>}
{main.breadcrumbs && main.breadcrumbs.length > 0 && (
<h1 className="page-header__title">
{this.renderBreadcrumb(main.breadcrumbs)}
</h1>)
}
{main.subTitle && <div className="page-header__sub-title">{main.subTitle}</div>}
{main.subType && (
<div className="page-header__stamps">
<i className={main.subType.icon} />
{main.subType.text}
</div>
)}
</div>
</div>
);
}
render() {
return (
<div className="page-header-canvas">
<div className="page-container">
<div className="page-header">
{this.renderHeaderTitle(this.props.model.main)}
{this.props.model.main.children && <Navigation main={this.props.model.main} />}
</div>
</div>
</div>
);
}
}

View File

@ -0,0 +1,62 @@
import React from 'react';
import PerfectScrollbar from 'perfect-scrollbar';
export interface Props {
children: any;
className: string;
}
export default class ScrollBar extends React.Component<Props, any> {
private container: any;
private ps: PerfectScrollbar;
constructor(props) {
super(props);
}
componentDidMount() {
this.ps = new PerfectScrollbar(this.container);
}
componentDidUpdate() {
this.ps.update();
}
componentWillUnmount() {
this.ps.destroy();
}
// methods can be invoked by outside
setScrollTop(top) {
if (this.container) {
this.container.scrollTop = top;
this.ps.update();
return true;
}
return false;
}
setScrollLeft(left) {
if (this.container) {
this.container.scrollLeft = left;
this.ps.update();
return true;
}
return false;
}
handleRef = ref => {
this.container = ref;
};
render() {
return (
<div className={this.props.className} ref={this.handleRef}>
{this.props.children}
</div>
);
}
}

View File

@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import config from 'app/core/config';
import _ from 'lodash';
import $ from 'jquery';
@ -12,7 +10,7 @@ import Drop from 'tether-drop';
export class GrafanaCtrl {
/** @ngInject */
constructor($scope, alertSrv, utilSrv, $rootScope, $controller, contextSrv) {
constructor($scope, alertSrv, utilSrv, $rootScope, $controller, contextSrv, globalEventSrv) {
$scope.init = function() {
$scope.contextSrv = contextSrv;
@ -23,6 +21,7 @@ export class GrafanaCtrl {
profiler.init(config, $rootScope);
alertSrv.init();
utilSrv.init();
globalEventSrv.init();
$scope.dashAlerts = alertSrv;
};
@ -78,11 +77,16 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
sidemenuOpen = scope.contextSrv.sidemenu;
body.toggleClass('sidemenu-open', sidemenuOpen);
scope.$watch('contextSrv.sidemenu', newVal => {
if (sidemenuOpen !== scope.contextSrv.sidemenu) {
sidemenuOpen = scope.contextSrv.sidemenu;
body.toggleClass('sidemenu-open', scope.contextSrv.sidemenu);
}
appEvents.on('toggle-sidemenu', () => {
body.toggleClass('sidemenu-open');
});
appEvents.on('toggle-sidemenu-mobile', () => {
body.toggleClass('sidemenu-open--xs');
});
appEvents.on('toggle-sidemenu-hidden', () => {
body.toggleClass('sidemenu-hidden');
});
// tooltip removal fix
@ -100,6 +104,9 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
}
}
// clear body class sidemenu states
body.removeClass('sidemenu-open--xs');
$("#tooltip, .tooltip").remove();
// check for kiosk url param

View File

@ -0,0 +1,98 @@
<div class="page-action-bar" ng-hide="!ctrl.hasFilters && ctrl.sections.length === 0">
<div class="gf-form gf-form--grow">
<label class="gf-form-label">Search</label>
<input type="text" class="gf-form-input max-width-30" placeholder="Find Dashboard by name" tabindex="1" give-focus="true" ng-model="ctrl.query.query" ng-model-options="{ debounce: 500 }" spellcheck='false' ng-change="ctrl.onQueryChange()" />
</div>
<div class="page-action-bar__spacer"></div>
<a class="btn btn-success" href="/dashboard/new">
<i class="fa fa-plus"></i>
Dashboard
</a>
<a class="btn btn-success" href="/dashboards/folder/new" ng-if="!ctrl.folderId">
<i class="fa fa-plus"></i>
Folder
</a>
</div>
<div class="gf-form" ng-if="ctrl.query.tag.length">
Filters:
<span ng-repeat="tagName in ctrl.query.tag">
<a ng-click="ctrl.removeTag(tagName, $event)" tag-color-from-name="tagName" class="label label-tag">
<i class="fa fa-remove"></i>
{{tagName}}
</a>
</span>
</div>
<div class="gf-form">
<div class="gf-form-button-row" ng-show="ctrl.hasFilters">
<button
type="button"
class="btn gf-form-button btn-inverse btn-small"
ng-click="ctrl.clearFilters()">
<i class="fa fa-close"></i> Clear current search query and filters
</button>
</div>
</div>
<div class="dashboard-list" ng-show="ctrl.sections.length > 0">
<div class="search-results-filter-row">
<gf-form-switch
on-change="ctrl.onSelectAllChanged()"
checked="ctrl.selectAllChecked"
switch-class="gf-form-switch--transparent gf-form-switch--search-result-filter-row__checkbox"
/>
<div class="search-results-filter-row__filters">
<select
class="search-results-filter-row__filters-item gf-form-input"
ng-model="ctrl.selectedStarredFilter"
ng-options="t.text disable when t.disabled for t in ctrl.starredFilterOptions"
ng-change="ctrl.onStarredFilterChange()"
ng-show="!(ctrl.canMove || ctrl.canDelete)"
/>
<select
class="search-results-filter-row__filters-item gf-form-input"
ng-model="ctrl.selectedTagFilter"
ng-options="t.term disable when t.disabled for t in ctrl.tagFilterOptions"
ng-change="ctrl.onTagFilterChange()"
ng-show="!(ctrl.canMove || ctrl.canDelete)"
/>
<div class="gf-form-button-row" ng-show="ctrl.canMove || ctrl.canDelete">
<button type="button"
class="btn gf-form-button btn-inverse"
ng-disabled="!ctrl.canMove"
ng-click="ctrl.moveTo()"
bs-tooltip="ctrl.canMove ? '' : 'Select a dashboard to move (cannot move folders)'"
data-placement="bottom">
<i class="fa fa-exchange"></i>&nbsp;&nbsp;Move
</button>
<button type="button"
class="btn gf-form-button btn-danger"
ng-click="ctrl.delete()"
ng-disabled="!ctrl.canDelete">
<i class="fa fa-trash"></i>&nbsp;&nbsp;Delete
</button>
</div>
</div>
</div>
<div class="search-results-container">
<dashboard-search-results
results="ctrl.sections"
editable="true"
on-selection-changed="ctrl.selectionChanged()"
on-tag-selected="ctrl.filterByTag($tag)" />
</div>
</div>
<div ng-if="ctrl.folderId && !ctrl.hasFilters && ctrl.sections.length === 0">
<empty-list-cta model="{
title: 'This folder doesn\'t have any dashboards yet',
buttonIcon: 'gicon gicon-dashboard-new',
buttonLink: '/dashboard/new',
buttonTitle: 'Create Dashboard',
proTip: 'You can bulk move dashboards into this folder from the main dashboard list.',
proTipLink: 'http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list',
proTipLinkTitle: 'Learn more',
proTipTarget: '_blank'
}" />
</div>

View File

@ -0,0 +1,221 @@
import _ from 'lodash';
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
import { SearchSrv } from 'app/core/services/search_srv';
export class ManageDashboardsCtrl {
public sections: any[];
tagFilterOptions: any[];
selectedTagFilter: any;
query: any;
navModel: any;
canDelete = false;
canMove = false;
hasFilters = false;
selectAllChecked = false;
starredFilterOptions = [{ text: 'Filter by Starred', disabled: true }, { text: 'Yes' }, { text: 'No' }];
selectedStarredFilter: any;
folderId?: number;
/** @ngInject */
constructor(private backendSrv, navModelSrv, private $q, private searchSrv: SearchSrv) {
this.query = { query: '', mode: 'tree', tag: [], starred: false, skipRecent: true, skipStarred: true };
if (this.folderId) {
this.query.folderIds = [this.folderId];
}
this.selectedStarredFilter = this.starredFilterOptions[0];
this.getDashboards().then(() => {
this.getTags();
});
}
getDashboards() {
return this.searchSrv.search(this.query).then((result) => {
return this.initDashboardList(result);
});
}
initDashboardList(result: any) {
this.canMove = false;
this.canDelete = false;
this.selectAllChecked = false;
this.hasFilters = this.query.query.length > 0 || this.query.tag.length > 0 || this.query.starred;
if (!result) {
this.sections = [];
return;
}
this.sections = result;
for (let section of this.sections) {
section.checked = false;
for (let dashboard of section.items) {
dashboard.checked = false;
}
}
}
selectionChanged() {
let selectedDashboards = 0;
for (let section of this.sections) {
selectedDashboards += _.filter(section.items, { checked: true }).length;
}
const selectedFolders = _.filter(this.sections, { checked: true }).length;
this.canMove = selectedDashboards > 0 && selectedFolders === 0;
this.canDelete = selectedDashboards > 0 || selectedFolders > 0;
}
getDashboardsToDelete() {
let selectedDashboards = [];
for (const section of this.sections) {
if (section.checked) {
selectedDashboards.push(section.uri);
} else {
const selected = _.filter(section.items, { checked: true });
selectedDashboards.push(..._.map(selected, 'uri'));
}
}
return selectedDashboards;
}
getFolderIds(sections) {
const ids = [];
for (let s of sections) {
if (s.checked) {
ids.push(s.id);
}
}
return ids;
}
delete() {
const selectedDashboards = this.getDashboardsToDelete();
appEvents.emit('confirm-modal', {
title: 'Delete',
text: `Do you want to delete the ${selectedDashboards.length} selected dashboards?`,
icon: 'fa-trash',
yesText: 'Delete',
onConfirm: () => {
const promises = [];
for (let dash of selectedDashboards) {
promises.push(this.backendSrv.delete(`/api/dashboards/${dash}`));
}
this.$q.all(promises).then(() => {
this.getDashboards();
});
}
});
}
getDashboardsToMove() {
let selectedDashboards = [];
for (const section of this.sections) {
const selected = _.filter(section.items, { checked: true });
selectedDashboards.push(..._.map(selected, 'uri'));
}
return selectedDashboards;
}
moveTo() {
const selectedDashboards = this.getDashboardsToMove();
const template = '<move-to-folder-modal dismiss="dismiss()" ' +
'dashboards="model.dashboards" after-save="model.afterSave()">' +
'</move-to-folder-modal>`';
appEvents.emit('show-modal', {
templateHtml: template,
modalClass: 'modal--narrow',
model: { dashboards: selectedDashboards, afterSave: this.getDashboards.bind(this) }
});
}
getTags() {
return this.searchSrv.getDashboardTags().then((results) => {
this.tagFilterOptions = [{ term: 'Filter By Tag', disabled: true }].concat(results);
this.selectedTagFilter = this.tagFilterOptions[0];
});
}
filterByTag(tag) {
if (_.indexOf(this.query.tag, tag) === -1) {
this.query.tag.push(tag);
}
return this.getDashboards();
}
onQueryChange() {
return this.getDashboards();
}
onTagFilterChange() {
var res = this.filterByTag(this.selectedTagFilter.term);
this.selectedTagFilter = this.tagFilterOptions[0];
return res;
}
removeTag(tag, evt) {
this.query.tag = _.without(this.query.tag, tag);
this.getDashboards();
if (evt) {
evt.stopPropagation();
evt.preventDefault();
}
}
onStarredFilterChange() {
this.query.starred = this.selectedStarredFilter.text === 'Yes';
return this.getDashboards();
}
onSelectAllChanged() {
for (let section of this.sections) {
if (!section.hideHeader) {
section.checked = this.selectAllChecked;
}
section.items = _.map(section.items, (item) => {
item.checked = this.selectAllChecked;
return item;
});
}
this.selectionChanged();
}
clearFilters() {
this.query.query = '';
this.query.tag = [];
this.query.starred = false;
this.getDashboards();
}
}
export function manageDashboardsDirective() {
return {
restrict: 'E',
templateUrl: 'public/app/core/components/manage_dashboards/manage_dashboards.html',
controller: ManageDashboardsCtrl,
bindToController: true,
controllerAs: 'ctrl',
scope: {
folderId: '='
}
};
}
coreModule.directive('manageDashboards', manageDashboardsDirective);

View File

@ -20,37 +20,14 @@
<div class="search-dropdown">
<div class="search-dropdown__col_1">
<div class="search-results-container" grafana-scrollbar>
<h6 ng-show="!ctrl.isLoading && results.length">No dashboards matching your query were found.</h6>
<div ng-repeat="section in ctrl.results" class="search-section">
<a class="search-section__header pointer" ng-hide="section.hideHeader" ng-click="ctrl.toggleFolder(section)">
<i class="search-section__header__icon" ng-class="section.icon"></i>
<span class="search-section__header__text">{{::section.title}}</span>
<i class="fa fa-minus search-section__header__toggle" ng-show="section.expanded"></i>
<i class="fa fa-plus search-section__header__toggle" ng-hide="section.expanded"></i>
</a>
<div ng-if="section.expanded">
<a ng-repeat="item in section.items" class="search-item" ng-class="{'selected': item.selected}" ng-href="{{::item.url}}">
<span class="search-item__icon">
<i class="fa fa-th-large"></i>
</span>
<span class="search-item__body">
<div class="search-item__body-title">{{::item.title}}</div>
<div class="search-item__body-sub-title" ng-show="item.folderTitle && section.hideHeader">
{{::item.folderTitle}}
</div>
</span>
<span class="search-item__tags">
<span ng-click="ctrl.filterByTag(tag, $event)" ng-repeat="tag in item.tags" tag-color-from-name="tag" class="label label-tag">
{{tag}}
</span>
</span>
</a>
</div>
<div class="search-results-container" grafana-scrollbar>
<h6 ng-show="!ctrl.isLoading && ctrl.results.length === 0">No dashboards matching your query were found.</h6>
<dashboard-search-results
results="ctrl.results"
on-tag-selected="ctrl.filterByTag($tag)"
on-folder-expanding="ctrl.folderExpanding()"
on-folder-expanded="ctrl.folderExpanded($folder)" />
</div>
</div>
</div>
<div class="search-dropdown__col_2">

View File

@ -64,18 +64,75 @@ export class SearchCtrl {
this.moveSelection(-1);
}
if (evt.keyCode === 13) {
var selectedDash = this.results[this.selectedIndex];
if (selectedDash) {
this.$location.search({});
this.$location.path(selectedDash.url);
const flattenedResult = this.getFlattenedResultForNavigation();
const currentItem = flattenedResult[this.selectedIndex];
if (currentItem) {
if (currentItem.dashboardIndex !== undefined) {
const selectedDash = this.results[currentItem.folderIndex].items[currentItem.dashboardIndex];
if (selectedDash) {
this.$location.search({});
this.$location.path(selectedDash.url);
}
} else {
const selectedFolder = this.results[currentItem.folderIndex];
if (selectedFolder) {
selectedFolder.toggle(selectedFolder);
}
}
}
}
}
moveSelection(direction) {
var max = (this.results || []).length;
var newIndex = this.selectedIndex + direction;
if (this.results.length === 0) {
return;
}
const flattenedResult = this.getFlattenedResultForNavigation();
const currentItem = flattenedResult[this.selectedIndex];
if (currentItem) {
if (currentItem.dashboardIndex !== undefined) {
this.results[currentItem.folderIndex].items[currentItem.dashboardIndex].selected = false;
} else {
this.results[currentItem.folderIndex].selected = false;
}
}
if (direction === 0) {
this.selectedIndex = -1;
return;
}
const max = flattenedResult.length;
let newIndex = this.selectedIndex + direction;
this.selectedIndex = ((newIndex %= max) < 0) ? newIndex + max : newIndex;
const selectedItem = flattenedResult[this.selectedIndex];
if (selectedItem.dashboardIndex === undefined && this.results[selectedItem.folderIndex].id === 0) {
this.moveSelection(direction);
return;
}
if (selectedItem.dashboardIndex !== undefined) {
if (!this.results[selectedItem.folderIndex].expanded) {
this.moveSelection(direction);
return;
}
this.results[selectedItem.folderIndex].items[selectedItem.dashboardIndex].selected = true;
return;
}
if (this.results[selectedItem.folderIndex].hideHeader) {
this.moveSelection(direction);
return;
}
this.results[selectedItem.folderIndex].selected = true;
}
searchDashboards() {
@ -84,8 +141,9 @@ export class SearchCtrl {
return this.searchSrv.search(this.query).then(results => {
if (localSearchId < this.currentSearchId) { return; }
this.results = results;
this.results = results || [];
this.isLoading = false;
this.moveSelection(1);
});
}
@ -94,13 +152,11 @@ export class SearchCtrl {
return query.query === '' && query.starred === false && query.tag.length === 0;
}
filterByTag(tag, evt) {
this.query.tag.push(tag);
this.search();
this.giveSearchFocus = this.giveSearchFocus + 1;
if (evt) {
evt.stopPropagation();
evt.preventDefault();
filterByTag(tag) {
if (_.indexOf(this.query.tag, tag) === -1) {
this.query.tag.push(tag);
this.search();
this.giveSearchFocus = this.giveSearchFocus + 1;
}
}
@ -127,12 +183,36 @@ export class SearchCtrl {
search() {
this.showImport = false;
this.selectedIndex = 0;
this.selectedIndex = -1;
this.searchDashboards();
}
toggleFolder(section) {
this.searchSrv.toggleSection(section);
folderExpanding() {
this.moveSelection(0);
}
private getFlattenedResultForNavigation() {
let folderIndex = 0;
return _.flatMap(this.results, (s) => {
let result = [];
result.push({
folderIndex: folderIndex
});
let dashboardIndex = 0;
result = result.concat(_.map(s.items || [], (i) => {
return {
folderIndex: folderIndex,
dashboardIndex: dashboardIndex++
};
}));
folderIndex++;
return result;
});
}
}

View File

@ -0,0 +1,47 @@
<div ng-repeat="section in ctrl.results" class="search-section">
<a class="search-section__header pointer" ng-hide="section.hideHeader" ng-class="{'selected': section.selected}" ng-click="ctrl.toggleFolderExpand(section)">
<div ng-click="ctrl.toggleSelection(section, $event)">
<gf-form-switch
ng-show="ctrl.editable"
on-change="ctrl.selectionChanged($event)"
checked="section.checked"
switch-class="gf-form-switch--transparent gf-form-switch--search-result__section">
</gf-form-switch>
</div>
<i class="search-section__header__icon" ng-class="section.icon"></i>
<span class="search-section__header__text">{{::section.title}}</span>
<div ng-show="ctrl.editable && section.id > 0" ng-click="ctrl.navigateToFolder(section, $event)">
<i class="fa fa-cog search-section__header__toggle"></i>&nbsp;
</div>
<i class="fa fa-minus search-section__header__toggle" ng-show="section.expanded"></i>
<i class="fa fa-plus search-section__header__toggle" ng-hide="section.expanded"></i>
</a>
<div class="search-section__header" ng-show="section.hideHeader"></div>
<div ng-if="section.expanded">
<a ng-repeat="item in section.items" class="search-item" ng-class="{'selected': item.selected}" ng-href="{{::item.url}}">
<div ng-click="ctrl.toggleSelection(item, $event)">
<gf-form-switch
ng-show="ctrl.editable"
on-change="ctrl.selectionChanged()"
checked="item.checked"
switch-class="gf-form-switch--transparent gf-form-switch--search-result__item">
</gf-form-switch>
</div>
<span class="search-item__icon">
<i class="fa fa-th-large"></i>
</span>
<span class="search-item__body">
<div class="search-item__body-title">{{::item.title}}</div>
<div class="search-item__body-sub-title" ng-show="item.folderTitle && section.hideHeader">
{{::item.folderTitle}}
</div>
</span>
<span class="search-item__tags">
<span ng-click="ctrl.selectTag(tag, $event)" ng-repeat="tag in item.tags" tag-color-from-name="tag" class="label label-tag">
{{tag}}
</span>
</span>
</a>
</div>
</div>

View File

@ -0,0 +1,76 @@
// import _ from 'lodash';
import coreModule from '../../core_module';
export class SearchResultsCtrl {
results: any;
onSelectionChanged: any;
onTagSelected: any;
onFolderExpanding: any;
/** @ngInject */
constructor(private $location) {
}
toggleFolderExpand(section) {
if (section.toggle) {
if (!section.expanded && this.onFolderExpanding) {
this.onFolderExpanding();
}
section.toggle(section);
}
}
navigateToFolder(section, evt) {
this.$location.path('/dashboards/folder/' + section.id + '/' + section.uri);
if (evt) {
evt.stopPropagation();
evt.preventDefault();
}
}
toggleSelection(item, evt) {
item.checked = !item.checked;
if (this.onSelectionChanged) {
this.onSelectionChanged();
}
if (evt) {
evt.stopPropagation();
evt.preventDefault();
}
}
selectTag(tag, evt) {
if (this.onTagSelected) {
this.onTagSelected({$tag: tag});
}
if (evt) {
evt.stopPropagation();
evt.preventDefault();
}
}
}
export function searchResultsDirective() {
return {
restrict: 'E',
templateUrl: 'public/app/core/components/search/search_results.html',
controller: SearchResultsCtrl,
bindToController: true,
controllerAs: 'ctrl',
scope: {
editable: '@',
results: '=',
onSelectionChanged: '&',
onTagSelected: '&',
onFolderExpanding: '&'
},
};
}
coreModule.directive('dashboardSearchResults', searchResultsDirective);

View File

@ -2,6 +2,11 @@
<img src="public/img/grafana_icon.svg"></img>
</a>
<a class="sidemenu__logo_small_breakpoint" ng-click="ctrl.toggleSideMenuSmallBreakpoint()">
<i class="fa fa-bars"></i>
<span class="sidemenu__close"><i class="fa fa-times"></i>&nbsp;Close</span>
</a>
<div class="sidemenu__top">
<div ng-repeat="item in ::ctrl.mainLinks" class="sidemenu-item dropdown">
<a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">
@ -54,7 +59,7 @@
</a>
</li>
<li ng-repeat="child in ::item.children" ng-class="{divider: child.divider}" ng-hide="::child.hideFromMenu">
<a href="{{::child.url}}" target="{{::child.target}}">
<a href="{{::child.url}}" target="{{::child.target}}" ng-click="ctrl.itemClicked(child, $event)">
<i class="{{::child.icon}}" ng-show="::child.icon"></i>
{{::child.text}}
</a>

View File

@ -1,9 +1,8 @@
///<reference path="../../../headers/common.d.ts" />
import _ from 'lodash';
import config from 'app/core/config';
import $ from 'jquery';
import coreModule from '../../core_module';
import appEvents from 'app/core/app_events';
export class SideMenuCtrl {
user: any;
@ -11,6 +10,7 @@ export class SideMenuCtrl {
bottomNav: any;
loginUrl: string;
isSignedIn: boolean;
isOpenMobile: boolean;
/** @ngInject */
constructor(private $scope, private $rootScope, private $location, private contextSrv, private $timeout) {
@ -34,16 +34,29 @@ export class SideMenuCtrl {
toggleSideMenu() {
this.contextSrv.toggleSideMenu();
appEvents.emit('toggle-sidemenu');
this.$timeout(() => {
this.$rootScope.$broadcast('render');
});
}
toggleSideMenuSmallBreakpoint() {
appEvents.emit('toggle-sidemenu-mobile');
}
switchOrg() {
this.$rootScope.appEvent('show-modal', {
templateHtml: '<org-switcher dismiss="dismiss()"></org-switcher>',
});
}
itemClicked(item, evt) {
if (item.url === '/shortcuts') {
appEvents.emit('show-modal', {templateHtml: '<help-modal></help-modal>'});
evt.preventDefault();
}
}
}
export function sideMenuDirective() {
@ -65,10 +78,6 @@ export function sideMenuDirective() {
parent.append(menu);
}, 100);
});
scope.$on("$destory", function() {
elem.off('click.dropdown');
});
}
};
}

View File

@ -17,6 +17,10 @@ class Settings {
alertingEnabled: boolean;
authProxyEnabled: boolean;
ldapEnabled: boolean;
oauth: any;
disableUserSignUp: boolean;
loginHint: any;
loginError: any;
constructor(options) {
var defaults = {

View File

@ -1,9 +0,0 @@
define([
'./inspect_ctrl',
'./json_editor_ctrl',
'./login_ctrl',
'./invited_ctrl',
'./signup_ctrl',
'./reset_password_ctrl',
'./error_ctrl',
], function () {});

View File

@ -0,0 +1,7 @@
import './inspect_ctrl';
import './json_editor_ctrl';
import './login_ctrl';
import './invited_ctrl';
import './signup_ctrl';
import './reset_password_ctrl';
import './error_ctrl';

View File

@ -1,23 +0,0 @@
define([
'angular',
'app/core/config',
'../core_module',
],
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() {
contextSrv.sidemenu = showSideMenu;
});
});
});

View File

@ -0,0 +1,24 @@
import config from 'app/core/config';
import coreModule from '../core_module';
import appEvents from 'app/core/app_events';
export class ErrorCtrl {
/** @ngInject */
constructor($scope, contextSrv, navModelSrv) {
$scope.navModel = navModelSrv.getNotFoundNav();
$scope.appSubUrl = config.appSubUrl;
if (!contextSrv.isSignedIn) {
appEvents.emit('toggle-sidemenu-hidden');
}
$scope.$on("destroy", () => {
if (!contextSrv.isSignedIn) {
appEvents.emit('toggle-sidemenu-hidden');
}
});
}
}
coreModule.controller('ErrorCtrl', ErrorCtrl);

View File

@ -1,14 +1,10 @@
define([
'angular',
'../core_module',
'app/core/config',
],
function (angular, coreModule, config) {
'use strict';
import coreModule from '../core_module';
import config from 'app/core/config';
config = config.default;
export class InvitedCtrl {
coreModule.default.controller('InvitedCtrl', function($scope, $routeParams, contextSrv, backendSrv) {
/** @ngInject */
constructor($scope, $routeParams, contextSrv, backendSrv) {
contextSrv.sidemenu = false;
$scope.formModel = {};
@ -35,6 +31,7 @@ function (angular, coreModule, config) {
};
$scope.init();
}
}
});
});
coreModule.controller('InvitedCtrl', InvitedCtrl);

View File

@ -1,12 +1,10 @@
define([
'angular',
'../core_module',
],
function (angular, coreModule) {
'use strict';
import angular from 'angular';
import coreModule from '../core_module';
coreModule.default.controller('JsonEditorCtrl', function($scope) {
export class JsonEditorCtrl {
/** @ngInject */
constructor($scope) {
$scope.json = angular.toJson($scope.object, true);
$scope.canUpdate = $scope.updateHandler !== void 0 && $scope.contextSrv.isEditor;
@ -14,7 +12,7 @@ function (angular, coreModule) {
var newObject = angular.fromJson($scope.json);
$scope.updateHandler(newObject, $scope.object);
};
}
}
});
});
coreModule.controller('JsonEditorCtrl', JsonEditorCtrl);

View File

@ -1,15 +1,11 @@
define([
'angular',
'lodash',
'../core_module',
'app/core/config',
],
function (angular, _, coreModule, config) {
'use strict';
import _ from 'lodash';
import coreModule from '../core_module';
import config from 'app/core/config';
config = config.default;
export class LoginCtrl {
coreModule.default.controller('LoginCtrl', function($scope, backendSrv, contextSrv, $location) {
/** @ngInject */
constructor($scope, backendSrv, contextSrv, $location) {
$scope.formModel = {
user: '',
email: '',
@ -74,8 +70,7 @@ function (angular, _, coreModule, config) {
if (params.redirect && params.redirect[0] === '/') {
window.location.href = config.appSubUrl + params.redirect;
}
else if (result.redirectUrl) {
} else if (result.redirectUrl) {
window.location.href = result.redirectUrl;
} else {
window.location.href = config.appSubUrl + '/';
@ -84,5 +79,7 @@ function (angular, _, coreModule, config) {
};
$scope.init();
});
});
}
}
coreModule.controller('LoginCtrl', LoginCtrl);

View File

@ -1,11 +1,9 @@
define([
'angular',
'../core_module',
],
function (angular, coreModule) {
'use strict';
import coreModule from '../core_module';
coreModule.default.controller('ResetPasswordCtrl', function($scope, contextSrv, backendSrv, $location) {
export class ResetPasswordCtrl {
/** @ngInject */
constructor($scope, contextSrv, backendSrv, $location) {
contextSrv.sidemenu = false;
$scope.formModel = {};
$scope.mode = 'send';
@ -37,7 +35,7 @@ function (angular, coreModule) {
$location.path('login');
});
};
}
}
});
});
coreModule.controller('ResetPasswordCtrl', ResetPasswordCtrl);

View File

@ -18,6 +18,7 @@ import './components/colorpicker/ColorPicker';
import './components/colorpicker/SeriesColorPicker';
import './components/colorpicker/spectrum_picker';
import './services/search_srv';
import './services/ng_react';
import {grafanaAppDirective} from './components/grafana_app';
import {sideMenuDirective} from './components/sidemenu/sidemenu';
@ -52,6 +53,10 @@ import {gfPageDirective} from './components/gf_page';
import {orgSwitcher} from './components/org_switcher';
import {profiler} from './profiler';
import {registerAngularDirectives} from './angular_wrappers';
import {updateLegendValues} from './time_series2';
import TimeSeries from './time_series2';
import {searchResultsDirective} from './components/search/search_results';
import {manageDashboardsDirective} from './components/manage_dashboards/manage_dashboards';
export {
profiler,
@ -83,5 +88,9 @@ export {
userGroupPicker,
geminiScrollbar,
gfPageDirective,
orgSwitcher
orgSwitcher,
manageDashboardsDirective,
TimeSeries,
updateLegendValues,
searchResultsDirective
};

View File

@ -70,13 +70,15 @@ export class NavModelSrv {
getNotFoundNav() {
var node = {
text: "Page not found ",
text: "Page not found",
icon: "fa fa-fw fa-warning",
subTitle: "404 Error"
};
return {
breadcrumbs: [node],
node: node
node: node,
main: node
};
}
@ -119,14 +121,6 @@ export class NavModelSrv {
clickHandler: () => dashNavCtrl.openEditView('annotations')
});
if (dashboard.meta.canAdmin) {
menu.push({
title: 'Permissions...',
icon: 'fa fa-fw fa-lock',
clickHandler: () => dashNavCtrl.openEditView('permissions')
});
}
if (!dashboard.meta.isHome) {
menu.push({
title: 'Version history',

View File

@ -48,6 +48,11 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
reloadOnSearch: false,
pageClass: 'page-dashboard',
})
.when('/dashboard/import', {
templateUrl: 'public/app/features/dashboard/partials/dashboardImport.html',
controller : 'DashboardImportCtrl',
controllerAs: 'ctrl',
})
.when('/datasources', {
templateUrl: 'public/app/features/plugins/partials/ds_list.html',
controller : 'DataSourcesCtrl',
@ -64,10 +69,25 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
controllerAs: 'ctrl',
})
.when('/dashboards', {
templateUrl: 'public/app/features/dashboard/partials/dashboardList.html',
templateUrl: 'public/app/features/dashboard/partials/dashboard_list.html',
controller : 'DashboardListCtrl',
controllerAs: 'ctrl',
})
.when('/dashboards/folder/new', {
templateUrl: 'public/app/features/dashboard/partials/create_folder.html',
controller : 'CreateFolderCtrl',
controllerAs: 'ctrl',
})
.when('/dashboards/folder/:folderId/:type/:slug/permissions', {
templateUrl: 'public/app/features/dashboard/partials/folder_permissions.html',
controller : 'FolderPermissionsCtrl',
controllerAs: 'ctrl',
})
.when('/dashboards/folder/:folderId/:type/:slug', {
templateUrl: 'public/app/features/dashboard/partials/folder_dashboards.html',
controller : 'FolderDashboardsCtrl',
controllerAs: 'ctrl',
})
.when('/org', {
templateUrl: 'public/app/features/org/partials/orgDetails.html',
controller : 'OrgDetailsCtrl',
@ -168,23 +188,27 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
.when('/login', {
templateUrl: 'public/app/partials/login.html',
controller : 'LoginCtrl',
pageClass: 'page-login',
pageClass: 'sidemenu-hidden',
})
.when('/invite/:code', {
templateUrl: 'public/app/partials/signup_invited.html',
controller : 'InvitedCtrl',
pageClass: 'sidemenu-hidden',
})
.when('/signup', {
templateUrl: 'public/app/partials/signup_step2.html',
controller : 'SignUpCtrl',
pageClass: 'sidemenu-hidden',
})
.when('/user/password/send-reset-email', {
templateUrl: 'public/app/partials/reset_password.html',
controller : 'ResetPasswordCtrl',
pageClass: 'sidemenu-hidden',
})
.when('/user/password/reset', {
templateUrl: 'public/app/partials/reset_password.html',
controller : 'ResetPasswordCtrl',
pageClass: 'sidemenu-hidden',
})
.when('/dashboard/snapshots', {
templateUrl: 'public/app/features/snapshot/partials/snapshots.html',

View File

@ -8,5 +8,6 @@ define([
'./segment_srv',
'./backend_srv',
'./dynamic_directive_srv',
'./global_event_srv'
],
function () {});

View File

@ -27,9 +27,10 @@ export class ContextSrv {
isGrafanaAdmin: any;
isEditor: any;
sidemenu: any;
sidemenuSmallBreakpoint = false;
constructor() {
this.sidemenu = store.getBool('grafana.sidemenu', false);
this.sidemenu = store.getBool('grafana.sidemenu', true);
if (!config.buildInfo) {
config.buildInfo = {};
@ -55,7 +56,7 @@ export class ContextSrv {
toggleSideMenu() {
this.sidemenu = !this.sidemenu;
store.set('grafana.sidemenu',this.sidemenu);
store.set('grafana.sidemenu', this.sidemenu);
}
}

View File

@ -0,0 +1,21 @@
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
// This service is for registering global events.
// Good for communication react > angular and vice verse
export class GlobalEventSrv {
/** @ngInject */
constructor(private $location, private $timeout) {
}
init() {
appEvents.on('location-change', payload => {
this.$timeout(() => { // A hack to use timeout when we're changing things (in this case the url) from outside of Angular.
this.$location.path(payload.href);
});
});
}
}
coreModule.service('globalEventSrv', GlobalEventSrv);

View File

@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import $ from 'jquery';
import _ from 'lodash';

View File

@ -0,0 +1,301 @@
//
// This is using ng-react with this PR applied https://github.com/ngReact/ngReact/pull/199
//
// # ngReact
// ### Use React Components inside of your Angular applications
//
// Composed of
// - reactComponent (generic directive for delegating off to React Components)
// - reactDirective (factory for creating specific directives that correspond to reactComponent directives)
import React from 'react';
import ReactDOM from 'react-dom';
import angular from 'angular';
// get a react component from name (components can be an angular injectable e.g. value, factory or
// available on window
function getReactComponent(name, $injector) {
// if name is a function assume it is component and return it
if (angular.isFunction(name)) {
return name;
}
// a React component name must be specified
if (!name) {
throw new Error('ReactComponent name attribute must be specified');
}
// ensure the specified React component is accessible, and fail fast if it's not
var reactComponent;
try {
reactComponent = $injector.get(name);
} catch (e) {}
if (!reactComponent) {
try {
reactComponent = name.split('.').reduce(function(current, namePart) {
return current[namePart];
}, window);
} catch (e) {}
}
if (!reactComponent) {
throw Error('Cannot find react component ' + name);
}
return reactComponent;
}
// wraps a function with scope.$apply, if already applied just return
function applied(fn, scope) {
if (fn.wrappedInApply) {
return fn;
}
var wrapped: any = function() {
var args = arguments;
var phase = scope.$root.$$phase;
if (phase === '$apply' || phase === '$digest') {
return fn.apply(null, args);
} else {
return scope.$apply(function() {
return fn.apply(null, args);
});
}
};
wrapped.wrappedInApply = true;
return wrapped;
}
/**
* wraps functions on obj in scope.$apply
*
* keeps backwards compatibility, as if propsConfig is not passed, it will
* work as before, wrapping all functions and won't wrap only when specified.
*
* @version 0.4.1
* @param obj react component props
* @param scope current scope
* @param propsConfig configuration object for all properties
* @returns {Object} props with the functions wrapped in scope.$apply
*/
function applyFunctions(obj, scope, propsConfig?) {
return Object.keys(obj || {}).reduce(function(prev, key) {
var value = obj[key];
var config = (propsConfig || {})[key] || {};
/**
* wrap functions in a function that ensures they are scope.$applied
* ensures that when function is called from a React component
* the Angular digest cycle is run
*/
prev[key] = angular.isFunction(value) && config.wrapApply !== false ? applied(value, scope) : value;
return prev;
}, {});
}
/**
*
* @param watchDepth (value of HTML watch-depth attribute)
* @param scope (angular scope)
*
* Uses the watchDepth attribute to determine how to watch props on scope.
* If watchDepth attribute is NOT reference or collection, watchDepth defaults to deep watching by value
*/
function watchProps(watchDepth, scope, watchExpressions, listener) {
var supportsWatchCollection = angular.isFunction(scope.$watchCollection);
var supportsWatchGroup = angular.isFunction(scope.$watchGroup);
var watchGroupExpressions = [];
watchExpressions.forEach(function(expr) {
var actualExpr = getPropExpression(expr);
var exprWatchDepth = getPropWatchDepth(watchDepth, expr);
if (exprWatchDepth === 'collection' && supportsWatchCollection) {
scope.$watchCollection(actualExpr, listener);
} else if (exprWatchDepth === 'reference' && supportsWatchGroup) {
watchGroupExpressions.push(actualExpr);
} else if (exprWatchDepth === 'one-time') {
//do nothing because we handle our one time bindings after this
} else {
scope.$watch(actualExpr, listener, exprWatchDepth !== 'reference');
}
});
if (watchDepth === 'one-time') {
listener();
}
if (watchGroupExpressions.length) {
scope.$watchGroup(watchGroupExpressions, listener);
}
}
// render React component, with scope[attrs.props] being passed in as the component props
function renderComponent(component, props, scope, elem) {
scope.$evalAsync(function() {
ReactDOM.render(React.createElement(component, props), elem[0]);
});
}
// get prop name from prop (string or array)
function getPropName(prop) {
return Array.isArray(prop) ? prop[0] : prop;
}
// get prop name from prop (string or array)
function getPropConfig(prop) {
return Array.isArray(prop) ? prop[1] : {};
}
// get prop expression from prop (string or array)
function getPropExpression(prop) {
return Array.isArray(prop) ? prop[0] : prop;
}
// find the normalized attribute knowing that React props accept any type of capitalization
function findAttribute(attrs, propName) {
var index = Object.keys(attrs).filter(function(attr) {
return attr.toLowerCase() === propName.toLowerCase();
})[0];
return attrs[index];
}
// get watch depth of prop (string or array)
function getPropWatchDepth(defaultWatch, prop) {
var customWatchDepth = Array.isArray(prop) && angular.isObject(prop[1]) && prop[1].watchDepth;
return customWatchDepth || defaultWatch;
}
// # reactComponent
// Directive that allows React components to be used in Angular templates.
//
// Usage:
// <react-component name="Hello" props="name"/>
//
// This requires that there exists an injectable or globally available 'Hello' React component.
// The 'props' attribute is optional and is passed to the component.
//
// The following would would create and register the component:
//
// var module = angular.module('ace.react.components');
// module.value('Hello', React.createClass({
// render: function() {
// return <div>Hello {this.props.name}</div>;
// }
// }));
//
var reactComponent = function($injector) {
return {
restrict: 'E',
replace: true,
link: function(scope, elem, attrs) {
var reactComponent = getReactComponent(attrs.name, $injector);
var renderMyComponent = function() {
var scopeProps = scope.$eval(attrs.props);
var props = applyFunctions(scopeProps, scope);
renderComponent(reactComponent, props, scope, elem);
};
// If there are props, re-render when they change
attrs.props ? watchProps(attrs.watchDepth, scope, [attrs.props], renderMyComponent) : renderMyComponent();
// cleanup when scope is destroyed
scope.$on('$destroy', function() {
if (!attrs.onScopeDestroy) {
ReactDOM.unmountComponentAtNode(elem[0]);
} else {
scope.$eval(attrs.onScopeDestroy, {
unmountComponent: ReactDOM.unmountComponentAtNode.bind(this, elem[0]),
});
}
});
},
};
};
// # reactDirective
// Factory function to create directives for React components.
//
// With a component like this:
//
// var module = angular.module('ace.react.components');
// module.value('Hello', React.createClass({
// render: function() {
// return <div>Hello {this.props.name}</div>;
// }
// }));
//
// A directive can be created and registered with:
//
// module.directive('hello', function(reactDirective) {
// return reactDirective('Hello', ['name']);
// });
//
// Where the first argument is the injectable or globally accessible name of the React component
// and the second argument is an array of property names to be watched and passed to the React component
// as props.
//
// This directive can then be used like this:
//
// <hello name="name"/>
//
var reactDirective = function($injector) {
return function(reactComponentName, props, conf, injectableProps) {
var directive = {
restrict: 'E',
replace: true,
link: function(scope, elem, attrs) {
var reactComponent = getReactComponent(reactComponentName, $injector);
// if props is not defined, fall back to use the React component's propTypes if present
props = props || Object.keys(reactComponent.propTypes || {});
// for each of the properties, get their scope value and set it to scope.props
var renderMyComponent = function() {
var scopeProps = {},
config = {};
props.forEach(function(prop) {
var propName = getPropName(prop);
scopeProps[propName] = scope.$eval(findAttribute(attrs, propName));
config[propName] = getPropConfig(prop);
});
scopeProps = applyFunctions(scopeProps, scope, config);
scopeProps = angular.extend({}, scopeProps, injectableProps);
renderComponent(reactComponent, scopeProps, scope, elem);
};
// watch each property name and trigger an update whenever something changes,
// to update scope.props with new values
var propExpressions = props.map(function(prop) {
return Array.isArray(prop) ? [attrs[getPropName(prop)], getPropConfig(prop)] : attrs[prop];
});
// If we don't have any props, then our watch statement won't fire.
props.length ? watchProps(attrs.watchDepth, scope, propExpressions, renderMyComponent) : renderMyComponent();
// cleanup when scope is destroyed
scope.$on('$destroy', function() {
if (!attrs.onScopeDestroy) {
ReactDOM.unmountComponentAtNode(elem[0]);
} else {
scope.$eval(attrs.onScopeDestroy, {
unmountComponent: ReactDOM.unmountComponentAtNode.bind(this, elem[0]),
});
}
});
},
};
return angular.extend(directive, conf);
};
};
let ngModule = angular.module('react', []);
ngModule.directive('reactComponent', ['$injector', reactComponent]);
ngModule.factory('reactDirective', ['$injector', reactDirective]);

View File

@ -51,7 +51,7 @@ export class SearchSrv {
store.set('search.sections.recent', this.recentIsOpen);
if (!section.expanded || section.items.length) {
return;
return Promise.resolve();
}
return this.queryForRecentDashboards().then(result => {
@ -62,6 +62,7 @@ export class SearchSrv {
private toggleStarred(section) {
this.starredIsOpen = section.expanded = !section.expanded;
store.set('search.sections.starred', this.starredIsOpen);
return Promise.resolve();
}
private getStarred(sections) {
@ -128,14 +129,20 @@ export class SearchSrv {
});
}
private browse() {
private browse(options) {
let sections: any = {};
let promises = [
this.getRecentDashboards(sections),
this.getStarred(sections),
this.getDashboardsAndFolders(sections),
];
let promises = [];
if (!options.skipRecent) {
promises.push(this.getRecentDashboards(sections));
}
if (!options.skipStarred) {
promises.push(this.getStarred(sections));
}
promises.push(this.getDashboardsAndFolders(sections));
return this.$q.all(promises).then(() => {
return _.sortBy(_.values(sections), 'score');
@ -148,15 +155,19 @@ export class SearchSrv {
}
search(options) {
if (!options.query && (!options.tag || options.tag.length === 0) && !options.starred) {
return this.browse();
if (!options.folderIds && !options.query && (!options.tag || options.tag.length === 0) && !options.starred) {
return this.browse(options);
}
let query = _.clone(options);
query.folderIds = [];
query.folderIds = options.folderIds || [];
query.type = 'dash-db';
return this.backendSrv.search(query).then(results => {
if (results.length === 0) {
return results;
}
let section = {
hideHeader: true,
items: [],
@ -179,7 +190,7 @@ export class SearchSrv {
section.icon = section.expanded ? 'fa fa-folder-open' : 'fa fa-folder';
if (section.items.length) {
return;
return Promise.resolve();
}
let query = {
@ -191,10 +202,6 @@ export class SearchSrv {
});
}
toggleSection(section) {
section.toggle(section);
}
getDashboardTags() {
return this.backendSrv.get('/api/dashboards/tags');
}

View File

@ -1,8 +1,8 @@
import { DashboardListCtrl } from '../dashboard_list_ctrl';
import { ManageDashboardsCtrl } from 'app/core/components/manage_dashboards/manage_dashboards';
import { SearchSrv } from 'app/core/services/search_srv';
import q from 'q';
describe('DashboardListCtrl', () => {
describe('ManageDashboards', () => {
let ctrl;
describe('when browsing dashboards', () => {
@ -537,13 +537,10 @@ function createCtrlWithStubs(searchResponse: any, tags?: any) {
search: (options: any) => {
return q.resolve(searchResponse);
},
toggleSection: (section) => {
return;
},
getDashboardTags: () => {
return q.resolve(tags || []);
}
};
return new DashboardListCtrl({}, { getNav: () => { } }, q, <SearchSrv>searchSrvStub);
return new ManageDashboardsCtrl({}, { getNav: () => { } }, q, <SearchSrv>searchSrvStub);
}

View File

@ -0,0 +1,335 @@
import { SearchCtrl } from '../components/search/search';
import { SearchSrv } from '../services/search_srv';
describe('SearchCtrl', () => {
const searchSrvStub = {
search: (options: any) => {},
getDashboardTags: () => {}
};
let ctrl = new SearchCtrl({}, {}, {}, <SearchSrv>searchSrvStub, { onAppEvent: () => { } });
describe('Given an empty result', () => {
beforeEach(() => {
ctrl.results = [];
});
describe('When navigating down one step', () => {
beforeEach(() => {
ctrl.selectedIndex = 0;
ctrl.moveSelection(1);
});
it('should not navigate', () => {
expect(ctrl.selectedIndex).toBe(0);
});
});
describe('When navigating up one step', () => {
beforeEach(() => {
ctrl.selectedIndex = 0;
ctrl.moveSelection(-1);
});
it('should not navigate', () => {
expect(ctrl.selectedIndex).toBe(0);
});
});
});
describe('Given a result of one selected collapsed folder with no dashboards and a root folder with 2 dashboards', () => {
beforeEach(() => {
ctrl.results = [
{
id: 1,
title: 'folder',
items: [],
selected: true,
expanded: false,
toggle: (i) => i.expanded = !i.expanded
},
{
id: 0,
title: 'Root',
items: [
{ id: 3, selected: false },
{ id: 5, selected: false }
],
selected: false,
expanded: true,
toggle: (i) => i.expanded = !i.expanded
}
];
});
describe('When navigating down one step', () => {
beforeEach(() => {
ctrl.selectedIndex = 0;
ctrl.moveSelection(1);
});
it('should select first dashboard in root folder', () => {
expect(ctrl.results[0].selected).toBeFalsy();
expect(ctrl.results[1].selected).toBeFalsy();
expect(ctrl.results[1].items[0].selected).toBeTruthy();
expect(ctrl.results[1].items[1].selected).toBeFalsy();
});
});
describe('When navigating down two steps', () => {
beforeEach(() => {
ctrl.selectedIndex = 0;
ctrl.moveSelection(1);
ctrl.moveSelection(1);
});
it('should select last dashboard in root folder', () => {
expect(ctrl.results[0].selected).toBeFalsy();
expect(ctrl.results[1].selected).toBeFalsy();
expect(ctrl.results[1].items[0].selected).toBeFalsy();
expect(ctrl.results[1].items[1].selected).toBeTruthy();
});
});
describe('When navigating down three steps', () => {
beforeEach(() => {
ctrl.selectedIndex = 0;
ctrl.moveSelection(1);
ctrl.moveSelection(1);
ctrl.moveSelection(1);
});
it('should select first folder', () => {
expect(ctrl.results[0].selected).toBeTruthy();
expect(ctrl.results[1].selected).toBeFalsy();
expect(ctrl.results[1].items[0].selected).toBeFalsy();
expect(ctrl.results[1].items[1].selected).toBeFalsy();
});
});
describe('When navigating up one step', () => {
beforeEach(() => {
ctrl.selectedIndex = 0;
ctrl.moveSelection(-1);
});
it('should select last dashboard in root folder', () => {
expect(ctrl.results[0].selected).toBeFalsy();
expect(ctrl.results[1].selected).toBeFalsy();
expect(ctrl.results[1].items[0].selected).toBeFalsy();
expect(ctrl.results[1].items[1].selected).toBeTruthy();
});
});
describe('When navigating up two steps', () => {
beforeEach(() => {
ctrl.selectedIndex = 0;
ctrl.moveSelection(-1);
ctrl.moveSelection(-1);
});
it('should select first dashboard in root folder', () => {
expect(ctrl.results[0].selected).toBeFalsy();
expect(ctrl.results[1].selected).toBeFalsy();
expect(ctrl.results[1].items[0].selected).toBeTruthy();
expect(ctrl.results[1].items[1].selected).toBeFalsy();
});
});
});
describe('Given a result of one selected collapsed folder with 2 dashboards and a root folder with 2 dashboards', () => {
beforeEach(() => {
ctrl.results = [
{
id: 1,
title: 'folder',
items: [
{ id: 2, selected: false },
{ id: 4, selected: false }
],
selected: true,
expanded: false,
toggle: (i) => i.expanded = !i.expanded
},
{
id: 0,
title: 'Root',
items: [
{ id: 3, selected: false },
{ id: 5, selected: false }
],
selected: false,
expanded: true,
toggle: (i) => i.expanded = !i.expanded
}
];
});
describe('When navigating down one step', () => {
beforeEach(() => {
ctrl.selectedIndex = 0;
ctrl.moveSelection(1);
});
it('should select first dashboard in root folder', () => {
expect(ctrl.results[0].selected).toBeFalsy();
expect(ctrl.results[1].selected).toBeFalsy();
expect(ctrl.results[0].items[0].selected).toBeFalsy();
expect(ctrl.results[0].items[1].selected).toBeFalsy();
expect(ctrl.results[1].items[0].selected).toBeTruthy();
expect(ctrl.results[1].items[1].selected).toBeFalsy();
});
});
describe('When navigating down two steps', () => {
beforeEach(() => {
ctrl.selectedIndex = 0;
ctrl.moveSelection(1);
ctrl.moveSelection(1);
});
it('should select last dashboard in root folder', () => {
expect(ctrl.results[0].selected).toBeFalsy();
expect(ctrl.results[1].selected).toBeFalsy();
expect(ctrl.results[0].items[0].selected).toBeFalsy();
expect(ctrl.results[0].items[1].selected).toBeFalsy();
expect(ctrl.results[1].items[0].selected).toBeFalsy();
expect(ctrl.results[1].items[1].selected).toBeTruthy();
});
});
describe('When navigating down three steps', () => {
beforeEach(() => {
ctrl.selectedIndex = 0;
ctrl.moveSelection(1);
ctrl.moveSelection(1);
ctrl.moveSelection(1);
});
it('should select first folder', () => {
expect(ctrl.results[0].selected).toBeTruthy();
expect(ctrl.results[1].selected).toBeFalsy();
expect(ctrl.results[0].items[0].selected).toBeFalsy();
expect(ctrl.results[0].items[1].selected).toBeFalsy();
expect(ctrl.results[1].items[0].selected).toBeFalsy();
expect(ctrl.results[1].items[1].selected).toBeFalsy();
});
});
describe('When navigating up one step', () => {
beforeEach(() => {
ctrl.selectedIndex = 0;
ctrl.moveSelection(-1);
});
it('should select last dashboard in root folder', () => {
expect(ctrl.results[0].selected).toBeFalsy();
expect(ctrl.results[1].selected).toBeFalsy();
expect(ctrl.results[0].items[0].selected).toBeFalsy();
expect(ctrl.results[0].items[1].selected).toBeFalsy();
expect(ctrl.results[1].items[0].selected).toBeFalsy();
expect(ctrl.results[1].items[1].selected).toBeTruthy();
});
});
describe('When navigating up two steps', () => {
beforeEach(() => {
ctrl.selectedIndex = 0;
ctrl.moveSelection(-1);
ctrl.moveSelection(-1);
});
it('should select first dashboard in root folder', () => {
expect(ctrl.results[0].selected).toBeFalsy();
expect(ctrl.results[1].selected).toBeFalsy();
expect(ctrl.results[1].items[0].selected).toBeTruthy();
expect(ctrl.results[1].items[1].selected).toBeFalsy();
});
});
});
describe('Given a result of a search with 2 dashboards where the first is selected', () => {
beforeEach(() => {
ctrl.results = [
{
hideHeader: true,
items: [
{ id: 3, selected: true },
{ id: 5, selected: false }
],
selected: false,
expanded: true,
toggle: (i) => i.expanded = !i.expanded
}
];
});
describe('When navigating down one step', () => {
beforeEach(() => {
ctrl.selectedIndex = 1;
ctrl.moveSelection(1);
});
it('should select last dashboard', () => {
expect(ctrl.results[0].selected).toBeFalsy();
expect(ctrl.results[0].items[0].selected).toBeFalsy();
expect(ctrl.results[0].items[1].selected).toBeTruthy();
});
});
describe('When navigating down two steps', () => {
beforeEach(() => {
ctrl.selectedIndex = 1;
ctrl.moveSelection(1);
ctrl.moveSelection(1);
});
it('should select first dashboard', () => {
expect(ctrl.results[0].selected).toBeFalsy();
expect(ctrl.results[0].items[0].selected).toBeTruthy();
expect(ctrl.results[0].items[1].selected).toBeFalsy();
});
});
describe('When navigating down three steps', () => {
beforeEach(() => {
ctrl.selectedIndex = 1;
ctrl.moveSelection(1);
ctrl.moveSelection(1);
ctrl.moveSelection(1);
});
it('should select last dashboard', () => {
expect(ctrl.results[0].selected).toBeFalsy();
expect(ctrl.results[0].items[0].selected).toBeFalsy();
expect(ctrl.results[0].items[1].selected).toBeTruthy();
});
});
describe('When navigating up one step', () => {
beforeEach(() => {
ctrl.selectedIndex = 1;
ctrl.moveSelection(-1);
});
it('should select last dashboard', () => {
expect(ctrl.results[0].selected).toBeFalsy();
expect(ctrl.results[0].items[0].selected).toBeFalsy();
expect(ctrl.results[0].items[1].selected).toBeTruthy();
});
});
describe('When navigating up two steps', () => {
beforeEach(() => {
ctrl.selectedIndex = 1;
ctrl.moveSelection(-1);
ctrl.moveSelection(-1);
});
it('should select first dashboard', () => {
expect(ctrl.results[0].selected).toBeFalsy();
expect(ctrl.results[0].items[0].selected).toBeTruthy();
expect(ctrl.results[0].items[1].selected).toBeFalsy();
});
});
});
});

View File

@ -0,0 +1,97 @@
import { SearchResultsCtrl } from '../components/search/search_results';
describe('SearchResultsCtrl', () => {
let ctrl;
describe('when checking an item that is not checked', () => {
let item = {checked: false};
let selectionChanged = false;
beforeEach(() => {
ctrl = new SearchResultsCtrl({});
ctrl.onSelectionChanged = () => selectionChanged = true;
ctrl.toggleSelection(item);
});
it('should set checked to true', () => {
expect(item.checked).toBeTruthy();
});
it('should trigger selection changed callback', () => {
expect(selectionChanged).toBeTruthy();
});
});
describe('when checking an item that is checked', () => {
let item = {checked: true};
let selectionChanged = false;
beforeEach(() => {
ctrl = new SearchResultsCtrl({});
ctrl.onSelectionChanged = () => selectionChanged = true;
ctrl.toggleSelection(item);
});
it('should set checked to false', () => {
expect(item.checked).toBeFalsy();
});
it('should trigger selection changed callback', () => {
expect(selectionChanged).toBeTruthy();
});
});
describe('when selecting a tag', () => {
let selectedTag = null;
beforeEach(() => {
ctrl = new SearchResultsCtrl({});
ctrl.onTagSelected = (tag) => selectedTag = tag;
ctrl.selectTag('tag-test');
});
it('should trigger tag selected callback', () => {
expect(selectedTag["$tag"]).toBe('tag-test');
});
});
describe('when toggle a collapsed folder', () => {
let folderExpanded = false;
beforeEach(() => {
ctrl = new SearchResultsCtrl({});
ctrl.onFolderExpanding = () => { folderExpanded = true; };
let folder = {
expanded: false,
toggle: () => {}
};
ctrl.toggleFolderExpand(folder);
});
it('should trigger folder expanding callback', () => {
expect(folderExpanded).toBeTruthy();
});
});
describe('when toggle an expanded folder', () => {
let folderExpanded = false;
beforeEach(() => {
ctrl = new SearchResultsCtrl({});
ctrl.onFolderExpanding = () => { folderExpanded = true; };
let folder = {
expanded: true,
toggle: () => {}
};
ctrl.toggleFolderExpand(folder);
});
it('should not trigger folder expanding callback', () => {
expect(folderExpanded).toBeFalsy();
});
});
});

View File

@ -2,6 +2,7 @@ import { SearchSrv } from 'app/core/services/search_srv';
import { BackendSrvMock } from 'test/mocks/backend_srv';
import impressionSrv from 'app/core/services/impression_srv';
import { contextSrv } from 'app/core/services/context_srv';
import { beforeEach } from 'test/lib/common';
jest.mock('app/core/store', () => {
return {
@ -244,4 +245,43 @@ describe('SearchSrv', () => {
expect(backendSrvMock.search.mock.calls[0][0].starred).toEqual(true);
});
});
describe('when skipping recent dashboards', () => {
let getRecentDashboardsCalled = false;
beforeEach(() => {
backendSrvMock.search = jest.fn();
backendSrvMock.search.mockReturnValue(Promise.resolve([]));
searchSrv.getRecentDashboards = () => {
getRecentDashboardsCalled = true;
};
return searchSrv.search({ skipRecent: true }).then(() => {});
});
it('should not fetch recent dashboards', () => {
expect(getRecentDashboardsCalled).toBeFalsy();
});
});
describe('when skipping starred dashboards', () => {
let getStarredCalled = false;
beforeEach(() => {
backendSrvMock.search = jest.fn();
backendSrvMock.search.mockReturnValue(Promise.resolve([]));
impressionSrv.getDashboardOpened = jest.fn().mockReturnValue([]);
searchSrv.getStarred = () => {
getStarredCalled = true;
};
return searchSrv.search({ skipStarred: true }).then(() => {});
});
it('should not fetch starred dashboards', () => {
expect(getStarredCalled).toBeFalsy();
});
});
});

View File

@ -1,4 +1,5 @@
import kbn from 'app/core/utils/kbn';
import {getFlotTickDecimals} from 'app/core/utils/ticks';
import _ from 'lodash';
function matchSeriesOverride(aliasOrRegex, seriesAlias) {
@ -16,6 +17,48 @@ function translateFillOption(fill) {
return fill === 0 ? 0.001 : fill/10;
}
/**
* Calculate decimals for legend and update values for each series.
* @param data series data
* @param panel
*/
export function updateLegendValues(data: TimeSeries[], panel) {
for (let i = 0; i < data.length; i++) {
let series = data[i];
let yaxes = panel.yaxes;
let axis = yaxes[series.yaxis - 1];
let {tickDecimals, scaledDecimals} = getFlotTickDecimals(data, axis);
let formater = kbn.valueFormats[panel.yaxes[series.yaxis - 1].format];
// decimal override
if (_.isNumber(panel.decimals)) {
series.updateLegendValues(formater, panel.decimals, null);
} else {
// auto decimals
// legend and tooltip gets one more decimal precision
// than graph legend ticks
tickDecimals = (tickDecimals || -1) + 1;
series.updateLegendValues(formater, tickDecimals, scaledDecimals + 2);
}
}
}
export function getDataMinMax(data: TimeSeries[]) {
let datamin = null;
let datamax = null;
for (let series of data) {
if (datamax === null || datamax < series.stats.max) {
datamax = series.stats.max;
}
if (datamin === null || datamin > series.stats.min) {
datamin = series.stats.min;
}
}
return {datamin, datamax};
}
export default class TimeSeries {
datapoints: any;
id: string;

View File

@ -1,10 +1,7 @@
import coreModule from 'app/core/core_module';
export function react2AngularDirective(name: string, component: any, options: any) {
coreModule.directive(name, ['reactDirective', reactDirective => {
return reactDirective(component, options);
}]);
}

View File

@ -1,3 +1,5 @@
import {getDataMinMax} from 'app/core/time_series2';
/**
* Calculate tick step.
* Implementation from d3-array (ticks.js)
@ -32,6 +34,7 @@ export function getScaledDecimals(decimals, tick_size) {
/**
* Calculate tick size based on min and max values, number of ticks and precision.
* Implementation from Flot.
* @param min Axis minimum
* @param max Axis maximum
* @param noTicks Number of ticks
@ -65,3 +68,91 @@ export function getFlotTickSize(min: number, max: number, noTicks: number, tickD
return size;
}
/**
* Calculate axis range (min and max).
* Implementation from Flot.
*/
export function getFlotRange(panelMin, panelMax, datamin, datamax) {
const autoscaleMargin = 0.02;
let min = +(panelMin != null ? panelMin : datamin);
let max = +(panelMax != null ? panelMax : datamax);
let delta = max - min;
if (delta === 0.0) {
// Grafana fix: wide Y min and max using increased wideFactor
// when all series values are the same
var wideFactor = 0.25;
var widen = Math.abs(max === 0 ? 1 : max * wideFactor);
if (panelMin === null) {
min -= widen;
}
// always widen max if we couldn't widen min to ensure we
// don't fall into min == max which doesn't work
if (panelMax == null || panelMin != null) {
max += widen;
}
} else {
// consider autoscaling
var margin = autoscaleMargin;
if (margin != null) {
if (panelMin == null) {
min -= delta * margin;
// make sure we don't go below zero if all values
// are positive
if (min < 0 && datamin != null && datamin >= 0) {
min = 0;
}
}
if (panelMax == null) {
max += delta * margin;
if (max > 0 && datamax != null && datamax <= 0) {
max = 0;
}
}
}
}
return {min, max};
}
/**
* Calculate tick decimals.
* Implementation from Flot.
*/
export function getFlotTickDecimals(data, axis) {
let {datamin, datamax} = getDataMinMax(data);
let {min, max} = getFlotRange(axis.min, axis.max, datamin, datamax);
let noTicks = 3;
let tickDecimals, maxDec;
let delta = (max - min) / noTicks;
let dec = -Math.floor(Math.log(delta) / Math.LN10);
let magn = Math.pow(10, -dec);
// norm is between 1.0 and 10.0
let norm = delta / magn;
let size;
if (norm < 1.5) {
size = 1;
} else if (norm < 3) {
size = 2;
// special case for 2.5, requires an extra decimal
if (norm > 2.25 && (maxDec == null || dec + 1 <= maxDec)) {
size = 2.5;
++dec;
}
} else if (norm < 7.5) {
size = 5;
} else {
size = 10;
}
size *= magn;
tickDecimals = Math.max(0, maxDec != null ? maxDec : dec);
// grafana addition
const scaledDecimals = tickDecimals - Math.floor(Math.log(size) / Math.LN10);
return {tickDecimals, scaledDecimals};
}

View File

@ -15,7 +15,6 @@ import './unsavedChangesSrv';
import './unsaved_changes_modal';
import './timepicker/timepicker';
import './upload';
import './import/dash_import';
import './export/export_modal';
import './export_data/export_data_modal';
import './ad_hoc_filters';
@ -31,4 +30,13 @@ import './settings/settings';
import coreModule from 'app/core/core_module';
import {DashboardListCtrl} from './dashboard_list_ctrl';
import {FolderDashboardsCtrl} from './folder_dashboards_ctrl';
import {FolderPermissionsCtrl} from './folder_permissions_ctrl';
import {DashboardImportCtrl} from './dashboard_import_ctrl';
import {CreateFolderCtrl} from './create_folder_ctrl';
coreModule.controller('DashboardListCtrl', DashboardListCtrl);
coreModule.controller('FolderDashboardsCtrl', FolderDashboardsCtrl);
coreModule.controller('FolderPermissionsCtrl', FolderPermissionsCtrl);
coreModule.controller('DashboardImportCtrl', DashboardImportCtrl);
coreModule.controller('CreateFolderCtrl', CreateFolderCtrl);

View File

@ -0,0 +1,41 @@
import appEvents from 'app/core/app_events';
export class CreateFolderCtrl {
title = '';
navModel: any;
nameExists = false;
titleTouched = false;
constructor(private backendSrv, private $location, navModelSrv) {
this.navModel = navModelSrv.getNav('create', 'folder');
}
create() {
if (!this.title || this.title.trim().length === 0) {
return;
}
const title = this.title.trim();
return this.backendSrv.createDashboardFolder(title).then(result => {
appEvents.emit('alert-success', ['Folder Created', 'OK']);
var folderUrl = `/dashboards/folder/${result.id}/db/${result.slug}`;
this.$location.url(folderUrl);
});
}
titleChanged() {
this.titleTouched = true;
this.backendSrv.search({query: this.title}).then(res => {
this.nameExists = false;
for (let hit of res) {
if (this.title === hit.title) {
this.nameExists = true;
break;
}
}
});
}
}

View File

@ -129,7 +129,6 @@ export class DashboardCtrl implements PanelContainer {
}
getPanelContainer() {
console.log('DashboardCtrl:getPanelContainer()');
return this;
}

View File

@ -1,10 +1,8 @@
///<reference path="../../../headers/common.d.ts" />
import coreModule from 'app/core/core_module';
import config from 'app/core/config';
import _ from 'lodash';
import config from 'app/core/config';
export class DashImportCtrl {
export class DashboardImportCtrl {
navModel: any;
step: number;
jsonText: string;
parseError: string;
@ -17,7 +15,9 @@ export class DashImportCtrl {
gnetInfo: any;
/** @ngInject */
constructor(private backendSrv, private $location, private $scope, $routeParams) {
constructor(private backendSrv, navModelSrv, private $location, private $scope, $routeParams) {
this.navModel = navModelSrv.getNav('create', 'import');
this.step = 1;
this.nameExists = false;
@ -160,17 +160,4 @@ export class DashImportCtrl {
this.gnetError = '';
this.gnetInfo = '';
}
}
export function dashImportDirective() {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/import/dash_import.html',
controller: DashImportCtrl,
bindToController: true,
controllerAs: 'ctrl',
};
}
coreModule.directive('dashImport', dashImportDirective);

View File

@ -1,206 +1,8 @@
import _ from 'lodash';
import appEvents from 'app/core/app_events';
import { SearchSrv } from 'app/core/services/search_srv';
export class DashboardListCtrl {
public sections: any [];
tagFilterOptions: any [];
selectedTagFilter: any;
query: any;
navModel: any;
canDelete = false;
canMove = false;
hasFilters = false;
selectAllChecked = false;
starredFilterOptions = [{text: 'Filter by Starred', disabled: true}, {text: 'Yes'}, {text: 'No'}];
selectedStarredFilter: any;
/** @ngInject */
constructor(private backendSrv, navModelSrv, private $q, private searchSrv: SearchSrv) {
this.navModel = navModelSrv.getNav('dashboards', 'dashboards', 0);
this.query = {query: '', mode: 'tree', tag: [], starred: false};
this.selectedStarredFilter = this.starredFilterOptions[0];
this.getDashboards().then(() => {
this.getTags();
});
}
getDashboards() {
return this.searchSrv.search(this.query).then((result) => {
return this.initDashboardList(result);
});
}
initDashboardList(result: any) {
this.canMove = false;
this.canDelete = false;
this.selectAllChecked = false;
this.hasFilters = this.query.query.length > 0 || this.query.tag.length > 0 || this.query.starred;
if (!result) {
this.sections = [];
return;
}
this.sections = result;
for (let section of this.sections) {
section.checked = false;
for (let dashboard of section.items) {
dashboard.checked = false;
}
}
}
selectionChanged() {
let selectedDashboards = 0;
for (let section of this.sections) {
selectedDashboards += _.filter(section.items, {checked: true}).length;
}
const selectedFolders = _.filter(this.sections, {checked: true}).length;
this.canMove = selectedDashboards > 0 && selectedFolders === 0;
this.canDelete = selectedDashboards > 0 || selectedFolders > 0;
}
getDashboardsToDelete() {
let selectedDashboards = [];
for (const section of this.sections) {
if (section.checked) {
selectedDashboards.push(section.uri);
} else {
const selected = _.filter(section.items, {checked: true});
selectedDashboards.push(... _.map(selected, 'uri'));
}
}
return selectedDashboards;
}
getFolderIds(sections) {
const ids = [];
for (let s of sections) {
if (s.checked) {
ids.push(s.id);
}
}
return ids;
}
delete() {
const selectedDashboards = this.getDashboardsToDelete();
appEvents.emit('confirm-modal', {
title: 'Delete',
text: `Do you want to delete the ${selectedDashboards.length} selected dashboards?`,
icon: 'fa-trash',
yesText: 'Delete',
onConfirm: () => {
const promises = [];
for (let dash of selectedDashboards) {
promises.push(this.backendSrv.delete(`/api/dashboards/${dash}`));
}
this.$q.all(promises).then(() => {
this.getDashboards();
});
}
});
}
getDashboardsToMove() {
let selectedDashboards = [];
for (const section of this.sections) {
const selected = _.filter(section.items, {checked: true});
selectedDashboards.push(... _.map(selected, 'uri'));
}
return selectedDashboards;
}
moveTo() {
const selectedDashboards = this.getDashboardsToMove();
const template = '<move-to-folder-modal dismiss="dismiss()" ' +
'dashboards="model.dashboards" after-save="model.afterSave()">' +
'</move-to-folder-modal>`';
appEvents.emit('show-modal', {
templateHtml: template,
modalClass: 'modal--narrow',
model: {dashboards: selectedDashboards, afterSave: this.getDashboards.bind(this)}
});
}
toggleFolder(section) {
return this.searchSrv.toggleSection(section);
}
getTags() {
return this.searchSrv.getDashboardTags().then((results) => {
this.tagFilterOptions = [{ term: 'Filter By Tag', disabled: true }].concat(results);
this.selectedTagFilter = this.tagFilterOptions[0];
});
}
filterByTag(tag, evt) {
this.query.tag.push(tag);
if (evt) {
evt.stopPropagation();
evt.preventDefault();
}
return this.getDashboards();
}
onQueryChange() {
return this.getDashboards();
}
onTagFilterChange() {
this.query.tag.push(this.selectedTagFilter.term);
this.selectedTagFilter = this.tagFilterOptions[0];
return this.getDashboards();
}
removeTag(tag, evt) {
this.query.tag = _.without(this.query.tag, tag);
this.getDashboards();
if (evt) {
evt.stopPropagation();
evt.preventDefault();
}
}
onStarredFilterChange() {
this.query.starred = this.selectedStarredFilter.text === 'Yes';
return this.getDashboards();
}
onSelectAllChanged() {
for (let section of this.sections) {
if (!section.hideHeader) {
section.checked = this.selectAllChecked;
}
section.items = _.map(section.items, (item) => {
item.checked = this.selectAllChecked;
return item;
});
}
this.selectionChanged();
}
clearFilters() {
this.query.query = '';
this.query.tag = [];
this.query.starred = false;
this.getDashboards();
constructor(navModelSrv) {
this.navModel = navModelSrv.getNav('dashboards', 'manage-dashboards', 0);
}
}

View File

@ -383,8 +383,8 @@ export class DashboardMigrator {
return;
}
// Add special "row" panels if even one row is collapsed or has visible title
const showRows = _.some(old.rows, (row) => row.collapse || row.showTitle);
// Add special "row" panels if even one row is collapsed, repeated or has visible title
const showRows = _.some(old.rows, (row) => row.collapse || row.showTitle || row.repeat);
for (let row of old.rows) {
let height: any = row.height || DEFAULT_ROW_HEIGHT;
@ -398,6 +398,7 @@ export class DashboardMigrator {
rowPanel.type = 'row';
rowPanel.title = row.title;
rowPanel.collapsed = row.collapse;
rowPanel.repeat = row.repeat;
rowPanel.panels = [];
rowPanel.gridPos = {x: 0, y: yPos, w: GRID_COLUMN_COUNT, h: rowGridHeight};
rowPanelModel = new PanelModel(rowPanel);

View File

@ -181,6 +181,14 @@ export class DashboardModel {
if (panel.id > max) {
max = panel.id;
}
if (panel.collapsed) {
for (let rowPanel of panel.panels) {
if (rowPanel.id > max) {
max = rowPanel.id;
}
}
}
}
return max + 1;
@ -251,16 +259,6 @@ export class DashboardModel {
}
}
// 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);
@ -274,21 +272,11 @@ export class DashboardModel {
return sourcePanel;
}
var clone = new PanelModel(sourcePanel.getSaveModel());
let clone = new PanelModel(sourcePanel.getSaveModel());
clone.id = this.getNextPanelId();
if (sourcePanel.type === 'row') {
// for row clones we need to figure out panels under row to clone and where to insert clone
let rowPanels = this.getRowPanels(sourcePanelIndex);
clone.panels = _.map(rowPanels, panel => panel.getSaveModel());
// insert after preceding row's panels
let insertPos = sourcePanelIndex + ((rowPanels.length + 1)*valueIndex);
this.panels.splice(insertPos, 0, clone);
} else {
// insert after source panel + value index
this.panels.splice(sourcePanelIndex+valueIndex, 0, clone);
}
// insert after source panel + value index
this.panels.splice(sourcePanelIndex+valueIndex, 0, clone);
clone.repeatIteration = this.iteration;
clone.repeatPanelId = sourcePanel.id;
@ -296,37 +284,60 @@ export class DashboardModel {
return clone;
}
getBottomYForRow() {
getRowRepeatClone(sourcePanel, valueIndex, sourcePanelIndex) {
// if first clone return source
if (valueIndex === 0) {
if (!sourcePanel.collapsed) {
let rowPanels = this.getRowPanels(sourcePanelIndex);
sourcePanel.panels = rowPanels;
}
return sourcePanel;
}
let clone = new PanelModel(sourcePanel.getSaveModel());
// for row clones we need to figure out panels under row to clone and where to insert clone
let rowPanels, insertPos;
if (sourcePanel.collapsed) {
rowPanels = _.cloneDeep(sourcePanel.panels);
clone.panels = rowPanels;
// insert copied row after preceding row
insertPos = sourcePanelIndex + valueIndex;
} else {
rowPanels = this.getRowPanels(sourcePanelIndex);
clone.panels = _.map(rowPanels, panel => panel.getSaveModel());
// insert copied row after preceding row's panels
insertPos = sourcePanelIndex + ((rowPanels.length + 1)*valueIndex);
}
this.panels.splice(insertPos, 0, clone);
this.updateRepeatedPanelIds(clone);
return clone;
}
repeatPanel(panel: PanelModel, panelIndex: number) {
var variable = _.find(this.templating.list, {name: panel.repeat});
let 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});
if (panel.type === 'row') {
this.repeatRow(panel, panelIndex, variable);
return;
}
let selectedOptions = this.getSelectedVariableOptions(variable);
let minWidth = panel.minSpan || 6;
let xPos = 0;
let yPos = panel.gridPos.y;
for (let index = 0; index < selected.length; index++) {
var option = selected[index];
var copy = this.getPanelRepeatClone(panel, index, panelIndex);
for (let index = 0; index < selectedOptions.length; index++) {
let option = selectedOptions[index];
let copy;
copy = this.getPanelRepeatClone(panel, index, panelIndex);
copy.scopedVars = {};
copy.scopedVars[variable.name] = option;
if (copy.type === 'row') {
// place row below row panels
}
if (panel.repeatDirection === REPEAT_DIR_VERTICAL) {
copy.gridPos.y = yPos;
yPos += copy.gridPos.h;
@ -334,7 +345,7 @@ export class DashboardModel {
// 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.w = Math.max(GRID_COLUMN_COUNT / selectedOptions.length, minWidth);
copy.gridPos.x = xPos;
copy.gridPos.y = yPos;
@ -349,6 +360,90 @@ export class DashboardModel {
}
}
repeatRow(panel: PanelModel, panelIndex: number, variable) {
let selectedOptions = this.getSelectedVariableOptions(variable);
let yPos = panel.gridPos.y;
function setScopedVars(panel, variableOption) {
panel.scopedVars = {};
panel.scopedVars[variable.name] = variableOption;
}
for (let optionIndex = 0; optionIndex < selectedOptions.length; optionIndex++) {
let option = selectedOptions[optionIndex];
let rowCopy = this.getRowRepeatClone(panel, optionIndex, panelIndex);
setScopedVars(rowCopy, option);
let rowHeight = this.getRowHeight(rowCopy);
let rowPanels = rowCopy.panels || [];
let panelBelowIndex;
if (panel.collapsed) {
// For collapsed row just copy its panels and set scoped vars and proper IDs
_.each(rowPanels, (rowPanel, i) => {
setScopedVars(rowPanel, option);
if (optionIndex > 0) {
this.updateRepeatedPanelIds(rowPanel);
}
});
rowCopy.gridPos.y += optionIndex;
yPos += optionIndex;
panelBelowIndex = panelIndex + optionIndex + 1;
} else {
// insert after 'row' panel
let insertPos = panelIndex + ((rowPanels.length + 1) * optionIndex) + 1;
_.each(rowPanels, (rowPanel, i) => {
setScopedVars(rowPanel, option);
if (optionIndex > 0) {
let cloneRowPanel = new PanelModel(rowPanel);
this.updateRepeatedPanelIds(cloneRowPanel);
// For exposed row additionally set proper Y grid position and add it to dashboard panels
cloneRowPanel.gridPos.y += rowHeight * optionIndex;
this.panels.splice(insertPos+i, 0, cloneRowPanel);
}
});
rowCopy.panels = [];
rowCopy.gridPos.y += rowHeight * optionIndex;
yPos += rowHeight;
panelBelowIndex = insertPos+rowPanels.length;
}
// Update gridPos for panels below
for (let i = panelBelowIndex; i< this.panels.length; i++) {
this.panels[i].gridPos.y += yPos;
}
}
}
updateRepeatedPanelIds(panel: PanelModel) {
panel.repeatPanelId = panel.id;
panel.id = this.getNextPanelId();
panel.repeatIteration = this.iteration;
panel.repeat = null;
return panel;
}
getSelectedVariableOptions(variable) {
let selectedOptions;
if (variable.current.text === 'All') {
selectedOptions = variable.options.slice(1, variable.options.length);
} else {
selectedOptions = _.filter(variable.options, {selected: true});
}
return selectedOptions;
}
getRowHeight(rowPanel: PanelModel): number {
if (!rowPanel.panels || rowPanel.panels.length === 0) {
return 0;
}
const positions = _.map(rowPanel.panels, 'gridPos');
const maxPos = _.maxBy(positions, (pos) => {
return pos.y + pos.h;
});
return maxPos.h + 1;
}
removePanel(panel: PanelModel) {
var index = _.indexOf(this.panels, panel);
this.panels.splice(index, 1);

View File

@ -4,6 +4,7 @@ import _ from 'lodash';
import config from 'app/core/config';
import {PanelModel} from '../panel_model';
import {PanelContainer} from './PanelContainer';
import ScrollBar from 'app/core/components/ScrollBar/ScrollBar';
export interface AddPanelPanelProps {
panel: PanelModel;
@ -78,9 +79,9 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
<span className="add-panel__title">New Panel</span>
<span className="add-panel__sub-title">Select a visualization</span>
</div>
<div className="add-panel__items">
<ScrollBar className="add-panel__items">
{this.state.panelPlugins.map(this.renderPanelItem.bind(this))}
</div>
</ScrollBar>
</div>
</div>
);

View File

@ -11,7 +11,7 @@ import sizeMe from 'react-sizeme';
let lastGridWidth = 1200;
function GridWrapper({size, layout, onLayoutChange, children, onResize, onResizeStop, onWidthChange}) {
function GridWrapper({size, layout, onLayoutChange, children, onResize, onResizeStop, onWidthChange, className}) {
if (size.width === 0) {
console.log('size is zero!');
}
@ -25,12 +25,12 @@ function GridWrapper({size, layout, onLayoutChange, children, onResize, onResize
return (
<ReactGridLayout
width={lastGridWidth}
className="layout"
className={className}
isDraggable={true}
isResizable={true}
measureBeforeMount={false}
containerPadding={[0, 0]}
useCSSTransforms={false}
useCSSTransforms={true}
margin={[GRID_CELL_VMARGIN, GRID_CELL_VMARGIN]}
cols={GRID_COLUMN_COUNT}
rowHeight={GRID_CELL_HEIGHT}
@ -64,6 +64,8 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
this.onResizeStop = this.onResizeStop.bind(this);
this.onWidthChange = this.onWidthChange.bind(this);
this.state = {animated: false};
// subscribe to dashboard events
this.dashboard = this.panelContainer.getDashboard();
this.dashboard.on('panel-added', this.triggerForceUpdate.bind(this));
@ -134,6 +136,14 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
this.panelMap[newItem.i].resizeDone();
}
componentDidMount() {
setTimeout(() => {
this.setState(() => {
return {animated: true};
});
});
}
renderPanels() {
const panelElements = [];
@ -152,6 +162,7 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
render() {
return (
<SizedReactLayoutGrid
className={classNames({'layout': true, 'animated': this.state.animated})}
layout={this.buildLayout()}
onLayoutChange={this.onLayoutChange}
onWidthChange={this.onWidthChange}

View File

@ -16,7 +16,6 @@ export class DashNavCtrl {
private dashboardSrv,
private $location,
private backendSrv,
private contextSrv,
public playlistSrv,
navModelSrv) {
this.navModel = navModelSrv.getDashboardNav(this.dashboard, this);
@ -33,12 +32,8 @@ export class DashNavCtrl {
}
}
toggleSideMenu() {
this.contextSrv.toggleSideMenu();
}
openSettings() {
var search = _.extend(this.$location.search(), {editview: 'general'});
openEditView(editview) {
var search = _.extend(this.$location.search(), {editview: editview});
this.$location.search(search);
}

View File

@ -26,7 +26,7 @@ export class DashExportCtrl {
}
saveJson() {
var clone = this.dashboardSrv.getCurrent().getSaveModelClone();
var clone = this.dash;
this.$scope.$root.appEvent('show-json-editor', {
object: clone,

View File

@ -0,0 +1,16 @@
import {FolderPageLoader} from './folder_page_loader';
export class FolderDashboardsCtrl {
navModel: any;
folderId: number;
/** @ngInject */
constructor(private backendSrv, navModelSrv, private $routeParams) {
if (this.$routeParams.folderId && this.$routeParams.type && this.$routeParams.slug) {
this.folderId = $routeParams.folderId;
this.navModel = navModelSrv.getNav('manage-folder', 'manage-folder-dashboards', 0);
new FolderPageLoader(this.backendSrv, this.$routeParams).load(this.navModel, this.folderId);
}
}
}

View File

@ -0,0 +1,21 @@
import _ from "lodash";
export class FolderPageLoader {
constructor(private backendSrv, private $routeParams) { }
load(navModel, folderId) {
this.backendSrv.getDashboard(this.$routeParams.type, this.$routeParams.slug).then(result => {
const folderTitle = result.dashboard.title;
navModel.main.text = '';
navModel.main.breadcrumbs = [
{ title: 'Dashboards', uri: '/dashboards' },
{ title: folderTitle }
];
const folderUrl = `/dashboards/folder/${folderId}/${result.meta.type}/${result.meta.slug}`;
const dashTab = _.find(navModel.main.children, { id: 'manage-folder-dashboards' });
dashTab.url = folderUrl;
const permTab = _.find(navModel.main.children, { id: 'manage-folder-permissions' });
permTab.url = folderUrl + '/permissions';
});
}
}

View File

@ -0,0 +1,16 @@
import {FolderPageLoader} from './folder_page_loader';
export class FolderPermissionsCtrl {
navModel: any;
folderId: number;
/** @ngInject */
constructor(private backendSrv, navModelSrv, private $routeParams) {
if (this.$routeParams.folderId && this.$routeParams.type && this.$routeParams.slug) {
this.folderId = $routeParams.folderId;
this.navModel = navModelSrv.getNav('manage-folder', 'manage-folder-permissions', 0);
new FolderPageLoader(this.backendSrv, this.$routeParams).load(this.navModel, this.folderId);
}
}
}

View File

@ -1,138 +0,0 @@
<div class="modal-header">
<h2 class="modal-header-title">
<i class="gicon gicon-dashboard-import"></i>
<span class="p-l-1">Import Dashboard</span>
</h2>
<a class="modal-header-close" ng-click="dismiss();">
<i class="fa fa-remove"></i>
</a>
</div>
<div class="modal-content" ng-cloak>
<div ng-if="ctrl.step === 1">
<form class="gf-form-group">
<dash-upload on-upload="ctrl.onUpload(dash)"></dash-upload>
</form>
<h5 class="section-heading">Grafana.com Dashboard</h5>
<div class="gf-form-group">
<div class="gf-form">
<input type="text" class="gf-form-input" ng-model="ctrl.gnetUrl" placeholder="Paste Grafana.com dashboard url or id" ng-blur="ctrl.checkGnetDashboard()"></textarea>
</div>
<div class="gf-form" ng-if="ctrl.gnetError">
<label class="gf-form-label text-warning">
<i class="fa fa-warning"></i>
{{ctrl.gnetError}}
</label>
</div>
</div>
<h5 class="section-heading">Or paste JSON</h5>
<div class="gf-form-group">
<div class="gf-form">
<textarea rows="7" data-share-panel-url="" class="gf-form-input" ng-model="ctrl.jsonText"></textarea>
</div>
<button type="button" class="btn btn-secondary" ng-click="ctrl.loadJsonText()">
<i class="fa fa-paste"></i>
Load
</button>
<span ng-if="ctrl.parseError" class="text-error p-l-1">
<i class="fa fa-warning"></i>
{{ctrl.parseError}}
</span>
</div>
</div>
<div ng-if="ctrl.step === 2">
<div class="gf-form-group" ng-if="ctrl.dash.gnetId">
<h3 class="section-heading">
Importing Dashboard from
<a href="https://grafana.com/dashboards/{{ctrl.dash.gnetId}}" class="external-link" target="_blank">Grafana.com</a>
</h3>
<div class="gf-form">
<label class="gf-form-label width-15">Published by</label>
<label class="gf-form-label width-15">{{ctrl.gnetInfo.orgName}}</label>
</div>
<div class="gf-form">
<label class="gf-form-label width-15">Updated on</label>
<label class="gf-form-label width-15">{{ctrl.gnetInfo.updatedAt | date : 'yyyy-MM-dd HH:mm:ss'}}</label>
</div>
</div>
<h3 class="section-heading">
Options
</h3>
<div class="gf-form-group">
<div class="gf-form-inline">
<div class="gf-form gf-form--grow">
<label class="gf-form-label width-15">Name</label>
<input type="text" class="gf-form-input" ng-model="ctrl.dash.title" give-focus="true" ng-change="ctrl.titleChanged()" ng-class="{'validation-error': ctrl.nameExists || !ctrl.dash.title}">
<label class="gf-form-label text-success" ng-if="!ctrl.nameExists && ctrl.dash.title">
<i class="fa fa-check"></i>
</label>
</div>
</div>
<div class="gf-form-inline" ng-if="ctrl.nameExists">
<div class="gf-form offset-width-15 gf-form--grow">
<label class="gf-form-label text-warning gf-form-label--grow">
<i class="fa fa-warning"></i>
A Dashboard with the same name already exists
</label>
</div>
</div>
<div class="gf-form-inline" ng-if="!ctrl.dash.title">
<div class="gf-form offset-width-15 gf-form--grow">
<label class="gf-form-label text-warning gf-form-label--grow">
<i class="fa fa-warning"></i>
A Dashboard should have a name
</label>
</div>
</div>
<div ng-repeat="input in ctrl.inputs">
<div class="gf-form">
<label class="gf-form-label width-15">
{{input.label}}
<info-popover mode="right-normal">
{{input.info}}
</info-popover>
</label>
<!-- Data source input -->
<div class="gf-form-select-wrapper" style="width: 100%" ng-if="input.type === 'datasource'">
<select class="gf-form-input" ng-model="input.value" ng-options="v.value as v.text for v in input.options" ng-change="ctrl.inputValueChanged()">
<option value="" ng-hide="input.value">{{input.info}}</option>
</select>
</div>
<!-- Constant input -->
<input ng-if="input.type === 'constant'" type="text" class="gf-form-input" ng-model="input.value" placeholder="{{input.default}}" ng-change="ctrl.inputValueChanged()">
<label class="gf-form-label text-success" ng-show="input.value">
<i class="fa fa-check"></i>
</label>
</div>
</div>
</div>
<div class="gf-form-button-row">
<button type="button" class="btn gf-form-btn btn-success width-12" ng-click="ctrl.saveDashboard()" ng-hide="ctrl.nameExists" ng-disabled="!ctrl.inputsValid">
<i class="fa fa-save"></i> Import
</button>
<button type="button" class="btn gf-form-btn btn-danger width-12" ng-click="ctrl.saveDashboard()" ng-show="ctrl.nameExists" ng-disabled="!ctrl.inputsValid">
<i class="fa fa-save"></i> Import (Overwrite)
</button>
<a class="btn btn-link" ng-click="dismiss()">Cancel</a>
<a class="btn btn-link" ng-click="ctrl.back()">Back</a>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,43 @@
<page-header model="ctrl.navModel"></page-header>
<div class="page-container page-body" ng-cloak>
<form name="ctrl.saveForm" ng-submit="ctrl.create()" class="modal-content folder-modal" novalidate>
<div class="gf-form-group">
<div class="gf-form-inline">
<div class="gf-form gf-form--grow">
<label class="gf-form-label width-10">Folder name</label>
<input type="text" class="gf-form-input" ng-model="ctrl.title" give-focus="true" ng-change="ctrl.titleChanged()" ng-model-options="{ debounce: 400 }" ng-class="{'validation-error': ctrl.nameExists || !ctrl.dash.title}">
<label class="gf-form-label text-success" ng-if="!ctrl.nameExists && ctrl.title">
<i class="fa fa-check"></i>
</label>
</div>
</div>
<div class="gf-form-inline" ng-if="ctrl.nameExists">
<div class="gf-form offset-width-10 gf-form--grow">
<label class="gf-form-label text-warning gf-form-label--grow">
<i class="fa fa-warning"></i>
A Folder or Dashboard with the same name already exists
</label>
</div>
</div>
<div class="gf-form-inline" ng-if="!ctrl.title && ctrl.titleTouched">
<div class="gf-form offset-width-10 gf-form--grow">
<label class="gf-form-label text-warning gf-form-label--grow">
<i class="fa fa-warning"></i>
A Folder should have a name
</label>
</div>
</div>
</div>
<div class="gf-form-button-row">
<button type="submit" class="btn btn-success width-12" ng-disabled="ctrl.nameExists || ctrl.title.length === 0">
<i class="fa fa-save"></i> Create
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,126 @@
<page-header model="ctrl.navModel"></page-header>
<div class="page-container page-body" ng-cloak>
<div ng-if="ctrl.step === 1">
<form class="page-action-bar">
<div class="page-action-bar__spacer"></div>
<dash-upload on-upload="ctrl.onUpload(dash)"></dash-upload>
</form>
<h5 class="section-heading">Grafana.com Dashboard</h5>
<div class="gf-form-group">
<div class="gf-form gf-form--grow">
<input type="text" class="gf-form-input max-width-30" ng-model="ctrl.gnetUrl" placeholder="Paste Grafana.com dashboard url or id" ng-blur="ctrl.checkGnetDashboard()"></textarea>
</div>
<div class="gf-form" ng-if="ctrl.gnetError">
<label class="gf-form-label text-warning">
<i class="fa fa-warning"></i>
{{ctrl.gnetError}}
</label>
</div>
</div>
<h5 class="section-heading">Or paste JSON</h5>
<div class="gf-form-group">
<div class="gf-form">
<textarea rows="10" data-share-panel-url="" class="gf-form-input" ng-model="ctrl.jsonText"></textarea>
</div>
<button type="button" class="btn btn-secondary" ng-click="ctrl.loadJsonText()">
<i class="fa fa-paste"></i>
Load
</button>
<span ng-if="ctrl.parseError" class="text-error p-l-1">
<i class="fa fa-warning"></i>
{{ctrl.parseError}}
</span>
</div>
</div>
<div ng-if="ctrl.step === 2">
<div class="gf-form-group" ng-if="ctrl.dash.gnetId">
<h3 class="section-heading">
Importing Dashboard from
<a href="https://grafana.com/dashboards/{{ctrl.dash.gnetId}}" class="external-link" target="_blank">Grafana.com</a>
</h3>
<div class="gf-form">
<label class="gf-form-label width-15">Published by</label>
<label class="gf-form-label width-15">{{ctrl.gnetInfo.orgName}}</label>
</div>
<div class="gf-form">
<label class="gf-form-label width-15">Updated on</label>
<label class="gf-form-label width-15">{{ctrl.gnetInfo.updatedAt | date : 'yyyy-MM-dd HH:mm:ss'}}</label>
</div>
</div>
<h3 class="section-heading">
Options
</h3>
<div class="gf-form-group">
<div class="gf-form-inline">
<div class="gf-form gf-form--grow">
<label class="gf-form-label width-15">Name</label>
<input type="text" class="gf-form-input" ng-model="ctrl.dash.title" give-focus="true" ng-change="ctrl.titleChanged()" ng-class="{'validation-error': ctrl.nameExists || !ctrl.dash.title}">
<label class="gf-form-label text-success" ng-if="!ctrl.nameExists && ctrl.dash.title">
<i class="fa fa-check"></i>
</label>
</div>
</div>
<div class="gf-form-inline" ng-if="ctrl.nameExists">
<div class="gf-form offset-width-15 gf-form--grow">
<label class="gf-form-label text-warning gf-form-label--grow">
<i class="fa fa-warning"></i>
A Dashboard with the same name already exists
</label>
</div>
</div>
<div class="gf-form-inline" ng-if="!ctrl.dash.title">
<div class="gf-form offset-width-15 gf-form--grow">
<label class="gf-form-label text-warning gf-form-label--grow">
<i class="fa fa-warning"></i>
A Dashboard should have a name
</label>
</div>
</div>
<div ng-repeat="input in ctrl.inputs">
<div class="gf-form">
<label class="gf-form-label width-15">
{{input.label}}
<info-popover mode="right-normal">
{{input.info}}
</info-popover>
</label>
<!-- Data source input -->
<div class="gf-form-select-wrapper" style="width: 100%" ng-if="input.type === 'datasource'">
<select class="gf-form-input" ng-model="input.value" ng-options="v.value as v.text for v in input.options" ng-change="ctrl.inputValueChanged()">
<option value="" ng-hide="input.value">{{input.info}}</option>
</select>
</div>
<!-- Constant input -->
<input ng-if="input.type === 'constant'" type="text" class="gf-form-input" ng-model="input.value" placeholder="{{input.default}}" ng-change="ctrl.inputValueChanged()">
<label class="gf-form-label text-success" ng-show="input.value">
<i class="fa fa-check"></i>
</label>
</div>
</div>
</div>
<div class="gf-form-button-row">
<button type="button" class="btn btn-success width-12" ng-click="ctrl.saveDashboard()" ng-hide="ctrl.nameExists" ng-disabled="!ctrl.inputsValid">
<i class="fa fa-save"></i> Import
</button>
<button type="button" class="btn btn-danger width-12" ng-click="ctrl.saveDashboard()" ng-show="ctrl.nameExists" ng-disabled="!ctrl.inputsValid">
<i class="fa fa-save"></i> Import (Overwrite)
</button>
<a class="btn btn-link" ng-click="ctrl.back()">Cancel</a>
</div>
</div>
</div>

View File

@ -1,131 +0,0 @@
<page-header model="ctrl.navModel"></page-header>
<div class="page-container page-body">
<div class="page-action-bar">
<div class="gf-form gf-form--grow">
<label class="gf-form-label">Search</label>
<input type="text" class="gf-form-input max-width-30" placeholder="Find Dashboard by name" tabindex="1" give-focus="true" ng-model="ctrl.query.query" ng-model-options="{ debounce: 500 }" spellcheck='false' ng-change="ctrl.onQueryChange()" />
</div>
<div class="page-action-bar__spacer"></div>
<a class="btn btn-success" href="/dashboard/new">
<i class="fa fa-plus"></i>
Dashboard
</a>
<a class="btn btn-success" href="/dashboard/new/?editview=new-folder">
<i class="fa fa-plus"></i>
Folder
</a>
</div>
<div class="gf-form" ng-if="ctrl.query.tag.length">
Filters:
<span ng-repeat="tagName in ctrl.query.tag">
<a ng-click="ctrl.removeTag(tagName, $event)" tag-color-from-name="tagName" class="label label-tag">
<i class="fa fa-remove"></i>
{{tagName}}
</a>
</span>
</div>
<div class="gf-form">
<div class="gf-form-button-row"
ng-show="ctrl.hasFilters">
<button
type="button"
class="btn gf-form-button btn-inverse btn-small"
ng-click="ctrl.clearFilters()">
<i class="fa fa-close"></i> Clear current search query and filters
</button>
</div>
</div>
<div class="gf-form-group">
<div class="gf-form-button-row">
<button type="button"
class="btn gf-form-button btn-secondary"
ng-disabled="!ctrl.canMove"
ng-click="ctrl.moveTo()"
bs-tooltip="ctrl.canMove ? '' : 'Select a dashboard to move (cannot move folders)'" data-placement="bottom">
<i class="fa fa-exchange"></i>&nbsp;&nbsp;Move to...
</button>
<button type="button"
class="btn gf-form-button btn-inverse"
ng-click="ctrl.delete()"
ng-disabled="!ctrl.canDelete">
<i class="fa fa-trash"></i>&nbsp;&nbsp;Delete
</button>
</div>
</div>
<div class="dashboard-list">
<div class="search-results-filter-row">
<gf-form-switch
on-change="ctrl.onSelectAllChanged()"
checked="ctrl.selectAllChecked"
/>
<div class="search-results-filter-row__filters">
<select
class="search-results-filter-row__filters-item gf-form-input"
ng-model="ctrl.selectedStarredFilter"
ng-options="t.text disable when t.disabled for t in ctrl.starredFilterOptions"
ng-change="ctrl.onStarredFilterChange()"
/>
<select
class="search-results-filter-row__filters-item gf-form-input"
ng-model="ctrl.selectedTagFilter"
ng-options="t.term disable when t.disabled for t in ctrl.tagFilterOptions"
ng-change="ctrl.onTagFilterChange()"
/>
</div>
</div>
<div class="search-results-container" ng-show="ctrl.sections.length > 0" grafana-scrollbar>
<div ng-repeat="section in ctrl.sections" class="search-section">
<div class="search-section__header__with-checkbox" ng-hide="section.hideHeader">
<gf-form-switch
on-change="ctrl.selectionChanged()"
checked="section.checked">
</gf-form-switch>
<a class="search-section__header pointer" ng-click="ctrl.toggleFolder(section)" ng-hide="section.hideHeader">
<i class="search-section__header__icon" ng-class="section.icon"></i>
<span class="search-section__header__text">{{::section.title}}</span>
<i class="fa fa-minus search-section__header__toggle" ng-show="section.expanded"></i>
<i class="fa fa-plus search-section__header__toggle" ng-hide="section.expanded"></i>
</a>
</div>
<div ng-if="section.expanded">
<div ng-repeat="item in section.items" class="search-item__with-checkbox" ng-class="{'selected': item.selected}">
<gf-form-switch
on-change="ctrl.selectionChanged()"
checked="item.checked" />
<a ng-href="{{::item.url}}" class="search-item">
<span class="search-item__icon">
<i class="fa fa-th-large"></i>
</span>
<span class="search-item__body">
<div class="search-item__body-title">{{::item.title}}</div>
<div class="search-item__body-sub-title" ng-show="item.folderTitle && section.hideHeader">
<i class="fa fa-folder-o"></i>
{{::item.folderTitle}}
</div>
</span>
<span class="search-item__tags">
<span ng-click="ctrl.filterByTag(tag, $event)" ng-repeat="tag in item.tags" tag-color-from-name="tag" class="label label-tag">
{{tag}}
</span>
</span>
<span class="search-item__actions">
<i class="fa" ng-class="{'fa-star': item.isStarred, 'fa-star-o': !item.isStarred}"></i>
</span>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<em class="muted" ng-hide="ctrl.sections.length > 0">
No Dashboards or Folders found.
</em>

View File

@ -0,0 +1,5 @@
<page-header model="ctrl.navModel"></page-header>
<div class="page-container page-body">
<manage-dashboards />
</div>

View File

@ -0,0 +1,5 @@
<page-header ng-if="ctrl.navModel" model="ctrl.navModel"></page-header>
<div class="page-container page-body">
<manage-dashboards ng-if="ctrl.folderId" folder-id="ctrl.folderId" />
</div>

View File

@ -0,0 +1,5 @@
<page-header model="ctrl.navModel"></page-header>
<div class="page-container page-body">
<h1>Coming soon! Permissions will be added in Grafana 5.0 beta.</h1>
</div>

View File

@ -1,5 +1,5 @@
<div class="edit-tab-with-sidemenu">
<div class="edit-tab-with-sidemenu" ng-if="ctrl.viewId !== 'timepicker'">
<aside class="edit-sidemenu-aside">
<h2>
<i class="fa fa-cog"></i>
@ -26,6 +26,9 @@
<div class="edit-tab-content" ng-if="ctrl.viewId === 'templating'">
annotations
</div>
</div>
<div ng-if="ctrl.viewId === 'timepicker'">
<gf-time-picker-dropdown dashboard="ctrl.dashboard"></gf-time-picker-dropdown>
</div>

View File

@ -1,18 +1,11 @@
define(['angular',
'lodash',
'jquery',
'moment',
'app/core/config',
],
function (angular, _, $, moment, config) {
'use strict';
import angular from 'angular';
import moment from 'moment';
import config from 'app/core/config';
config = config.default;
var module = angular.module('grafana.controllers');
module.controller('ShareModalCtrl', function($scope, $rootScope, $location, $timeout, timeSrv, templateSrv, linkSrv) {
export class ShareModalCtrl {
/** @ngInject */
constructor($scope, $rootScope, $location, $timeout, timeSrv, templateSrv, linkSrv) {
$scope.options = { forCurrent: true, includeTemplateVars: true, theme: 'current' };
$scope.editor = { index: $scope.tabIndex || 0};
@ -93,7 +86,7 @@ function (angular, _, $, moment, config) {
$scope.getShareUrl = function() {
return $scope.shareUrl;
};
}
}
});
});
angular.module('grafana.controllers').controller('ShareModalCtrl', ShareModalCtrl);

View File

@ -1,25 +1,24 @@
import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common';
import {DashboardImportCtrl} from '../dashboard_import_ctrl';
import config from '../../../core/config';
import {DashImportCtrl} from 'app/features/dashboard/import/dash_import';
import config from 'app/core/config';
describe('DashImportCtrl', function() {
describe('DashboardImportCtrl', function() {
var ctx: any = {};
var backendSrv = {
search: sinon.stub().returns(Promise.resolve([])),
get: sinon.stub()
};
beforeEach(angularMocks.module('grafana.core'));
let navModelSrv;
let backendSrv;
beforeEach(angularMocks.inject(($rootScope, $controller, $q) => {
ctx.$q = $q;
ctx.scope = $rootScope.$new();
ctx.ctrl = $controller(DashImportCtrl, {
$scope: ctx.scope,
backendSrv: backendSrv,
});
}));
beforeEach(() => {
navModelSrv = {
getNav: () => {}
};
backendSrv = {
search: jest.fn().mockReturnValue(Promise.resolve([])),
get: jest.fn()
};
ctx.ctrl = new DashboardImportCtrl(backendSrv, navModelSrv, {}, {}, {});
});
describe('when uploading json', function() {
beforeEach(function() {
@ -37,13 +36,13 @@ describe('DashImportCtrl', function() {
});
it('should build input model', function() {
expect(ctx.ctrl.inputs.length).to.eql(1);
expect(ctx.ctrl.inputs[0].name).to.eql('ds');
expect(ctx.ctrl.inputs[0].info).to.eql('Select a Test DB data source');
expect(ctx.ctrl.inputs.length).toBe(1);
expect(ctx.ctrl.inputs[0].name).toBe('ds');
expect(ctx.ctrl.inputs[0].info).toBe('Select a Test DB data source');
});
it('should set inputValid to false', function() {
expect(ctx.ctrl.inputsValid).to.eql(false);
expect(ctx.ctrl.inputsValid).toBe(false);
});
});
@ -51,7 +50,7 @@ describe('DashImportCtrl', function() {
beforeEach(function() {
ctx.ctrl.gnetUrl = 'http://grafana.com/dashboards/123';
// setup api mock
backendSrv.get = sinon.spy(() => {
backendSrv.get = jest.fn(() => {
return Promise.resolve({
json: {}
});
@ -60,7 +59,7 @@ describe('DashImportCtrl', function() {
});
it('should call gnet api with correct dashboard id', function() {
expect(backendSrv.get.getCall(0).args[0]).to.eql('api/gnet/dashboards/123');
expect(backendSrv.get.mock.calls[0][0]).toBe('api/gnet/dashboards/123');
});
});
@ -68,7 +67,7 @@ describe('DashImportCtrl', function() {
beforeEach(function() {
ctx.ctrl.gnetUrl = '2342';
// setup api mock
backendSrv.get = sinon.spy(() => {
backendSrv.get = jest.fn(() => {
return Promise.resolve({
json: {}
});
@ -77,10 +76,8 @@ describe('DashImportCtrl', function() {
});
it('should call gnet api with correct dashboard id', function() {
expect(backendSrv.get.getCall(0).args[0]).to.eql('api/gnet/dashboards/2342');
expect(backendSrv.get.mock.calls[0][0]).toBe('api/gnet/dashboards/2342');
});
});
});

View File

@ -2,6 +2,7 @@ import _ from 'lodash';
import { DashboardModel } from '../dashboard_model';
import { PanelModel } from '../panel_model';
import {GRID_CELL_HEIGHT, GRID_CELL_VMARGIN} from 'app/core/constants';
import { expect } from 'test/lib/common';
jest.mock('app/core/services/context_srv', () => ({}));
@ -315,12 +316,33 @@ describe('DashboardModel', function() {
expect(panelGridPos).toEqual(expectedGrid);
});
it('should add repeated row if repeat set', function() {
model.rows = [
createRow({showTitle: true, title: "Row", height: 8, repeat: "server"}, [[6]]),
createRow({height: 8}, [[12]])
];
let dashboard = new DashboardModel(model);
let panelGridPos = getGridPositions(dashboard);
let expectedGrid = [
{x: 0, y: 0, w: 24, h: 8},
{x: 0, y: 1, w: 12, h: 8},
{x: 0, y: 9, w: 24, h: 8},
{x: 0, y: 10, w: 24, h: 8}
];
expect(panelGridPos).toEqual(expectedGrid);
expect(dashboard.panels[0].repeat).toBe("server");
expect(dashboard.panels[1].repeat).toBeUndefined();
expect(dashboard.panels[2].repeat).toBeUndefined();
expect(dashboard.panels[3].repeat).toBeUndefined();
});
});
});
function createRow(options, panelDescriptions: any[]) {
const PANEL_HEIGHT_STEP = GRID_CELL_HEIGHT + GRID_CELL_VMARGIN;
let {collapse, height, showTitle, title} = options;
let {collapse, height, showTitle, title, repeat} = options;
height = height * PANEL_HEIGHT_STEP;
let panels = [];
_.each(panelDescriptions, panelDesc => {
@ -330,7 +352,7 @@ function createRow(options, panelDescriptions: any[]) {
}
panels.push(panel);
});
let row = {collapse, height, showTitle, title, panels};
let row = {collapse, height, showTitle, title, panels, repeat};
return row;
}

View File

@ -1,4 +1,6 @@
import _ from 'lodash';
import {DashboardModel} from '../dashboard_model';
import { expect } from 'test/lib/common';
jest.mock('app/core/services/context_srv', () => ({
@ -146,19 +148,19 @@ describe('given dashboard with panel repeat in vertical direction', function() {
});
});
describe.skip('given dashboard with row repeat', function() {
var dashboard;
describe('given dashboard with row repeat', function() {
let dashboard, dashboardJSON;
beforeEach(function() {
dashboard = new DashboardModel({
dashboardJSON = {
panels: [
{id: 1, type: 'row', repeat: 'apps', gridPos: {x: 0, y: 0, h: 1 , w: 24}},
{id: 1, type: 'row', gridPos: {x: 0, y: 0, h: 1 , w: 24}, repeat: 'apps'},
{id: 2, type: 'graph', gridPos: {x: 0, y: 1, h: 1 , w: 6}},
{id: 3, type: 'graph', gridPos: {x: 6, y: 1, h: 1 , w: 6}},
{id: 4, type: 'row', gridPos: {x: 0, y: 2, h: 1 , w: 24}},
{id: 5, type: 'graph', gridPos: {x: 0, y: 3, h: 1 , w: 12}},
],
templating: {
templating: {
list: [{
name: 'apps',
current: {
@ -172,33 +174,137 @@ describe.skip('given dashboard with row repeat', function() {
]
}]
}
});
};
dashboard = new DashboardModel(dashboardJSON);
dashboard.processRepeats();
});
it('should not repeat only row', function() {
expect(dashboard.panels[1].type).toBe('graph');
const panel_types = _.map(dashboard.panels, 'type');
expect(panel_types).toEqual([
'row', 'graph', 'graph',
'row', 'graph', 'graph',
'row', 'graph'
]);
});
it('should set scopedVars for each panel', function() {
dashboardJSON.templating.list[0].options[2].selected = true;
dashboard = new DashboardModel(dashboardJSON);
dashboard.processRepeats();
expect(dashboard.panels[1].scopedVars).toMatchObject({apps: {text: 'se1', value: 'se1'}});
expect(dashboard.panels[4].scopedVars).toMatchObject({apps: {text: 'se2', value: 'se2'}});
const scopedVars = _.compact(_.map(dashboard.panels, (panel) => {
return panel.scopedVars ? panel.scopedVars.apps.value : null;
}));
expect(scopedVars).toEqual([
'se1', 'se1', 'se1',
'se2', 'se2', 'se2',
'se3', 'se3', 'se3',
]);
});
it('should repeat only configured row', function() {
expect(dashboard.panels[6].id).toBe(4);
expect(dashboard.panels[7].id).toBe(5);
});
it('should repeat only row if it is collapsed', function() {
dashboardJSON.panels = [
{
id: 1, type: 'row', collapsed: true, repeat: 'apps', gridPos: {x: 0, y: 0, h: 1 , w: 24},
panels: [
{id: 2, type: 'graph', gridPos: {x: 0, y: 1, h: 1 , w: 6}},
{id: 3, type: 'graph', gridPos: {x: 6, y: 1, h: 1 , w: 6}},
]
},
{id: 4, type: 'row', gridPos: {x: 0, y: 1, h: 1 , w: 24}},
{id: 5, type: 'graph', gridPos: {x: 0, y: 2, h: 1 , w: 12}},
];
dashboard = new DashboardModel(dashboardJSON);
dashboard.processRepeats();
const panel_types = _.map(dashboard.panels, 'type');
expect(panel_types).toEqual([
'row', 'row', 'row', 'graph'
]);
expect(dashboard.panels[0].panels).toHaveLength(2);
expect(dashboard.panels[1].panels).toHaveLength(2);
});
it('should properly repeat multiple rows', function() {
dashboardJSON.panels = [
{id: 1, type: 'row', gridPos: {x: 0, y: 0, h: 1 , w: 24}, repeat: 'apps'}, // repeat
{id: 2, type: 'graph', gridPos: {x: 0, y: 1, h: 1 , w: 6}},
{id: 3, type: 'graph', gridPos: {x: 6, y: 1, h: 1 , w: 6}},
{id: 4, type: 'row', gridPos: {x: 0, y: 2, h: 1 , w: 24}}, // don't touch
{id: 5, type: 'graph', gridPos: {x: 0, y: 3, h: 1 , w: 12}},
{id: 6, type: 'row', gridPos: {x: 0, y: 4, h: 1 , w: 24}, repeat: 'hosts'}, // repeat
{id: 7, type: 'graph', gridPos: {x: 0, y: 5, h: 1 , w: 6}},
{id: 8, type: 'graph', gridPos: {x: 6, y: 5, h: 1 , w: 6}}
];
dashboardJSON.templating.list.push({
name: 'hosts',
current: {
text: 'backend01, backend02',
value: ['backend01', 'backend02']
},
options: [
{text: 'backend01', value: 'backend01', selected: true},
{text: 'backend02', value: 'backend02', selected: true},
{text: 'backend03', value: 'backend03', selected: false}
]
});
dashboard = new DashboardModel(dashboardJSON);
dashboard.processRepeats();
const panel_types = _.map(dashboard.panels, 'type');
expect(panel_types).toEqual([
'row', 'graph', 'graph',
'row', 'graph', 'graph',
'row', 'graph',
'row', 'graph', 'graph',
'row', 'graph', 'graph',
]);
expect(dashboard.panels[0].scopedVars['apps'].value).toBe('se1');
expect(dashboard.panels[1].scopedVars['apps'].value).toBe('se1');
expect(dashboard.panels[3].scopedVars['apps'].value).toBe('se2');
expect(dashboard.panels[4].scopedVars['apps'].value).toBe('se2');
expect(dashboard.panels[8].scopedVars['hosts'].value).toBe('backend01');
expect(dashboard.panels[9].scopedVars['hosts'].value).toBe('backend01');
expect(dashboard.panels[11].scopedVars['hosts'].value).toBe('backend02');
expect(dashboard.panels[12].scopedVars['hosts'].value).toBe('backend02');
});
it('should assign unique ids for repeated panels', function() {
dashboardJSON.panels = [
{
id: 1, type: 'row', collapsed: true, repeat: 'apps', gridPos: {x: 0, y: 0, h: 1 , w: 24},
panels: [
{id: 2, type: 'graph', gridPos: {x: 0, y: 1, h: 1 , w: 6}},
{id: 3, type: 'graph', gridPos: {x: 6, y: 1, h: 1 , w: 6}},
]
},
{id: 4, type: 'row', gridPos: {x: 0, y: 1, h: 1 , w: 24}},
{id: 5, type: 'graph', gridPos: {x: 0, y: 2, h: 1 , w: 12}},
];
dashboard = new DashboardModel(dashboardJSON);
dashboard.processRepeats();
const panel_ids = _.flattenDeep(_.map(dashboard.panels, (panel) => {
let ids = [];
if (panel.panels && panel.panels.length) {
ids = _.map(panel.panels, 'id');
}
ids.push(panel.id);
return ids;
}));
expect(panel_ids.length).toEqual(_.uniq(panel_ids).length);
});
//
// it('should set scopedVars on panels', function() {
// expect(dashboard.panels[1].scopedVars).toMatchObject({apps: {text: 'se1', value: 'se1'}})
// });
//
// it.skip('should repeat row and panels below two times', function() {
// expect(dashboard.panels).toMatchObject([
// // first (original row)
// {id: 1, type: 'row', repeat: 'apps', gridPos: {x: 0, y: 0, h: 1 , w: 24}},
// {id: 2, type: 'graph', gridPos: {x: 0, y: 1, h: 1 , w: 6}},
// {id: 3, type: 'graph', gridPos: {x: 6, y: 1, h: 1 , w: 6}},
// // repeated row
// {id: 1, type: 'row', repeatPanelId: 1, gridPos: {x: 0, y: 0, h: 1 , w: 24}},
// {id: 2, type: 'graph', repeatPanelId: 1, gridPos: {x: 0, y: 1, h: 1 , w: 6}},
// {id: 3, type: 'graph', repeatPanelId: 1, gridPos: {x: 6, y: 1, h: 1 , w: 6}},
// // row below dont touch
// {id: 4, type: 'row', gridPos: {x: 0, y: 2, h: 1 , w: 24}},
// {id: 5, type: 'graph', gridPos: {x: 0, y: 3, h: 1 , w: 12}},
// ]);
// });
});

View File

@ -1,72 +1 @@
<div class="graph-annotation">
<div class="graph-annotation__header">
<div class="graph-annotation__user" bs-tooltip="'Created by {{ctrl.login}}'">
</div>
<div class="graph-annotation__title">
<span>Time Picker</span>
</div>
</div>
<div class="graph-annotation__body">
<form name="timeForm" class="gf-timepicker-absolute-section">
<h3 class="section-heading">Custom range</h3>
<label class="small">From:</label>
<div class="gf-form-inline">
<div class="gf-form max-width-28">
<input type="text" class="gf-form-input input-large" ng-model="ctrl.editTimeRaw.from" input-datetime>
</div>
<div class="gf-form">
<button class="btn gf-form-btn btn-primary" type="button" ng-click="openFromPicker=!openFromPicker">
<i class="fa fa-calendar"></i>
</button>
</div>
</div>
<div ng-if="openFromPicker">
<datepicker ng-model="ctrl.absolute.fromJs" class="gf-timepicker-component" show-weeks="false" starting-day="ctrl.firstDayOfWeek" ng-change="ctrl.absoluteFromChanged()"></datepicker>
</div>
<label class="small">To:</label>
<div class="gf-form-inline">
<div class="gf-form max-width-28">
<input type="text" class="gf-form-input input-large" ng-model="ctrl.editTimeRaw.to" input-datetime>
</div>
<div class="gf-form">
<button class="btn gf-form-btn btn-primary" type="button" ng-click="openToPicker=!openToPicker">
<i class="fa fa-calendar"></i>
</button>
</div>
</div>
<div ng-if="openToPicker">
<datepicker ng-model="ctrl.absolute.toJs" class="gf-timepicker-component" show-weeks="false" starting-day="ctrl.firstDayOfWeek" ng-change="ctrl.absoluteToChanged()"></datepicker>
</div>
<label class="small">Refreshing every:</label>
<div class="gf-form-inline">
<div class="gf-form max-width-28">
<select ng-model="ctrl.refresh.value" class="gf-form-input input-medium" ng-options="f.value as f.text for f in ctrl.refresh.options"></select>
</div>
<div class="gf-form">
<button type="submit" class="btn gf-form-btn btn-secondary" ng-click="ctrl.applyCustom();" ng-disabled="!timeForm.$valid">Apply</button>
</div>
</div>
</form>
<div class="gf-timepicker-relative-section">
<h3 class="section-heading">Quick ranges</h3>
<ul ng-repeat="group in ctrl.timeOptions">
<li bindonce ng-repeat='option in group' ng-class="{active: option.active}">
<a ng-click="ctrl.setRelativeFilter(option)" bo-text="option.display"></a>
</li>
</ul>
</div>
<div class="clearfix"></div>
</div>
</div>

View File

@ -24,3 +24,62 @@
<i class="fa fa-refresh"></i>
</button>
</div>
<div ng-if="ctrl.isOpen" class="gf-timepicker-dropdown">
<form name="timeForm" class="gf-timepicker-absolute-section">
<h3 class="section-heading">Time range</h3>
<label class="small">From:</label>
<div class="gf-form-inline">
<div class="gf-form max-width-28">
<input type="text" class="gf-form-input input-large" ng-model="ctrl.editTimeRaw.from" input-datetime>
</div>
<div class="gf-form">
<button class="btn gf-form-btn btn-primary" type="button" ng-click="openFromPicker=!openFromPicker">
<i class="fa fa-calendar"></i>
</button>
</div>
</div>
<div ng-if="openFromPicker">
<datepicker ng-model="ctrl.absolute.fromJs" class="gf-timepicker-component" show-weeks="false" starting-day="ctrl.firstDayOfWeek" ng-change="ctrl.absoluteFromChanged()"></datepicker>
</div>
<label class="small">To:</label>
<div class="gf-form-inline">
<div class="gf-form max-width-28">
<input type="text" class="gf-form-input input-large" ng-model="ctrl.editTimeRaw.to" input-datetime>
</div>
<div class="gf-form">
<button class="btn gf-form-btn btn-primary" type="button" ng-click="openToPicker=!openToPicker">
<i class="fa fa-calendar"></i>
</button>
</div>
</div>
<div ng-if="openToPicker">
<datepicker ng-model="ctrl.absolute.toJs" class="gf-timepicker-component" show-weeks="false" starting-day="ctrl.firstDayOfWeek" ng-change="ctrl.absoluteToChanged()"></datepicker>
</div>
<label class="small">Refreshing every:</label>
<div class="gf-form-inline">
<div class="gf-form max-width-28">
<select ng-model="ctrl.refresh.value" class="gf-form-input input-medium" ng-options="f.value as f.text for f in ctrl.refresh.options"></select>
</div>
<div class="gf-form">
<button type="submit" class="btn gf-form-btn btn-secondary" ng-click="ctrl.applyCustom();" ng-disabled="!timeForm.$valid">Apply</button>
</div>
</div>
</form>
<div class="gf-timepicker-relative-section">
<h3 class="section-heading">Quick ranges</h3>
<ul ng-repeat="group in ctrl.timeOptions">
<li bindonce ng-repeat='option in group' ng-class="{active: option.active}">
<a ng-click="ctrl.setRelativeFilter(option)" bo-text="option.display"></a>
</li>
</ul>
</div>
</div>

View File

@ -24,9 +24,10 @@ export class TimePickerCtrl {
isUtc: boolean;
firstDayOfWeek: number;
closeDropdown: any;
isOpen: boolean;
/** @ngInject */
constructor(private $scope, private $rootScope, private timeSrv, private popoverSrv, private $element) {
constructor(private $scope, private $rootScope, private timeSrv) {
this.$scope.ctrl = this;
$rootScope.onAppEvent('shift-time-forward', () => this.move(1), $scope);
@ -95,6 +96,11 @@ export class TimePickerCtrl {
}
openDropdown() {
if (this.isOpen) {
this.isOpen = false;
return;
}
this.onRefresh();
this.editTimeRaw = this.timeRaw;
this.timeOptions = rangeUtil.getRelativeTimesList(this.panel, this.rangeString);
@ -106,17 +112,7 @@ export class TimePickerCtrl {
};
this.refresh.options.unshift({text: 'off'});
this.closeDropdown = this.popoverSrv.show({
element: this.$element[0],
position: 'bottom center',
template: '<gf-time-picker-dropdown ctrl="ctrl" />',
openOn: 'click',
classNames: 'drop-popover drop-popover--form',
model: {
ctrl: this
},
});
this.isOpen = true;
}
applyCustom() {
@ -125,7 +121,7 @@ export class TimePickerCtrl {
}
this.timeSrv.setTime(this.editTimeRaw);
this.closeDropdown();
this.isOpen = false;
}
absoluteFromChanged() {
@ -148,7 +144,7 @@ export class TimePickerCtrl {
}
this.timeSrv.setTime(range);
this.closeDropdown();
this.isOpen = false;
}
}
@ -179,20 +175,8 @@ export function timePickerDirective() {
};
}
export function timePickerDropdown() {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/timepicker/dropdown.html',
scope: {
ctrl: "="
}
};
}
angular.module('grafana.directives').directive('gfTimePickerSettings', settingsDirective);
angular.module('grafana.directives').directive('gfTimePicker', timePickerDirective);
angular.module('grafana.directives').directive('gfTimePickerDropdown', timePickerDropdown);
import {inputDateDirective} from './input_date';
angular.module("grafana.directives").directive('inputDatetime', inputDateDirective);

View File

@ -4,7 +4,7 @@ import coreModule from 'app/core/core_module';
var template = `
<input type="file" id="dashupload" name="dashupload" class="hide"/>
<label class="btn btn-secondary" for="dashupload">
<label class="btn btn-success" for="dashupload">
<i class="fa fa-upload"></i>
Upload .json File
</label>

View File

@ -26,11 +26,6 @@
<prefs-control mode="user"></prefs-control>
<h3 class="page-heading">Password</h3>
<div class="gf-form-group">
<a href="profile/password" class="btn btn-inverse">Change Password</a>
</div>
<h3 class="page-heading" ng-show="ctrl.showOrgsList">Organizations</h3>
<div class="gf-form-group" ng-show="ctrl.showOrgsList">
<table class="filter-table form-inline">

View File

@ -35,15 +35,6 @@
</tr>
</table>
</div>
</div>
<div class="row" style="margin-top: 50px">
<div class="version-footer text-center small">
Grafana version: {{buildInfo.version}}, commit: {{buildInfo.commit}},
build date: {{buildInfo.buildstamp | date: 'yyyy-MM-dd HH:mm:ss' }}
</div>
</div>
</div>

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