mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into feature/add_es_alerting
This commit is contained in:
@@ -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
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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)
|
||||
|
||||
|
||||
11
devenv/README.md
Normal file
11
devenv/README.md
Normal file
@@ -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
|
||||
```
|
||||
9
devenv/dashboards/bulk-testing/bulk-dashboards.yaml
Normal file
9
devenv/dashboards/bulk-testing/bulk-dashboards.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: 'Bulk dashboards'
|
||||
folder: 'Bulk dashboards'
|
||||
type: file
|
||||
options:
|
||||
path: devenv/dashboards/bulk-testing
|
||||
|
||||
1140
devenv/dashboards/bulk-testing/bulkdash.jsonnet
Normal file
1140
devenv/dashboards/bulk-testing/bulkdash.jsonnet
Normal file
File diff suppressed because it is too large
Load Diff
73
devenv/datasources/default/default.yaml
Normal file
73
devenv/datasources/default/default.yaml
Normal file
@@ -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
|
||||
|
||||
61
devenv/setup.sh
Executable file
61
devenv/setup.sh
Executable file
@@ -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;
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
74
public/app/containers/Explore/TimePicker.jest.tsx
Normal file
74
public/app/containers/Explore/TimePicker.jest.tsx
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
245
public/app/containers/Explore/TimePicker.tsx
Normal file
245
public/app/containers/Explore/TimePicker.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
33
public/app/containers/Explore/Wrapper.tsx
Normal file
33
public/app/containers/Explore/Wrapper.tsx
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
90
public/app/features/dashboard/specs/save_modal.jest.ts
Normal file
90
public/app/features/dashboard/specs/save_modal.jest.ts
Normal file
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
25
public/vendor/plugin-css/css.js
vendored
25
public/vendor/plugin-css/css.js
vendored
@@ -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