Merge branch 'master' into feature/add_es_alerting
This commit is contained in:
70 files changed
+2735
-485
No files matched your search
@@ -12,6 +12,45 @@ aliases:
|
||||
version: 2
|
||||
|
||||
jobs:
|
||||
mysql-integration-test:
|
||||
docker:
|
||||
- image: circleci/golang:1.10
|
||||
- image: circleci/mysql:5.6-ram
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: rootpass
|
||||
MYSQL_DATABASE: grafana_tests
|
||||
MYSQL_USER: grafana
|
||||
MYSQL_PASSWORD: password
|
||||
working_directory: /go/src/github.com/grafana/grafana
|
||||
steps:
|
||||
- checkout
|
||||
- run: sudo apt update
|
||||
- run: sudo apt install -y mysql-client
|
||||
- run: dockerize -wait tcp://127.0.0.1:3306 -timeout 120s
|
||||
- run: cat docker/blocks/mysql_tests/setup.sql | mysql -h 127.0.0.1 -P 3306 -u root -prootpass
|
||||
- run:
|
||||
name: mysql integration tests
|
||||
command: 'GRAFANA_TEST_DB=mysql go test ./pkg/services/sqlstore/... ./pkg/tsdb/mysql/... '
|
||||
|
||||
postgres-integration-test:
|
||||
docker:
|
||||
- image: circleci/golang:1.10
|
||||
- image: circleci/postgres:9.3-ram
|
||||
environment:
|
||||
POSTGRES_USER: grafanatest
|
||||
POSTGRES_PASSWORD: grafanatest
|
||||
POSTGRES_DB: grafanatest
|
||||
working_directory: /go/src/github.com/grafana/grafana
|
||||
steps:
|
||||
- checkout
|
||||
- run: sudo apt update
|
||||
- run: sudo apt install -y postgresql-client
|
||||
- run: dockerize -wait tcp://127.0.0.1:5432 -timeout 120s
|
||||
- run: 'PGPASSWORD=grafanatest psql -p 5432 -h 127.0.0.1 -U grafanatest -d grafanatest -f docker/blocks/postgres_tests/setup.sql'
|
||||
- run:
|
||||
name: postgres integration tests
|
||||
command: 'GRAFANA_TEST_DB=postgres go test ./pkg/services/sqlstore/... ./pkg/tsdb/postgres/...'
|
||||
|
||||
codespell:
|
||||
docker:
|
||||
- image: circleci/python
|
||||
@@ -188,6 +227,10 @@ workflows:
|
||||
filters: *filter-not-release
|
||||
- test-backend:
|
||||
filters: *filter-not-release
|
||||
- mysql-integration-test:
|
||||
filters: *filter-not-release
|
||||
- postgres-integration-test:
|
||||
filters: *filter-not-release
|
||||
- deploy-master:
|
||||
requires:
|
||||
- build-all
|
||||
@@ -195,6 +238,8 @@ workflows:
|
||||
- test-frontend
|
||||
- codespell
|
||||
- gometalinter
|
||||
- mysql-integration-test
|
||||
- postgres-integration-test
|
||||
filters:
|
||||
branches:
|
||||
only: master
|
||||
@@ -210,6 +255,10 @@ workflows:
|
||||
filters: *filter-only-release
|
||||
- test-backend:
|
||||
filters: *filter-only-release
|
||||
- mysql-integration-test:
|
||||
filters: *filter-only-release
|
||||
- postgres-integration-test:
|
||||
filters: *filter-only-release
|
||||
- deploy-release:
|
||||
requires:
|
||||
- build-all
|
||||
@@ -217,4 +266,6 @@ workflows:
|
||||
- test-frontend
|
||||
- codespell
|
||||
- gometalinter
|
||||
- mysql-integration-test
|
||||
- postgres-integration-test
|
||||
filters: *filter-only-release
|
||||
@@ -66,3 +66,5 @@ debug.test
|
||||
/vendor/**/.editorconfig
|
||||
/vendor/**/appengine*
|
||||
*.orig
|
||||
|
||||
/devenv/dashboards/bulk-testing/*.json
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
### Minor
|
||||
|
||||
* **Dashboard**: Modified time range and variables are now not saved by default [#10748](https://github.com/grafana/grafana/issues/10748), [#8805](https://github.com/grafana/grafana/issues/8805)
|
||||
* **Graph**: Show invisible highest value bucket in histogram [#11498](https://github.com/grafana/grafana/issues/11498)
|
||||
* **Dashboard**: Enable "Save As..." if user has edit permission [#11625](https://github.com/grafana/grafana/issues/11625)
|
||||
* **Prometheus**: Table columns order now changes when rearrange queries [#11690](https://github.com/grafana/grafana/issues/11690), thx [@mtanda](https://github.com/mtanda)
|
||||
@@ -9,6 +10,7 @@
|
||||
* **Dashboard**: Fix date selector styling for dark/light theme in time picker control [#11616](https://github.com/grafana/grafana/issues/11616)
|
||||
* **Discord**: Alert notification channel type for Discord, [#7964](https://github.com/grafana/grafana/issues/7964) thx [@jereksel](https://github.com/jereksel),
|
||||
* **InfluxDB**: Support SELECT queries in templating query, [#5013](https://github.com/grafana/grafana/issues/5013)
|
||||
* **InfluxDB**: Support count distinct aggregation [#11645](https://github.com/grafana/grafana/issues/11645), thx [@kichristensen](https://github.com/kichristensen)
|
||||
* **Dashboard**: JSON Model under dashboard settings can now be updated & changes saved, [#1429](https://github.com/grafana/grafana/issues/1429), thx [@jereksel](https://github.com/jereksel)
|
||||
* **Security**: Fix XSS vulnerabilities in dashboard links [#11813](https://github.com/grafana/grafana/pull/11813)
|
||||
* **Singlestat**: Fix "time of last point" shows local time when dashboard timezone set to UTC [#10338](https://github.com/grafana/grafana/issues/10338)
|
||||
@@ -17,6 +19,11 @@
|
||||
* **Login**: Use proxy server from environment variable if available [#9703](https://github.com/grafana/grafana/issues/9703), thx [@iyeonok](https://github.com/iyeonok)
|
||||
* **Invite users**: Friendlier error message when smtp is not configured [#12087](https://github.com/grafana/grafana/issues/12087), thx [@thurt](https://github.com/thurt)
|
||||
* **Graphite**: Don't send distributed tracing headers when using direct/browser access mode [#11494](https://github.com/grafana/grafana/issues/11494)
|
||||
* **Sidenav**: Show create dashboard link for viewers if at least editor in one folder [#11858](https://github.com/grafana/grafana/issues/11858)
|
||||
* **SQL**: Second epochs are now correctly converted to ms. [#12085](https://github.com/grafana/grafana/pull/12085)
|
||||
* **Singlestat**: Fix singlestat threshold tooltip [#11971](https://github.com/grafana/grafana/issues/11971)
|
||||
* **Dashboard**: Hide grid controls in fullscreen/low-activity views [#11771](https://github.com/grafana/grafana/issues/11771)
|
||||
* **Dashboard**: Validate uid when importing dashboards [#11515](https://github.com/grafana/grafana/issues/11515)
|
||||
|
||||
# 5.1.3 (2018-05-16)
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
This folder contains useful scripts and configuration for...
|
||||
|
||||
* Configuring datasources in Grafana
|
||||
* Provision example dashboards in Grafana
|
||||
* Run preconfiured datasources as docker containers
|
||||
|
||||
want to know more? run setup!
|
||||
|
||||
```bash
|
||||
./setup.sh
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: 'Bulk dashboards'
|
||||
folder: 'Bulk dashboards'
|
||||
type: file
|
||||
options:
|
||||
path: devenv/dashboards/bulk-testing
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,73 @@
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Graphite
|
||||
type: graphite
|
||||
access: proxy
|
||||
url: http://localhost:8080
|
||||
jsonData:
|
||||
graphiteVersion: "1.1"
|
||||
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
isDefault: true
|
||||
url: http://localhost:9090
|
||||
|
||||
- name: InfluxDB
|
||||
type: influxdb
|
||||
access: proxy
|
||||
database: site
|
||||
user: grafana
|
||||
password: grafana
|
||||
url: http://localhost:8086
|
||||
jsonData:
|
||||
timeInterval: "15s"
|
||||
|
||||
- name: OpenTsdb
|
||||
type: opentsdb
|
||||
access: proxy
|
||||
url: http://localhost:4242
|
||||
jsonData:
|
||||
tsdbResolution: 1
|
||||
tsdbVersion: 1
|
||||
|
||||
- name: Elastic
|
||||
type: elasticsearch
|
||||
access: proxy
|
||||
database: "[metrics-]YYYY.MM.DD"
|
||||
url: http://localhost:9200
|
||||
jsonData:
|
||||
interval: Daily
|
||||
timeField: "@timestamp"
|
||||
|
||||
- name: MySQL
|
||||
type: mysql
|
||||
url: localhost:3306
|
||||
database: grafana
|
||||
user: grafana
|
||||
password: password
|
||||
|
||||
- name: MSSQL
|
||||
type: mssql
|
||||
url: localhost:1433
|
||||
database: grafana
|
||||
user: grafana
|
||||
password: "Password!"
|
||||
|
||||
- name: Postgres
|
||||
type: postgres
|
||||
url: localhost:5432
|
||||
database: grafana
|
||||
user: grafana
|
||||
password: password
|
||||
jsonData:
|
||||
sslmode: "disable"
|
||||
|
||||
- name: Cloudwatch
|
||||
type: cloudwatch
|
||||
editable: true
|
||||
jsonData:
|
||||
authType: credentials
|
||||
defaultRegion: eu-west-2
|
||||
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#/bin/bash
|
||||
|
||||
bulkDashboard() {
|
||||
|
||||
requiresJsonnet
|
||||
|
||||
COUNTER=0
|
||||
MAX=400
|
||||
while [ $COUNTER -lt $MAX ]; do
|
||||
jsonnet -o "dashboards/bulk-testing/dashboard${COUNTER}.json" -e "local bulkDash = import 'dashboards/bulk-testing/bulkdash.jsonnet'; bulkDash + { uid: 'uid-${COUNTER}', title: 'title-${COUNTER}' }"
|
||||
let COUNTER=COUNTER+1
|
||||
done
|
||||
|
||||
ln -s -f -r ./dashboards/bulk-testing/bulk-dashboards.yaml ../conf/provisioning/dashboards/custom.yaml
|
||||
}
|
||||
|
||||
requiresJsonnet() {
|
||||
if ! type "jsonnet" > /dev/null; then
|
||||
echo "you need you install jsonnet to run this script"
|
||||
echo "follow the instructions on https://github.com/google/jsonnet"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
defaultDashboards() {
|
||||
echo "not implemented yet"
|
||||
}
|
||||
|
||||
defaultDatasources() {
|
||||
echo "setting up all default datasources using provisioning"
|
||||
|
||||
ln -s -f -r ./datasources/default/default.yaml ../conf/provisioning/datasources/custom.yaml
|
||||
}
|
||||
|
||||
usage() {
|
||||
echo -e "install.sh\n\tThis script installs my basic setup for a debian laptop\n"
|
||||
echo "Usage:"
|
||||
echo " bulk-dashboards - create and provisioning 400 dashboards"
|
||||
echo " default-datasources - provisiong all core datasources"
|
||||
}
|
||||
|
||||
main() {
|
||||
local cmd=$1
|
||||
|
||||
if [[ -z "$cmd" ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $cmd == "bulk-dashboards" ]]; then
|
||||
bulkDashboard
|
||||
elif [[ $cmd == "default-datasources" ]]; then
|
||||
defaultDatasources
|
||||
elif [[ $cmd == "default-dashboards" ]]; then
|
||||
bulkDashboard
|
||||
else
|
||||
usage
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,5 +1,5 @@
|
||||
mysql:
|
||||
image: mysql:latest
|
||||
image: mysql:5.6
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: rootpass
|
||||
MYSQL_DATABASE: grafana
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
FROM mysql:latest
|
||||
FROM mysql:5.6
|
||||
ADD setup.sql /docker-entrypoint-initdb.d
|
||||
CMD ["mysqld"]
|
||||
CMD ["mysqld"]
|
||||
@@ -1,5 +1,5 @@
|
||||
postgrestest:
|
||||
image: postgres:latest
|
||||
image: postgres:9.3
|
||||
environment:
|
||||
POSTGRES_USER: grafana
|
||||
POSTGRES_PASSWORD: password
|
||||
@@ -13,4 +13,4 @@
|
||||
network_mode: bridge
|
||||
environment:
|
||||
FD_DATASOURCE: postgres
|
||||
FD_PORT: 5432
|
||||
FD_PORT: 5432
|
||||
@@ -1,3 +1,3 @@
|
||||
FROM postgres:latest
|
||||
FROM postgres:9.3
|
||||
ADD setup.sql /docker-entrypoint-initdb.d
|
||||
CMD ["postgres"]
|
||||
CMD ["postgres"]
|
||||
@@ -1,3 +1,3 @@
|
||||
CREATE DATABASE grafanadstest;
|
||||
REVOKE CONNECT ON DATABASE grafanadstest FROM PUBLIC;
|
||||
GRANT CONNECT ON DATABASE grafanadstest TO grafanatest;
|
||||
GRANT CONNECT ON DATABASE grafanadstest TO grafanatest;
|
||||
+15
-10
@@ -92,17 +92,22 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
|
||||
data.Theme = "light"
|
||||
}
|
||||
|
||||
if c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR {
|
||||
if hasEditPermissionInFoldersQuery.Result {
|
||||
children := []*dtos.NavLink{
|
||||
{Text: "Dashboard", Icon: "gicon gicon-dashboard-new", Url: setting.AppSubUrl + "/dashboard/new"},
|
||||
}
|
||||
|
||||
if c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR {
|
||||
children = append(children, &dtos.NavLink{Text: "Folder", SubTitle: "Create a new folder to organize your dashboards", Id: "folder", Icon: "gicon gicon-folder-new", Url: setting.AppSubUrl + "/dashboards/folder/new"})
|
||||
children = append(children, &dtos.NavLink{Text: "Import", SubTitle: "Import dashboard from file or Grafana.com", Id: "import", Icon: "gicon gicon-dashboard-import", Url: setting.AppSubUrl + "/dashboard/import"})
|
||||
}
|
||||
|
||||
data.NavTree = append(data.NavTree, &dtos.NavLink{
|
||||
Text: "Create",
|
||||
Id: "create",
|
||||
Icon: "fa fa-fw fa-plus",
|
||||
Url: setting.AppSubUrl + "/dashboard/new",
|
||||
Children: []*dtos.NavLink{
|
||||
{Text: "Dashboard", Icon: "gicon gicon-dashboard-new", Url: setting.AppSubUrl + "/dashboard/new"},
|
||||
{Text: "Folder", SubTitle: "Create a new folder to organize your dashboards", Id: "folder", Icon: "gicon gicon-folder-new", Url: setting.AppSubUrl + "/dashboards/folder/new"},
|
||||
{Text: "Import", SubTitle: "Import dashboard from file or Grafana.com", Id: "import", Icon: "gicon gicon-dashboard-import", Url: setting.AppSubUrl + "/dashboard/import"},
|
||||
},
|
||||
Text: "Create",
|
||||
Id: "create",
|
||||
Icon: "fa fa-fw fa-plus",
|
||||
Url: setting.AppSubUrl + "/dashboard/new",
|
||||
Children: children,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -47,9 +47,15 @@ func NewDashboardFileReader(cfg *DashboardsAsConfig, log log.Logger) (*fileReade
|
||||
log.Error("Cannot read directory", "error", err)
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
log.Error("Could not create absolute path ", "path", path)
|
||||
absPath = path //if .Abs return an error we fallback to path
|
||||
}
|
||||
|
||||
return &fileReader{
|
||||
Cfg: cfg,
|
||||
Path: path,
|
||||
Path: absPath,
|
||||
log: log,
|
||||
dashboardService: dashboards.NewProvisioningService(),
|
||||
}, nil
|
||||
|
||||
@@ -3,6 +3,7 @@ package dashboards
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -15,14 +16,59 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
defaultDashboards = "./testdata/test-dashboards/folder-one"
|
||||
brokenDashboards = "./testdata/test-dashboards/broken-dashboards"
|
||||
oneDashboard = "./testdata/test-dashboards/one-dashboard"
|
||||
containingId = "./testdata/test-dashboards/containing-id"
|
||||
defaultDashboards = "testdata/test-dashboards/folder-one"
|
||||
brokenDashboards = "testdata/test-dashboards/broken-dashboards"
|
||||
oneDashboard = "testdata/test-dashboards/one-dashboard"
|
||||
containingId = "testdata/test-dashboards/containing-id"
|
||||
|
||||
fakeService *fakeDashboardProvisioningService
|
||||
)
|
||||
|
||||
func TestCreatingNewDashboardFileReader(t *testing.T) {
|
||||
Convey("creating new dashboard file reader", t, func() {
|
||||
cfg := &DashboardsAsConfig{
|
||||
Name: "Default",
|
||||
Type: "file",
|
||||
OrgId: 1,
|
||||
Folder: "",
|
||||
Options: map[string]interface{}{},
|
||||
}
|
||||
|
||||
Convey("using path parameter", func() {
|
||||
cfg.Options["path"] = defaultDashboards
|
||||
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
|
||||
So(err, ShouldBeNil)
|
||||
So(reader.Path, ShouldNotEqual, "")
|
||||
})
|
||||
|
||||
Convey("using folder as options", func() {
|
||||
cfg.Options["folder"] = defaultDashboards
|
||||
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
|
||||
So(err, ShouldBeNil)
|
||||
So(reader.Path, ShouldNotEqual, "")
|
||||
})
|
||||
|
||||
Convey("using full path", func() {
|
||||
cfg.Options["folder"] = "/var/lib/grafana/dashboards"
|
||||
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
So(reader.Path, ShouldEqual, "/var/lib/grafana/dashboards")
|
||||
}
|
||||
So(filepath.IsAbs(reader.Path), ShouldBeTrue)
|
||||
})
|
||||
|
||||
Convey("using relative path", func() {
|
||||
cfg.Options["folder"] = defaultDashboards
|
||||
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
So(filepath.IsAbs(reader.Path), ShouldBeTrue)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestDashboardFileReader(t *testing.T) {
|
||||
Convey("Dashboard file reader", t, func() {
|
||||
bus.ClearBusHandlers()
|
||||
@@ -170,30 +216,6 @@ func TestDashboardFileReader(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Can use bpth path and folder as dashboard path", func() {
|
||||
cfg := &DashboardsAsConfig{
|
||||
Name: "Default",
|
||||
Type: "file",
|
||||
OrgId: 1,
|
||||
Folder: "",
|
||||
Options: map[string]interface{}{},
|
||||
}
|
||||
|
||||
Convey("using path parameter", func() {
|
||||
cfg.Options["path"] = defaultDashboards
|
||||
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
|
||||
So(err, ShouldBeNil)
|
||||
So(reader.Path, ShouldEqual, defaultDashboards)
|
||||
})
|
||||
|
||||
Convey("using folder as options", func() {
|
||||
cfg.Options["folder"] = defaultDashboards
|
||||
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
|
||||
So(err, ShouldBeNil)
|
||||
So(reader.Path, ShouldEqual, defaultDashboards)
|
||||
})
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
dashboards.NewProvisioningService = origNewDashboardProvisioningService
|
||||
})
|
||||
|
||||
@@ -13,12 +13,12 @@ import (
|
||||
var (
|
||||
logger log.Logger = log.New("fake.log")
|
||||
|
||||
twoDatasourcesConfig = "./test-configs/two-datasources"
|
||||
twoDatasourcesConfigPurgeOthers = "./test-configs/insert-two-delete-two"
|
||||
doubleDatasourcesConfig = "./test-configs/double-default"
|
||||
allProperties = "./test-configs/all-properties"
|
||||
versionZero = "./test-configs/version-0"
|
||||
brokenYaml = "./test-configs/broken-yaml"
|
||||
twoDatasourcesConfig = "testdata/two-datasources"
|
||||
twoDatasourcesConfigPurgeOthers = "testdata/insert-two-delete-two"
|
||||
doubleDatasourcesConfig = "testdata/double-default"
|
||||
allProperties = "testdata/all-properties"
|
||||
versionZero = "testdata/version-0"
|
||||
brokenYaml = "testdata/broken-yaml"
|
||||
|
||||
fakeRepo *fakeRepository
|
||||
)
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -76,5 +76,13 @@ func TestInfluxdbQueryPart(t *testing.T) {
|
||||
res := part.Render(query, queryContext, "mean(value)")
|
||||
So(res, ShouldEqual, `mean(value) AS "test"`)
|
||||
})
|
||||
|
||||
Convey("render count distinct", func() {
|
||||
part, err := NewQueryPart("count", []string{})
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
res := part.Render(query, queryContext, "distinct(value)")
|
||||
So(res, ShouldEqual, `count(distinct(value))`)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -601,7 +601,7 @@ func TestMySQL(t *testing.T) {
|
||||
Queries: []*tsdb.Query{
|
||||
{
|
||||
Model: simplejson.NewFromAny(map[string]interface{}{
|
||||
"rawSql": `SELECT $__time(time), CONCAT(measurement, ' - value one') as metric, valueOne FROM metric_values ORDER BY 1`,
|
||||
"rawSql": `SELECT $__time(time), CONCAT(measurement, ' - value one') as metric, valueOne FROM metric_values ORDER BY 1,2`,
|
||||
"format": "time_series",
|
||||
}),
|
||||
RefId: "A",
|
||||
@@ -615,8 +615,8 @@ func TestMySQL(t *testing.T) {
|
||||
So(queryResult.Error, ShouldBeNil)
|
||||
|
||||
So(len(queryResult.Series), ShouldEqual, 2)
|
||||
So(queryResult.Series[0].Name, ShouldEqual, "Metric B - value one")
|
||||
So(queryResult.Series[1].Name, ShouldEqual, "Metric A - value one")
|
||||
So(queryResult.Series[0].Name, ShouldEqual, "Metric A - value one")
|
||||
So(queryResult.Series[1].Name, ShouldEqual, "Metric B - value one")
|
||||
})
|
||||
|
||||
Convey("When doing a metric query grouping by time should return correct series", func() {
|
||||
|
||||
@@ -41,6 +41,6 @@ export default class ElapsedTime extends PureComponent<any, any> {
|
||||
const { elapsed } = this.state;
|
||||
const { className, time } = this.props;
|
||||
const value = (time || elapsed) / 1000;
|
||||
return <span className={className}>{value.toFixed(1)}s</span>;
|
||||
return <span className={`elapsed-time ${className}`}>{value.toFixed(1)}s</span>;
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import colors from 'app/core/utils/colors';
|
||||
import TimeSeries from 'app/core/time_series2';
|
||||
|
||||
import ElapsedTime from './ElapsedTime';
|
||||
import Legend from './Legend';
|
||||
import QueryRows from './QueryRows';
|
||||
import Graph from './Graph';
|
||||
import Table from './Table';
|
||||
import TimePicker, { DEFAULT_RANGE } from './TimePicker';
|
||||
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { buildQueryOptions, ensureQueries, generateQueryKey, hasQuery } from './utils/query';
|
||||
import { decodePathComponent } from 'app/core/utils/location_util';
|
||||
@@ -16,39 +16,30 @@ function makeTimeSeriesList(dataList, options) {
|
||||
return dataList.map((seriesData, index) => {
|
||||
const datapoints = seriesData.datapoints || [];
|
||||
const alias = seriesData.target;
|
||||
|
||||
const colorIndex = index % colors.length;
|
||||
const color = colors[colorIndex];
|
||||
|
||||
const series = new TimeSeries({
|
||||
datapoints: datapoints,
|
||||
alias: alias,
|
||||
color: color,
|
||||
datapoints,
|
||||
alias,
|
||||
color,
|
||||
unit: seriesData.unit,
|
||||
});
|
||||
|
||||
if (datapoints && datapoints.length > 0) {
|
||||
const last = datapoints[datapoints.length - 1][1];
|
||||
const from = options.range.from;
|
||||
if (last - from < -10000) {
|
||||
series.isOutsideRange = true;
|
||||
}
|
||||
}
|
||||
|
||||
return series;
|
||||
});
|
||||
}
|
||||
|
||||
function parseInitialQueries(initial) {
|
||||
if (!initial) {
|
||||
return [];
|
||||
}
|
||||
function parseInitialState(initial) {
|
||||
try {
|
||||
const parsed = JSON.parse(decodePathComponent(initial));
|
||||
return parsed.queries.map(q => q.query);
|
||||
return {
|
||||
queries: parsed.queries.map(q => q.query),
|
||||
range: parsed.range,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return [];
|
||||
return { queries: [], range: DEFAULT_RANGE };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +51,8 @@ interface IExploreState {
|
||||
latency: number;
|
||||
loading: any;
|
||||
queries: any;
|
||||
queryError: any;
|
||||
range: any;
|
||||
requestOptions: any;
|
||||
showingGraph: boolean;
|
||||
showingTable: boolean;
|
||||
@@ -72,7 +65,7 @@ export class Explore extends React.Component<any, IExploreState> {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const initialQueries = parseInitialQueries(props.routeParams.initial);
|
||||
const { range, queries } = parseInitialState(props.routeParams.initial);
|
||||
this.state = {
|
||||
datasource: null,
|
||||
datasourceError: null,
|
||||
@@ -80,11 +73,14 @@ export class Explore extends React.Component<any, IExploreState> {
|
||||
graphResult: null,
|
||||
latency: 0,
|
||||
loading: false,
|
||||
queries: ensureQueries(initialQueries),
|
||||
queries: ensureQueries(queries),
|
||||
queryError: null,
|
||||
range: range || { ...DEFAULT_RANGE },
|
||||
requestOptions: null,
|
||||
showingGraph: true,
|
||||
showingTable: true,
|
||||
tableResult: null,
|
||||
...props.initialState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,6 +94,10 @@ export class Explore extends React.Component<any, IExploreState> {
|
||||
}
|
||||
}
|
||||
|
||||
componentDidCatch(error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
handleAddQueryRow = index => {
|
||||
const { queries } = this.state;
|
||||
const nextQueries = [
|
||||
@@ -119,10 +119,32 @@ export class Explore extends React.Component<any, IExploreState> {
|
||||
this.setState({ queries: nextQueries });
|
||||
};
|
||||
|
||||
handleChangeTime = nextRange => {
|
||||
const range = {
|
||||
from: nextRange.from,
|
||||
to: nextRange.to,
|
||||
};
|
||||
this.setState({ range }, () => this.handleSubmit());
|
||||
};
|
||||
|
||||
handleClickCloseSplit = () => {
|
||||
const { onChangeSplit } = this.props;
|
||||
if (onChangeSplit) {
|
||||
onChangeSplit(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleClickGraphButton = () => {
|
||||
this.setState(state => ({ showingGraph: !state.showingGraph }));
|
||||
};
|
||||
|
||||
handleClickSplit = () => {
|
||||
const { onChangeSplit } = this.props;
|
||||
if (onChangeSplit) {
|
||||
onChangeSplit(true, this.state);
|
||||
}
|
||||
};
|
||||
|
||||
handleClickTableButton = () => {
|
||||
this.setState(state => ({ showingTable: !state.showingTable }));
|
||||
};
|
||||
@@ -147,17 +169,17 @@ export class Explore extends React.Component<any, IExploreState> {
|
||||
};
|
||||
|
||||
async runGraphQuery() {
|
||||
const { datasource, queries } = this.state;
|
||||
const { datasource, queries, range } = this.state;
|
||||
if (!hasQuery(queries)) {
|
||||
return;
|
||||
}
|
||||
this.setState({ latency: 0, loading: true, graphResult: null });
|
||||
this.setState({ latency: 0, loading: true, graphResult: null, queryError: null });
|
||||
const now = Date.now();
|
||||
const options = buildQueryOptions({
|
||||
format: 'time_series',
|
||||
interval: datasource.interval,
|
||||
instant: false,
|
||||
now,
|
||||
range,
|
||||
queries: queries.map(q => q.query),
|
||||
});
|
||||
try {
|
||||
@@ -165,24 +187,25 @@ export class Explore extends React.Component<any, IExploreState> {
|
||||
const result = makeTimeSeriesList(res.data, options);
|
||||
const latency = Date.now() - now;
|
||||
this.setState({ latency, loading: false, graphResult: result, requestOptions: options });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.setState({ loading: false, graphResult: error });
|
||||
} catch (response) {
|
||||
console.error(response);
|
||||
const queryError = response.data ? response.data.error : response;
|
||||
this.setState({ loading: false, queryError });
|
||||
}
|
||||
}
|
||||
|
||||
async runTableQuery() {
|
||||
const { datasource, queries } = this.state;
|
||||
const { datasource, queries, range } = this.state;
|
||||
if (!hasQuery(queries)) {
|
||||
return;
|
||||
}
|
||||
this.setState({ latency: 0, loading: true, tableResult: null });
|
||||
this.setState({ latency: 0, loading: true, queryError: null, tableResult: null });
|
||||
const now = Date.now();
|
||||
const options = buildQueryOptions({
|
||||
format: 'table',
|
||||
interval: datasource.interval,
|
||||
instant: true,
|
||||
now,
|
||||
range,
|
||||
queries: queries.map(q => q.query),
|
||||
});
|
||||
try {
|
||||
@@ -190,9 +213,10 @@ export class Explore extends React.Component<any, IExploreState> {
|
||||
const tableModel = res.data[0];
|
||||
const latency = Date.now() - now;
|
||||
this.setState({ latency, loading: false, tableResult: tableModel, requestOptions: options });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.setState({ loading: false, tableResult: null });
|
||||
} catch (response) {
|
||||
console.error(response);
|
||||
const queryError = response.data ? response.data.error : response;
|
||||
this.setState({ loading: false, queryError });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +226,7 @@ export class Explore extends React.Component<any, IExploreState> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { position, split } = this.props;
|
||||
const {
|
||||
datasource,
|
||||
datasourceError,
|
||||
@@ -210,59 +235,93 @@ export class Explore extends React.Component<any, IExploreState> {
|
||||
latency,
|
||||
loading,
|
||||
queries,
|
||||
queryError,
|
||||
range,
|
||||
requestOptions,
|
||||
showingGraph,
|
||||
showingTable,
|
||||
tableResult,
|
||||
} = this.state;
|
||||
const showingBoth = showingGraph && showingTable;
|
||||
const graphHeight = showingBoth ? '200px' : null;
|
||||
const graphButtonClassName = showingBoth || showingGraph ? 'btn m-r-1' : 'btn btn-inverse m-r-1';
|
||||
const tableButtonClassName = showingBoth || showingTable ? 'btn m-r-1' : 'btn btn-inverse m-r-1';
|
||||
const graphHeight = showingBoth ? '200px' : '400px';
|
||||
const graphButtonActive = showingBoth || showingGraph ? 'active' : '';
|
||||
const tableButtonActive = showingBoth || showingTable ? 'active' : '';
|
||||
const exploreClass = split ? 'explore explore-split' : 'explore';
|
||||
return (
|
||||
<div className="explore">
|
||||
<div className="page-body page-full">
|
||||
<h2 className="page-sub-heading">Explore</h2>
|
||||
{datasourceLoading ? <div>Loading datasource...</div> : null}
|
||||
|
||||
{datasourceError ? <div title={datasourceError}>Error connecting to datasource.</div> : null}
|
||||
|
||||
{datasource ? (
|
||||
<div className="m-r-3">
|
||||
<div className="nav m-b-1">
|
||||
<div className="pull-right">
|
||||
{loading || latency ? <ElapsedTime time={latency} className="" /> : null}
|
||||
<button type="submit" className="m-l-1 btn btn-primary" onClick={this.handleSubmit}>
|
||||
<i className="fa fa-return" /> Run Query
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button className={graphButtonClassName} onClick={this.handleClickGraphButton}>
|
||||
Graph
|
||||
</button>
|
||||
<button className={tableButtonClassName} onClick={this.handleClickTableButton}>
|
||||
Table
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<QueryRows
|
||||
queries={queries}
|
||||
request={this.request}
|
||||
onAddQueryRow={this.handleAddQueryRow}
|
||||
onChangeQuery={this.handleChangeQuery}
|
||||
onExecuteQuery={this.handleSubmit}
|
||||
onRemoveQueryRow={this.handleRemoveQueryRow}
|
||||
/>
|
||||
<main className="m-t-2">
|
||||
{showingGraph ? (
|
||||
<Graph data={graphResult} id="explore-1" options={requestOptions} height={graphHeight} />
|
||||
) : null}
|
||||
{showingGraph ? <Legend data={graphResult} /> : null}
|
||||
{showingTable ? <Table data={tableResult} className="m-t-3" /> : null}
|
||||
</main>
|
||||
<div className={exploreClass}>
|
||||
<div className="navbar">
|
||||
{position === 'left' ? (
|
||||
<div>
|
||||
<a className="navbar-page-btn">
|
||||
<i className="fa fa-rocket" />
|
||||
Explore
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="navbar-buttons explore-first-button">
|
||||
<button className="btn navbar-button" onClick={this.handleClickCloseSplit}>
|
||||
Close Split
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="navbar__spacer" />
|
||||
{position === 'left' && !split ? (
|
||||
<div className="navbar-buttons">
|
||||
<button className="btn navbar-button" onClick={this.handleClickSplit}>
|
||||
Split
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="navbar-buttons">
|
||||
<button className={`btn navbar-button ${graphButtonActive}`} onClick={this.handleClickGraphButton}>
|
||||
Graph
|
||||
</button>
|
||||
<button className={`btn navbar-button ${tableButtonActive}`} onClick={this.handleClickTableButton}>
|
||||
Table
|
||||
</button>
|
||||
</div>
|
||||
<TimePicker range={range} onChangeTime={this.handleChangeTime} />
|
||||
<div className="navbar-buttons relative">
|
||||
<button className="btn navbar-button--primary" onClick={this.handleSubmit}>
|
||||
Run Query <i className="fa fa-level-down run-icon" />
|
||||
</button>
|
||||
{loading || latency ? <ElapsedTime time={latency} className="text-info" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{datasourceLoading ? <div className="explore-container">Loading datasource...</div> : null}
|
||||
|
||||
{datasourceError ? (
|
||||
<div className="explore-container" title={datasourceError}>
|
||||
Error connecting to datasource.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{datasource ? (
|
||||
<div className="explore-container">
|
||||
<QueryRows
|
||||
queries={queries}
|
||||
request={this.request}
|
||||
onAddQueryRow={this.handleAddQueryRow}
|
||||
onChangeQuery={this.handleChangeQuery}
|
||||
onExecuteQuery={this.handleSubmit}
|
||||
onRemoveQueryRow={this.handleRemoveQueryRow}
|
||||
/>
|
||||
{queryError ? <div className="text-warning m-a-2">{queryError}</div> : null}
|
||||
<main className="m-t-2">
|
||||
{showingGraph ? (
|
||||
<Graph
|
||||
data={graphResult}
|
||||
id={`explore-graph-${position}`}
|
||||
options={requestOptions}
|
||||
height={graphHeight}
|
||||
split={split}
|
||||
/>
|
||||
) : null}
|
||||
{showingTable ? <Table data={tableResult} className="m-t-3" /> : null}
|
||||
</main>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import $ from 'jquery';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import TimeSeries from 'app/core/time_series2';
|
||||
import moment from 'moment';
|
||||
|
||||
import 'vendor/flot/jquery.flot';
|
||||
import 'vendor/flot/jquery.flot.time';
|
||||
import * as dateMath from 'app/core/utils/datemath';
|
||||
import TimeSeries from 'app/core/time_series2';
|
||||
|
||||
import Legend from './Legend';
|
||||
|
||||
// Copied from graph.ts
|
||||
function time_format(ticks, min, max) {
|
||||
@@ -72,6 +75,7 @@ class Graph extends Component<any, any> {
|
||||
if (
|
||||
prevProps.data !== this.props.data ||
|
||||
prevProps.options !== this.props.options ||
|
||||
prevProps.split !== this.props.split ||
|
||||
prevProps.height !== this.props.height
|
||||
) {
|
||||
this.draw();
|
||||
@@ -84,14 +88,22 @@ class Graph extends Component<any, any> {
|
||||
return;
|
||||
}
|
||||
const series = data.map((ts: TimeSeries) => ({
|
||||
color: ts.color,
|
||||
label: ts.label,
|
||||
data: ts.getFlotPairs('null'),
|
||||
}));
|
||||
|
||||
const $el = $(`#${this.props.id}`);
|
||||
const ticks = $el.width() / 100;
|
||||
const min = userOptions.range.from.valueOf();
|
||||
const max = userOptions.range.to.valueOf();
|
||||
let { from, to } = userOptions.range;
|
||||
if (!moment.isMoment(from)) {
|
||||
from = dateMath.parse(from, false);
|
||||
}
|
||||
if (!moment.isMoment(to)) {
|
||||
to = dateMath.parse(to, true);
|
||||
}
|
||||
const min = from.valueOf();
|
||||
const max = to.valueOf();
|
||||
const dynamicOptions = {
|
||||
xaxis: {
|
||||
mode: 'time',
|
||||
@@ -111,12 +123,13 @@ class Graph extends Component<any, any> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const style = {
|
||||
height: this.props.height || '400px',
|
||||
width: this.props.width || '100%',
|
||||
};
|
||||
|
||||
return <div id={this.props.id} style={style} />;
|
||||
const { data, height } = this.props;
|
||||
return (
|
||||
<div className="panel-container">
|
||||
<div id={this.props.id} className="explore-graph" style={{ height }} />
|
||||
<Legend data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ class Portal extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.node = document.createElement('div');
|
||||
this.node.classList.add(`query-field-portal-${props.index}`);
|
||||
this.node.classList.add('explore-typeahead', `explore-typeahead-${props.index}`);
|
||||
document.body.appendChild(this.node);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,10 +48,10 @@ class QueryRow extends PureComponent<any, any> {
|
||||
return (
|
||||
<div className="query-row">
|
||||
<div className="query-row-tools">
|
||||
<button className="btn btn-small btn-inverse" onClick={this.handleClickAddButton}>
|
||||
<button className="btn navbar-button navbar-button--tight" onClick={this.handleClickAddButton}>
|
||||
<i className="fa fa-plus" />
|
||||
</button>
|
||||
<button className="btn btn-small btn-inverse" onClick={this.handleClickRemoveButton}>
|
||||
<button className="btn navbar-button navbar-button--tight" onClick={this.handleClickRemoveButton}>
|
||||
<i className="fa fa-minus" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -60,6 +60,7 @@ class QueryRow extends PureComponent<any, any> {
|
||||
initialQuery={edited ? null : query}
|
||||
onPressEnter={this.handlePressEnter}
|
||||
onQueryChange={this.handleChangeQuery}
|
||||
placeholder="Enter a PromQL query"
|
||||
request={request}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import * as rangeUtil from 'app/core/utils/rangeutil';
|
||||
import TimePicker, { DEFAULT_RANGE, parseTime } from './TimePicker';
|
||||
|
||||
describe('<TimePicker />', () => {
|
||||
it('renders closed with default values', () => {
|
||||
const rangeString = rangeUtil.describeTimeRange(DEFAULT_RANGE);
|
||||
const wrapper = shallow(<TimePicker />);
|
||||
expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
|
||||
expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders with relative range', () => {
|
||||
const range = {
|
||||
from: 'now-7h',
|
||||
to: 'now',
|
||||
};
|
||||
const rangeString = rangeUtil.describeTimeRange(range);
|
||||
const wrapper = shallow(<TimePicker range={range} isOpen />);
|
||||
expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
|
||||
expect(wrapper.state('fromRaw')).toBe(range.from);
|
||||
expect(wrapper.state('toRaw')).toBe(range.to);
|
||||
expect(wrapper.find('.timepicker-from').props().value).toBe(range.from);
|
||||
expect(wrapper.find('.timepicker-to').props().value).toBe(range.to);
|
||||
});
|
||||
|
||||
it('renders with epoch (millies) range converted to ISO-ish', () => {
|
||||
const range = {
|
||||
from: '1',
|
||||
to: '1000',
|
||||
};
|
||||
const rangeString = rangeUtil.describeTimeRange({
|
||||
from: parseTime(range.from),
|
||||
to: parseTime(range.to),
|
||||
});
|
||||
const wrapper = shallow(<TimePicker range={range} isUtc isOpen />);
|
||||
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:00');
|
||||
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:01');
|
||||
expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
|
||||
expect(wrapper.find('.timepicker-from').props().value).toBe('1970-01-01 00:00:00');
|
||||
expect(wrapper.find('.timepicker-to').props().value).toBe('1970-01-01 00:00:01');
|
||||
});
|
||||
|
||||
it('moves ranges forward and backward by half the range on arrow click', () => {
|
||||
const range = {
|
||||
from: '2000',
|
||||
to: '4000',
|
||||
};
|
||||
const rangeString = rangeUtil.describeTimeRange({
|
||||
from: parseTime(range.from),
|
||||
to: parseTime(range.to),
|
||||
});
|
||||
|
||||
const onChangeTime = sinon.spy();
|
||||
const wrapper = shallow(<TimePicker range={range} isUtc isOpen onChangeTime={onChangeTime} />);
|
||||
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02');
|
||||
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:04');
|
||||
expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
|
||||
expect(wrapper.find('.timepicker-from').props().value).toBe('1970-01-01 00:00:02');
|
||||
expect(wrapper.find('.timepicker-to').props().value).toBe('1970-01-01 00:00:04');
|
||||
|
||||
wrapper.find('.timepicker-left').simulate('click');
|
||||
expect(onChangeTime.calledOnce).toBe(true);
|
||||
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:01');
|
||||
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:03');
|
||||
|
||||
wrapper.find('.timepicker-right').simulate('click');
|
||||
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02');
|
||||
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:04');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import moment from 'moment';
|
||||
|
||||
import * as dateMath from 'app/core/utils/datemath';
|
||||
import * as rangeUtil from 'app/core/utils/rangeutil';
|
||||
|
||||
const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss';
|
||||
|
||||
export const DEFAULT_RANGE = {
|
||||
from: 'now-6h',
|
||||
to: 'now',
|
||||
};
|
||||
|
||||
export function parseTime(value, isUtc = false, asString = false) {
|
||||
if (value.indexOf('now') !== -1) {
|
||||
return value;
|
||||
}
|
||||
if (!isNaN(value)) {
|
||||
const epoch = parseInt(value);
|
||||
const m = isUtc ? moment.utc(epoch) : moment(epoch);
|
||||
return asString ? m.format(DATE_FORMAT) : m;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default class TimePicker extends PureComponent<any, any> {
|
||||
dropdownEl: any;
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const fromRaw = props.range ? props.range.from : DEFAULT_RANGE.from;
|
||||
const toRaw = props.range ? props.range.to : DEFAULT_RANGE.to;
|
||||
const range = {
|
||||
from: parseTime(fromRaw),
|
||||
to: parseTime(toRaw),
|
||||
};
|
||||
this.state = {
|
||||
fromRaw: parseTime(fromRaw, props.isUtc, true),
|
||||
isOpen: props.isOpen,
|
||||
isUtc: props.isUtc,
|
||||
rangeString: rangeUtil.describeTimeRange(range),
|
||||
refreshInterval: '',
|
||||
toRaw: parseTime(toRaw, props.isUtc, true),
|
||||
};
|
||||
}
|
||||
|
||||
move(direction) {
|
||||
const { onChangeTime } = this.props;
|
||||
const { fromRaw, toRaw } = this.state;
|
||||
const range = {
|
||||
from: dateMath.parse(fromRaw, false),
|
||||
to: dateMath.parse(toRaw, true),
|
||||
};
|
||||
|
||||
const timespan = (range.to.valueOf() - range.from.valueOf()) / 2;
|
||||
let to, from;
|
||||
if (direction === -1) {
|
||||
to = range.to.valueOf() - timespan;
|
||||
from = range.from.valueOf() - timespan;
|
||||
} else if (direction === 1) {
|
||||
to = range.to.valueOf() + timespan;
|
||||
from = range.from.valueOf() + timespan;
|
||||
if (to > Date.now() && range.to < Date.now()) {
|
||||
to = Date.now();
|
||||
from = range.from.valueOf();
|
||||
}
|
||||
} else {
|
||||
to = range.to.valueOf();
|
||||
from = range.from.valueOf();
|
||||
}
|
||||
|
||||
const rangeString = rangeUtil.describeTimeRange(range);
|
||||
// No need to convert to UTC again
|
||||
to = moment(to);
|
||||
from = moment(from);
|
||||
|
||||
this.setState(
|
||||
{
|
||||
rangeString,
|
||||
fromRaw: from.format(DATE_FORMAT),
|
||||
toRaw: to.format(DATE_FORMAT),
|
||||
},
|
||||
() => {
|
||||
onChangeTime({ to, from });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
handleChangeFrom = e => {
|
||||
this.setState({
|
||||
fromRaw: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
handleChangeTo = e => {
|
||||
this.setState({
|
||||
toRaw: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
handleClickApply = () => {
|
||||
const { onChangeTime } = this.props;
|
||||
const { toRaw, fromRaw } = this.state;
|
||||
const range = {
|
||||
from: dateMath.parse(fromRaw, false),
|
||||
to: dateMath.parse(toRaw, true),
|
||||
};
|
||||
const rangeString = rangeUtil.describeTimeRange(range);
|
||||
this.setState(
|
||||
{
|
||||
isOpen: false,
|
||||
rangeString,
|
||||
},
|
||||
() => {
|
||||
if (onChangeTime) {
|
||||
onChangeTime(range);
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
handleClickLeft = () => this.move(-1);
|
||||
handleClickPicker = () => {
|
||||
this.setState(state => ({
|
||||
isOpen: !state.isOpen,
|
||||
}));
|
||||
};
|
||||
handleClickRight = () => this.move(1);
|
||||
handleClickRefresh = () => {};
|
||||
handleClickRelativeOption = range => {
|
||||
const { onChangeTime } = this.props;
|
||||
const rangeString = rangeUtil.describeTimeRange(range);
|
||||
this.setState(
|
||||
{
|
||||
toRaw: range.to,
|
||||
fromRaw: range.from,
|
||||
isOpen: false,
|
||||
rangeString,
|
||||
},
|
||||
() => {
|
||||
if (onChangeTime) {
|
||||
onChangeTime(range);
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
getTimeOptions() {
|
||||
return rangeUtil.getRelativeTimesList({}, this.state.rangeString);
|
||||
}
|
||||
|
||||
dropdownRef = el => {
|
||||
this.dropdownEl = el;
|
||||
};
|
||||
|
||||
renderDropdown() {
|
||||
const { fromRaw, isOpen, toRaw } = this.state;
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
const timeOptions = this.getTimeOptions();
|
||||
return (
|
||||
<div ref={this.dropdownRef} className="gf-timepicker-dropdown">
|
||||
<div className="gf-timepicker-absolute-section">
|
||||
<h3 className="section-heading">Custom range</h3>
|
||||
|
||||
<label className="small">From:</label>
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form max-width-28">
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input input-large timepicker-from"
|
||||
value={fromRaw}
|
||||
onChange={this.handleChangeFrom}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="small">To:</label>
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form max-width-28">
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input input-large timepicker-to"
|
||||
value={toRaw}
|
||||
onChange={this.handleChangeTo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <label className="small">Refreshing every:</label>
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form max-width-28">
|
||||
<select className="gf-form-input input-medium" ng-options="f.value as f.text for f in ctrl.refresh.options"></select>
|
||||
</div>
|
||||
</div> */}
|
||||
<div className="gf-form">
|
||||
<button className="btn gf-form-btn btn-secondary" onClick={this.handleClickApply}>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gf-timepicker-relative-section">
|
||||
<h3 className="section-heading">Quick ranges</h3>
|
||||
{Object.keys(timeOptions).map(section => {
|
||||
const group = timeOptions[section];
|
||||
return (
|
||||
<ul key={section}>
|
||||
{group.map(option => (
|
||||
<li className={option.active ? 'active' : ''} key={option.display}>
|
||||
<a onClick={() => this.handleClickRelativeOption(option)}>{option.display}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isUtc, rangeString, refreshInterval } = this.state;
|
||||
return (
|
||||
<div className="timepicker">
|
||||
<div className="navbar-buttons">
|
||||
<button className="btn navbar-button navbar-button--tight timepicker-left" onClick={this.handleClickLeft}>
|
||||
<i className="fa fa-chevron-left" />
|
||||
</button>
|
||||
<button className="btn navbar-button gf-timepicker-nav-btn" onClick={this.handleClickPicker}>
|
||||
<i className="fa fa-clock-o" />
|
||||
<span className="timepicker-rangestring">{rangeString}</span>
|
||||
{isUtc ? <span className="gf-timepicker-utc">UTC</span> : null}
|
||||
{refreshInterval ? <span className="text-warning"> Refresh every {refreshInterval}</span> : null}
|
||||
</button>
|
||||
<button className="btn navbar-button navbar-button--tight timepicker-right" onClick={this.handleClickRight}>
|
||||
<i className="fa fa-chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
{this.renderDropdown()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
import Explore from './Explore';
|
||||
|
||||
export default class Wrapper extends PureComponent<any, any> {
|
||||
state = {
|
||||
initialState: null,
|
||||
split: false,
|
||||
};
|
||||
|
||||
handleChangeSplit = (split, initialState) => {
|
||||
this.setState({ split, initialState });
|
||||
};
|
||||
|
||||
render() {
|
||||
// State overrides for props from first Explore
|
||||
const { initialState, split } = this.state;
|
||||
return (
|
||||
<div className="explore-wrapper">
|
||||
<Explore {...this.props} position="left" onChangeSplit={this.handleChangeSplit} split={split} />
|
||||
{split ? (
|
||||
<Explore
|
||||
{...this.props}
|
||||
initialState={initialState}
|
||||
onChangeSplit={this.handleChangeSplit}
|
||||
position="right"
|
||||
split={split}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
export function buildQueryOptions({ format, interval, instant, now, queries }) {
|
||||
const to = now;
|
||||
const from = to - 1000 * 60 * 60 * 3;
|
||||
export function buildQueryOptions({ format, interval, instant, range, queries }) {
|
||||
return {
|
||||
interval,
|
||||
range: {
|
||||
from,
|
||||
to,
|
||||
},
|
||||
range,
|
||||
targets: queries.map(expr => ({
|
||||
expr,
|
||||
format,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<i class="gf-form-input-icon fa fa-search"></i>
|
||||
</label>
|
||||
<div class="page-action-bar__spacer"></div>
|
||||
<a class="btn btn-success" ng-href="{{ctrl.createDashboardUrl()}}" ng-if="ctrl.isEditor || ctrl.canSave">
|
||||
<a class="btn btn-success" ng-href="{{ctrl.createDashboardUrl()}}" ng-if="ctrl.hasEditPermissionInFolders || ctrl.canSave">
|
||||
<i class="fa fa-plus"></i>
|
||||
Dashboard
|
||||
</a>
|
||||
|
||||
@@ -42,9 +42,12 @@ export class ManageDashboardsCtrl {
|
||||
// if user has editor role or higher
|
||||
isEditor: boolean;
|
||||
|
||||
hasEditPermissionInFolders: boolean;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private backendSrv, navModelSrv, private searchSrv: SearchSrv, private contextSrv) {
|
||||
this.isEditor = this.contextSrv.isEditor;
|
||||
this.hasEditPermissionInFolders = this.contextSrv.hasEditPermissionInFolders;
|
||||
|
||||
this.query = {
|
||||
query: '',
|
||||
@@ -80,6 +83,9 @@ export class ManageDashboardsCtrl {
|
||||
|
||||
return this.backendSrv.getFolderByUid(this.folderUid).then(folder => {
|
||||
this.canSave = folder.canSave;
|
||||
if (!this.canSave) {
|
||||
this.hasEditPermissionInFolders = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,14 +45,14 @@
|
||||
</tag-filter>
|
||||
</div>
|
||||
|
||||
<div class="search-filter-box" ng-if="ctrl.isEditor">
|
||||
<div class="search-filter-box" ng-if="ctrl.isEditor || ctrl.hasEditPermissionInFolders">
|
||||
<a href="dashboard/new" class="search-filter-box-link">
|
||||
<i class="gicon gicon-dashboard-new"></i> New dashboard
|
||||
</a>
|
||||
<a href="dashboards/folder/new" class="search-filter-box-link">
|
||||
<a href="dashboards/folder/new" class="search-filter-box-link" ng-if="ctrl.isEditor">
|
||||
<i class="gicon gicon-folder-new"></i> New folder
|
||||
</a>
|
||||
<a href="dashboard/import" class="search-filter-box-link">
|
||||
<a href="dashboard/import" class="search-filter-box-link" ng-if="ctrl.isEditor">
|
||||
<i class="gicon gicon-dashboard-import"></i> Import dashboard
|
||||
</a>
|
||||
<a class="search-filter-box-link" target="_blank" href="https://grafana.com/dashboards?utm_source=grafana_search">
|
||||
|
||||
@@ -17,6 +17,7 @@ export class SearchCtrl {
|
||||
isLoading: boolean;
|
||||
initialFolderFilterTitle: string;
|
||||
isEditor: string;
|
||||
hasEditPermissionInFolders: boolean;
|
||||
|
||||
/** @ngInject */
|
||||
constructor($scope, private $location, private $timeout, private searchSrv: SearchSrv) {
|
||||
@@ -27,6 +28,7 @@ export class SearchCtrl {
|
||||
this.getTags = this.getTags.bind(this);
|
||||
this.onTagSelect = this.onTagSelect.bind(this);
|
||||
this.isEditor = contextSrv.isEditor;
|
||||
this.hasEditPermissionInFolders = contextSrv.hasEditPermissionInFolders;
|
||||
}
|
||||
|
||||
closeSearch() {
|
||||
|
||||
@@ -14,7 +14,7 @@ export class KeybindingSrv {
|
||||
timepickerOpen = false;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private $rootScope, private $location, private datasourceSrv) {
|
||||
constructor(private $rootScope, private $location, private datasourceSrv, private timeSrv) {
|
||||
// clear out all shortcuts on route change
|
||||
$rootScope.$on('$routeChangeSuccess', () => {
|
||||
Mousetrap.reset();
|
||||
@@ -182,7 +182,12 @@ export class KeybindingSrv {
|
||||
const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
|
||||
const datasource = await this.datasourceSrv.get(panel.datasource);
|
||||
if (datasource && datasource.supportsExplore) {
|
||||
const exploreState = encodePathComponent(JSON.stringify(datasource.getExploreState(panel)));
|
||||
const range = this.timeSrv.timeRangeForUrl();
|
||||
const state = {
|
||||
...datasource.getExploreState(panel),
|
||||
range,
|
||||
};
|
||||
const exploreState = encodePathComponent(JSON.stringify(state));
|
||||
this.$location.url(`/explore/${exploreState}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export class DashboardImportCtrl {
|
||||
jsonText: string;
|
||||
parseError: string;
|
||||
nameExists: boolean;
|
||||
uidExists: boolean;
|
||||
dash: any;
|
||||
inputs: any[];
|
||||
inputsValid: boolean;
|
||||
@@ -16,6 +17,10 @@ export class DashboardImportCtrl {
|
||||
titleTouched: boolean;
|
||||
hasNameValidationError: boolean;
|
||||
nameValidationError: any;
|
||||
hasUidValidationError: boolean;
|
||||
uidValidationError: any;
|
||||
autoGenerateUid: boolean;
|
||||
autoGenerateUidValue: string;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private backendSrv, private validationSrv, navModelSrv, private $location, $routeParams) {
|
||||
@@ -23,6 +28,9 @@ export class DashboardImportCtrl {
|
||||
|
||||
this.step = 1;
|
||||
this.nameExists = false;
|
||||
this.uidExists = false;
|
||||
this.autoGenerateUid = true;
|
||||
this.autoGenerateUidValue = 'auto-generated';
|
||||
|
||||
// check gnetId in url
|
||||
if ($routeParams.gnetId) {
|
||||
@@ -61,6 +69,7 @@ export class DashboardImportCtrl {
|
||||
|
||||
this.inputsValid = this.inputs.length === 0;
|
||||
this.titleChanged();
|
||||
this.uidChanged(true);
|
||||
}
|
||||
|
||||
setDatasourceOptions(input, inputModel) {
|
||||
@@ -107,6 +116,28 @@ export class DashboardImportCtrl {
|
||||
});
|
||||
}
|
||||
|
||||
uidChanged(initial) {
|
||||
this.uidExists = false;
|
||||
this.hasUidValidationError = false;
|
||||
|
||||
if (initial === true && this.dash.uid) {
|
||||
this.autoGenerateUidValue = 'value set';
|
||||
}
|
||||
|
||||
this.backendSrv
|
||||
.getDashboardByUid(this.dash.uid)
|
||||
.then(res => {
|
||||
this.uidExists = true;
|
||||
this.hasUidValidationError = true;
|
||||
this.uidValidationError = `Dashboard named '${res.dashboard.title}' in folder '${
|
||||
res.meta.folderTitle
|
||||
}' has the same uid`;
|
||||
})
|
||||
.catch(err => {
|
||||
err.isHandled = true;
|
||||
});
|
||||
}
|
||||
|
||||
saveDashboard() {
|
||||
var inputs = this.inputs.map(input => {
|
||||
return {
|
||||
|
||||
@@ -22,8 +22,10 @@ export class DashboardModel {
|
||||
editable: any;
|
||||
graphTooltip: any;
|
||||
time: any;
|
||||
originalTime: any;
|
||||
timepicker: any;
|
||||
templating: any;
|
||||
originalTemplating: any;
|
||||
annotations: any;
|
||||
refresh: any;
|
||||
snapshot: any;
|
||||
@@ -68,8 +70,12 @@ export class DashboardModel {
|
||||
this.editable = data.editable !== false;
|
||||
this.graphTooltip = data.graphTooltip || 0;
|
||||
this.time = data.time || { from: 'now-6h', to: 'now' };
|
||||
this.originalTime = _.cloneDeep(this.time);
|
||||
this.timepicker = data.timepicker || {};
|
||||
this.templating = this.ensureListExist(data.templating);
|
||||
this.originalTemplating = _.map(this.templating.list, variable => {
|
||||
return { name: variable.name, current: _.clone(variable.current) };
|
||||
});
|
||||
this.annotations = this.ensureListExist(data.annotations);
|
||||
this.refresh = data.refresh;
|
||||
this.snapshot = data.snapshot;
|
||||
@@ -130,7 +136,12 @@ export class DashboardModel {
|
||||
}
|
||||
|
||||
// cleans meta data and other non persistent state
|
||||
getSaveModelClone() {
|
||||
getSaveModelClone(options?) {
|
||||
let defaults = _.defaults(options || {}, {
|
||||
saveVariables: false,
|
||||
saveTimerange: false,
|
||||
});
|
||||
|
||||
// make clone
|
||||
var copy: any = {};
|
||||
for (var property in this) {
|
||||
@@ -142,10 +153,23 @@ export class DashboardModel {
|
||||
}
|
||||
|
||||
// get variable save models
|
||||
//console.log(this.templating.list);
|
||||
copy.templating = {
|
||||
list: _.map(this.templating.list, variable => (variable.getSaveModel ? variable.getSaveModel() : variable)),
|
||||
};
|
||||
|
||||
if (!defaults.saveVariables && copy.templating.list.length === this.originalTemplating.length) {
|
||||
for (let i = 0; i < copy.templating.list.length; i++) {
|
||||
if (copy.templating.list[i].name === this.originalTemplating[i].name) {
|
||||
copy.templating.list[i].current = this.originalTemplating[i].current;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!defaults.saveTimerange) {
|
||||
copy.time = this.originalTime;
|
||||
}
|
||||
|
||||
// get panel save models
|
||||
copy.panels = _.chain(this.panels)
|
||||
.filter(panel => panel.type !== 'add-panel')
|
||||
|
||||
@@ -80,6 +80,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form gf-form--grow">
|
||||
<span class="gf-form-label width-15">
|
||||
Unique identifier (uid)
|
||||
<info-popover mode="right-normal">
|
||||
The unique identifier (uid) of a dashboard can be used for uniquely identify a dashboard between multiple Grafana installs.
|
||||
The uid allows having consistent URL’s for accessing dashboards so changing the title of a dashboard will not break any
|
||||
bookmarked links to that dashboard.
|
||||
</info-popover>
|
||||
</span>
|
||||
<input type="text" class="gf-form-input" disabled="disabled" ng-model="ctrl.autoGenerateUidValue" ng-if="ctrl.autoGenerateUid">
|
||||
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.autoGenerateUid = false" ng-if="ctrl.autoGenerateUid">change</a>
|
||||
<input type="text" class="gf-form-input" maxlength="40" placeholder="optional, will be auto-generated if empty" ng-model="ctrl.dash.uid" ng-change="ctrl.uidChanged()" ng-if="!ctrl.autoGenerateUid">
|
||||
<label class="gf-form-label text-success" ng-if="!ctrl.autoGenerateUid && !ctrl.hasUidValidationError">
|
||||
<i class="fa fa-check"></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-inline" ng-if="ctrl.hasUidValidationError">
|
||||
<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>
|
||||
{{ctrl.uidValidationError}}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-repeat="input in ctrl.inputs">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-15">
|
||||
@@ -104,10 +132,10 @@
|
||||
</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">
|
||||
<button type="button" class="btn btn-success width-12" ng-click="ctrl.saveDashboard()" ng-hide="ctrl.nameExists || ctrl.uidExists" 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">
|
||||
<button type="button" class="btn btn-danger width-12" ng-click="ctrl.saveDashboard()" ng-show="ctrl.nameExists || ctrl.uidExists" ng-disabled="!ctrl.inputsValid">
|
||||
<i class="fa fa-save"></i> Import (Overwrite)
|
||||
</button>
|
||||
<a class="btn btn-link" ng-click="ctrl.back()">Cancel</a>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import coreModule from 'app/core/core_module';
|
||||
import _ from 'lodash';
|
||||
|
||||
const template = `
|
||||
<div class="modal-body">
|
||||
@@ -14,19 +15,29 @@ const template = `
|
||||
</div>
|
||||
|
||||
<form name="ctrl.saveForm" ng-submit="ctrl.save()" class="modal-content" novalidate>
|
||||
<h6 class="text-center">Add a note to describe your changes</h6>
|
||||
<div class="p-t-2">
|
||||
<div class="p-t-1">
|
||||
<div class="gf-form-group" ng-if="ctrl.timeChange || ctrl.variableChange">
|
||||
<gf-form-switch class="gf-form"
|
||||
label="Save current time range" ng-if="ctrl.timeChange" label-class="width-12" switch-class="max-width-6"
|
||||
checked="ctrl.saveTimerange" on-change="buildUrl()">
|
||||
</gf-form-switch>
|
||||
<gf-form-switch class="gf-form"
|
||||
label="Save current variables" ng-if="ctrl.variableChange" label-class="width-12" switch-class="max-width-6"
|
||||
checked="ctrl.saveVariables" on-change="buildUrl()">
|
||||
</gf-form-switch>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-hint">
|
||||
<input
|
||||
type="text"
|
||||
name="message"
|
||||
class="gf-form-input"
|
||||
placeholder="Updates to …"
|
||||
placeholder="Add a note to describe your changes …"
|
||||
give-focus="true"
|
||||
ng-model="ctrl.message"
|
||||
ng-model-options="{allowInvalid: true}"
|
||||
ng-maxlength="this.max"
|
||||
maxlength="64"
|
||||
autocomplete="off" />
|
||||
<small class="gf-form-hint-text muted" ng-cloak>
|
||||
<span ng-class="{'text-error': ctrl.saveForm.message.$invalid && ctrl.saveForm.message.$dirty }">
|
||||
@@ -40,7 +51,7 @@ const template = `
|
||||
|
||||
<div class="gf-form-button-row text-center">
|
||||
<button type="submit" class="btn btn-success" ng-disabled="ctrl.saveForm.$invalid">Save</button>
|
||||
<button class="btn btn-inverse" ng-click="ctrl.dismiss();">Cancel</button>
|
||||
<a class="btn btn-link" ng-click="ctrl.dismiss();">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -48,14 +59,51 @@ const template = `
|
||||
|
||||
export class SaveDashboardModalCtrl {
|
||||
message: string;
|
||||
saveVariables = false;
|
||||
saveTimerange = false;
|
||||
templating: any;
|
||||
time: any;
|
||||
originalTime: any;
|
||||
current = [];
|
||||
originalCurrent = [];
|
||||
max: number;
|
||||
saveForm: any;
|
||||
dismiss: () => void;
|
||||
timeChange = false;
|
||||
variableChange = false;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private dashboardSrv) {
|
||||
this.message = '';
|
||||
this.max = 64;
|
||||
this.templating = dashboardSrv.dash.templating.list;
|
||||
|
||||
this.compareTemplating();
|
||||
this.compareTime();
|
||||
}
|
||||
|
||||
compareTime() {
|
||||
if (_.isEqual(this.dashboardSrv.dash.time, this.dashboardSrv.dash.originalTime)) {
|
||||
this.timeChange = false;
|
||||
} else {
|
||||
this.timeChange = true;
|
||||
}
|
||||
}
|
||||
|
||||
compareTemplating() {
|
||||
if (this.dashboardSrv.dash.templating.list.length > 0) {
|
||||
for (let i = 0; i < this.dashboardSrv.dash.templating.list.length; i++) {
|
||||
if (
|
||||
this.dashboardSrv.dash.templating.list[i].current.text !==
|
||||
this.dashboardSrv.dash.originalTemplating[i].current.text
|
||||
) {
|
||||
return (this.variableChange = true);
|
||||
}
|
||||
}
|
||||
return (this.variableChange = false);
|
||||
} else {
|
||||
return (this.variableChange = false);
|
||||
}
|
||||
}
|
||||
|
||||
save() {
|
||||
@@ -63,9 +111,14 @@ export class SaveDashboardModalCtrl {
|
||||
return;
|
||||
}
|
||||
|
||||
var options = {
|
||||
saveVariables: this.saveVariables,
|
||||
saveTimerange: this.saveTimerange,
|
||||
message: this.message,
|
||||
};
|
||||
|
||||
var dashboard = this.dashboardSrv.getCurrent();
|
||||
var saveModel = dashboard.getSaveModelClone();
|
||||
var options = { message: this.message };
|
||||
var saveModel = dashboard.getSaveModelClone(options);
|
||||
|
||||
return this.dashboardSrv.save(saveModel, options).then(this.dismiss);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('DashboardImportCtrl', function() {
|
||||
|
||||
backendSrv = {
|
||||
search: jest.fn().mockReturnValue(Promise.resolve([])),
|
||||
getDashboardByUid: jest.fn().mockReturnValue(Promise.resolve([])),
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
|
||||
@@ -434,4 +434,63 @@ describe('DashboardModel', function() {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('save variables and timeline', () => {
|
||||
let model;
|
||||
|
||||
beforeEach(() => {
|
||||
model = new DashboardModel({
|
||||
templating: {
|
||||
list: [
|
||||
{
|
||||
name: 'Server',
|
||||
current: {
|
||||
selected: true,
|
||||
text: 'server_001',
|
||||
value: 'server_001',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
time: {
|
||||
from: 'now-6h',
|
||||
to: 'now',
|
||||
},
|
||||
});
|
||||
model.templating.list[0] = {
|
||||
name: 'Server',
|
||||
current: {
|
||||
selected: true,
|
||||
text: 'server_002',
|
||||
value: 'server_002',
|
||||
},
|
||||
};
|
||||
model.time = {
|
||||
from: 'now-3h',
|
||||
to: 'now',
|
||||
};
|
||||
});
|
||||
|
||||
it('should not save variables and timeline', () => {
|
||||
let options = {
|
||||
saveVariables: false,
|
||||
saveTimerange: false,
|
||||
};
|
||||
let saveModel = model.getSaveModelClone(options);
|
||||
|
||||
expect(saveModel.templating.list[0].current.text).toBe('server_001');
|
||||
expect(saveModel.time.from).toBe('now-6h');
|
||||
});
|
||||
|
||||
it('should save variables and timeline', () => {
|
||||
let options = {
|
||||
saveVariables: true,
|
||||
saveTimerange: true,
|
||||
};
|
||||
let saveModel = model.getSaveModelClone(options);
|
||||
|
||||
expect(saveModel.templating.list[0].current.text).toBe('server_002');
|
||||
expect(saveModel.time.from).toBe('now-3h');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { SaveDashboardModalCtrl } from '../save_modal';
|
||||
|
||||
jest.mock('app/core/services/context_srv', () => ({}));
|
||||
|
||||
describe('SaveDashboardModal', () => {
|
||||
describe('save modal checkboxes', () => {
|
||||
it('should show checkboxes', () => {
|
||||
let fakeDashboardSrv = {
|
||||
dash: {
|
||||
templating: {
|
||||
list: [
|
||||
{
|
||||
current: {
|
||||
selected: true,
|
||||
tags: Array(0),
|
||||
text: 'server_001',
|
||||
value: 'server_001',
|
||||
},
|
||||
name: 'Server',
|
||||
},
|
||||
],
|
||||
},
|
||||
originalTemplating: [
|
||||
{
|
||||
current: {
|
||||
selected: true,
|
||||
text: 'server_002',
|
||||
value: 'server_002',
|
||||
},
|
||||
name: 'Server',
|
||||
},
|
||||
],
|
||||
time: {
|
||||
from: 'now-3h',
|
||||
to: 'now',
|
||||
},
|
||||
originalTime: {
|
||||
from: 'now-6h',
|
||||
to: 'now',
|
||||
},
|
||||
},
|
||||
};
|
||||
let modal = new SaveDashboardModalCtrl(fakeDashboardSrv);
|
||||
|
||||
expect(modal.timeChange).toBe(true);
|
||||
expect(modal.variableChange).toBe(true);
|
||||
});
|
||||
|
||||
it('should hide checkboxes', () => {
|
||||
let fakeDashboardSrv = {
|
||||
dash: {
|
||||
templating: {
|
||||
list: [
|
||||
{
|
||||
current: {
|
||||
selected: true,
|
||||
//tags: Array(0),
|
||||
text: 'server_002',
|
||||
value: 'server_002',
|
||||
},
|
||||
name: 'Server',
|
||||
},
|
||||
],
|
||||
},
|
||||
originalTemplating: [
|
||||
{
|
||||
current: {
|
||||
selected: true,
|
||||
text: 'server_002',
|
||||
value: 'server_002',
|
||||
},
|
||||
name: 'Server',
|
||||
},
|
||||
],
|
||||
time: {
|
||||
from: 'now-3h',
|
||||
to: 'now',
|
||||
},
|
||||
originalTime: {
|
||||
from: 'now-3h',
|
||||
to: 'now',
|
||||
},
|
||||
},
|
||||
};
|
||||
let modal = new SaveDashboardModalCtrl(fakeDashboardSrv);
|
||||
expect(modal.timeChange).toBe(false);
|
||||
expect(modal.variableChange).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -324,7 +324,12 @@ class MetricsPanelCtrl extends PanelCtrl {
|
||||
}
|
||||
|
||||
explore() {
|
||||
const exploreState = encodePathComponent(JSON.stringify(this.datasource.getExploreState(this.panel)));
|
||||
const range = this.timeSrv.timeRangeForUrl();
|
||||
const state = {
|
||||
...this.datasource.getExploreState(this.panel),
|
||||
range,
|
||||
};
|
||||
const exploreState = encodePathComponent(JSON.stringify(state));
|
||||
this.$location.url(`/explore/${exploreState}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,13 @@ import 'rxjs/add/observable/from';
|
||||
import 'rxjs/add/operator/map';
|
||||
import 'rxjs/add/operator/combineAll';
|
||||
|
||||
// add cache busting
|
||||
const bust = `?_cache=${Date.now()}`;
|
||||
function locate(load) {
|
||||
return load.address + bust;
|
||||
}
|
||||
System.registry.set('plugin-loader', System.newModule({ locate: locate }));
|
||||
|
||||
System.config({
|
||||
baseURL: 'public',
|
||||
defaultExtension: 'js',
|
||||
@@ -40,23 +47,14 @@ System.config({
|
||||
css: 'vendor/plugin-css/css.js',
|
||||
},
|
||||
meta: {
|
||||
'*': {
|
||||
'plugin*': {
|
||||
esModule: true,
|
||||
authorization: true,
|
||||
loader: 'plugin-loader',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// add cache busting
|
||||
var systemLocate = System.locate;
|
||||
System.cacheBust = '?bust=' + Date.now();
|
||||
System.locate = function(load) {
|
||||
var System = this;
|
||||
return Promise.resolve(systemLocate.call(this, load)).then(function(address) {
|
||||
return address + System.cacheBust;
|
||||
});
|
||||
};
|
||||
|
||||
function exposeToPlugin(name: string, component: any) {
|
||||
System.registerDynamic(name, [], true, function(require, exports, module) {
|
||||
module.exports = component;
|
||||
|
||||
@@ -44,6 +44,28 @@ function replaceAggregationAddStrategy(selectParts, partModel) {
|
||||
for (var i = 0; i < selectParts.length; i++) {
|
||||
var part = selectParts[i];
|
||||
if (part.def.category === categories.Aggregations) {
|
||||
if (part.def.type === partModel.def.type) {
|
||||
return;
|
||||
}
|
||||
// count distinct is allowed
|
||||
if (part.def.type === 'count' && partModel.def.type === 'distinct') {
|
||||
break;
|
||||
}
|
||||
// remove next aggregation if distinct was replaced
|
||||
if (part.def.type === 'distinct') {
|
||||
var morePartsAvailable = selectParts.length >= i + 2;
|
||||
if (partModel.def.type !== 'count' && morePartsAvailable) {
|
||||
var nextPart = selectParts[i + 1];
|
||||
if (nextPart.def.category === categories.Aggregations) {
|
||||
selectParts.splice(i + 1, 1);
|
||||
}
|
||||
} else if (partModel.def.type === 'count') {
|
||||
if (!morePartsAvailable || selectParts[i + 1].def.type !== 'count') {
|
||||
selectParts.splice(i + 1, 0, partModel);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
selectParts[i] = partModel;
|
||||
return;
|
||||
}
|
||||
@@ -434,4 +456,5 @@ export default {
|
||||
getCategories: function() {
|
||||
return categories;
|
||||
},
|
||||
replaceAggregationAdd: replaceAggregationAddStrategy,
|
||||
};
|
||||
@@ -40,5 +40,149 @@ describe('InfluxQueryPart', () => {
|
||||
expect(part.text).toBe('alias(test)');
|
||||
expect(part.render('mean(value)')).toBe('mean(value) AS "test"');
|
||||
});
|
||||
|
||||
it('should nest distinct when count is selected', () => {
|
||||
var selectParts = [
|
||||
queryPart.create({
|
||||
type: 'field',
|
||||
category: queryPart.getCategories().Fields,
|
||||
}),
|
||||
queryPart.create({
|
||||
type: 'count',
|
||||
category: queryPart.getCategories().Aggregations,
|
||||
}),
|
||||
];
|
||||
var partModel = queryPart.create({
|
||||
type: 'distinct',
|
||||
category: queryPart.getCategories().Aggregations,
|
||||
});
|
||||
|
||||
queryPart.replaceAggregationAdd(selectParts, partModel);
|
||||
|
||||
expect(selectParts[1].text).toBe('distinct()');
|
||||
expect(selectParts[2].text).toBe('count()');
|
||||
});
|
||||
|
||||
it('should convert to count distinct when distinct is selected and count added', () => {
|
||||
var selectParts = [
|
||||
queryPart.create({
|
||||
type: 'field',
|
||||
category: queryPart.getCategories().Fields,
|
||||
}),
|
||||
queryPart.create({
|
||||
type: 'distinct',
|
||||
category: queryPart.getCategories().Aggregations,
|
||||
}),
|
||||
];
|
||||
var partModel = queryPart.create({
|
||||
type: 'count',
|
||||
category: queryPart.getCategories().Aggregations,
|
||||
});
|
||||
|
||||
queryPart.replaceAggregationAdd(selectParts, partModel);
|
||||
|
||||
expect(selectParts[1].text).toBe('distinct()');
|
||||
expect(selectParts[2].text).toBe('count()');
|
||||
});
|
||||
|
||||
it('should replace count distinct if an aggregation is selected', () => {
|
||||
var selectParts = [
|
||||
queryPart.create({
|
||||
type: 'field',
|
||||
category: queryPart.getCategories().Fields,
|
||||
}),
|
||||
queryPart.create({
|
||||
type: 'distinct',
|
||||
category: queryPart.getCategories().Aggregations,
|
||||
}),
|
||||
queryPart.create({
|
||||
type: 'count',
|
||||
category: queryPart.getCategories().Aggregations,
|
||||
}),
|
||||
];
|
||||
var partModel = queryPart.create({
|
||||
type: 'mean',
|
||||
category: queryPart.getCategories().Selectors,
|
||||
});
|
||||
|
||||
queryPart.replaceAggregationAdd(selectParts, partModel);
|
||||
|
||||
expect(selectParts[1].text).toBe('mean()');
|
||||
expect(selectParts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should not allowed nested counts when count distinct is selected', () => {
|
||||
var selectParts = [
|
||||
queryPart.create({
|
||||
type: 'field',
|
||||
category: queryPart.getCategories().Fields,
|
||||
}),
|
||||
queryPart.create({
|
||||
type: 'distinct',
|
||||
category: queryPart.getCategories().Aggregations,
|
||||
}),
|
||||
queryPart.create({
|
||||
type: 'count',
|
||||
category: queryPart.getCategories().Aggregations,
|
||||
}),
|
||||
];
|
||||
var partModel = queryPart.create({
|
||||
type: 'count',
|
||||
category: queryPart.getCategories().Aggregations,
|
||||
});
|
||||
|
||||
queryPart.replaceAggregationAdd(selectParts, partModel);
|
||||
|
||||
expect(selectParts[1].text).toBe('distinct()');
|
||||
expect(selectParts[2].text).toBe('count()');
|
||||
expect(selectParts).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should not remove count distinct when distinct is added', () => {
|
||||
var selectParts = [
|
||||
queryPart.create({
|
||||
type: 'field',
|
||||
category: queryPart.getCategories().Fields,
|
||||
}),
|
||||
queryPart.create({
|
||||
type: 'distinct',
|
||||
category: queryPart.getCategories().Aggregations,
|
||||
}),
|
||||
queryPart.create({
|
||||
type: 'count',
|
||||
category: queryPart.getCategories().Aggregations,
|
||||
}),
|
||||
];
|
||||
var partModel = queryPart.create({
|
||||
type: 'distinct',
|
||||
category: queryPart.getCategories().Aggregations,
|
||||
});
|
||||
|
||||
queryPart.replaceAggregationAdd(selectParts, partModel);
|
||||
|
||||
expect(selectParts[1].text).toBe('distinct()');
|
||||
expect(selectParts[2].text).toBe('count()');
|
||||
expect(selectParts).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should remove distinct when sum aggregation is selected', () => {
|
||||
var selectParts = [
|
||||
queryPart.create({
|
||||
type: 'field',
|
||||
category: queryPart.getCategories().Fields,
|
||||
}),
|
||||
queryPart.create({
|
||||
type: 'distinct',
|
||||
category: queryPart.getCategories().Aggregations,
|
||||
}),
|
||||
];
|
||||
var partModel = queryPart.create({
|
||||
type: 'sum',
|
||||
category: queryPart.getCategories().Aggregations,
|
||||
});
|
||||
queryPart.replaceAggregationAdd(selectParts, partModel);
|
||||
|
||||
expect(selectParts[1].text).toBe('sum()');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,15 @@ import PrometheusMetricFindQuery from './metric_find_query';
|
||||
import { ResultTransformer } from './result_transformer';
|
||||
import { BackendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
export function alignRange(start, end, step) {
|
||||
const alignedEnd = Math.ceil(end / step) * step;
|
||||
const alignedStart = Math.floor(start / step) * step;
|
||||
return {
|
||||
end: alignedEnd,
|
||||
start: alignedStart,
|
||||
};
|
||||
}
|
||||
|
||||
export function prometheusRegularEscape(value) {
|
||||
return value.replace(/'/g, "\\\\'");
|
||||
}
|
||||
@@ -109,15 +118,6 @@ export class PrometheusDatasource {
|
||||
return this.templateSrv.variableExists(target.expr);
|
||||
}
|
||||
|
||||
clampRange(start, end, step) {
|
||||
const clampedEnd = Math.ceil(end / step) * step;
|
||||
const clampedRange = Math.floor((end - start) / step) * step;
|
||||
return {
|
||||
end: clampedEnd,
|
||||
start: clampedEnd - clampedRange,
|
||||
};
|
||||
}
|
||||
|
||||
query(options) {
|
||||
var start = this.getPrometheusTime(options.range.from, false);
|
||||
var end = this.getPrometheusTime(options.range.to, true);
|
||||
@@ -164,6 +164,7 @@ export class PrometheusDatasource {
|
||||
legendFormat: activeTargets[index].legendFormat,
|
||||
start: start,
|
||||
end: end,
|
||||
query: queries[index].expr,
|
||||
responseListLength: responseList.length,
|
||||
responseIndex: index,
|
||||
refId: activeTargets[index].refId,
|
||||
@@ -205,7 +206,7 @@ export class PrometheusDatasource {
|
||||
query.requestId = options.panelId + target.refId;
|
||||
|
||||
// Align query interval with step
|
||||
const adjusted = this.clampRange(start, end, query.step);
|
||||
const adjusted = alignRange(start, end, query.step);
|
||||
query.start = adjusted.start;
|
||||
query.end = adjusted.end;
|
||||
|
||||
|
||||
@@ -123,11 +123,16 @@ export class ResultTransformer {
|
||||
}
|
||||
|
||||
createMetricLabel(labelData, options) {
|
||||
let label = '';
|
||||
if (_.isUndefined(options) || _.isEmpty(options.legendFormat)) {
|
||||
return this.getOriginalMetricName(labelData);
|
||||
label = this.getOriginalMetricName(labelData);
|
||||
} else {
|
||||
label = this.renderTemplate(this.templateSrv.replace(options.legendFormat), labelData);
|
||||
}
|
||||
|
||||
return this.renderTemplate(this.templateSrv.replace(options.legendFormat), labelData) || '{}';
|
||||
if (!label || label === '{}') {
|
||||
label = options.query;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
renderTemplate(aliasPattern, aliasData) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import q from 'q';
|
||||
import { PrometheusDatasource, prometheusSpecialRegexEscape, prometheusRegularEscape } from '../datasource';
|
||||
import { alignRange, PrometheusDatasource, prometheusSpecialRegexEscape, prometheusRegularEscape } from '../datasource';
|
||||
|
||||
describe('PrometheusDatasource', () => {
|
||||
let ctx: any = {};
|
||||
@@ -142,6 +142,29 @@ describe('PrometheusDatasource', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('alignRange', function() {
|
||||
it('does not modify already aligned intervals with perfect step', function() {
|
||||
const range = alignRange(0, 3, 3);
|
||||
expect(range.start).toEqual(0);
|
||||
expect(range.end).toEqual(3);
|
||||
});
|
||||
it('does modify end-aligned intervals to reflect number of steps possible', function() {
|
||||
const range = alignRange(1, 6, 3);
|
||||
expect(range.start).toEqual(0);
|
||||
expect(range.end).toEqual(6);
|
||||
});
|
||||
it('does align intervals that are a multiple of steps', function() {
|
||||
const range = alignRange(1, 4, 3);
|
||||
expect(range.start).toEqual(0);
|
||||
expect(range.end).toEqual(6);
|
||||
});
|
||||
it('does align intervals that are not a multiple of steps', function() {
|
||||
const range = alignRange(1, 5, 3);
|
||||
expect(range.start).toEqual(0);
|
||||
expect(range.end).toEqual(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Prometheus regular escaping', function() {
|
||||
it('should not escape simple string', function() {
|
||||
expect(prometheusRegularEscape('cryptodepression')).toEqual('cryptodepression');
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('PrometheusDatasource', function() {
|
||||
};
|
||||
// Interval alignment with step
|
||||
var urlExpected =
|
||||
'proxied/api/v1/query_range?query=' + encodeURIComponent('test{job="testjob"}') + '&start=120&end=240&step=60';
|
||||
'proxied/api/v1/query_range?query=' + encodeURIComponent('test{job="testjob"}') + '&start=60&end=240&step=60';
|
||||
var response = {
|
||||
status: 'success',
|
||||
data: {
|
||||
@@ -181,7 +181,7 @@ describe('PrometheusDatasource', function() {
|
||||
var urlExpected =
|
||||
'proxied/api/v1/query_range?query=' +
|
||||
encodeURIComponent('ALERTS{alertstate="firing"}') +
|
||||
'&start=120&end=180&step=60';
|
||||
'&start=60&end=180&step=60';
|
||||
var options = {
|
||||
annotation: {
|
||||
expr: 'ALERTS{alertstate="firing"}',
|
||||
@@ -348,7 +348,7 @@ describe('PrometheusDatasource', function() {
|
||||
interval: '5s',
|
||||
};
|
||||
// times get rounded up to interval
|
||||
var urlExpected = 'proxied/api/v1/query_range?query=test&start=100&end=450&step=50';
|
||||
var urlExpected = 'proxied/api/v1/query_range?query=test&start=50&end=450&step=50';
|
||||
ctx.$httpBackend.expect('GET', urlExpected).respond(response);
|
||||
ctx.ds.query(query);
|
||||
ctx.$httpBackend.verifyNoOutstandingExpectation();
|
||||
@@ -384,8 +384,8 @@ describe('PrometheusDatasource', function() {
|
||||
],
|
||||
interval: '10s',
|
||||
};
|
||||
// times get rounded up to interval
|
||||
var urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=200&end=500&step=100';
|
||||
// times get aligned to interval
|
||||
var urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=0&end=500&step=100';
|
||||
ctx.$httpBackend.expect('GET', urlExpected).respond(response);
|
||||
ctx.ds.query(query);
|
||||
ctx.$httpBackend.verifyNoOutstandingExpectation();
|
||||
@@ -511,7 +511,7 @@ describe('PrometheusDatasource', function() {
|
||||
},
|
||||
};
|
||||
var urlExpected =
|
||||
'proxied/api/v1/query_range?query=' + encodeURIComponent('rate(test[100s])') + '&start=200&end=500&step=100';
|
||||
'proxied/api/v1/query_range?query=' + encodeURIComponent('rate(test[100s])') + '&start=0&end=500&step=100';
|
||||
ctx.$httpBackend.expect('GET', urlExpected).respond(response);
|
||||
ctx.ds.query(query);
|
||||
ctx.$httpBackend.verifyNoOutstandingExpectation();
|
||||
@@ -539,7 +539,7 @@ describe('PrometheusDatasource', function() {
|
||||
},
|
||||
};
|
||||
var urlExpected =
|
||||
'proxied/api/v1/query_range?query=' + encodeURIComponent('rate(test[50s])') + '&start=100&end=450&step=50';
|
||||
'proxied/api/v1/query_range?query=' + encodeURIComponent('rate(test[50s])') + '&start=50&end=450&step=50';
|
||||
ctx.$httpBackend.expect('GET', urlExpected).respond(response);
|
||||
ctx.ds.query(query);
|
||||
ctx.$httpBackend.verifyNoOutstandingExpectation();
|
||||
@@ -613,29 +613,6 @@ describe('PrometheusDatasource', function() {
|
||||
expect(query.scopedVars.__interval_ms.value).to.be(5 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Step alignment of intervals', function() {
|
||||
it('does not modify already aligned intervals with perfect step', function() {
|
||||
const range = ctx.ds.clampRange(0, 3, 3);
|
||||
expect(range.start).to.be(0);
|
||||
expect(range.end).to.be(3);
|
||||
});
|
||||
it('does modify end-aligned intervals to reflect number of steps possible', function() {
|
||||
const range = ctx.ds.clampRange(1, 6, 3);
|
||||
expect(range.start).to.be(3);
|
||||
expect(range.end).to.be(6);
|
||||
});
|
||||
it('does align intervals that are a multiple of steps', function() {
|
||||
const range = ctx.ds.clampRange(1, 4, 3);
|
||||
expect(range.start).to.be(3);
|
||||
expect(range.end).to.be(6);
|
||||
});
|
||||
it('does align intervals that are not a multiple of steps', function() {
|
||||
const range = ctx.ds.clampRange(1, 5, 3);
|
||||
expect(range.start).to.be(3);
|
||||
expect(range.end).to.be(6);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PrometheusDatasource for POST', function() {
|
||||
@@ -667,7 +644,7 @@ describe('PrometheusDatasource for POST', function() {
|
||||
var urlExpected = 'proxied/api/v1/query_range';
|
||||
var dataExpected = $.param({
|
||||
query: 'test{job="testjob"}',
|
||||
start: 2 * 60,
|
||||
start: 1 * 60,
|
||||
end: 3 * 60,
|
||||
step: 60,
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form max-width-21">
|
||||
<label class="gf-form-label width-8">Thresholds
|
||||
<tip>Define two threshold values<br /> 50,80 will produce: <50 = Green, 50:80 = Yellow, >80 = Red</tip>
|
||||
<tip>Define two threshold values<br /> 50,80 will produce: value < 50 = Green, 50 <= value < 80 = Yellow, value >= 80 = Red</tip>
|
||||
</label>
|
||||
<input type="text" class="gf-form-input" ng-model="ctrl.panel.thresholds" ng-blur="ctrl.render()" placeholder="50,80"></input>
|
||||
</div>
|
||||
|
||||
@@ -714,11 +714,13 @@ function getColorForValue(data, value) {
|
||||
if (!_.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (var i = data.thresholds.length; i > 0; i--) {
|
||||
if (value >= data.thresholds[i - 1]) {
|
||||
return data.colorMap[i];
|
||||
}
|
||||
}
|
||||
|
||||
return _.first(data.colorMap);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import './ReactContainer';
|
||||
|
||||
import ServerStats from 'app/containers/ServerStats/ServerStats';
|
||||
import AlertRuleList from 'app/containers/AlertRuleList/AlertRuleList';
|
||||
// import Explore from 'app/containers/Explore/Explore';
|
||||
import FolderSettings from 'app/containers/ManageDashboards/FolderSettings';
|
||||
import FolderPermissions from 'app/containers/ManageDashboards/FolderPermissions';
|
||||
|
||||
@@ -114,7 +113,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||
.when('/explore/:initial?', {
|
||||
template: '<react-container />',
|
||||
resolve: {
|
||||
component: () => import(/* webpackChunkName: "explore" */ 'app/containers/Explore/Explore'),
|
||||
component: () => import(/* webpackChunkName: "explore" */ 'app/containers/Explore/Wrapper'),
|
||||
},
|
||||
})
|
||||
.when('/org', {
|
||||
|
||||
@@ -45,6 +45,10 @@ $brand-warning: $brand-primary;
|
||||
$brand-danger: $red;
|
||||
|
||||
$query-blue: $blue;
|
||||
$query-red: $red;
|
||||
$query-green: $green;
|
||||
$query-purple: $purple;
|
||||
$query-orange: $orange;
|
||||
|
||||
// Status colors
|
||||
// -------------------------
|
||||
@@ -176,6 +180,9 @@ $btn-inverse-bg-hl: lighten($dark-3, 4%);
|
||||
$btn-inverse-text-color: $link-color;
|
||||
$btn-inverse-text-shadow: 0px 1px 0 rgba(0, 0, 0, 0.1);
|
||||
|
||||
$btn-active-bg: $gray-4;
|
||||
$btn-active-text-color: $blue-dark;
|
||||
|
||||
$btn-link-color: $gray-3;
|
||||
|
||||
$iconContainerBackground: $black;
|
||||
@@ -204,6 +211,11 @@ $input-invalid-border-color: lighten($red, 5%);
|
||||
$search-shadow: 0 0 30px 0 $black;
|
||||
$search-filter-box-bg: $gray-blue;
|
||||
|
||||
// Typeahead
|
||||
$typeahead-shadow: 0 5px 10px 0 $black;
|
||||
$typeahead-selected-bg: $dark-4;
|
||||
$typeahead-selected-color: $blue;
|
||||
|
||||
// Dropdowns
|
||||
// -------------------------
|
||||
$dropdownBackground: $dark-3;
|
||||
|
||||
@@ -46,6 +46,10 @@ $brand-warning: $orange;
|
||||
$brand-danger: $red;
|
||||
|
||||
$query-blue: $blue-dark;
|
||||
$query-red: $red;
|
||||
$query-green: $green;
|
||||
$query-purple: $purple;
|
||||
$query-orange: $orange;
|
||||
|
||||
// Status colors
|
||||
// -------------------------
|
||||
@@ -173,6 +177,9 @@ $btn-inverse-bg-hl: darken($gray-6, 5%);
|
||||
$btn-inverse-text-color: $gray-1;
|
||||
$btn-inverse-text-shadow: 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||
|
||||
$btn-active-bg: $white;
|
||||
$btn-active-text-color: $blue-dark;
|
||||
|
||||
$btn-link-color: $gray-1;
|
||||
|
||||
$btn-divider-left: $gray-4;
|
||||
@@ -226,6 +233,11 @@ $tab-border-color: $gray-5;
|
||||
$search-shadow: 0 5px 30px 0 $gray-4;
|
||||
$search-filter-box-bg: $gray-7;
|
||||
|
||||
// Typeahead
|
||||
$typeahead-shadow: 0 5px 10px 0 $gray-5;
|
||||
$typeahead-selected-bg: lighten($blue, 25%);
|
||||
$typeahead-selected-color: $blue-dark;
|
||||
|
||||
// Dropdowns
|
||||
// -------------------------
|
||||
$dropdownBackground: $white;
|
||||
|
||||
@@ -18,6 +18,20 @@
|
||||
height: 100% !important;
|
||||
transform: translate(0px, 0px) !important;
|
||||
}
|
||||
|
||||
// Disable grid interaction indicators in fullscreen panels
|
||||
|
||||
.panel-header:hover {
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.panel-title-container {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.react-resizable-handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
|
||||
.playlist-active,
|
||||
.user-activity-low {
|
||||
.react-resizable-handle .add-row-panel-hint,
|
||||
.react-resizable-handle,
|
||||
.add-row-panel-hint,
|
||||
.dash-row-menu-container,
|
||||
.navbar-button--refresh,
|
||||
.navbar-buttons--zoom,
|
||||
|
||||
+163
-244
@@ -1,11 +1,89 @@
|
||||
.explore {
|
||||
width: 100%;
|
||||
|
||||
&-container {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
&-wrapper {
|
||||
display: flex;
|
||||
|
||||
> .explore-split {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
// Push split button a bit
|
||||
.explore-first-button {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
// Graph panel needs a bit extra padding at top
|
||||
.panel-container {
|
||||
padding: $panel-padding;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
// Make sure wrap buttons around on small screens
|
||||
.navbar {
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.navbar-page-btn {
|
||||
margin-right: 1rem;
|
||||
|
||||
// Explore icon in header
|
||||
.fa {
|
||||
font-size: 100%;
|
||||
opacity: 0.75;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle mode
|
||||
.navbar-button.active {
|
||||
color: $btn-active-text-color;
|
||||
background-color: $btn-active-bg;
|
||||
}
|
||||
|
||||
.elapsed-time {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 3.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.graph-legend {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.timepicker {
|
||||
display: flex;
|
||||
|
||||
&-rangestring {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.run-icon {
|
||||
margin-left: 0.5em;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.explore + .explore {
|
||||
border-left: 1px dotted $table-border;
|
||||
}
|
||||
|
||||
.query-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
& + & {
|
||||
margin-top: 0.5rem;
|
||||
@@ -13,17 +91,12 @@
|
||||
}
|
||||
|
||||
.query-row-tools {
|
||||
position: absolute;
|
||||
left: -4rem;
|
||||
top: 0.33rem;
|
||||
> * {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
width: 4rem;
|
||||
}
|
||||
|
||||
.query-field {
|
||||
font-size: 14px;
|
||||
font-family: Consolas, Menlo, Courier, monospace;
|
||||
font-size: $font-size-root;
|
||||
font-family: $font-family-monospace;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@@ -33,54 +106,52 @@
|
||||
padding: 6px 7px 4px;
|
||||
width: 100%;
|
||||
cursor: text;
|
||||
line-height: 1.5;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
background-color: #fff;
|
||||
line-height: $line-height-base;
|
||||
color: $text-color-weak;
|
||||
background-color: $panel-bg;
|
||||
background-image: none;
|
||||
border: 1px solid lightgray;
|
||||
border-radius: 3px;
|
||||
border: $panel-border;
|
||||
border-radius: $border-radius;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.explore {
|
||||
.explore-typeahead {
|
||||
.typeahead {
|
||||
position: absolute;
|
||||
z-index: auto;
|
||||
top: -10000px;
|
||||
left: -10000px;
|
||||
opacity: 0;
|
||||
border-radius: 4px;
|
||||
border-radius: $border-radius;
|
||||
transition: opacity 0.75s;
|
||||
border: 1px solid #e4e4e4;
|
||||
border: $panel-border;
|
||||
max-height: calc(66vh);
|
||||
overflow-y: scroll;
|
||||
max-width: calc(66%);
|
||||
overflow-x: hidden;
|
||||
outline: none;
|
||||
list-style: none;
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
background: $panel-bg;
|
||||
color: $text-color;
|
||||
transition: opacity 0.4s ease-out;
|
||||
box-shadow: $typeahead-shadow;
|
||||
}
|
||||
|
||||
.typeahead-group__title {
|
||||
color: rgba(0, 0, 0, 0.43);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
padding: 8px 16px;
|
||||
color: $text-color-weak;
|
||||
font-size: $font-size-sm;
|
||||
line-height: $line-height-base;
|
||||
padding: $input-padding-y $input-padding-x;
|
||||
}
|
||||
|
||||
.typeahead-item {
|
||||
line-height: 200%;
|
||||
height: auto;
|
||||
font-family: Consolas, Menlo, Courier, monospace;
|
||||
padding: 0 16px 0 28px;
|
||||
font-size: 12px;
|
||||
font-family: $font-family-monospace;
|
||||
padding: $input-padding-y $input-padding-x;
|
||||
padding-left: $input-padding-x-lg;
|
||||
font-size: $font-size-sm;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
margin-left: -1px;
|
||||
left: 1px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
@@ -90,234 +161,82 @@
|
||||
}
|
||||
|
||||
.typeahead-item__selected {
|
||||
background-color: #ecf6fd;
|
||||
color: #108ee9;
|
||||
background-color: $typeahead-selected-bg;
|
||||
color: $typeahead-selected-color;
|
||||
}
|
||||
}
|
||||
|
||||
/* SYNTAX */
|
||||
|
||||
/**
|
||||
* prism.js Coy theme for JavaScript, CoffeeScript, CSS and HTML
|
||||
* Based on https://github.com/tshedor/workshop-wp-theme (Example: http://workshop.kansan.com/category/sessions/basics or http://workshop.timshedor.com/category/sessions/basics);
|
||||
* @author Tim Shedor
|
||||
*/
|
||||
.explore {
|
||||
.token.comment,
|
||||
.token.block-comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: $text-color-weak;
|
||||
}
|
||||
|
||||
code[class*='language-'],
|
||||
pre[class*='language-'] {
|
||||
color: black;
|
||||
background: none;
|
||||
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
word-wrap: normal;
|
||||
line-height: 1.5;
|
||||
.token.punctuation {
|
||||
color: $text-color-weak;
|
||||
}
|
||||
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.boolean,
|
||||
.token.number,
|
||||
.token.function-name,
|
||||
.token.constant,
|
||||
.token.symbol,
|
||||
.token.deleted {
|
||||
color: $query-red;
|
||||
}
|
||||
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
}
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.function,
|
||||
.token.builtin,
|
||||
.token.inserted {
|
||||
color: $query-green;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre[class*='language-'] {
|
||||
position: relative;
|
||||
margin: 0.5em 0;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
pre[class*='language-'] > code {
|
||||
position: relative;
|
||||
border-left: 10px solid #358ccb;
|
||||
box-shadow: -1px 0px 0px 0px #358ccb, 0px 0px 0px 1px #dfdfdf;
|
||||
background-color: #fdfdfd;
|
||||
background-image: linear-gradient(transparent 50%, rgba(69, 142, 209, 0.04) 50%);
|
||||
background-size: 3em 3em;
|
||||
background-origin: content-box;
|
||||
background-attachment: local;
|
||||
}
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.token.variable {
|
||||
color: $query-purple;
|
||||
}
|
||||
|
||||
code[class*='language'] {
|
||||
max-height: inherit;
|
||||
height: inherit;
|
||||
padding: 0 1em;
|
||||
display: block;
|
||||
overflow: auto;
|
||||
}
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.keyword,
|
||||
.token.class-name {
|
||||
color: $query-blue;
|
||||
}
|
||||
|
||||
/* Margin bottom to accomodate shadow */
|
||||
:not(pre) > code[class*='language-'],
|
||||
pre[class*='language-'] {
|
||||
background-color: #fdfdfd;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.token.regex,
|
||||
.token.important {
|
||||
color: $query-orange;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:not(pre) > code[class*='language-'] {
|
||||
position: relative;
|
||||
padding: 0.2em;
|
||||
border-radius: 0.3em;
|
||||
color: #c92c2c;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
display: inline;
|
||||
white-space: normal;
|
||||
}
|
||||
.token.important {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
pre[class*='language-']:before,
|
||||
pre[class*='language-']:after {
|
||||
content: '';
|
||||
z-index: -2;
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 0.75em;
|
||||
left: 0.18em;
|
||||
width: 40%;
|
||||
height: 20%;
|
||||
max-height: 13em;
|
||||
box-shadow: 0px 13px 8px #979797;
|
||||
-webkit-transform: rotate(-2deg);
|
||||
-moz-transform: rotate(-2deg);
|
||||
-ms-transform: rotate(-2deg);
|
||||
-o-transform: rotate(-2deg);
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
.token.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
:not(pre) > code[class*='language-']:after,
|
||||
pre[class*='language-']:after {
|
||||
right: 0.75em;
|
||||
left: auto;
|
||||
-webkit-transform: rotate(2deg);
|
||||
-moz-transform: rotate(2deg);
|
||||
-ms-transform: rotate(2deg);
|
||||
-o-transform: rotate(2deg);
|
||||
transform: rotate(2deg);
|
||||
}
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.block-comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: #7d8b99;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #5f6364;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.boolean,
|
||||
.token.number,
|
||||
.token.function-name,
|
||||
.token.constant,
|
||||
.token.symbol,
|
||||
.token.deleted {
|
||||
color: #c92c2c;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.function,
|
||||
.token.builtin,
|
||||
.token.inserted {
|
||||
color: #2f9c0a;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.token.variable {
|
||||
color: #a67f59;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.keyword,
|
||||
.token.class-name {
|
||||
color: #1990b8;
|
||||
}
|
||||
|
||||
.token.regex,
|
||||
.token.important {
|
||||
color: #e90;
|
||||
}
|
||||
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #a67f59;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.token.important {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.token.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.namespace {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
pre[class*='language-']:before,
|
||||
pre[class*='language-']:after {
|
||||
bottom: 14px;
|
||||
box-shadow: none;
|
||||
.namespace {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
/* Plugin styles */
|
||||
.token.tab:not(:empty):before,
|
||||
.token.cr:before,
|
||||
.token.lf:before {
|
||||
color: #e0d7d1;
|
||||
}
|
||||
|
||||
/* Plugin styles: Line Numbers */
|
||||
pre[class*='language-'].line-numbers {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
pre[class*='language-'].line-numbers code {
|
||||
padding-left: 3.8em;
|
||||
}
|
||||
|
||||
pre[class*='language-'].line-numbers .line-numbers-rows {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Plugin styles: Line Highlight */
|
||||
pre[class*='language-'][data-line] {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
pre[data-line] code {
|
||||
position: relative;
|
||||
padding-left: 4em;
|
||||
}
|
||||
pre .line-highlight {
|
||||
margin-top: 0;
|
||||
}
|
||||
Vendored
+13
-12
@@ -1,6 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
var bust = '?_cache=' + Date.now();
|
||||
var waitSeconds = 100;
|
||||
|
||||
var head = document.getElementsByTagName('head')[0];
|
||||
@@ -13,8 +14,8 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
|
||||
var isWebkit = !!window.navigator.userAgent.match(/AppleWebKit\/([^ ;]*)/);
|
||||
var webkitLoadCheck = function(link, callback) {
|
||||
setTimeout(function() {
|
||||
var webkitLoadCheck = function (link, callback) {
|
||||
setTimeout(function () {
|
||||
for (var i = 0; i < document.styleSheets.length; i++) {
|
||||
var sheet = document.styleSheets[i];
|
||||
if (sheet.href === link.href) {
|
||||
@@ -25,17 +26,17 @@ if (typeof window !== 'undefined') {
|
||||
}, 10);
|
||||
};
|
||||
|
||||
var noop = function() {};
|
||||
var noop = function () { };
|
||||
|
||||
var loadCSS = function(url) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var timeout = setTimeout(function() {
|
||||
var loadCSS = function (url) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var timeout = setTimeout(function () {
|
||||
reject('Unable to load CSS');
|
||||
}, waitSeconds * 1000);
|
||||
var _callback = function(error) {
|
||||
var _callback = function (error) {
|
||||
clearTimeout(timeout);
|
||||
link.onload = link.onerror = noop;
|
||||
setTimeout(function() {
|
||||
setTimeout(function () {
|
||||
if (error) {
|
||||
reject(error);
|
||||
}
|
||||
@@ -47,22 +48,22 @@ if (typeof window !== 'undefined') {
|
||||
var link = document.createElement('link');
|
||||
link.type = 'text/css';
|
||||
link.rel = 'stylesheet';
|
||||
link.href = url;
|
||||
link.href = url + bust;
|
||||
if (!isWebkit) {
|
||||
link.onload = function() {
|
||||
link.onload = function () {
|
||||
_callback();
|
||||
}
|
||||
} else {
|
||||
webkitLoadCheck(link, _callback);
|
||||
}
|
||||
link.onerror = function(event) {
|
||||
link.onerror = function (event) {
|
||||
_callback(event.error || new Error('Error loading CSS file.'));
|
||||
};
|
||||
head.appendChild(link);
|
||||
});
|
||||
};
|
||||
|
||||
exports.fetch = function(load) {
|
||||
exports.fetch = function (load) {
|
||||
// dont reload styles loaded in the head
|
||||
for (var i = 0; i < linkHrefs.length; i++)
|
||||
if (load.address == linkHrefs[i])
|
||||
|
||||
Reference in new issue
Block a user