mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'develop' into develop-settings
This commit is contained in:
commit
9369a87e93
2
.gitignore
vendored
2
.gitignore
vendored
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
208
LICENSE.md
208
LICENSE.md
@ -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.
|
||||
|
16
NOTICE.md
16
NOTICE.md
@ -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.
|
||||
|
6
build.go
6
build.go
@ -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")...)
|
||||
|
@ -7,3 +7,4 @@
|
||||
MYSQL_PASSWORD: password
|
||||
ports:
|
||||
- "3306:3306"
|
||||
tmpfs: /var/lib/mysql:rw
|
||||
|
@ -5,3 +5,4 @@
|
||||
POSTGRES_PASSWORD: grafanatest
|
||||
ports:
|
||||
- "5432:5432"
|
||||
tmpfs: /var/lib/postgresql/data:rw
|
@ -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.
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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]
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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{
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
|
@ -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": [
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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")
|
||||
|
||||
})
|
||||
|
||||
|
@ -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": [
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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';
|
||||
|
@ -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']);
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
34
public/app/core/components/EmptyListCTA/EmptyListCTA.tsx
Normal file
34
public/app/core/components/EmptyListCTA/EmptyListCTA.tsx
Normal 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;
|
@ -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>
|
||||
`;
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
127
public/app/core/components/PageHeader/PageHeader.tsx
Normal file
127
public/app/core/components/PageHeader/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
62
public/app/core/components/ScrollBar/ScrollBar.tsx
Normal file
62
public/app/core/components/ScrollBar/ScrollBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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> 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> 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>
|
@ -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);
|
@ -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">
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
47
public/app/core/components/search/search_results.html
Normal file
47
public/app/core/components/search/search_results.html
Normal 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>
|
||||
</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>
|
76
public/app/core/components/search/search_results.ts
Normal file
76
public/app/core/components/search/search_results.ts
Normal 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);
|
@ -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> 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>
|
||||
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -17,6 +17,10 @@ class Settings {
|
||||
alertingEnabled: boolean;
|
||||
authProxyEnabled: boolean;
|
||||
ldapEnabled: boolean;
|
||||
oauth: any;
|
||||
disableUserSignUp: boolean;
|
||||
loginHint: any;
|
||||
loginError: any;
|
||||
|
||||
constructor(options) {
|
||||
var defaults = {
|
||||
|
@ -1,9 +0,0 @@
|
||||
define([
|
||||
'./inspect_ctrl',
|
||||
'./json_editor_ctrl',
|
||||
'./login_ctrl',
|
||||
'./invited_ctrl',
|
||||
'./signup_ctrl',
|
||||
'./reset_password_ctrl',
|
||||
'./error_ctrl',
|
||||
], function () {});
|
7
public/app/core/controllers/all.ts
Normal file
7
public/app/core/controllers/all.ts
Normal 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';
|
@ -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;
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
24
public/app/core/controllers/error_ctrl.ts
Normal file
24
public/app/core/controllers/error_ctrl.ts
Normal 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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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
|
||||
};
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -8,5 +8,6 @@ define([
|
||||
'./segment_srv',
|
||||
'./backend_srv',
|
||||
'./dynamic_directive_srv',
|
||||
'./global_event_srv'
|
||||
],
|
||||
function () {});
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
21
public/app/core/services/global_event_srv.ts
Normal file
21
public/app/core/services/global_event_srv.ts
Normal 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);
|
@ -1,5 +1,3 @@
|
||||
///<reference path="../../headers/common.d.ts" />
|
||||
|
||||
import $ from 'jquery';
|
||||
import _ from 'lodash';
|
||||
|
||||
|
301
public/app/core/services/ng_react.ts
Normal file
301
public/app/core/services/ng_react.ts
Normal 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]);
|
@ -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');
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
335
public/app/core/specs/search.jest.ts
Normal file
335
public/app/core/specs/search.jest.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
97
public/app/core/specs/search_results.jest.ts
Normal file
97
public/app/core/specs/search_results.jest.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}]);
|
||||
|
||||
}
|
||||
|
||||
|
@ -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};
|
||||
}
|
||||
|
@ -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);
|
||||
|
41
public/app/features/dashboard/create_folder_ctrl.ts
Normal file
41
public/app/features/dashboard/create_folder_ctrl.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -129,7 +129,6 @@ export class DashboardCtrl implements PanelContainer {
|
||||
}
|
||||
|
||||
getPanelContainer() {
|
||||
console.log('DashboardCtrl:getPanelContainer()');
|
||||
return this;
|
||||
}
|
||||
|
||||
|
@ -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);
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
16
public/app/features/dashboard/folder_dashboards_ctrl.ts
Normal file
16
public/app/features/dashboard/folder_dashboards_ctrl.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
21
public/app/features/dashboard/folder_page_loader.ts
Normal file
21
public/app/features/dashboard/folder_page_loader.ts
Normal 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';
|
||||
});
|
||||
}
|
||||
}
|
16
public/app/features/dashboard/folder_permissions_ctrl.ts
Normal file
16
public/app/features/dashboard/folder_permissions_ctrl.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
43
public/app/features/dashboard/partials/create_folder.html
Normal file
43
public/app/features/dashboard/partials/create_folder.html
Normal 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>
|
126
public/app/features/dashboard/partials/dashboardImport.html
Normal file
126
public/app/features/dashboard/partials/dashboardImport.html
Normal 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>
|
@ -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> 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> 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>
|
@ -0,0 +1,5 @@
|
||||
<page-header model="ctrl.navModel"></page-header>
|
||||
|
||||
<div class="page-container page-body">
|
||||
<manage-dashboards />
|
||||
</div>
|
@ -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>
|
@ -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>
|
@ -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>
|
||||
|
||||
|
@ -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);
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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}},
|
||||
// ]);
|
||||
// });
|
||||
});
|
||||
|
||||
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user