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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -39,6 +39,8 @@ conf/custom.ini
|
|||||||
fig.yml
|
fig.yml
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
docker-compose.yaml
|
docker-compose.yaml
|
||||||
|
/conf/dashboards/custom.yaml
|
||||||
|
/conf/datasources/custom.yaml
|
||||||
profile.cov
|
profile.cov
|
||||||
/grafana
|
/grafana
|
||||||
.notouch
|
.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)
|
* **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)
|
* **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)
|
* **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
|
## Tech
|
||||||
* **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645)
|
* **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
|
Apache License
|
||||||
may not use this file except in compliance with the License. You may
|
Version 2.0, January 2004
|
||||||
obtain a copy of the License at
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
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
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
Unless required by applicable law or agreed to in writing, software
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
implied. See the License for the specific language governing
|
See the License for the specific language governing permissions and
|
||||||
permissions and limitations under the License.
|
limitations under the License.
|
||||||
|
|
||||||
|
|||||||
14
NOTICE.md
14
NOTICE.md
@@ -1,16 +1,6 @@
|
|||||||
|
|
||||||
|
Copyright 2014-2017 Grafana Labs
|
||||||
|
|
||||||
This software is based on Kibana:
|
This software is based on Kibana:
|
||||||
========================================
|
|
||||||
Copyright 2012-2013 Elasticsearch BV
|
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.
|
|
||||||
|
|||||||
@@ -7,3 +7,4 @@
|
|||||||
MYSQL_PASSWORD: password
|
MYSQL_PASSWORD: password
|
||||||
ports:
|
ports:
|
||||||
- "3306:3306"
|
- "3306:3306"
|
||||||
|
tmpfs: /var/lib/mysql:rw
|
||||||
|
|||||||
@@ -5,3 +5,4 @@
|
|||||||
POSTGRES_PASSWORD: grafanatest
|
POSTGRES_PASSWORD: grafanatest
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "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
|
Tool | Project
|
||||||
-----|------------
|
-----|------------
|
||||||
Puppet | [https://forge.puppet.com/puppet/grafana](https://forge.puppet.com/puppet/grafana)
|
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)
|
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)
|
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)
|
Saltstack | [https://github.com/salt-formulas/salt-formula-grafana](https://github.com/salt-formulas/salt-formula-grafana)
|
||||||
|
|
||||||
## Datasources
|
## 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.
|
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
|
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:
|
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
|
```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
|
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:
|
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
|
```sql
|
||||||
|
|||||||
@@ -93,7 +93,10 @@ Directory where grafana will automatically scan and look for plugins
|
|||||||
|
|
||||||
### datasources
|
### 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]
|
## [server]
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,6 @@
|
|||||||
"lodash": "^4.17.4",
|
"lodash": "^4.17.4",
|
||||||
"moment": "^2.18.1",
|
"moment": "^2.18.1",
|
||||||
"mousetrap": "^1.6.0",
|
"mousetrap": "^1.6.0",
|
||||||
"ngreact": "^0.4.1",
|
|
||||||
"perfect-scrollbar": "^1.2.0",
|
"perfect-scrollbar": "^1.2.0",
|
||||||
"prop-types": "^15.6.0",
|
"prop-types": "^15.6.0",
|
||||||
"react": "^16.1.1",
|
"react": "^16.1.1",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ After=postgresql.service mariadb.service mysql.service
|
|||||||
EnvironmentFile=/etc/sysconfig/grafana-server
|
EnvironmentFile=/etc/sysconfig/grafana-server
|
||||||
User=grafana
|
User=grafana
|
||||||
Group=grafana
|
Group=grafana
|
||||||
Type=simple
|
Type=notify
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
WorkingDirectory=/usr/share/grafana
|
WorkingDirectory=/usr/share/grafana
|
||||||
RuntimeDirectory=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 {
|
if c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR {
|
||||||
data.NavTree = append(data.NavTree, &dtos.NavLink{
|
data.NavTree = append(data.NavTree, &dtos.NavLink{
|
||||||
Text: "Create",
|
Text: "Create",
|
||||||
|
Id: "create",
|
||||||
Icon: "fa fa-fw fa-plus",
|
Icon: "fa fa-fw fa-plus",
|
||||||
Url: "#",
|
Url: "#",
|
||||||
Children: []*dtos.NavLink{
|
Children: []*dtos.NavLink{
|
||||||
{Text: "Dashboard", Icon: "gicon gicon-dashboard-new", Url: setting.AppSubUrl + "/dashboard/new"},
|
{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: "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", Icon: "gicon gicon-dashboard-import", Url: setting.AppSubUrl + "/dashboard/new/?editview=import"},
|
{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{
|
dashboardChildNavs := []*dtos.NavLink{
|
||||||
{Text: "Home", Url: setting.AppSubUrl + "/", Icon: "fa fa-fw fa-home", HideFromTabs: true},
|
{Text: "Home", Url: setting.AppSubUrl + "/", Icon: "fa fa-fw fa-home", HideFromTabs: true},
|
||||||
{Divider: true, 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: "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"},
|
{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,
|
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 {
|
if c.IsSignedIn {
|
||||||
profileNode := &dtos.NavLink{
|
profileNode := &dtos.NavLink{
|
||||||
Text: c.SignedInUser.Name,
|
Text: c.SignedInUser.Name,
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func RenderToPng(c *middleware.Context) {
|
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)
|
queryParams := fmt.Sprintf("?%s", c.Req.URL.RawQuery)
|
||||||
|
|
||||||
renderOpts := &renderer.RenderOpts{
|
renderOpts := &renderer.RenderOpts{
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -96,6 +98,7 @@ func (g *GrafanaServerImpl) Start() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SendSystemdNotification("READY=1")
|
||||||
g.startHttpServer()
|
g.startHttpServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,3 +172,28 @@ func (g *GrafanaServerImpl) writePIDFile() {
|
|||||||
|
|
||||||
g.log.Info("Writing PID file", "path", *pidFile, "pid", pid)
|
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)
|
So(fileLogWrite.maxlines_curlines, ShouldEqual, 3)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
fileLogWrite.Close()
|
||||||
err = os.Remove(fileLogWrite.Filename)
|
err = os.Remove(fileLogWrite.Filename)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -366,7 +366,6 @@ func TestAlertRuleExtraction(t *testing.T) {
|
|||||||
"steppedLine": false,
|
"steppedLine": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"dsType": "influxdb",
|
|
||||||
"groupBy": [
|
"groupBy": [
|
||||||
{
|
{
|
||||||
"params": [
|
"params": [
|
||||||
@@ -411,7 +410,6 @@ func TestAlertRuleExtraction(t *testing.T) {
|
|||||||
"tags": []
|
"tags": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"dsType": "influxdb",
|
|
||||||
"groupBy": [
|
"groupBy": [
|
||||||
{
|
{
|
||||||
"params": [
|
"params": [
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/bus"
|
"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 {
|
func SlackFileUpload(evalContext *alerting.EvalContext, log log.Logger, url string, recipient string, token string) error {
|
||||||
if evalContext.ImageOnDiskPath == "" {
|
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")
|
log.Info("Uploading to slack via file.upload API")
|
||||||
headers, uploadBody, err := GenerateSlackBody(evalContext.ImageOnDiskPath, token, recipient)
|
headers, uploadBody, err := GenerateSlackBody(evalContext.ImageOnDiskPath, token, recipient)
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ func SearchUsers(query *m.SearchUsersQuery) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if query.Query != "" {
|
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)
|
whereParams = append(whereParams, queryWithWildcards, queryWithWildcards, queryWithWildcards)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/tsdb"
|
"github.com/grafana/grafana/pkg/tsdb"
|
||||||
opentracing "github.com/opentracing/opentracing-go"
|
"github.com/opentracing/opentracing-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GraphiteExecutor struct {
|
type GraphiteExecutor struct {
|
||||||
@@ -158,7 +158,7 @@ func formatTimeRange(input string) string {
|
|||||||
if input == "now" {
|
if input == "now" {
|
||||||
return input
|
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 {
|
func fixIntervalFormat(target string) string {
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ func TestGraphiteFunctions(t *testing.T) {
|
|||||||
Convey("formatting time range for now-1m", func() {
|
Convey("formatting time range for now-1m", func() {
|
||||||
|
|
||||||
timeRange := formatTimeRange("now-1m")
|
timeRange := formatTimeRange("now-1m")
|
||||||
So(timeRange, ShouldEqual, "now-1min")
|
So(timeRange, ShouldEqual, "-1min")
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Convey("formatting time range for now-1M", func() {
|
Convey("formatting time range for now-1M", func() {
|
||||||
|
|
||||||
timeRange := formatTimeRange("now-1M")
|
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() {
|
Convey("can parse influxdb json model", func() {
|
||||||
json := `
|
json := `
|
||||||
{
|
{
|
||||||
"dsType": "influxdb",
|
|
||||||
"groupBy": [
|
"groupBy": [
|
||||||
{
|
{
|
||||||
"params": [
|
"params": [
|
||||||
@@ -123,7 +122,6 @@ func TestInfluxdbQueryParser(t *testing.T) {
|
|||||||
Convey("can part raw query json model", func() {
|
Convey("can part raw query json model", func() {
|
||||||
json := `
|
json := `
|
||||||
{
|
{
|
||||||
"dsType": "influxdb",
|
|
||||||
"groupBy": [
|
"groupBy": [
|
||||||
{
|
{
|
||||||
"params": [
|
"params": [
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ func (rp *ResponseParser) transformRows(rows []Row, queryResult *tsdb.QueryResul
|
|||||||
result = append(result, &tsdb.TimeSeries{
|
result = append(result, &tsdb.TimeSeries{
|
||||||
Name: rp.formatSerieName(row, column, query),
|
Name: rp.formatSerieName(row, column, query),
|
||||||
Points: points,
|
Points: points,
|
||||||
|
Tags: row.Tags,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ func (e PostgresQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Ro
|
|||||||
|
|
||||||
rowLimit := 1000000
|
rowLimit := 1000000
|
||||||
rowCount := 0
|
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++ {
|
for ; rows.Next(); rowCount++ {
|
||||||
if rowCount > rowLimit {
|
if rowCount > rowLimit {
|
||||||
@@ -89,6 +98,15 @@ func (e PostgresQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Ro
|
|||||||
return err
|
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)
|
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 {
|
func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult) error {
|
||||||
pointsBySeries := make(map[string]*tsdb.TimeSeries)
|
pointsBySeries := make(map[string]*tsdb.TimeSeries)
|
||||||
seriesByQueryOrder := list.New()
|
seriesByQueryOrder := list.New()
|
||||||
columnNames, err := rows.Columns()
|
|
||||||
|
|
||||||
|
columnNames, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
columnTypes, err := rows.ColumnTypes()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -153,13 +176,21 @@ func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *co
|
|||||||
timeIndex := -1
|
timeIndex := -1
|
||||||
metricIndex := -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 {
|
for i, col := range columnNames {
|
||||||
switch col {
|
switch col {
|
||||||
case "time":
|
case "time":
|
||||||
timeIndex = i
|
timeIndex = i
|
||||||
case "metric":
|
case "metric":
|
||||||
metricIndex = i
|
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
|
values url.Values
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUrlQueryReader(url *url.URL) *UrlQueryReader {
|
func NewUrlQueryReader(urlInfo *url.URL) (*UrlQueryReader, error) {
|
||||||
return &UrlQueryReader{
|
u, err := url.ParseQuery(urlInfo.String())
|
||||||
values: url.Query(),
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return &UrlQueryReader{
|
||||||
|
values: u,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UrlQueryReader) Get(name string, def string) string {
|
func (r *UrlQueryReader) Get(name string, def string) string {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import 'angular-native-dragdrop';
|
|||||||
import 'angular-bindonce';
|
import 'angular-bindonce';
|
||||||
import 'react';
|
import 'react';
|
||||||
import 'react-dom';
|
import 'react-dom';
|
||||||
import 'ngreact';
|
|
||||||
|
|
||||||
import 'vendor/bootstrap/bootstrap';
|
import 'vendor/bootstrap/bootstrap';
|
||||||
import 'vendor/angular-ui/ui-bootstrap-tpls';
|
import 'vendor/angular-ui/ui-bootstrap-tpls';
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { react2AngularDirective } from 'app/core/utils/react2angular';
|
import { react2AngularDirective } from 'app/core/utils/react2angular';
|
||||||
import { PasswordStrength } from './components/PasswordStrength';
|
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() {
|
export function registerAngularDirectives() {
|
||||||
|
|
||||||
react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
|
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 config from 'app/core/config';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import $ from 'jquery';
|
import $ from 'jquery';
|
||||||
@@ -12,7 +10,7 @@ import Drop from 'tether-drop';
|
|||||||
export class GrafanaCtrl {
|
export class GrafanaCtrl {
|
||||||
|
|
||||||
/** @ngInject */
|
/** @ngInject */
|
||||||
constructor($scope, alertSrv, utilSrv, $rootScope, $controller, contextSrv) {
|
constructor($scope, alertSrv, utilSrv, $rootScope, $controller, contextSrv, globalEventSrv) {
|
||||||
|
|
||||||
$scope.init = function() {
|
$scope.init = function() {
|
||||||
$scope.contextSrv = contextSrv;
|
$scope.contextSrv = contextSrv;
|
||||||
@@ -23,6 +21,7 @@ export class GrafanaCtrl {
|
|||||||
profiler.init(config, $rootScope);
|
profiler.init(config, $rootScope);
|
||||||
alertSrv.init();
|
alertSrv.init();
|
||||||
utilSrv.init();
|
utilSrv.init();
|
||||||
|
globalEventSrv.init();
|
||||||
|
|
||||||
$scope.dashAlerts = alertSrv;
|
$scope.dashAlerts = alertSrv;
|
||||||
};
|
};
|
||||||
@@ -78,11 +77,16 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
|
|||||||
sidemenuOpen = scope.contextSrv.sidemenu;
|
sidemenuOpen = scope.contextSrv.sidemenu;
|
||||||
body.toggleClass('sidemenu-open', sidemenuOpen);
|
body.toggleClass('sidemenu-open', sidemenuOpen);
|
||||||
|
|
||||||
scope.$watch('contextSrv.sidemenu', newVal => {
|
appEvents.on('toggle-sidemenu', () => {
|
||||||
if (sidemenuOpen !== scope.contextSrv.sidemenu) {
|
body.toggleClass('sidemenu-open');
|
||||||
sidemenuOpen = scope.contextSrv.sidemenu;
|
});
|
||||||
body.toggleClass('sidemenu-open', scope.contextSrv.sidemenu);
|
|
||||||
}
|
appEvents.on('toggle-sidemenu-mobile', () => {
|
||||||
|
body.toggleClass('sidemenu-open--xs');
|
||||||
|
});
|
||||||
|
|
||||||
|
appEvents.on('toggle-sidemenu-hidden', () => {
|
||||||
|
body.toggleClass('sidemenu-hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
// tooltip removal fix
|
// 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();
|
$("#tooltip, .tooltip").remove();
|
||||||
|
|
||||||
// check for kiosk url param
|
// 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);
|
||||||
@@ -21,35 +21,12 @@
|
|||||||
<div class="search-dropdown">
|
<div class="search-dropdown">
|
||||||
<div class="search-dropdown__col_1">
|
<div class="search-dropdown__col_1">
|
||||||
<div class="search-results-container" grafana-scrollbar>
|
<div class="search-results-container" grafana-scrollbar>
|
||||||
<h6 ng-show="!ctrl.isLoading && results.length">No dashboards matching your query were found.</h6>
|
<h6 ng-show="!ctrl.isLoading && ctrl.results.length === 0">No dashboards matching your query were found.</h6>
|
||||||
|
<dashboard-search-results
|
||||||
<div ng-repeat="section in ctrl.results" class="search-section">
|
results="ctrl.results"
|
||||||
<a class="search-section__header pointer" ng-hide="section.hideHeader" ng-click="ctrl.toggleFolder(section)">
|
on-tag-selected="ctrl.filterByTag($tag)"
|
||||||
<i class="search-section__header__icon" ng-class="section.icon"></i>
|
on-folder-expanding="ctrl.folderExpanding()"
|
||||||
<span class="search-section__header__text">{{::section.title}}</span>
|
on-folder-expanded="ctrl.folderExpanded($folder)" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -64,18 +64,75 @@ export class SearchCtrl {
|
|||||||
this.moveSelection(-1);
|
this.moveSelection(-1);
|
||||||
}
|
}
|
||||||
if (evt.keyCode === 13) {
|
if (evt.keyCode === 13) {
|
||||||
var selectedDash = this.results[this.selectedIndex];
|
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) {
|
if (selectedDash) {
|
||||||
this.$location.search({});
|
this.$location.search({});
|
||||||
this.$location.path(selectedDash.url);
|
this.$location.path(selectedDash.url);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const selectedFolder = this.results[currentItem.folderIndex];
|
||||||
|
|
||||||
|
if (selectedFolder) {
|
||||||
|
selectedFolder.toggle(selectedFolder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
moveSelection(direction) {
|
moveSelection(direction) {
|
||||||
var max = (this.results || []).length;
|
if (this.results.length === 0) {
|
||||||
var newIndex = this.selectedIndex + direction;
|
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;
|
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() {
|
searchDashboards() {
|
||||||
@@ -84,8 +141,9 @@ export class SearchCtrl {
|
|||||||
|
|
||||||
return this.searchSrv.search(this.query).then(results => {
|
return this.searchSrv.search(this.query).then(results => {
|
||||||
if (localSearchId < this.currentSearchId) { return; }
|
if (localSearchId < this.currentSearchId) { return; }
|
||||||
this.results = results;
|
this.results = results || [];
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
|
this.moveSelection(1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,13 +152,11 @@ export class SearchCtrl {
|
|||||||
return query.query === '' && query.starred === false && query.tag.length === 0;
|
return query.query === '' && query.starred === false && query.tag.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
filterByTag(tag, evt) {
|
filterByTag(tag) {
|
||||||
|
if (_.indexOf(this.query.tag, tag) === -1) {
|
||||||
this.query.tag.push(tag);
|
this.query.tag.push(tag);
|
||||||
this.search();
|
this.search();
|
||||||
this.giveSearchFocus = this.giveSearchFocus + 1;
|
this.giveSearchFocus = this.giveSearchFocus + 1;
|
||||||
if (evt) {
|
|
||||||
evt.stopPropagation();
|
|
||||||
evt.preventDefault();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,12 +183,36 @@ export class SearchCtrl {
|
|||||||
|
|
||||||
search() {
|
search() {
|
||||||
this.showImport = false;
|
this.showImport = false;
|
||||||
this.selectedIndex = 0;
|
this.selectedIndex = -1;
|
||||||
this.searchDashboards();
|
this.searchDashboards();
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleFolder(section) {
|
folderExpanding() {
|
||||||
this.searchSrv.toggleSection(section);
|
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>
|
<img src="public/img/grafana_icon.svg"></img>
|
||||||
</a>
|
</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 class="sidemenu__top">
|
||||||
<div ng-repeat="item in ::ctrl.mainLinks" class="sidemenu-item dropdown">
|
<div ng-repeat="item in ::ctrl.mainLinks" class="sidemenu-item dropdown">
|
||||||
<a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">
|
<a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">
|
||||||
@@ -54,7 +59,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li ng-repeat="child in ::item.children" ng-class="{divider: child.divider}" ng-hide="::child.hideFromMenu">
|
<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>
|
<i class="{{::child.icon}}" ng-show="::child.icon"></i>
|
||||||
{{::child.text}}
|
{{::child.text}}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
///<reference path="../../../headers/common.d.ts" />
|
|
||||||
|
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import config from 'app/core/config';
|
import config from 'app/core/config';
|
||||||
import $ from 'jquery';
|
import $ from 'jquery';
|
||||||
import coreModule from '../../core_module';
|
import coreModule from '../../core_module';
|
||||||
|
import appEvents from 'app/core/app_events';
|
||||||
|
|
||||||
export class SideMenuCtrl {
|
export class SideMenuCtrl {
|
||||||
user: any;
|
user: any;
|
||||||
@@ -11,6 +10,7 @@ export class SideMenuCtrl {
|
|||||||
bottomNav: any;
|
bottomNav: any;
|
||||||
loginUrl: string;
|
loginUrl: string;
|
||||||
isSignedIn: boolean;
|
isSignedIn: boolean;
|
||||||
|
isOpenMobile: boolean;
|
||||||
|
|
||||||
/** @ngInject */
|
/** @ngInject */
|
||||||
constructor(private $scope, private $rootScope, private $location, private contextSrv, private $timeout) {
|
constructor(private $scope, private $rootScope, private $location, private contextSrv, private $timeout) {
|
||||||
@@ -34,16 +34,29 @@ export class SideMenuCtrl {
|
|||||||
|
|
||||||
toggleSideMenu() {
|
toggleSideMenu() {
|
||||||
this.contextSrv.toggleSideMenu();
|
this.contextSrv.toggleSideMenu();
|
||||||
|
appEvents.emit('toggle-sidemenu');
|
||||||
|
|
||||||
this.$timeout(() => {
|
this.$timeout(() => {
|
||||||
this.$rootScope.$broadcast('render');
|
this.$rootScope.$broadcast('render');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleSideMenuSmallBreakpoint() {
|
||||||
|
appEvents.emit('toggle-sidemenu-mobile');
|
||||||
|
}
|
||||||
|
|
||||||
switchOrg() {
|
switchOrg() {
|
||||||
this.$rootScope.appEvent('show-modal', {
|
this.$rootScope.appEvent('show-modal', {
|
||||||
templateHtml: '<org-switcher dismiss="dismiss()"></org-switcher>',
|
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() {
|
export function sideMenuDirective() {
|
||||||
@@ -65,10 +78,6 @@ export function sideMenuDirective() {
|
|||||||
parent.append(menu);
|
parent.append(menu);
|
||||||
}, 100);
|
}, 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
scope.$on("$destory", function() {
|
|
||||||
elem.off('click.dropdown');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ class Settings {
|
|||||||
alertingEnabled: boolean;
|
alertingEnabled: boolean;
|
||||||
authProxyEnabled: boolean;
|
authProxyEnabled: boolean;
|
||||||
ldapEnabled: boolean;
|
ldapEnabled: boolean;
|
||||||
|
oauth: any;
|
||||||
|
disableUserSignUp: boolean;
|
||||||
|
loginHint: any;
|
||||||
|
loginError: any;
|
||||||
|
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
var defaults = {
|
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([
|
import coreModule from '../core_module';
|
||||||
'angular',
|
import config from 'app/core/config';
|
||||||
'../core_module',
|
|
||||||
'app/core/config',
|
|
||||||
],
|
|
||||||
function (angular, coreModule, config) {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
config = config.default;
|
export class InvitedCtrl {
|
||||||
|
|
||||||
coreModule.default.controller('InvitedCtrl', function($scope, $routeParams, contextSrv, backendSrv) {
|
/** @ngInject */
|
||||||
|
constructor($scope, $routeParams, contextSrv, backendSrv) {
|
||||||
contextSrv.sidemenu = false;
|
contextSrv.sidemenu = false;
|
||||||
$scope.formModel = {};
|
$scope.formModel = {};
|
||||||
|
|
||||||
@@ -35,6 +31,7 @@ function (angular, coreModule, config) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
$scope.init();
|
$scope.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
});
|
coreModule.controller('InvitedCtrl', InvitedCtrl);
|
||||||
});
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
define([
|
import angular from 'angular';
|
||||||
'angular',
|
import coreModule from '../core_module';
|
||||||
'../core_module',
|
|
||||||
],
|
|
||||||
function (angular, coreModule) {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
coreModule.default.controller('JsonEditorCtrl', function($scope) {
|
export class JsonEditorCtrl {
|
||||||
|
|
||||||
|
/** @ngInject */
|
||||||
|
constructor($scope) {
|
||||||
$scope.json = angular.toJson($scope.object, true);
|
$scope.json = angular.toJson($scope.object, true);
|
||||||
$scope.canUpdate = $scope.updateHandler !== void 0 && $scope.contextSrv.isEditor;
|
$scope.canUpdate = $scope.updateHandler !== void 0 && $scope.contextSrv.isEditor;
|
||||||
|
|
||||||
@@ -14,7 +12,7 @@ function (angular, coreModule) {
|
|||||||
var newObject = angular.fromJson($scope.json);
|
var newObject = angular.fromJson($scope.json);
|
||||||
$scope.updateHandler(newObject, $scope.object);
|
$scope.updateHandler(newObject, $scope.object);
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
});
|
coreModule.controller('JsonEditorCtrl', JsonEditorCtrl);
|
||||||
|
|
||||||
});
|
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
define([
|
import _ from 'lodash';
|
||||||
'angular',
|
import coreModule from '../core_module';
|
||||||
'lodash',
|
import config from 'app/core/config';
|
||||||
'../core_module',
|
|
||||||
'app/core/config',
|
|
||||||
],
|
|
||||||
function (angular, _, coreModule, config) {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
config = config.default;
|
export class LoginCtrl {
|
||||||
|
|
||||||
coreModule.default.controller('LoginCtrl', function($scope, backendSrv, contextSrv, $location) {
|
/** @ngInject */
|
||||||
|
constructor($scope, backendSrv, contextSrv, $location) {
|
||||||
$scope.formModel = {
|
$scope.formModel = {
|
||||||
user: '',
|
user: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -74,8 +70,7 @@ function (angular, _, coreModule, config) {
|
|||||||
|
|
||||||
if (params.redirect && params.redirect[0] === '/') {
|
if (params.redirect && params.redirect[0] === '/') {
|
||||||
window.location.href = config.appSubUrl + params.redirect;
|
window.location.href = config.appSubUrl + params.redirect;
|
||||||
}
|
} else if (result.redirectUrl) {
|
||||||
else if (result.redirectUrl) {
|
|
||||||
window.location.href = result.redirectUrl;
|
window.location.href = result.redirectUrl;
|
||||||
} else {
|
} else {
|
||||||
window.location.href = config.appSubUrl + '/';
|
window.location.href = config.appSubUrl + '/';
|
||||||
@@ -84,5 +79,7 @@ function (angular, _, coreModule, config) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
$scope.init();
|
$scope.init();
|
||||||
});
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
coreModule.controller('LoginCtrl', LoginCtrl);
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
define([
|
import coreModule from '../core_module';
|
||||||
'angular',
|
|
||||||
'../core_module',
|
|
||||||
],
|
|
||||||
function (angular, coreModule) {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
coreModule.default.controller('ResetPasswordCtrl', function($scope, contextSrv, backendSrv, $location) {
|
export class ResetPasswordCtrl {
|
||||||
|
|
||||||
|
/** @ngInject */
|
||||||
|
constructor($scope, contextSrv, backendSrv, $location) {
|
||||||
contextSrv.sidemenu = false;
|
contextSrv.sidemenu = false;
|
||||||
$scope.formModel = {};
|
$scope.formModel = {};
|
||||||
$scope.mode = 'send';
|
$scope.mode = 'send';
|
||||||
@@ -37,7 +35,7 @@ function (angular, coreModule) {
|
|||||||
$location.path('login');
|
$location.path('login');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
});
|
coreModule.controller('ResetPasswordCtrl', ResetPasswordCtrl);
|
||||||
|
|
||||||
});
|
|
||||||
@@ -18,6 +18,7 @@ import './components/colorpicker/ColorPicker';
|
|||||||
import './components/colorpicker/SeriesColorPicker';
|
import './components/colorpicker/SeriesColorPicker';
|
||||||
import './components/colorpicker/spectrum_picker';
|
import './components/colorpicker/spectrum_picker';
|
||||||
import './services/search_srv';
|
import './services/search_srv';
|
||||||
|
import './services/ng_react';
|
||||||
|
|
||||||
import {grafanaAppDirective} from './components/grafana_app';
|
import {grafanaAppDirective} from './components/grafana_app';
|
||||||
import {sideMenuDirective} from './components/sidemenu/sidemenu';
|
import {sideMenuDirective} from './components/sidemenu/sidemenu';
|
||||||
@@ -52,6 +53,10 @@ import {gfPageDirective} from './components/gf_page';
|
|||||||
import {orgSwitcher} from './components/org_switcher';
|
import {orgSwitcher} from './components/org_switcher';
|
||||||
import {profiler} from './profiler';
|
import {profiler} from './profiler';
|
||||||
import {registerAngularDirectives} from './angular_wrappers';
|
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 {
|
export {
|
||||||
profiler,
|
profiler,
|
||||||
@@ -83,5 +88,9 @@ export {
|
|||||||
userGroupPicker,
|
userGroupPicker,
|
||||||
geminiScrollbar,
|
geminiScrollbar,
|
||||||
gfPageDirective,
|
gfPageDirective,
|
||||||
orgSwitcher
|
orgSwitcher,
|
||||||
|
manageDashboardsDirective,
|
||||||
|
TimeSeries,
|
||||||
|
updateLegendValues,
|
||||||
|
searchResultsDirective
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -70,13 +70,15 @@ export class NavModelSrv {
|
|||||||
|
|
||||||
getNotFoundNav() {
|
getNotFoundNav() {
|
||||||
var node = {
|
var node = {
|
||||||
text: "Page not found ",
|
text: "Page not found",
|
||||||
icon: "fa fa-fw fa-warning",
|
icon: "fa fa-fw fa-warning",
|
||||||
|
subTitle: "404 Error"
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
breadcrumbs: [node],
|
breadcrumbs: [node],
|
||||||
node: node
|
node: node,
|
||||||
|
main: node
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,14 +121,6 @@ export class NavModelSrv {
|
|||||||
clickHandler: () => dashNavCtrl.openEditView('annotations')
|
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) {
|
if (!dashboard.meta.isHome) {
|
||||||
menu.push({
|
menu.push({
|
||||||
title: 'Version history',
|
title: 'Version history',
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
|
|||||||
reloadOnSearch: false,
|
reloadOnSearch: false,
|
||||||
pageClass: 'page-dashboard',
|
pageClass: 'page-dashboard',
|
||||||
})
|
})
|
||||||
|
.when('/dashboard/import', {
|
||||||
|
templateUrl: 'public/app/features/dashboard/partials/dashboardImport.html',
|
||||||
|
controller : 'DashboardImportCtrl',
|
||||||
|
controllerAs: 'ctrl',
|
||||||
|
})
|
||||||
.when('/datasources', {
|
.when('/datasources', {
|
||||||
templateUrl: 'public/app/features/plugins/partials/ds_list.html',
|
templateUrl: 'public/app/features/plugins/partials/ds_list.html',
|
||||||
controller : 'DataSourcesCtrl',
|
controller : 'DataSourcesCtrl',
|
||||||
@@ -64,10 +69,25 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
|
|||||||
controllerAs: 'ctrl',
|
controllerAs: 'ctrl',
|
||||||
})
|
})
|
||||||
.when('/dashboards', {
|
.when('/dashboards', {
|
||||||
templateUrl: 'public/app/features/dashboard/partials/dashboardList.html',
|
templateUrl: 'public/app/features/dashboard/partials/dashboard_list.html',
|
||||||
controller : 'DashboardListCtrl',
|
controller : 'DashboardListCtrl',
|
||||||
controllerAs: 'ctrl',
|
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', {
|
.when('/org', {
|
||||||
templateUrl: 'public/app/features/org/partials/orgDetails.html',
|
templateUrl: 'public/app/features/org/partials/orgDetails.html',
|
||||||
controller : 'OrgDetailsCtrl',
|
controller : 'OrgDetailsCtrl',
|
||||||
@@ -168,23 +188,27 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
|
|||||||
.when('/login', {
|
.when('/login', {
|
||||||
templateUrl: 'public/app/partials/login.html',
|
templateUrl: 'public/app/partials/login.html',
|
||||||
controller : 'LoginCtrl',
|
controller : 'LoginCtrl',
|
||||||
pageClass: 'page-login',
|
pageClass: 'sidemenu-hidden',
|
||||||
})
|
})
|
||||||
.when('/invite/:code', {
|
.when('/invite/:code', {
|
||||||
templateUrl: 'public/app/partials/signup_invited.html',
|
templateUrl: 'public/app/partials/signup_invited.html',
|
||||||
controller : 'InvitedCtrl',
|
controller : 'InvitedCtrl',
|
||||||
|
pageClass: 'sidemenu-hidden',
|
||||||
})
|
})
|
||||||
.when('/signup', {
|
.when('/signup', {
|
||||||
templateUrl: 'public/app/partials/signup_step2.html',
|
templateUrl: 'public/app/partials/signup_step2.html',
|
||||||
controller : 'SignUpCtrl',
|
controller : 'SignUpCtrl',
|
||||||
|
pageClass: 'sidemenu-hidden',
|
||||||
})
|
})
|
||||||
.when('/user/password/send-reset-email', {
|
.when('/user/password/send-reset-email', {
|
||||||
templateUrl: 'public/app/partials/reset_password.html',
|
templateUrl: 'public/app/partials/reset_password.html',
|
||||||
controller : 'ResetPasswordCtrl',
|
controller : 'ResetPasswordCtrl',
|
||||||
|
pageClass: 'sidemenu-hidden',
|
||||||
})
|
})
|
||||||
.when('/user/password/reset', {
|
.when('/user/password/reset', {
|
||||||
templateUrl: 'public/app/partials/reset_password.html',
|
templateUrl: 'public/app/partials/reset_password.html',
|
||||||
controller : 'ResetPasswordCtrl',
|
controller : 'ResetPasswordCtrl',
|
||||||
|
pageClass: 'sidemenu-hidden',
|
||||||
})
|
})
|
||||||
.when('/dashboard/snapshots', {
|
.when('/dashboard/snapshots', {
|
||||||
templateUrl: 'public/app/features/snapshot/partials/snapshots.html',
|
templateUrl: 'public/app/features/snapshot/partials/snapshots.html',
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ define([
|
|||||||
'./segment_srv',
|
'./segment_srv',
|
||||||
'./backend_srv',
|
'./backend_srv',
|
||||||
'./dynamic_directive_srv',
|
'./dynamic_directive_srv',
|
||||||
|
'./global_event_srv'
|
||||||
],
|
],
|
||||||
function () {});
|
function () {});
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ export class ContextSrv {
|
|||||||
isGrafanaAdmin: any;
|
isGrafanaAdmin: any;
|
||||||
isEditor: any;
|
isEditor: any;
|
||||||
sidemenu: any;
|
sidemenu: any;
|
||||||
|
sidemenuSmallBreakpoint = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.sidemenu = store.getBool('grafana.sidemenu', false);
|
this.sidemenu = store.getBool('grafana.sidemenu', true);
|
||||||
|
|
||||||
if (!config.buildInfo) {
|
if (!config.buildInfo) {
|
||||||
config.buildInfo = {};
|
config.buildInfo = {};
|
||||||
@@ -55,7 +56,7 @@ export class ContextSrv {
|
|||||||
|
|
||||||
toggleSideMenu() {
|
toggleSideMenu() {
|
||||||
this.sidemenu = !this.sidemenu;
|
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 'jquery';
|
||||||
import _ from 'lodash';
|
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);
|
store.set('search.sections.recent', this.recentIsOpen);
|
||||||
|
|
||||||
if (!section.expanded || section.items.length) {
|
if (!section.expanded || section.items.length) {
|
||||||
return;
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.queryForRecentDashboards().then(result => {
|
return this.queryForRecentDashboards().then(result => {
|
||||||
@@ -62,6 +62,7 @@ export class SearchSrv {
|
|||||||
private toggleStarred(section) {
|
private toggleStarred(section) {
|
||||||
this.starredIsOpen = section.expanded = !section.expanded;
|
this.starredIsOpen = section.expanded = !section.expanded;
|
||||||
store.set('search.sections.starred', this.starredIsOpen);
|
store.set('search.sections.starred', this.starredIsOpen);
|
||||||
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
private getStarred(sections) {
|
private getStarred(sections) {
|
||||||
@@ -128,14 +129,20 @@ export class SearchSrv {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private browse() {
|
private browse(options) {
|
||||||
let sections: any = {};
|
let sections: any = {};
|
||||||
|
|
||||||
let promises = [
|
let promises = [];
|
||||||
this.getRecentDashboards(sections),
|
|
||||||
this.getStarred(sections),
|
if (!options.skipRecent) {
|
||||||
this.getDashboardsAndFolders(sections),
|
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 this.$q.all(promises).then(() => {
|
||||||
return _.sortBy(_.values(sections), 'score');
|
return _.sortBy(_.values(sections), 'score');
|
||||||
@@ -148,15 +155,19 @@ export class SearchSrv {
|
|||||||
}
|
}
|
||||||
|
|
||||||
search(options) {
|
search(options) {
|
||||||
if (!options.query && (!options.tag || options.tag.length === 0) && !options.starred) {
|
if (!options.folderIds && !options.query && (!options.tag || options.tag.length === 0) && !options.starred) {
|
||||||
return this.browse();
|
return this.browse(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = _.clone(options);
|
let query = _.clone(options);
|
||||||
query.folderIds = [];
|
query.folderIds = options.folderIds || [];
|
||||||
query.type = 'dash-db';
|
query.type = 'dash-db';
|
||||||
|
|
||||||
return this.backendSrv.search(query).then(results => {
|
return this.backendSrv.search(query).then(results => {
|
||||||
|
if (results.length === 0) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
let section = {
|
let section = {
|
||||||
hideHeader: true,
|
hideHeader: true,
|
||||||
items: [],
|
items: [],
|
||||||
@@ -179,7 +190,7 @@ export class SearchSrv {
|
|||||||
section.icon = section.expanded ? 'fa fa-folder-open' : 'fa fa-folder';
|
section.icon = section.expanded ? 'fa fa-folder-open' : 'fa fa-folder';
|
||||||
|
|
||||||
if (section.items.length) {
|
if (section.items.length) {
|
||||||
return;
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = {
|
let query = {
|
||||||
@@ -191,10 +202,6 @@ export class SearchSrv {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSection(section) {
|
|
||||||
section.toggle(section);
|
|
||||||
}
|
|
||||||
|
|
||||||
getDashboardTags() {
|
getDashboardTags() {
|
||||||
return this.backendSrv.get('/api/dashboards/tags');
|
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 { SearchSrv } from 'app/core/services/search_srv';
|
||||||
import q from 'q';
|
import q from 'q';
|
||||||
|
|
||||||
describe('DashboardListCtrl', () => {
|
describe('ManageDashboards', () => {
|
||||||
let ctrl;
|
let ctrl;
|
||||||
|
|
||||||
describe('when browsing dashboards', () => {
|
describe('when browsing dashboards', () => {
|
||||||
@@ -537,13 +537,10 @@ function createCtrlWithStubs(searchResponse: any, tags?: any) {
|
|||||||
search: (options: any) => {
|
search: (options: any) => {
|
||||||
return q.resolve(searchResponse);
|
return q.resolve(searchResponse);
|
||||||
},
|
},
|
||||||
toggleSection: (section) => {
|
|
||||||
return;
|
|
||||||
},
|
|
||||||
getDashboardTags: () => {
|
getDashboardTags: () => {
|
||||||
return q.resolve(tags || []);
|
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 { BackendSrvMock } from 'test/mocks/backend_srv';
|
||||||
import impressionSrv from 'app/core/services/impression_srv';
|
import impressionSrv from 'app/core/services/impression_srv';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
|
import { beforeEach } from 'test/lib/common';
|
||||||
|
|
||||||
jest.mock('app/core/store', () => {
|
jest.mock('app/core/store', () => {
|
||||||
return {
|
return {
|
||||||
@@ -244,4 +245,43 @@ describe('SearchSrv', () => {
|
|||||||
expect(backendSrvMock.search.mock.calls[0][0].starred).toEqual(true);
|
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 kbn from 'app/core/utils/kbn';
|
||||||
|
import {getFlotTickDecimals} from 'app/core/utils/ticks';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
|
||||||
function matchSeriesOverride(aliasOrRegex, seriesAlias) {
|
function matchSeriesOverride(aliasOrRegex, seriesAlias) {
|
||||||
@@ -16,6 +17,48 @@ function translateFillOption(fill) {
|
|||||||
return fill === 0 ? 0.001 : fill/10;
|
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 {
|
export default class TimeSeries {
|
||||||
datapoints: any;
|
datapoints: any;
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import coreModule from 'app/core/core_module';
|
import coreModule from 'app/core/core_module';
|
||||||
|
|
||||||
export function react2AngularDirective(name: string, component: any, options: any) {
|
export function react2AngularDirective(name: string, component: any, options: any) {
|
||||||
|
|
||||||
coreModule.directive(name, ['reactDirective', reactDirective => {
|
coreModule.directive(name, ['reactDirective', reactDirective => {
|
||||||
return reactDirective(component, options);
|
return reactDirective(component, options);
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import {getDataMinMax} from 'app/core/time_series2';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate tick step.
|
* Calculate tick step.
|
||||||
* Implementation from d3-array (ticks.js)
|
* 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.
|
* Calculate tick size based on min and max values, number of ticks and precision.
|
||||||
|
* Implementation from Flot.
|
||||||
* @param min Axis minimum
|
* @param min Axis minimum
|
||||||
* @param max Axis maximum
|
* @param max Axis maximum
|
||||||
* @param noTicks Number of ticks
|
* @param noTicks Number of ticks
|
||||||
@@ -65,3 +68,91 @@ export function getFlotTickSize(min: number, max: number, noTicks: number, tickD
|
|||||||
|
|
||||||
return size;
|
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 './unsaved_changes_modal';
|
||||||
import './timepicker/timepicker';
|
import './timepicker/timepicker';
|
||||||
import './upload';
|
import './upload';
|
||||||
import './import/dash_import';
|
|
||||||
import './export/export_modal';
|
import './export/export_modal';
|
||||||
import './export_data/export_data_modal';
|
import './export_data/export_data_modal';
|
||||||
import './ad_hoc_filters';
|
import './ad_hoc_filters';
|
||||||
@@ -31,4 +30,13 @@ import './settings/settings';
|
|||||||
|
|
||||||
import coreModule from 'app/core/core_module';
|
import coreModule from 'app/core/core_module';
|
||||||
import {DashboardListCtrl} from './dashboard_list_ctrl';
|
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('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() {
|
getPanelContainer() {
|
||||||
console.log('DashboardCtrl:getPanelContainer()');
|
|
||||||
return this;
|
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 _ from 'lodash';
|
||||||
|
import config from 'app/core/config';
|
||||||
|
|
||||||
export class DashImportCtrl {
|
export class DashboardImportCtrl {
|
||||||
|
navModel: any;
|
||||||
step: number;
|
step: number;
|
||||||
jsonText: string;
|
jsonText: string;
|
||||||
parseError: string;
|
parseError: string;
|
||||||
@@ -17,7 +15,9 @@ export class DashImportCtrl {
|
|||||||
gnetInfo: any;
|
gnetInfo: any;
|
||||||
|
|
||||||
/** @ngInject */
|
/** @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.step = 1;
|
||||||
this.nameExists = false;
|
this.nameExists = false;
|
||||||
|
|
||||||
@@ -160,17 +160,4 @@ export class DashImportCtrl {
|
|||||||
this.gnetError = '';
|
this.gnetError = '';
|
||||||
this.gnetInfo = '';
|
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 {
|
export class DashboardListCtrl {
|
||||||
public sections: any [];
|
|
||||||
tagFilterOptions: any [];
|
|
||||||
selectedTagFilter: any;
|
|
||||||
query: any;
|
|
||||||
navModel: 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 */
|
/** @ngInject */
|
||||||
constructor(private backendSrv, navModelSrv, private $q, private searchSrv: SearchSrv) {
|
constructor(navModelSrv) {
|
||||||
this.navModel = navModelSrv.getNav('dashboards', 'dashboards', 0);
|
this.navModel = navModelSrv.getNav('dashboards', 'manage-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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,8 +383,8 @@ export class DashboardMigrator {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add special "row" panels if even one row is collapsed or has visible title
|
// 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);
|
const showRows = _.some(old.rows, (row) => row.collapse || row.showTitle || row.repeat);
|
||||||
|
|
||||||
for (let row of old.rows) {
|
for (let row of old.rows) {
|
||||||
let height: any = row.height || DEFAULT_ROW_HEIGHT;
|
let height: any = row.height || DEFAULT_ROW_HEIGHT;
|
||||||
@@ -398,6 +398,7 @@ export class DashboardMigrator {
|
|||||||
rowPanel.type = 'row';
|
rowPanel.type = 'row';
|
||||||
rowPanel.title = row.title;
|
rowPanel.title = row.title;
|
||||||
rowPanel.collapsed = row.collapse;
|
rowPanel.collapsed = row.collapse;
|
||||||
|
rowPanel.repeat = row.repeat;
|
||||||
rowPanel.panels = [];
|
rowPanel.panels = [];
|
||||||
rowPanel.gridPos = {x: 0, y: yPos, w: GRID_COLUMN_COUNT, h: rowGridHeight};
|
rowPanel.gridPos = {x: 0, y: yPos, w: GRID_COLUMN_COUNT, h: rowGridHeight};
|
||||||
rowPanelModel = new PanelModel(rowPanel);
|
rowPanelModel = new PanelModel(rowPanel);
|
||||||
|
|||||||
@@ -181,6 +181,14 @@ export class DashboardModel {
|
|||||||
if (panel.id > max) {
|
if (panel.id > max) {
|
||||||
max = panel.id;
|
max = panel.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (panel.collapsed) {
|
||||||
|
for (let rowPanel of panel.panels) {
|
||||||
|
if (rowPanel.id > max) {
|
||||||
|
max = rowPanel.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return max + 1;
|
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
|
// remove panels
|
||||||
_.pull(this.panels, ...panelsToRemove);
|
_.pull(this.panels, ...panelsToRemove);
|
||||||
|
|
||||||
@@ -274,21 +272,11 @@ export class DashboardModel {
|
|||||||
return sourcePanel;
|
return sourcePanel;
|
||||||
}
|
}
|
||||||
|
|
||||||
var clone = new PanelModel(sourcePanel.getSaveModel());
|
let clone = new PanelModel(sourcePanel.getSaveModel());
|
||||||
clone.id = this.getNextPanelId();
|
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
|
// insert after source panel + value index
|
||||||
this.panels.splice(sourcePanelIndex+valueIndex, 0, clone);
|
this.panels.splice(sourcePanelIndex+valueIndex, 0, clone);
|
||||||
}
|
|
||||||
|
|
||||||
clone.repeatIteration = this.iteration;
|
clone.repeatIteration = this.iteration;
|
||||||
clone.repeatPanelId = sourcePanel.id;
|
clone.repeatPanelId = sourcePanel.id;
|
||||||
@@ -296,37 +284,60 @@ export class DashboardModel {
|
|||||||
return clone;
|
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) {
|
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) {
|
if (!variable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var selected;
|
if (panel.type === 'row') {
|
||||||
if (variable.current.text === 'All') {
|
this.repeatRow(panel, panelIndex, variable);
|
||||||
selected = variable.options.slice(1, variable.options.length);
|
return;
|
||||||
} else {
|
|
||||||
selected = _.filter(variable.options, {selected: true});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let selectedOptions = this.getSelectedVariableOptions(variable);
|
||||||
let minWidth = panel.minSpan || 6;
|
let minWidth = panel.minSpan || 6;
|
||||||
let xPos = 0;
|
let xPos = 0;
|
||||||
let yPos = panel.gridPos.y;
|
let yPos = panel.gridPos.y;
|
||||||
|
|
||||||
for (let index = 0; index < selected.length; index++) {
|
for (let index = 0; index < selectedOptions.length; index++) {
|
||||||
var option = selected[index];
|
let option = selectedOptions[index];
|
||||||
var copy = this.getPanelRepeatClone(panel, index, panelIndex);
|
let copy;
|
||||||
|
|
||||||
|
copy = this.getPanelRepeatClone(panel, index, panelIndex);
|
||||||
copy.scopedVars = {};
|
copy.scopedVars = {};
|
||||||
copy.scopedVars[variable.name] = option;
|
copy.scopedVars[variable.name] = option;
|
||||||
|
|
||||||
if (copy.type === 'row') {
|
|
||||||
// place row below row panels
|
|
||||||
}
|
|
||||||
|
|
||||||
if (panel.repeatDirection === REPEAT_DIR_VERTICAL) {
|
if (panel.repeatDirection === REPEAT_DIR_VERTICAL) {
|
||||||
copy.gridPos.y = yPos;
|
copy.gridPos.y = yPos;
|
||||||
yPos += copy.gridPos.h;
|
yPos += copy.gridPos.h;
|
||||||
@@ -334,7 +345,7 @@ export class DashboardModel {
|
|||||||
// set width based on how many are selected
|
// set width based on how many are selected
|
||||||
// assumed the repeated panels should take up full row width
|
// 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.x = xPos;
|
||||||
copy.gridPos.y = yPos;
|
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) {
|
removePanel(panel: PanelModel) {
|
||||||
var index = _.indexOf(this.panels, panel);
|
var index = _.indexOf(this.panels, panel);
|
||||||
this.panels.splice(index, 1);
|
this.panels.splice(index, 1);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import _ from 'lodash';
|
|||||||
import config from 'app/core/config';
|
import config from 'app/core/config';
|
||||||
import {PanelModel} from '../panel_model';
|
import {PanelModel} from '../panel_model';
|
||||||
import {PanelContainer} from './PanelContainer';
|
import {PanelContainer} from './PanelContainer';
|
||||||
|
import ScrollBar from 'app/core/components/ScrollBar/ScrollBar';
|
||||||
|
|
||||||
export interface AddPanelPanelProps {
|
export interface AddPanelPanelProps {
|
||||||
panel: PanelModel;
|
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__title">New Panel</span>
|
||||||
<span className="add-panel__sub-title">Select a visualization</span>
|
<span className="add-panel__sub-title">Select a visualization</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="add-panel__items">
|
<ScrollBar className="add-panel__items">
|
||||||
{this.state.panelPlugins.map(this.renderPanelItem.bind(this))}
|
{this.state.panelPlugins.map(this.renderPanelItem.bind(this))}
|
||||||
</div>
|
</ScrollBar>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import sizeMe from 'react-sizeme';
|
|||||||
|
|
||||||
let lastGridWidth = 1200;
|
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) {
|
if (size.width === 0) {
|
||||||
console.log('size is zero!');
|
console.log('size is zero!');
|
||||||
}
|
}
|
||||||
@@ -25,12 +25,12 @@ function GridWrapper({size, layout, onLayoutChange, children, onResize, onResize
|
|||||||
return (
|
return (
|
||||||
<ReactGridLayout
|
<ReactGridLayout
|
||||||
width={lastGridWidth}
|
width={lastGridWidth}
|
||||||
className="layout"
|
className={className}
|
||||||
isDraggable={true}
|
isDraggable={true}
|
||||||
isResizable={true}
|
isResizable={true}
|
||||||
measureBeforeMount={false}
|
measureBeforeMount={false}
|
||||||
containerPadding={[0, 0]}
|
containerPadding={[0, 0]}
|
||||||
useCSSTransforms={false}
|
useCSSTransforms={true}
|
||||||
margin={[GRID_CELL_VMARGIN, GRID_CELL_VMARGIN]}
|
margin={[GRID_CELL_VMARGIN, GRID_CELL_VMARGIN]}
|
||||||
cols={GRID_COLUMN_COUNT}
|
cols={GRID_COLUMN_COUNT}
|
||||||
rowHeight={GRID_CELL_HEIGHT}
|
rowHeight={GRID_CELL_HEIGHT}
|
||||||
@@ -64,6 +64,8 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
|
|||||||
this.onResizeStop = this.onResizeStop.bind(this);
|
this.onResizeStop = this.onResizeStop.bind(this);
|
||||||
this.onWidthChange = this.onWidthChange.bind(this);
|
this.onWidthChange = this.onWidthChange.bind(this);
|
||||||
|
|
||||||
|
this.state = {animated: false};
|
||||||
|
|
||||||
// subscribe to dashboard events
|
// subscribe to dashboard events
|
||||||
this.dashboard = this.panelContainer.getDashboard();
|
this.dashboard = this.panelContainer.getDashboard();
|
||||||
this.dashboard.on('panel-added', this.triggerForceUpdate.bind(this));
|
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();
|
this.panelMap[newItem.i].resizeDone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setState(() => {
|
||||||
|
return {animated: true};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
renderPanels() {
|
renderPanels() {
|
||||||
const panelElements = [];
|
const panelElements = [];
|
||||||
|
|
||||||
@@ -152,6 +162,7 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
|
|||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<SizedReactLayoutGrid
|
<SizedReactLayoutGrid
|
||||||
|
className={classNames({'layout': true, 'animated': this.state.animated})}
|
||||||
layout={this.buildLayout()}
|
layout={this.buildLayout()}
|
||||||
onLayoutChange={this.onLayoutChange}
|
onLayoutChange={this.onLayoutChange}
|
||||||
onWidthChange={this.onWidthChange}
|
onWidthChange={this.onWidthChange}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export class DashNavCtrl {
|
|||||||
private dashboardSrv,
|
private dashboardSrv,
|
||||||
private $location,
|
private $location,
|
||||||
private backendSrv,
|
private backendSrv,
|
||||||
private contextSrv,
|
|
||||||
public playlistSrv,
|
public playlistSrv,
|
||||||
navModelSrv) {
|
navModelSrv) {
|
||||||
this.navModel = navModelSrv.getDashboardNav(this.dashboard, this);
|
this.navModel = navModelSrv.getDashboardNav(this.dashboard, this);
|
||||||
@@ -33,12 +32,8 @@ export class DashNavCtrl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSideMenu() {
|
openEditView(editview) {
|
||||||
this.contextSrv.toggleSideMenu();
|
var search = _.extend(this.$location.search(), {editview: editview});
|
||||||
}
|
|
||||||
|
|
||||||
openSettings() {
|
|
||||||
var search = _.extend(this.$location.search(), {editview: 'general'});
|
|
||||||
this.$location.search(search);
|
this.$location.search(search);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export class DashExportCtrl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
saveJson() {
|
saveJson() {
|
||||||
var clone = this.dashboardSrv.getCurrent().getSaveModelClone();
|
var clone = this.dash;
|
||||||
|
|
||||||
this.$scope.$root.appEvent('show-json-editor', {
|
this.$scope.$root.appEvent('show-json-editor', {
|
||||||
object: clone,
|
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">
|
<aside class="edit-sidemenu-aside">
|
||||||
<h2>
|
<h2>
|
||||||
<i class="fa fa-cog"></i>
|
<i class="fa fa-cog"></i>
|
||||||
@@ -26,6 +26,9 @@
|
|||||||
<div class="edit-tab-content" ng-if="ctrl.viewId === 'templating'">
|
<div class="edit-tab-content" ng-if="ctrl.viewId === 'templating'">
|
||||||
annotations
|
annotations
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ng-if="ctrl.viewId === 'timepicker'">
|
||||||
|
<gf-time-picker-dropdown dashboard="ctrl.dashboard"></gf-time-picker-dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
define(['angular',
|
import angular from 'angular';
|
||||||
'lodash',
|
import moment from 'moment';
|
||||||
'jquery',
|
import config from 'app/core/config';
|
||||||
'moment',
|
|
||||||
'app/core/config',
|
|
||||||
],
|
|
||||||
function (angular, _, $, moment, config) {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
config = config.default;
|
export class ShareModalCtrl {
|
||||||
|
|
||||||
var module = angular.module('grafana.controllers');
|
|
||||||
|
|
||||||
module.controller('ShareModalCtrl', function($scope, $rootScope, $location, $timeout, timeSrv, templateSrv, linkSrv) {
|
|
||||||
|
|
||||||
|
/** @ngInject */
|
||||||
|
constructor($scope, $rootScope, $location, $timeout, timeSrv, templateSrv, linkSrv) {
|
||||||
$scope.options = { forCurrent: true, includeTemplateVars: true, theme: 'current' };
|
$scope.options = { forCurrent: true, includeTemplateVars: true, theme: 'current' };
|
||||||
$scope.editor = { index: $scope.tabIndex || 0};
|
$scope.editor = { index: $scope.tabIndex || 0};
|
||||||
|
|
||||||
@@ -93,7 +86,7 @@ function (angular, _, $, moment, config) {
|
|||||||
$scope.getShareUrl = function() {
|
$scope.getShareUrl = function() {
|
||||||
return $scope.shareUrl;
|
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';
|
describe('DashboardImportCtrl', function() {
|
||||||
import config from 'app/core/config';
|
|
||||||
|
|
||||||
describe('DashImportCtrl', function() {
|
|
||||||
var ctx: any = {};
|
var ctx: any = {};
|
||||||
var backendSrv = {
|
|
||||||
search: sinon.stub().returns(Promise.resolve([])),
|
let navModelSrv;
|
||||||
get: sinon.stub()
|
let backendSrv;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
navModelSrv = {
|
||||||
|
getNav: () => {}
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(angularMocks.module('grafana.core'));
|
backendSrv = {
|
||||||
|
search: jest.fn().mockReturnValue(Promise.resolve([])),
|
||||||
|
get: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(angularMocks.inject(($rootScope, $controller, $q) => {
|
ctx.ctrl = new DashboardImportCtrl(backendSrv, navModelSrv, {}, {}, {});
|
||||||
ctx.$q = $q;
|
|
||||||
ctx.scope = $rootScope.$new();
|
|
||||||
ctx.ctrl = $controller(DashImportCtrl, {
|
|
||||||
$scope: ctx.scope,
|
|
||||||
backendSrv: backendSrv,
|
|
||||||
});
|
});
|
||||||
}));
|
|
||||||
|
|
||||||
describe('when uploading json', function() {
|
describe('when uploading json', function() {
|
||||||
beforeEach(function() {
|
beforeEach(function() {
|
||||||
@@ -37,13 +36,13 @@ describe('DashImportCtrl', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should build input model', function() {
|
it('should build input model', function() {
|
||||||
expect(ctx.ctrl.inputs.length).to.eql(1);
|
expect(ctx.ctrl.inputs.length).toBe(1);
|
||||||
expect(ctx.ctrl.inputs[0].name).to.eql('ds');
|
expect(ctx.ctrl.inputs[0].name).toBe('ds');
|
||||||
expect(ctx.ctrl.inputs[0].info).to.eql('Select a Test DB data source');
|
expect(ctx.ctrl.inputs[0].info).toBe('Select a Test DB data source');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set inputValid to false', function() {
|
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() {
|
beforeEach(function() {
|
||||||
ctx.ctrl.gnetUrl = 'http://grafana.com/dashboards/123';
|
ctx.ctrl.gnetUrl = 'http://grafana.com/dashboards/123';
|
||||||
// setup api mock
|
// setup api mock
|
||||||
backendSrv.get = sinon.spy(() => {
|
backendSrv.get = jest.fn(() => {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
json: {}
|
json: {}
|
||||||
});
|
});
|
||||||
@@ -60,7 +59,7 @@ describe('DashImportCtrl', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call gnet api with correct dashboard id', 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() {
|
beforeEach(function() {
|
||||||
ctx.ctrl.gnetUrl = '2342';
|
ctx.ctrl.gnetUrl = '2342';
|
||||||
// setup api mock
|
// setup api mock
|
||||||
backendSrv.get = sinon.spy(() => {
|
backendSrv.get = jest.fn(() => {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
json: {}
|
json: {}
|
||||||
});
|
});
|
||||||
@@ -77,10 +76,8 @@ describe('DashImportCtrl', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call gnet api with correct dashboard id', 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 { DashboardModel } from '../dashboard_model';
|
||||||
import { PanelModel } from '../panel_model';
|
import { PanelModel } from '../panel_model';
|
||||||
import {GRID_CELL_HEIGHT, GRID_CELL_VMARGIN} from 'app/core/constants';
|
import {GRID_CELL_HEIGHT, GRID_CELL_VMARGIN} from 'app/core/constants';
|
||||||
|
import { expect } from 'test/lib/common';
|
||||||
|
|
||||||
jest.mock('app/core/services/context_srv', () => ({}));
|
jest.mock('app/core/services/context_srv', () => ({}));
|
||||||
|
|
||||||
@@ -315,12 +316,33 @@ describe('DashboardModel', function() {
|
|||||||
|
|
||||||
expect(panelGridPos).toEqual(expectedGrid);
|
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[]) {
|
function createRow(options, panelDescriptions: any[]) {
|
||||||
const PANEL_HEIGHT_STEP = GRID_CELL_HEIGHT + GRID_CELL_VMARGIN;
|
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;
|
height = height * PANEL_HEIGHT_STEP;
|
||||||
let panels = [];
|
let panels = [];
|
||||||
_.each(panelDescriptions, panelDesc => {
|
_.each(panelDescriptions, panelDesc => {
|
||||||
@@ -330,7 +352,7 @@ function createRow(options, panelDescriptions: any[]) {
|
|||||||
}
|
}
|
||||||
panels.push(panel);
|
panels.push(panel);
|
||||||
});
|
});
|
||||||
let row = {collapse, height, showTitle, title, panels};
|
let row = {collapse, height, showTitle, title, panels, repeat};
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
import {DashboardModel} from '../dashboard_model';
|
import {DashboardModel} from '../dashboard_model';
|
||||||
|
import { expect } from 'test/lib/common';
|
||||||
|
|
||||||
jest.mock('app/core/services/context_srv', () => ({
|
jest.mock('app/core/services/context_srv', () => ({
|
||||||
|
|
||||||
@@ -146,13 +148,13 @@ describe('given dashboard with panel repeat in vertical direction', function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.skip('given dashboard with row repeat', function() {
|
describe('given dashboard with row repeat', function() {
|
||||||
var dashboard;
|
let dashboard, dashboardJSON;
|
||||||
|
|
||||||
beforeEach(function() {
|
beforeEach(function() {
|
||||||
dashboard = new DashboardModel({
|
dashboardJSON = {
|
||||||
panels: [
|
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: 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: 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: 4, type: 'row', gridPos: {x: 0, y: 2, h: 1 , w: 24}},
|
||||||
@@ -172,33 +174,137 @@ describe.skip('given dashboard with row repeat', function() {
|
|||||||
]
|
]
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
dashboard = new DashboardModel(dashboardJSON);
|
||||||
dashboard.processRepeats();
|
dashboard.processRepeats();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not repeat only row', function() {
|
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>
|
<i class="fa fa-refresh"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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;
|
isUtc: boolean;
|
||||||
firstDayOfWeek: number;
|
firstDayOfWeek: number;
|
||||||
closeDropdown: any;
|
closeDropdown: any;
|
||||||
|
isOpen: boolean;
|
||||||
|
|
||||||
/** @ngInject */
|
/** @ngInject */
|
||||||
constructor(private $scope, private $rootScope, private timeSrv, private popoverSrv, private $element) {
|
constructor(private $scope, private $rootScope, private timeSrv) {
|
||||||
this.$scope.ctrl = this;
|
this.$scope.ctrl = this;
|
||||||
|
|
||||||
$rootScope.onAppEvent('shift-time-forward', () => this.move(1), $scope);
|
$rootScope.onAppEvent('shift-time-forward', () => this.move(1), $scope);
|
||||||
@@ -95,6 +96,11 @@ export class TimePickerCtrl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openDropdown() {
|
openDropdown() {
|
||||||
|
if (this.isOpen) {
|
||||||
|
this.isOpen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.onRefresh();
|
this.onRefresh();
|
||||||
this.editTimeRaw = this.timeRaw;
|
this.editTimeRaw = this.timeRaw;
|
||||||
this.timeOptions = rangeUtil.getRelativeTimesList(this.panel, this.rangeString);
|
this.timeOptions = rangeUtil.getRelativeTimesList(this.panel, this.rangeString);
|
||||||
@@ -106,17 +112,7 @@ export class TimePickerCtrl {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.refresh.options.unshift({text: 'off'});
|
this.refresh.options.unshift({text: 'off'});
|
||||||
|
this.isOpen = true;
|
||||||
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
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applyCustom() {
|
applyCustom() {
|
||||||
@@ -125,7 +121,7 @@ export class TimePickerCtrl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.timeSrv.setTime(this.editTimeRaw);
|
this.timeSrv.setTime(this.editTimeRaw);
|
||||||
this.closeDropdown();
|
this.isOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
absoluteFromChanged() {
|
absoluteFromChanged() {
|
||||||
@@ -148,7 +144,7 @@ export class TimePickerCtrl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.timeSrv.setTime(range);
|
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('gfTimePickerSettings', settingsDirective);
|
||||||
angular.module('grafana.directives').directive('gfTimePicker', timePickerDirective);
|
angular.module('grafana.directives').directive('gfTimePicker', timePickerDirective);
|
||||||
angular.module('grafana.directives').directive('gfTimePickerDropdown', timePickerDropdown);
|
|
||||||
|
|
||||||
import {inputDateDirective} from './input_date';
|
import {inputDateDirective} from './input_date';
|
||||||
angular.module("grafana.directives").directive('inputDatetime', inputDateDirective);
|
angular.module("grafana.directives").directive('inputDatetime', inputDateDirective);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import coreModule from 'app/core/core_module';
|
|||||||
|
|
||||||
var template = `
|
var template = `
|
||||||
<input type="file" id="dashupload" name="dashupload" class="hide"/>
|
<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>
|
<i class="fa fa-upload"></i>
|
||||||
Upload .json File
|
Upload .json File
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -26,11 +26,6 @@
|
|||||||
|
|
||||||
<prefs-control mode="user"></prefs-control>
|
<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>
|
<h3 class="page-heading" ng-show="ctrl.showOrgsList">Organizations</h3>
|
||||||
<div class="gf-form-group" ng-show="ctrl.showOrgsList">
|
<div class="gf-form-group" ng-show="ctrl.showOrgsList">
|
||||||
<table class="filter-table form-inline">
|
<table class="filter-table form-inline">
|
||||||
|
|||||||
@@ -35,15 +35,6 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ var panelTemplate = `
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-content">
|
<div class="panel-content">
|
||||||
<ng-transclude></ng-transclude>
|
<ng-transclude class="panel-height-helper"></ng-transclude>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user