Merge branch 'master' into data-source-settings-to-react

This commit is contained in:
Peter Holmberg 2018-11-01 13:52:53 +01:00
commit f15ed848f9
198 changed files with 6366 additions and 2577 deletions

View File

@ -206,6 +206,10 @@ jobs:
- run: docker info
- run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker
- run: cd packaging/docker && ./build-deploy.sh "master-${CIRCLE_SHA1}"
- run: rm packaging/docker/grafana-latest.linux-x64.tar.gz
- run: cp enterprise-dist/grafana-enterprise-*.linux-amd64.tar.gz packaging/docker/grafana-latest.linux-x64.tar.gz
- run: cd packaging/docker && ./build-enterprise.sh "master"
grafana-docker-pr:
docker:
@ -230,6 +234,9 @@ jobs:
- run: docker info
- run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker
- run: cd packaging/docker && ./build-deploy.sh "${CIRCLE_TAG}"
- run: rm packaging/docker/grafana-latest.linux-x64.tar.gz
- run: cp enterprise-dist/grafana-enterprise-*.linux-amd64.tar.gz packaging/docker/grafana-latest.linux-x64.tar.gz
- run: cd packaging/docker && ./build-enterprise.sh "${CIRCLE_TAG}"
build-enterprise:
docker:
@ -312,39 +319,49 @@ jobs:
deploy-enterprise-master:
docker:
- image: circleci/python:2.7-stretch
- image: grafana/grafana-ci-deploy:1.0.0
steps:
- attach_workspace:
at: .
- run:
name: install awscli
command: 'sudo pip install awscli'
name: gcp credentials
command: 'echo ${GCP_GRAFANA_UPLOAD_KEY} > /tmp/gcpkey.json'
- run:
name: sign in to gcp
command: '/opt/google-cloud-sdk/bin/gcloud auth activate-service-account --key-file=/tmp/gcpkey.json'
- run:
name: deploy to s3
command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/master'
- run:
name: deploy to gcp
command: '/opt/google-cloud-sdk/bin/gsutil cp ./enterprise-dist/* gs://$GCP_BUCKET_NAME/enterprise/master'
deploy-enterprise-release:
docker:
- image: circleci/python:2.7-stretch
- image: grafana/grafana-ci-deploy:1.0.0
steps:
- attach_workspace:
at: .
- run:
name: install awscli
command: 'sudo pip install awscli'
- run:
name: deploy to s3
command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/release'
- attach_workspace:
at: .
- run:
name: gcp credentials
command: 'echo ${GCP_GRAFANA_UPLOAD_KEY} > /tmp/gcpkey.json'
- run:
name: sign in to gcp
command: '/opt/google-cloud-sdk/bin/gcloud auth activate-service-account --key-file=/tmp/gcpkey.json'
- run:
name: deploy to s3
command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/release'
- run:
name: deploy to gcp
command: '/opt/google-cloud-sdk/bin/gsutil cp ./enterprise-dist/* gs://$GCP_BUCKET_NAME/enterprise/release'
deploy-master:
docker:
- image: circleci/python:2.7-stretch
- image: grafana/grafana-ci-deploy:1.0.0
steps:
- attach_workspace:
at: .
- run:
name: install awscli
command: 'sudo pip install awscli'
- run:
name: deploy to s3
command: |
@ -354,6 +371,15 @@ jobs:
- run:
name: Trigger Windows build
command: './scripts/trigger_windows_build.sh ${APPVEYOR_TOKEN} ${CIRCLE_SHA1} master'
- run:
name: gcp credentials
command: 'echo ${GCP_GRAFANA_UPLOAD_KEY} > /tmp/gcpkey.json'
- run:
name: sign in to gcp
command: '/opt/google-cloud-sdk/bin/gcloud auth activate-service-account --key-file=/tmp/gcpkey.json'
- run:
name: deploy to gcp
command: '/opt/google-cloud-sdk/bin/gsutil cp ./dist/* gs://$GCP_BUCKET_NAME/oss/master'
- run:
name: Publish to Grafana.com
command: |
@ -362,16 +388,22 @@ jobs:
deploy-release:
docker:
- image: circleci/python:2.7-stretch
- image: grafana/grafana-ci-deploy:1.0.0
steps:
- attach_workspace:
at: .
- run:
name: install awscli
command: 'sudo pip install awscli'
- run:
name: deploy to s3
command: 'aws s3 sync ./dist s3://$BUCKET_NAME/release'
- run:
name: gcp credentials
command: 'echo ${GCP_GRAFANA_UPLOAD_KEY} > /tmp/gcpkey.json'
- run:
name: sign in to gcp
command: '/opt/google-cloud-sdk/bin/gcloud auth activate-service-account --key-file=/tmp/gcpkey.json'
- run:
name: deploy to gcp
command: '/opt/google-cloud-sdk/bin/gsutil cp ./dist/* gs://R/oss/release'
- run:
name: Deploy to Grafana.com
command: './scripts/build/publish.sh'
@ -409,6 +441,7 @@ workflows:
- grafana-docker-master:
requires:
- build-all
- build-all-enterprise
- test-backend
- test-frontend
- codespell

View File

@ -4,20 +4,28 @@
* **Alerting**: Option to disable OK alert notifications [#12330](https://github.com/grafana/grafana/issues/12330) & [#6696](https://github.com/grafana/grafana/issues/6696), thx [@davewat](https://github.com/davewat)
* **Postgres/MySQL/MSSQL**: Adds support for configuration of max open/idle connections and connection max lifetime. Also, panels with multiple SQL queries will now be executed concurrently [#11711](https://github.com/grafana/grafana/issues/11711), thx [@connection-reset](https://github.com/connection-reset)
* **MSSQL**: Add encrypt setting to allow configuration of how data sent between client and server are encrypted [#13629](https://github.com/grafana/grafana/issues/13629), thx [@ramiro](https://github.com/ramiro)
* **MySQL**: Graphical query builder [#13762](https://github.com/grafana/grafana/issues/13762), thx [svenklemm](https://github.com/svenklemm)
* **MySQL**: Support connecting thru Unix socket for MySQL datasource [#12342](https://github.com/grafana/grafana/issues/12342), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino)
* **MSSQL**: Add encrypt setting to allow configuration of how data sent between client and server are encrypted [#13629](https://github.com/grafana/grafana/issues/13629), thx [@ramiro](https://github.com/ramiro)
* **Stackdriver**: Not possible to authenticate using GCE metadata server [#13669](https://github.com/grafana/grafana/issues/13669)
### Minor
* **Cloudwatch**: Show all available CloudWatch regions [#12308](https://github.com/grafana/grafana/issues/12308), thx [@mtanda](https://github.com/mtanda)
* **Postgres**: Add delta window function to postgres query builder [#13925](https://github.com/grafana/grafana/issues/13925), thx [svenklemm](https://github.com/svenklemm)
* **Units**: New clock time format, to format ms or second values as for example `01h:59m`, [#13635](https://github.com/grafana/grafana/issues/13635), thx [@franciscocpg](https://github.com/franciscocpg)
* **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu)
* **DingDing**: Can't receive DingDing alert when alert is triggered [#13723](https://github.com/grafana/grafana/issues/13723), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino)
* **Internal metrics**: Renamed `grafana_info` to `grafana_build_info` and added branch, goversion and revision [#13876](https://github.com/grafana/grafana/pull/13876)
### Breaking changes
* Postgres/MySQL/MSSQL datasources now per default uses `max open connections` = `unlimited` (earlier 10), `max idle connections` = `2` (earlier 10) and `connection max lifetime` = `4` hours (earlier unlimited)
# 5.3.3 (unreleased)
* **MySQL**: Fix `$__timeFilter()` should respect local time zone [#13769](https://github.com/grafana/grafana/issues/13769)
# 5.3.2 (2018-10-24)
* **InfluxDB/Graphite/Postgres**: Prevent cross site scripting (XSS) in query editor [#13667](https://github.com/grafana/grafana/issues/13667), thx [@svenklemm](https://github.com/svenklemm)

View File

@ -9,12 +9,17 @@ module.exports = function (grunt) {
destDir: 'dist',
tempDir: 'tmp',
platform: process.platform.replace('win32', 'windows'),
enterprise: false,
};
if (grunt.option('platform')) {
config.platform = grunt.option('platform');
}
if (grunt.option('enterprise')) {
config.enterprise = true;
}
if (grunt.option('arch')) {
config.arch = grunt.option('arch');
} else {

View File

@ -5,8 +5,7 @@ all: deps build
deps-go:
go run build.go setup
deps-js:
yarn install --pure-lockfile --no-progress
deps-js: node_modules
deps: deps-js
@ -43,3 +42,10 @@ test: test-go test-js
run:
./bin/grafana-server
clean:
rm -rf node_modules
rm -rf public/build
node_modules: package.json yarn.lock
yarn install --pure-lockfile --no-progress

View File

@ -403,6 +403,10 @@ func gruntBuildArg(task string) []string {
if phjsToRelease != "" {
args = append(args, fmt.Sprintf("--phjsToRelease=%v", phjsToRelease))
}
if enterprise {
args = append(args, "--enterprise")
}
args = append(args, fmt.Sprintf("--platform=%v", goos))
return args
@ -467,6 +471,7 @@ func ldflags() string {
b.WriteString(fmt.Sprintf(" -X main.version=%s", version))
b.WriteString(fmt.Sprintf(" -X main.commit=%s", getGitSha()))
b.WriteString(fmt.Sprintf(" -X main.buildstamp=%d", buildStamp()))
b.WriteString(fmt.Sprintf(" -X main.buildBranch=%s", getGitBranch()))
return b.String()
}
@ -514,6 +519,14 @@ func setBuildEnv() {
}
}
func getGitBranch() string {
v, err := runError("git", "rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
return "master"
}
return string(v)
}
func getGitSha() string {
v, err := runError("git", "rev-parse", "--short", "HEAD")
if err != nil {

View File

@ -557,3 +557,7 @@ callback_url =
[panels]
enable_alpha = false
[enterprise]
license_path =

View File

@ -475,3 +475,8 @@ log_queries =
# Options to configure external image rendering server like https://github.com/grafana/grafana-image-renderer
;server_url =
;callback_url =
[enterprise]
# Path to a valid Grafana Enterprise license.jwt file
;license_path =

View File

@ -927,6 +927,123 @@
"title": "",
"type": "text"
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "gdev-testdata",
"editable": true,
"error": false,
"fill": 0,
"gridPos": {
"h": 7,
"w": 16,
"x": 0,
"y": 44
},
"id": 21,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 2,
"links": [],
"nullPointMode": "null",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [
{
"alias": "C-series",
"steppedLine": true
}
],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"alias": "",
"hide": false,
"refId": "B",
"scenarioId": "csv_metric_values",
"stringInput": "1,null,40,null,90,null,null,100,null,null,100,null,null,80,null",
"target": ""
},
{
"alias": "",
"hide": false,
"refId": "C",
"scenarioId": "csv_metric_values",
"stringInput": "20,null40,null,null,50,null,70,null,100,null,10,null,30,null",
"target": ""
}
],
"thresholds": [],
"timeFrom": null,
"timeShift": null,
"title": "Null between points",
"tooltip": {
"msResolution": false,
"shared": true,
"sort": 0,
"value_type": "cumulative"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"content": "Left is showing null between values for a normal line graph and staircase graph. Orphaned data points should be rendered as points",
"editable": true,
"error": false,
"gridPos": {
"h": 7,
"w": 8,
"x": 16,
"y": 44
},
"id": 22,
"links": [],
"mode": "markdown",
"title": "",
"type": "text"
},
{
"aliasColors": {},
"bars": false,
@ -939,7 +1056,7 @@
"h": 7,
"w": 24,
"x": 0,
"y": 44
"y": 51
},
"id": 20,
"legend": {
@ -1024,7 +1141,7 @@
"h": 7,
"w": 12,
"x": 0,
"y": 51
"y": 58
},
"id": 16,
"legend": {
@ -1127,7 +1244,7 @@
"h": 7,
"w": 12,
"x": 12,
"y": 51
"y": 58
},
"id": 17,
"legend": {
@ -1266,7 +1383,7 @@
"h": 7,
"w": 12,
"x": 0,
"y": 58
"y": 65
},
"id": 18,
"legend": {
@ -1370,7 +1487,7 @@
"h": 7,
"w": 12,
"x": 12,
"y": 58
"y": 65
},
"id": 19,
"legend": {
@ -1554,5 +1671,5 @@
"timezone": "browser",
"title": "Panel Tests - Graph",
"uid": "5SdHCadmz",
"version": 3
"version": 1
}

View File

@ -158,7 +158,7 @@ Since not all datasources have the same configuration settings we only have the
| timeInterval | string | Prometheus, Elasticsearch, InfluxDB, MySQL, PostgreSQL & MSSQL | Lowest interval/step value that should be used for this data source |
| esVersion | number | Elasticsearch | Elasticsearch version as a number (2/5/56) |
| timeField | string | Elasticsearch | Which field that should be used as timestamp |
| interval | string | Elasticsearch | Index date time format |
| interval | string | Elasticsearch | Index date time format. nil(No Pattern), 'Hourly', 'Daily', 'Weekly', 'Monthly' or 'Yearly' |
| authType | string | Cloudwatch | Auth provider. keys/credentials/arn |
| assumeRoleArn | string | Cloudwatch | ARN of Assume Role |
| defaultRegion | string | Cloudwatch | AWS region |

View File

@ -140,7 +140,7 @@ In DingTalk PC Client:
6. There will be a Webhook URL in the panel, looks like this: https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxxx. Copy this URL to the grafana Dingtalk setting page and then click "finish".
Dingtalk supports the following "message type": `text`, `link` and `markdown`. Only the `text` message type is supported.
Dingtalk supports the following "message type": `text`, `link` and `markdown`. Only the `link` message type is supported.
### Kafka

View File

@ -100,12 +100,12 @@ display name, especially if the display name contains spaces or special
characters. Make sure you always use the group or subgroup name as it appears
in the URL of the group or subgroup.
Here's a complete example with `alloed_sign_up` enabled, and access limited to
Here's a complete example with `allow_sign_up` enabled, and access limited to
the `example` and `foo/bar` groups:
```ini
[auth.gitlab]
enabled = false
enabled = true
allow_sign_up = true
client_id = GITLAB_APPLICATION_ID
client_secret = GITLAB_SECRET

View File

@ -73,6 +73,58 @@ Example:
You can use wildcards (`*`) in place of database or table if you want to grant access to more databases and tables.
## Query Editor
> Only available in Grafana v5.4+.
{{< docs-imagebox img="/img/docs/v54/mysql_query_still.png" class="docs-image--no-shadow" animated-gif="/img/docs/v54/mysql_query.gif" >}}
You find the MySQL query editor in the metrics tab in a panel's edit mode. You enter edit mode by clicking the
panel title, then edit.
The query editor has a link named `Generated SQL` that shows up after a query has been executed, while in panel edit mode. Click on it and it will expand and show the raw interpolated SQL string that was executed.
### Select table, time column and metric column (FROM)
When you enter edit mode for the first time or add a new query Grafana will try to prefill the query builder with the first table that has a timestamp column and a numeric column.
In the FROM field, Grafana will suggest tables that are in the configured database. To select a table or view in another database that your database user has access to you can manually enter a fully qualified name (database.table) like `otherDb.metrics`.
The Time column field refers to the name of the column holding your time values. Selecting a value for the Metric column field is optional. If a value is selected, the Metric column field will be used as the series name.
The metric column suggestions will only contain columns with a text datatype (text, tinytext, mediumtext, longtext, varchar, char).
If you want to use a column with a different datatype as metric column you may enter the column name with a cast: `CAST(numericColumn as CHAR)`.
You may also enter arbitrary SQL expressions in the metric column field that evaluate to a text datatype like
`CONCAT(column1, " ", CAST(numericColumn as CHAR))`.
### Columns and Aggregation functions (SELECT)
In the `SELECT` row you can specify what columns and functions you want to use.
In the column field you may write arbitrary expressions instead of a column name like `column1 * column2 / column3`.
If you use aggregate functions you need to group your resultset. The editor will automatically add a `GROUP BY time` if you add an aggregate function.
You may add further value columns by clicking the plus button and selecting `Column` from the menu. Multiple value columns will be plotted as separate series in the graph panel.
### Filter data (WHERE)
To add a filter click the plus icon to the right of the `WHERE` condition. You can remove filters by clicking on
the filter and selecting `Remove`. A filter for the current selected timerange is automatically added to new queries.
### Group By
To group by time or any other columns click the plus icon at the end of the GROUP BY row. The suggestion dropdown will only show text columns of your currently selected table but you may manually enter any column.
You can remove the group by clicking on the item and then selecting `Remove`.
If you add any grouping, all selected columns need to have an aggregate function applied. The query builder will automatically add aggregate functions to all columns without aggregate functions when you add groupings.
#### Gap Filling
Grafana can fill in missing values when you group by time. The time function accepts two arguments. The first argument is the time window that you would like to group by, and the second argument is the value you want Grafana to fill missing items with.
### Text Editor Mode (RAW)
You can switch to the raw query editor mode by clicking the hamburger icon and selecting `Switch editor mode` or by clicking `Edit SQL` below the query.
> If you use the raw query editor, be sure your query at minimum has `ORDER BY time` and a filter on the returned time range.
## Macros
To simplify syntax and to allow for dynamic parts, like date range filters, the query can contain macros.

View File

@ -47,6 +47,7 @@
"grunt-contrib-copy": "~1.0.0",
"grunt-contrib-cssmin": "~1.0.2",
"grunt-exec": "^1.0.1",
"grunt-newer": "^1.3.0",
"grunt-notify": "^0.4.5",
"grunt-postcss": "^0.8.0",
"grunt-sass": "^2.0.0",

View File

@ -0,0 +1,12 @@
#!/bin/sh
set -e
_grafana_tag=$1
_docker_repo=${2:-grafana/grafana-enterprise}
docker build \
--tag "${_docker_repo}:${_grafana_tag}"\
--no-cache=true \
.
docker push "${_docker_repo}:${_grafana_tag}"

View File

@ -1,29 +0,0 @@
# New Grafana Release Processes
## Building release packages
1) Update package.json so that it has the right version.
2) Create a git tag for the release: `git tag -a v3.0.4 -m "3.0.4 release"`
3) Push branch & tag to github!
2) Packages from master a built automatically by circle CI for this repo [grafana/grafana-packer](https://github.com/grafana/grafana-packer)
### Non master branch
When building from non master branch create a new branch in repo [grafana/grafana-packer](https://github.com/grafana/grafana-packer)
and configure circle.yml to deploy that branch as well, https://github.com/grafana/grafana-packer/blob/master/circle.yml#L25,
you also need to update https://github.com/grafana/grafana-packer/blob/v3.1.x/deploy.sh#L7.
### Windows build
Sign into ci.appveyor.com and the Grafana project's build history page. Builds for windows take a long time (around 20min)
and fail quite often for random reasons so I usually continue with the release process without a windows build already built.
1) Click on the green build that has the correct version and tag
2) Click on `DEPLOYMENTS`
3) Click on `NEW DEPLOYMENT`
4) Select GrafanaBuildS3
4) Select the build you want to deploy.
The deployment should be quick (just uploads the release zip file to S3)

View File

@ -14,6 +14,7 @@ type IndexViewData struct {
NewGrafanaVersionExists bool
NewGrafanaVersion string
AppName string
AppNameBodyClass string
}
type PluginCss struct {

View File

@ -83,6 +83,7 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er
NewGrafanaVersion: plugins.GrafanaLatestVersion,
NewGrafanaVersionExists: plugins.GrafanaHasUpdate,
AppName: setting.ApplicationName,
AppNameBodyClass: getAppNameBodyClass(setting.ApplicationName),
}
if setting.DisableGravatar {
@ -377,3 +378,14 @@ func (hs *HTTPServer) NotFoundHandler(c *m.ReqContext) {
c.HTML(404, "index", data)
}
func getAppNameBodyClass(name string) string {
switch name {
case setting.APP_NAME:
return "app-grafana"
case setting.APP_NAME_ENTERPRISE:
return "app-enterprise"
default:
return ""
}
}

View File

@ -3,6 +3,8 @@ package main
import (
"flag"
"fmt"
"net/http"
_ "net/http/pprof"
"os"
"os/signal"
"runtime"
@ -11,16 +13,12 @@ import (
"syscall"
"time"
"net/http"
_ "net/http/pprof"
extensions "github.com/grafana/grafana/pkg/extensions"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/metrics"
"github.com/grafana/grafana/pkg/setting"
extensions "github.com/grafana/grafana/pkg/extensions"
_ "github.com/grafana/grafana/pkg/services/alerting/conditions"
_ "github.com/grafana/grafana/pkg/services/alerting/notifiers"
"github.com/grafana/grafana/pkg/setting"
_ "github.com/grafana/grafana/pkg/tsdb/cloudwatch"
_ "github.com/grafana/grafana/pkg/tsdb/elasticsearch"
_ "github.com/grafana/grafana/pkg/tsdb/graphite"
@ -35,6 +33,7 @@ import (
var version = "5.0.0"
var commit = "NA"
var buildBranch = "master"
var buildstamp string
var configFile = flag.String("config", "", "path to config file")
@ -47,7 +46,7 @@ func main() {
profilePort := flag.Int("profile-port", 6060, "Define custom port for profiling")
flag.Parse()
if *v {
fmt.Printf("Version %s (commit: %s)\n", version, commit)
fmt.Printf("Version %s (commit: %s, branch: %s)\n", version, commit, buildBranch)
os.Exit(0)
}
@ -78,9 +77,10 @@ func main() {
setting.BuildVersion = version
setting.BuildCommit = commit
setting.BuildStamp = buildstampInt64
setting.BuildBranch = buildBranch
setting.IsEnterprise = extensions.IsEnterprise
metrics.M_Grafana_Version.WithLabelValues(version).Set(1)
metrics.SetBuildInformation(version, commit, buildBranch)
server := NewGrafanaServer()

View File

@ -12,24 +12,16 @@ import (
"time"
"github.com/facebookgo/inject"
"github.com/grafana/grafana/pkg/api"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/registry"
"golang.org/x/sync/errgroup"
"github.com/grafana/grafana/pkg/api"
_ "github.com/grafana/grafana/pkg/extensions"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/login"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/social"
// self registering services
_ "github.com/grafana/grafana/pkg/extensions"
_ "github.com/grafana/grafana/pkg/metrics"
"github.com/grafana/grafana/pkg/middleware"
_ "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/registry"
_ "github.com/grafana/grafana/pkg/services/alerting"
_ "github.com/grafana/grafana/pkg/services/cleanup"
_ "github.com/grafana/grafana/pkg/services/notifications"
@ -37,7 +29,10 @@ import (
_ "github.com/grafana/grafana/pkg/services/rendering"
_ "github.com/grafana/grafana/pkg/services/search"
_ "github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/social" // self registering services
_ "github.com/grafana/grafana/pkg/tracing"
"golang.org/x/sync/errgroup"
)
func NewGrafanaServer() *GrafanaServerImpl {
@ -159,7 +154,7 @@ func (g *GrafanaServerImpl) loadConfiguration() {
os.Exit(1)
}
g.log.Info("Starting "+setting.ApplicationName, "version", version, "commit", commit, "compiled", time.Unix(setting.BuildStamp, 0))
g.log.Info("Starting "+setting.ApplicationName, "version", version, "commit", commit, "branch", buildBranch, "compiled", time.Unix(setting.BuildStamp, 0))
g.cfg.LogConfigSources()
}

View File

@ -185,7 +185,7 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo
if ldapUser.isMemberOf(group.GroupDN) {
extUser.OrgRoles[group.OrgId] = group.OrgRole
if extUser.IsGrafanaAdmin == nil || *extUser.IsGrafanaAdmin == false {
if extUser.IsGrafanaAdmin == nil || !*extUser.IsGrafanaAdmin {
extUser.IsGrafanaAdmin = group.IsGrafanaAdmin
}
}

View File

@ -58,7 +58,14 @@ var (
M_StatActive_Users prometheus.Gauge
M_StatTotal_Orgs prometheus.Gauge
M_StatTotal_Playlists prometheus.Gauge
M_Grafana_Version *prometheus.GaugeVec
// M_Grafana_Version is a gauge that contains build info about this binary
//
// Deprecated: use M_Grafana_Build_Version instead.
M_Grafana_Version *prometheus.GaugeVec
// grafanaBuildVersion is a gauge that contains build info about this binary
grafanaBuildVersion *prometheus.GaugeVec
)
func newCounterVecStartingAtZero(opts prometheus.CounterOpts, labels []string, labelValues ...string) *prometheus.CounterVec {
@ -293,9 +300,25 @@ func init() {
M_Grafana_Version = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "info",
Help: "Information about the Grafana",
Help: "Information about the Grafana. This metric is deprecated. please use `grafana_build_info`",
Namespace: exporterName,
}, []string{"version"})
grafanaBuildVersion = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "build_info",
Help: "A metric with a constant '1' value labeled by version, revision, branch, and goversion from which Grafana was built.",
Namespace: exporterName,
}, []string{"version", "revision", "branch", "goversion"})
}
// SetBuildInformation sets the build information for this binary
func SetBuildInformation(version, revision, branch string) {
// We export this info twice for backwards compability.
// Once this have been released for some time we should be able to remote `M_Grafana_Version`
// The reason we added a new one is that its common practice in the prometheus community
// to name this metric `*_build_info` so its easy to do aggregation on all programs.
M_Grafana_Version.WithLabelValues(version).Set(1)
grafanaBuildVersion.WithLabelValues(version, revision, branch, runtime.Version()).Set(1)
}
func initMetricVars() {
@ -334,7 +357,8 @@ func initMetricVars() {
M_StatActive_Users,
M_StatTotal_Orgs,
M_StatTotal_Playlists,
M_Grafana_Version)
M_Grafana_Version,
grafanaBuildVersion)
}

View File

@ -43,12 +43,13 @@ func GetContextHandler() macaron.Handler {
// then init session and look for userId in session
// then look for api key in session (special case for render calls via api)
// then test if anonymous access is enabled
if initContextWithRenderAuth(ctx) ||
initContextWithApiKey(ctx) ||
initContextWithBasicAuth(ctx, orgId) ||
initContextWithAuthProxy(ctx, orgId) ||
initContextWithUserSessionCookie(ctx, orgId) ||
initContextWithAnonymousUser(ctx) {
switch {
case initContextWithRenderAuth(ctx):
case initContextWithApiKey(ctx):
case initContextWithBasicAuth(ctx, orgId):
case initContextWithAuthProxy(ctx, orgId):
case initContextWithUserSessionCookie(ctx, orgId):
case initContextWithAnonymousUser(ctx):
}
ctx.Logger = log.New("context", "userId", ctx.UserId, "orgId", ctx.OrgId, "uname", ctx.Login)

View File

@ -121,7 +121,6 @@ func (pm *PluginManager) Run(ctx context.Context) error {
pm.checkForUpdates()
case <-ctx.Done():
run = false
break
}
}

View File

@ -57,6 +57,9 @@ func (this *DingDingNotifier) Notify(evalContext *alerting.EvalContext) error {
message := evalContext.Rule.Message
picUrl := evalContext.ImagePublicUrl
title := evalContext.GetNotificationTitle()
if message == "" {
message = title
}
bodyJSON, err := simplejson.NewJson([]byte(`{
"msgtype": "link",

View File

@ -14,7 +14,7 @@ import (
)
const (
captionLengthLimit = 200
captionLengthLimit = 1024
)
var (

View File

@ -61,7 +61,7 @@ func TestTelegramNotifier(t *testing.T) {
})
caption := generateImageCaption(evalContext, "http://grafa.url/abcdef", "")
So(len(caption), ShouldBeLessThanOrEqualTo, 200)
So(len(caption), ShouldBeLessThanOrEqualTo, 1024)
So(caption, ShouldContainSubstring, "Some kind of message.")
So(caption, ShouldContainSubstring, "[OK] This is an alarm")
So(caption, ShouldContainSubstring, "http://grafa.url/abcdef")
@ -78,9 +78,9 @@ func TestTelegramNotifier(t *testing.T) {
})
caption := generateImageCaption(evalContext,
"http://grafa.url/abcdefaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"http://grafa.url/abcdefaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"foo bar")
So(len(caption), ShouldBeLessThanOrEqualTo, 200)
So(len(caption), ShouldBeLessThanOrEqualTo, 1024)
So(caption, ShouldContainSubstring, "Some kind of message.")
So(caption, ShouldContainSubstring, "[OK] This is an alarm")
So(caption, ShouldContainSubstring, "foo bar")
@ -91,31 +91,31 @@ func TestTelegramNotifier(t *testing.T) {
evalContext := alerting.NewEvalContext(context.Background(),
&alerting.Rule{
Name: "This is an alarm",
Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it.",
Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it. But suddenly Telegram increased the length so now we need some lorem ipsum to fix this test. Here we go: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus consectetur molestie cursus. Donec suscipit egestas nisi. Proin ut efficitur ex. Mauris mi augue, volutpat a nisi vel, euismod dictum arcu. Sed quis tempor eros, sed malesuada dolor. Ut orci augue, viverra sit amet blandit quis, faucibus sit amet ex. Duis condimentum efficitur lectus, id dignissim quam tempor id. Morbi sollicitudin rhoncus diam, id tincidunt lectus scelerisque vitae. Etiam imperdiet semper sem, vel eleifend ligula mollis eget. Etiam ultrices fringilla lacus, sit amet pharetra ex blandit quis. Suspendisse in egestas neque, et posuere lectus. Vestibulum eu ex dui. Sed molestie nulla a lobortis scelerisque. Nulla ipsum ex, iaculis vitae vehicula sit amet, fermentum eu eros.",
State: m.AlertStateOK,
})
caption := generateImageCaption(evalContext,
"http://grafa.url/foo",
"")
So(len(caption), ShouldBeLessThanOrEqualTo, 200)
So(len(caption), ShouldBeLessThanOrEqualTo, 1024)
So(caption, ShouldContainSubstring, "[OK] This is an alarm")
So(caption, ShouldNotContainSubstring, "http")
So(caption, ShouldContainSubstring, "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise ")
So(caption, ShouldContainSubstring, "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it. But suddenly Telegram increased the length so now we need some lorem ipsum to fix this test. Here we go: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus consectetur molestie cursus. Donec suscipit egestas nisi. Proin ut efficitur ex. Mauris mi augue, volutpat a nisi vel, euismod dictum arcu. Sed quis tempor eros, sed malesuada dolor. Ut orci augue, viverra sit amet blandit quis, faucibus sit amet ex. Duis condimentum efficitur lectus, id dignissim quam tempor id. Morbi sollicitudin rhoncus diam, id tincidunt lectus scelerisque vitae. Etiam imperdiet semper sem, vel eleifend ligula mollis eget. Etiam ultrices fringilla lacus, sit amet pharetra ex blandit quis. Suspendisse in egestas neque, et posuere lectus. Vestibulum eu ex dui. Sed molestie nulla a lobortis sceleri")
})
Convey("Metrics should be skipped if they don't fit", func() {
evalContext := alerting.NewEvalContext(context.Background(),
&alerting.Rule{
Name: "This is an alarm",
Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I ",
Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it. But suddenly Telegram increased the length so now we need some lorem ipsum to fix this test. Here we go: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus consectetur molestie cursus. Donec suscipit egestas nisi. Proin ut efficitur ex. Mauris mi augue, volutpat a nisi vel, euismod dictum arcu. Sed quis tempor eros, sed malesuada dolor. Ut orci augue, viverra sit amet blandit quis, faucibus sit amet ex. Duis condimentum efficitur lectus, id dignissim quam tempor id. Morbi sollicitudin rhoncus diam, id tincidunt lectus scelerisque vitae. Etiam imperdiet semper sem, vel eleifend ligula mollis eget. Etiam ultrices fringilla lacus, sit amet pharetra ex blandit quis. Suspendisse in egestas neque, et posuere lectus. Vestibulum eu ex dui. Sed molestie nulla a lobortis sceleri",
State: m.AlertStateOK,
})
caption := generateImageCaption(evalContext,
"http://grafa.url/foo",
"foo bar long song")
So(len(caption), ShouldBeLessThanOrEqualTo, 200)
So(len(caption), ShouldBeLessThanOrEqualTo, 1024)
So(caption, ShouldContainSubstring, "[OK] This is an alarm")
So(caption, ShouldNotContainSubstring, "http")
So(caption, ShouldNotContainSubstring, "foo bar")

View File

@ -34,11 +34,8 @@ func NewRuleReader() *DefaultRuleReader {
func (arr *DefaultRuleReader) initReader() {
heartbeat := time.NewTicker(time.Second * 10)
for {
select {
case <-heartbeat.C:
arr.heartbeat()
}
for range heartbeat.C {
arr.heartbeat()
}
}

View File

@ -13,15 +13,12 @@ import (
"regexp"
"runtime"
"strings"
"gopkg.in/ini.v1"
"github.com/go-macaron/session"
"time"
"github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/util"
"gopkg.in/ini.v1"
)
type Scheme string
@ -34,9 +31,11 @@ const (
)
const (
DEV string = "development"
PROD string = "production"
TEST string = "test"
DEV = "development"
PROD = "production"
TEST = "test"
APP_NAME = "Grafana"
APP_NAME_ENTERPRISE = "Grafana Enterprise"
)
var (
@ -49,6 +48,7 @@ var (
// build
BuildVersion string
BuildCommit string
BuildBranch string
BuildStamp int64
IsEnterprise bool
ApplicationName string
@ -209,12 +209,10 @@ type Cfg struct {
RendererLimitAlerting int
DisableBruteForceLoginProtection bool
TempDataLifetime time.Duration
MetricsEndpointEnabled bool
EnableAlphaPanels bool
TempDataLifetime time.Duration
MetricsEndpointEnabled bool
EnableAlphaPanels bool
EnterpriseLicensePath string
}
type CommandLineArgs struct {
@ -533,9 +531,9 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
// Temporary keep global, to make refactor in steps
Raw = cfg.Raw
ApplicationName = "Grafana"
ApplicationName = APP_NAME
if IsEnterprise {
ApplicationName += " Enterprise"
ApplicationName = APP_NAME_ENTERPRISE
}
Env = iniFile.Section("").Key("app_mode").MustString("development")
@ -715,6 +713,10 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
imageUploadingSection := iniFile.Section("external_image_storage")
ImageUploadProvider = imageUploadingSection.Key("provider").MustString("")
enterprise := iniFile.Section("enterprise")
cfg.EnterpriseLicensePath = enterprise.Key("license_path").MustString(filepath.Join(cfg.DataPath, "license.jwt"))
return nil
}

View File

@ -35,6 +35,7 @@ type CustomMetricsCache struct {
var customMetricsMetricsMap map[string]map[string]map[string]*CustomMetricsCache
var customMetricsDimensionsMap map[string]map[string]map[string]*CustomMetricsCache
var regionCache sync.Map
func init() {
metricsMap = map[string][]string{
@ -233,13 +234,20 @@ func parseMultiSelectValue(input string) []string {
// Whenever this list is updated, frontend list should also be updated.
// Please update the region list in public/app/plugins/datasource/cloudwatch/partials/config.html
func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) {
dsInfo := e.getDsInfo("default")
profile := dsInfo.Profile
if cache, ok := regionCache.Load(profile); ok {
if cache2, ok2 := cache.([]suggestData); ok2 {
return cache2, nil
}
}
regions := []string{
"ap-northeast-1", "ap-northeast-2", "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", "ca-central-1",
"eu-central-1", "eu-north-1", "eu-west-1", "eu-west-2", "eu-west-3", "me-south-1", "sa-east-1", "us-east-1", "us-east-2", "us-west-1", "us-west-2",
"cn-north-1", "cn-northwest-1", "us-gov-east-1", "us-gov-west-1", "us-isob-east-1", "us-iso-east-1",
}
err := e.ensureClientSession("us-east-1")
err := e.ensureClientSession("default")
if err != nil {
return nil, err
}
@ -269,6 +277,7 @@ func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *s
for _, region := range regions {
result = append(result, suggestData{Text: region, Value: region})
}
regionCache.Store(profile, result)
return result, nil
}

View File

@ -9,20 +9,26 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
"github.com/bmizerany/assert"
"github.com/grafana/grafana/pkg/components/securejsondata"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/tsdb"
. "github.com/smartystreets/goconvey/convey"
)
type mockedEc2 struct {
ec2iface.EC2API
Resp ec2.DescribeInstancesOutput
Resp ec2.DescribeInstancesOutput
RespRegions ec2.DescribeRegionsOutput
}
func (m mockedEc2) DescribeInstancesPages(in *ec2.DescribeInstancesInput, fn func(*ec2.DescribeInstancesOutput, bool) bool) error {
fn(&m.Resp, true)
return nil
}
func (m mockedEc2) DescribeRegions(in *ec2.DescribeRegionsInput) (*ec2.DescribeRegionsOutput, error) {
return &m.RespRegions, nil
}
func TestCloudWatchMetrics(t *testing.T) {
@ -82,6 +88,31 @@ func TestCloudWatchMetrics(t *testing.T) {
})
})
Convey("When calling handleGetRegions", t, func() {
executor := &CloudWatchExecutor{
ec2Svc: mockedEc2{RespRegions: ec2.DescribeRegionsOutput{
Regions: []*ec2.Region{
{
RegionName: aws.String("ap-northeast-2"),
},
},
}},
}
jsonData := simplejson.New()
jsonData.Set("defaultRegion", "default")
executor.DataSource = &models.DataSource{
JsonData: jsonData,
SecureJsonData: securejsondata.SecureJsonData{},
}
result, _ := executor.handleGetRegions(context.Background(), simplejson.New(), &tsdb.TsdbQuery{})
Convey("Should return regions", func() {
So(result[0].Text, ShouldEqual, "ap-northeast-1")
So(result[1].Text, ShouldEqual, "ap-northeast-2")
})
})
Convey("When calling handleGetEc2InstanceAttribute", t, func() {
executor := &CloudWatchExecutor{
ec2Svc: mockedEc2{Resp: ec2.DescribeInstancesOutput{

View File

@ -164,14 +164,12 @@ func formatTimeRange(input string) string {
func fixIntervalFormat(target string) string {
rMinute := regexp.MustCompile(`'(\d+)m'`)
rMin := regexp.MustCompile("m")
target = rMinute.ReplaceAllStringFunc(target, func(m string) string {
return rMin.ReplaceAllString(m, "min")
return strings.Replace(m, "m", "min", -1)
})
rMonth := regexp.MustCompile(`'(\d+)M'`)
rMon := regexp.MustCompile("M")
target = rMonth.ReplaceAllStringFunc(target, func(M string) string {
return rMon.ReplaceAllString(M, "mon")
return strings.Replace(M, "M", "mon", -1)
})
return target
}

View File

@ -60,7 +60,7 @@ func (m *mySqlMacroEngine) evaluateMacro(name string, args []string) (string, er
return "", fmt.Errorf("missing time column argument for macro %v", name)
}
return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
return fmt.Sprintf("%s BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", args[0], m.timeRange.GetFromAsSecondsEpoch(), m.timeRange.GetToAsSecondsEpoch()), nil
case "__timeGroup":
if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval", name)

View File

@ -60,7 +60,7 @@ func TestMacroEngine(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix()))
})
Convey("interpolate __unixEpochFilter function", func() {
@ -92,7 +92,7 @@ func TestMacroEngine(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix()))
})
Convey("interpolate __unixEpochFilter function", func() {
@ -112,7 +112,7 @@ func TestMacroEngine(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix()))
})
Convey("interpolate __unixEpochFilter function", func() {

View File

@ -186,8 +186,7 @@ func reverse(s string) string {
}
func interpolateFilterWildcards(value string) string {
re := regexp.MustCompile("[*]")
matches := len(re.FindAllStringIndex(value, -1))
matches := strings.Count(value, "*")
if matches == 2 && strings.HasSuffix(value, "*") && strings.HasPrefix(value, "*") {
value = strings.Replace(value, "*", "", -1)
value = fmt.Sprintf(`has_substring("%s")`, value)

View File

@ -0,0 +1,28 @@
import { AppNotification } from 'app/types/';
export enum ActionTypes {
AddAppNotification = 'ADD_APP_NOTIFICATION',
ClearAppNotification = 'CLEAR_APP_NOTIFICATION',
}
interface AddAppNotificationAction {
type: ActionTypes.AddAppNotification;
payload: AppNotification;
}
interface ClearAppNotificationAction {
type: ActionTypes.ClearAppNotification;
payload: number;
}
export type Action = AddAppNotificationAction | ClearAppNotificationAction;
export const clearAppNotification = (appNotificationId: number) => ({
type: ActionTypes.ClearAppNotification,
payload: appNotificationId,
});
export const notifyApp = (appNotification: AppNotification) => ({
type: ActionTypes.AddAppNotification,
payload: appNotification,
});

View File

@ -1,4 +1,5 @@
import { updateLocation } from './location';
import { updateNavIndex, UpdateNavIndexAction } from './navModel';
import { notifyApp, clearAppNotification } from './appNotification';
export { updateLocation, updateNavIndex, UpdateNavIndexAction };
export { updateLocation, updateNavIndex, UpdateNavIndexAction, notifyApp, clearAppNotification };

View File

@ -0,0 +1,28 @@
import { ThunkAction } from 'redux-thunk';
import { getBackendSrv } from '../services/backend_srv';
import { DashboardAcl, DashboardSearchHit, StoreState } from '../../types';
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, any>;
export type Action = LoadStarredDashboardsAction;
export enum ActionTypes {
LoadStarredDashboards = 'LOAD_STARRED_DASHBOARDS',
}
interface LoadStarredDashboardsAction {
type: ActionTypes.LoadStarredDashboards;
payload: DashboardSearchHit[];
}
const starredDashboardsLoaded = (dashboards: DashboardAcl[]) => ({
type: ActionTypes.LoadStarredDashboards,
payload: dashboards,
});
export function loadStarredDashboards(): ThunkResult<void> {
return async dispatch => {
const starredDashboards = await getBackendSrv().search({ starred: true });
dispatch(starredDashboardsLoaded(starredDashboards));
};
}

View File

@ -5,10 +5,12 @@ import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
import { SearchResult } from './components/search/SearchResult';
import { TagFilter } from './components/TagFilter/TagFilter';
import { SideMenu } from './components/sidemenu/SideMenu';
import AppNotificationList from './components/AppNotifications/AppNotificationList';
export function registerAngularDirectives() {
react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
react2AngularDirective('sidemenu', SideMenu, []);
react2AngularDirective('appNotificationsList', AppNotificationList, []);
react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']);
react2AngularDirective('emptyListCta', EmptyListCTA, ['model']);
react2AngularDirective('searchResult', SearchResult, []);

View File

@ -1,15 +1,22 @@
import React from 'react';
import React from 'react';
import Transition from 'react-transition-group/Transition';
const defaultMaxHeight = '200px'; // When animating using max-height we need to use a static value.
interface Style {
transition?: string;
overflow?: string;
}
// When animating using max-height we need to use a static value.
// If this is not enough, pass in <SlideDown maxHeight="....
const defaultMaxHeight = '200px';
const defaultDuration = 200;
const defaultStyle = {
export const defaultStyle: Style = {
transition: `max-height ${defaultDuration}ms ease-in-out`,
overflow: 'hidden',
};
export default ({ children, in: inProp, maxHeight = defaultMaxHeight }) => {
export default ({ children, in: inProp, maxHeight = defaultMaxHeight, style = defaultStyle }) => {
// There are 4 main states a Transition can be in:
// ENTERING, ENTERED, EXITING, EXITED
// https://reactcommunity.org/react-transition-group/
@ -25,7 +32,7 @@ export default ({ children, in: inProp, maxHeight = defaultMaxHeight }) => {
{state => (
<div
style={{
...defaultStyle,
...style,
...transitionStyles[state],
}}
>

View File

@ -0,0 +1,38 @@
import React, { Component } from 'react';
import { AppNotification } from 'app/types';
interface Props {
appNotification: AppNotification;
onClearNotification: (id) => void;
}
export default class AppNotificationItem extends Component<Props> {
shouldComponentUpdate(nextProps) {
return this.props.appNotification.id !== nextProps.appNotification.id;
}
componentDidMount() {
const { appNotification, onClearNotification } = this.props;
setTimeout(() => {
onClearNotification(appNotification.id);
}, appNotification.timeout);
}
render() {
const { appNotification, onClearNotification } = this.props;
return (
<div className={`alert-${appNotification.severity} alert`}>
<div className="alert-icon">
<i className={appNotification.icon} />
</div>
<div className="alert-body">
<div className="alert-title">{appNotification.title}</div>
<div className="alert-text">{appNotification.text}</div>
</div>
<button type="button" className="alert-close" onClick={() => onClearNotification(appNotification.id)}>
<i className="fa fa fa-remove" />
</button>
</div>
);
}
}

View File

@ -0,0 +1,60 @@
import React, { PureComponent } from 'react';
import appEvents from 'app/core/app_events';
import AppNotificationItem from './AppNotificationItem';
import { notifyApp, clearAppNotification } from 'app/core/actions';
import { connectWithStore } from 'app/core/utils/connectWithReduxStore';
import { AppNotification, StoreState } from 'app/types';
import {
createErrorNotification,
createSuccessNotification,
createWarningNotification,
} from '../../copy/appNotification';
export interface Props {
appNotifications: AppNotification[];
notifyApp: typeof notifyApp;
clearAppNotification: typeof clearAppNotification;
}
export class AppNotificationList extends PureComponent<Props> {
componentDidMount() {
const { notifyApp } = this.props;
appEvents.on('alert-warning', options => notifyApp(createWarningNotification(options[0], options[1])));
appEvents.on('alert-success', options => notifyApp(createSuccessNotification(options[0], options[1])));
appEvents.on('alert-error', options => notifyApp(createErrorNotification(options[0], options[1])));
}
onClearAppNotification = id => {
this.props.clearAppNotification(id);
};
render() {
const { appNotifications } = this.props;
return (
<div>
{appNotifications.map((appNotification, index) => {
return (
<AppNotificationItem
key={`${appNotification.id}-${index}`}
appNotification={appNotification}
onClearNotification={id => this.onClearAppNotification(id)}
/>
);
})}
</div>
);
}
}
const mapStateToProps = (state: StoreState) => ({
appNotifications: state.appNotifications.appNotifications,
});
const mapDispatchToProps = {
notifyApp,
clearAppNotification,
};
export default connectWithStore(AppNotificationList, mapStateToProps, mapDispatchToProps);

View File

@ -7,6 +7,7 @@ const model = {
buttonIcon: 'ga css class',
buttonLink: 'http://url/to/destination',
buttonTitle: 'Click me',
onClick: jest.fn(),
proTip: 'This is a tip',
proTipLink: 'http://url/to/tip/destination',
proTipLinkTitle: 'Learn more',

View File

@ -11,6 +11,7 @@ class EmptyListCTA extends Component<Props, any> {
buttonIcon,
buttonLink,
buttonTitle,
onClick,
proTip,
proTipLink,
proTipLinkTitle,
@ -19,7 +20,7 @@ class EmptyListCTA extends Component<Props, any> {
return (
<div className="empty-list-cta">
<div className="empty-list-cta__title">{title}</div>
<a href={buttonLink} className="empty-list-cta__button btn btn-xlarge btn-success">
<a onClick={onClick} href={buttonLink} className="empty-list-cta__button btn btn-xlarge btn-success">
<i className={buttonIcon} />
{buttonTitle}
</a>

View File

@ -12,6 +12,7 @@ exports[`EmptyListCTA renders correctly 1`] = `
<a
className="empty-list-cta__button btn btn-xlarge btn-success"
href="http://url/to/destination"
onClick={[MockFunction]}
>
<i
className="ga css class"

View File

@ -5,11 +5,12 @@ interface Props {
tooltip?: string;
for?: string;
children: ReactNode;
width?: number;
}
export const Label: SFC<Props> = props => {
return (
<span className="gf-form-label width-10">
<span className={`gf-form-label width-${props.width ? props.width : '10'}`}>
<span>{props.children}</span>
{props.tooltip && (
<Tooltip className="gf-form-help-icon--right-normal" placement="auto" content={props.tooltip}>

View File

@ -0,0 +1,46 @@
import React, { SFC } from 'react';
import Select from 'react-select';
import DescriptionOption from './DescriptionOption';
import ResetStyles from './ResetStyles';
interface Props {
className?: string;
defaultValue: any;
getOptionLabel: (item: any) => string;
getOptionValue: (item: any) => string;
onSelected: (item: any) => {} | void;
options: any[];
placeholder?: string;
width: number;
}
const SimplePicker: SFC<Props> = ({
className,
defaultValue,
getOptionLabel,
getOptionValue,
onSelected,
options,
placeholder,
width,
}) => {
return (
<Select
classNamePrefix={`gf-form-select-box`}
className={`width-${width} gf-form-input gf-form-input--form-dropdown ${className || ''}`}
components={{
Option: DescriptionOption,
}}
defaultValue={defaultValue}
getOptionLabel={getOptionLabel}
getOptionValue={getOptionValue}
isSearchable={false}
onChange={onSelected}
options={options}
placeholder={placeholder || 'Choose'}
styles={ResetStyles}
/>
);
};
export default SimplePicker;

View File

@ -0,0 +1,46 @@
import React, { PureComponent } from 'react';
import _ from 'lodash';
export interface Props {
label: string;
checked: boolean;
labelClass?: string;
switchClass?: string;
onChange: (event) => any;
}
export interface State {
id: any;
}
export class Switch extends PureComponent<Props, State> {
state = {
id: _.uniqueId(),
};
internalOnChange = event => {
event.stopPropagation();
this.props.onChange(event);
};
render() {
const { labelClass, switchClass, label, checked } = this.props;
const labelId = `check-${this.state.id}`;
const labelClassName = `gf-form-label ${labelClass} pointer`;
const switchClassName = `gf-form-switch ${switchClass}`;
return (
<div className="gf-form">
{label && (
<label htmlFor={labelId} className={labelClassName}>
{label}
</label>
)}
<div className={switchClassName}>
<input id={labelId} type="checkbox" checked={checked} onChange={this.internalOnChange} />
<label htmlFor={labelId} />
</div>
</div>
);
}
}

View File

@ -1,37 +1,28 @@
import React from 'react';
import React, { PureComponent } from 'react';
import withTooltip from './withTooltip';
import { Target } from 'react-popper';
interface TooltipProps {
interface Props {
tooltipSetState: (prevState: object) => void;
}
class Tooltip extends React.Component<TooltipProps, any> {
constructor(props) {
super(props);
this.showTooltip = this.showTooltip.bind(this);
this.hideTooltip = this.hideTooltip.bind(this);
}
showTooltip() {
class Tooltip extends PureComponent<Props> {
showTooltip = () => {
const { tooltipSetState } = this.props;
tooltipSetState(prevState => {
return {
...prevState,
show: true,
};
});
}
hideTooltip() {
tooltipSetState(prevState => ({
...prevState,
show: true,
}));
};
hideTooltip = () => {
const { tooltipSetState } = this.props;
tooltipSetState(prevState => {
return {
...prevState,
show: false,
};
});
}
tooltipSetState(prevState => ({
...prevState,
show: false,
}));
};
render() {
return (

View File

@ -1,53 +1,84 @@
import React from 'react';
import { ColorPickerPopover } from './ColorPickerPopover';
import { react2AngularDirective } from 'app/core/utils/react2angular';
import ReactDOM from 'react-dom';
import Drop from 'tether-drop';
import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
export interface Props {
series: any;
onColorChange: (color: string) => void;
onToggleAxis: () => void;
export interface SeriesColorPickerProps {
color: string;
yaxis?: number;
optionalClass?: string;
onColorChange: (newColor: string) => void;
onToggleAxis?: () => void;
}
export class SeriesColorPicker extends React.Component<Props, any> {
export class SeriesColorPicker extends React.Component<SeriesColorPickerProps> {
pickerElem: any;
colorPickerDrop: any;
static defaultProps = {
optionalClass: '',
yaxis: undefined,
onToggleAxis: () => {},
};
constructor(props) {
super(props);
this.onColorChange = this.onColorChange.bind(this);
this.onToggleAxis = this.onToggleAxis.bind(this);
}
onColorChange(color) {
this.props.onColorChange(color);
componentWillUnmount() {
this.destroyDrop();
}
onToggleAxis() {
this.props.onToggleAxis();
}
onClickToOpen = () => {
if (this.colorPickerDrop) {
this.destroyDrop();
}
renderAxisSelection() {
const leftButtonClass = this.props.series.yaxis === 1 ? 'btn-success' : 'btn-inverse';
const rightButtonClass = this.props.series.yaxis === 2 ? 'btn-success' : 'btn-inverse';
return (
<div className="p-b-1">
<label className="small p-r-1">Y Axis:</label>
<button onClick={this.onToggleAxis} className={'btn btn-small ' + leftButtonClass}>
Left
</button>
<button onClick={this.onToggleAxis} className={'btn btn-small ' + rightButtonClass}>
Right
</button>
</div>
const { color, yaxis, onColorChange, onToggleAxis } = this.props;
const dropContent = (
<SeriesColorPickerPopover color={color} yaxis={yaxis} onColorChange={onColorChange} onToggleAxis={onToggleAxis} />
);
const dropContentElem = document.createElement('div');
ReactDOM.render(dropContent, dropContentElem);
const drop = new Drop({
target: this.pickerElem,
content: dropContentElem,
position: 'top center',
classes: 'drop-popover',
openOn: 'hover',
hoverCloseDelay: 200,
remove: true,
tetherOptions: {
constraints: [{ to: 'scrollParent', attachment: 'none both' }],
},
});
drop.on('close', this.closeColorPicker.bind(this));
this.colorPickerDrop = drop;
this.colorPickerDrop.open();
};
closeColorPicker() {
setTimeout(() => {
this.destroyDrop();
}, 100);
}
destroyDrop() {
if (this.colorPickerDrop && this.colorPickerDrop.tether) {
this.colorPickerDrop.destroy();
this.colorPickerDrop = null;
}
}
render() {
const { optionalClass, children } = this.props;
return (
<div className="graph-legend-popover">
{this.props.series.yaxis && this.renderAxisSelection()}
<ColorPickerPopover color={this.props.series.color} onColorSelect={this.onColorChange} />
<div className={optionalClass} ref={e => (this.pickerElem = e)} onClick={this.onClickToOpen}>
{children}
</div>
);
}
}
react2AngularDirective('seriesColorPicker', SeriesColorPicker, ['series', 'onColorChange', 'onToggleAxis']);

View File

@ -0,0 +1,70 @@
import React from 'react';
import { ColorPickerPopover } from './ColorPickerPopover';
import { react2AngularDirective } from 'app/core/utils/react2angular';
export interface SeriesColorPickerPopoverProps {
color: string;
yaxis?: number;
onColorChange: (color: string) => void;
onToggleAxis?: () => void;
}
export class SeriesColorPickerPopover extends React.PureComponent<SeriesColorPickerPopoverProps, any> {
render() {
return (
<div className="graph-legend-popover">
{this.props.yaxis && <AxisSelector yaxis={this.props.yaxis} onToggleAxis={this.props.onToggleAxis} />}
<ColorPickerPopover color={this.props.color} onColorSelect={this.props.onColorChange} />
</div>
);
}
}
interface AxisSelectorProps {
yaxis: number;
onToggleAxis: () => void;
}
interface AxisSelectorState {
yaxis: number;
}
export class AxisSelector extends React.PureComponent<AxisSelectorProps, AxisSelectorState> {
constructor(props) {
super(props);
this.state = {
yaxis: this.props.yaxis,
};
this.onToggleAxis = this.onToggleAxis.bind(this);
}
onToggleAxis() {
this.setState({
yaxis: this.state.yaxis === 2 ? 1 : 2,
});
this.props.onToggleAxis();
}
render() {
const leftButtonClass = this.state.yaxis === 1 ? 'btn-success' : 'btn-inverse';
const rightButtonClass = this.state.yaxis === 2 ? 'btn-success' : 'btn-inverse';
return (
<div className="p-b-1">
<label className="small p-r-1">Y Axis:</label>
<button onClick={this.onToggleAxis} className={'btn btn-small ' + leftButtonClass}>
Left
</button>
<button onClick={this.onToggleAxis} className={'btn btn-small ' + rightButtonClass}>
Right
</button>
</div>
);
}
}
react2AngularDirective('seriesColorPickerPopover', SeriesColorPickerPopover, [
'series',
'onColorChange',
'onToggleAxis',
]);

View File

@ -0,0 +1,46 @@
import { AppNotification, AppNotificationSeverity, AppNotificationTimeout } from 'app/types';
const defaultSuccessNotification: AppNotification = {
title: '',
text: '',
severity: AppNotificationSeverity.Success,
icon: 'fa fa-check',
timeout: AppNotificationTimeout.Success,
};
const defaultWarningNotification: AppNotification = {
title: '',
text: '',
severity: AppNotificationSeverity.Warning,
icon: 'fa fa-exclamation',
timeout: AppNotificationTimeout.Warning,
};
const defaultErrorNotification: AppNotification = {
title: '',
text: '',
severity: AppNotificationSeverity.Error,
icon: 'fa fa-exclamation-triangle',
timeout: AppNotificationTimeout.Error,
};
export const createSuccessNotification = (title: string, text?: string): AppNotification => ({
...defaultSuccessNotification,
title: title,
text: text,
id: Date.now(),
});
export const createErrorNotification = (title: string, text?: string): AppNotification => ({
...defaultErrorNotification,
title: title,
text: text,
id: Date.now(),
});
export const createWarningNotification = (title: string, text?: string): AppNotification => ({
...defaultWarningNotification,
title: title,
text: text,
id: Date.now(),
});

View File

@ -14,7 +14,7 @@ import './components/jsontree/jsontree';
import './components/code_editor/code_editor';
import './utils/outline';
import './components/colorpicker/ColorPicker';
import './components/colorpicker/SeriesColorPicker';
import './components/colorpicker/SeriesColorPickerPopover';
import './components/colorpicker/spectrum_picker';
import './services/search_srv';
import './services/ng_react';

View File

@ -1,3 +1,5 @@
import _ from 'lodash';
export enum LogLevel {
crit = 'crit',
warn = 'warn',
@ -27,3 +29,15 @@ export interface LogRow {
export interface LogsModel {
rows: LogRow[];
}
export function mergeStreams(streams: LogsModel[], limit?: number): LogsModel {
const combinedEntries = streams.reduce((acc, stream) => {
return [...acc, ...stream.rows];
}, []);
const sortedEntries = _.chain(combinedEntries)
.sortBy('timestamp')
.reverse()
.slice(0, limit || combinedEntries.length)
.value();
return { rows: sortedEntries };
}

View File

@ -0,0 +1,51 @@
import { appNotificationsReducer } from './appNotification';
import { ActionTypes } from '../actions/appNotification';
import { AppNotificationSeverity, AppNotificationTimeout } from 'app/types/';
describe('clear alert', () => {
it('should filter alert', () => {
const id1 = 1540301236048;
const id2 = 1540301248293;
const initialState = {
appNotifications: [
{
id: id1,
severity: AppNotificationSeverity.Success,
icon: 'success',
title: 'test',
text: 'test alert',
timeout: AppNotificationTimeout.Success,
},
{
id: id2,
severity: AppNotificationSeverity.Warning,
icon: 'warning',
title: 'test2',
text: 'test alert fail 2',
timeout: AppNotificationTimeout.Warning,
},
],
};
const result = appNotificationsReducer(initialState, {
type: ActionTypes.ClearAppNotification,
payload: id2,
});
const expectedResult = {
appNotifications: [
{
id: id1,
severity: AppNotificationSeverity.Success,
icon: 'success',
title: 'test',
text: 'test alert',
timeout: AppNotificationTimeout.Success,
},
],
};
expect(result).toEqual(expectedResult);
});
});

View File

@ -0,0 +1,19 @@
import { AppNotification, AppNotificationsState } from 'app/types/';
import { Action, ActionTypes } from '../actions/appNotification';
export const initialState: AppNotificationsState = {
appNotifications: [] as AppNotification[],
};
export const appNotificationsReducer = (state = initialState, action: Action): AppNotificationsState => {
switch (action.type) {
case ActionTypes.AddAppNotification:
return { ...state, appNotifications: state.appNotifications.concat([action.payload]) };
case ActionTypes.ClearAppNotification:
return {
...state,
appNotifications: state.appNotifications.filter(appNotification => appNotification.id !== action.payload),
};
}
return state;
};

View File

@ -1,7 +1,11 @@
import { navIndexReducer as navIndex } from './navModel';
import { locationReducer as location } from './location';
import { appNotificationsReducer as appNotifications } from './appNotification';
import { userReducer as user } from './user';
export default {
navIndex,
location,
appNotifications,
user,
};

View File

@ -0,0 +1,15 @@
import { DashboardSearchHit, UserState } from '../../types';
import { Action, ActionTypes } from '../actions/user';
const initialState: UserState = {
starredDashboards: [] as DashboardSearchHit[],
};
export const userReducer = (state: UserState = initialState, action: Action): UserState => {
switch (action.type) {
case ActionTypes.LoadStarredDashboards:
return { ...state, starredDashboards: action.payload };
}
return state;
};

View File

@ -1,100 +1,12 @@
import angular from 'angular';
import _ from 'lodash';
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
export class AlertSrv {
list: any[];
constructor() {}
/** @ngInject */
constructor(private $timeout, private $rootScope) {
this.list = [];
}
init() {
this.$rootScope.onAppEvent(
'alert-error',
(e, alert) => {
this.set(alert[0], alert[1], 'error', 12000);
},
this.$rootScope
);
this.$rootScope.onAppEvent(
'alert-warning',
(e, alert) => {
this.set(alert[0], alert[1], 'warning', 5000);
},
this.$rootScope
);
this.$rootScope.onAppEvent(
'alert-success',
(e, alert) => {
this.set(alert[0], alert[1], 'success', 3000);
},
this.$rootScope
);
appEvents.on('alert-warning', options => this.set(options[0], options[1], 'warning', 5000));
appEvents.on('alert-success', options => this.set(options[0], options[1], 'success', 3000));
appEvents.on('alert-error', options => this.set(options[0], options[1], 'error', 7000));
}
getIconForSeverity(severity) {
switch (severity) {
case 'success':
return 'fa fa-check';
case 'error':
return 'fa fa-exclamation-triangle';
default:
return 'fa fa-exclamation';
}
}
set(title, text, severity, timeout) {
if (_.isObject(text)) {
console.log('alert error', text);
if (text.statusText) {
text = `HTTP Error (${text.status}) ${text.statusText}`;
}
}
const newAlert = {
title: title || '',
text: text || '',
severity: severity || 'info',
icon: this.getIconForSeverity(severity),
};
const newAlertJson = angular.toJson(newAlert);
// remove same alert if it already exists
_.remove(this.list, value => {
return angular.toJson(value) === newAlertJson;
});
this.list.push(newAlert);
if (timeout > 0) {
this.$timeout(() => {
this.list = _.without(this.list, newAlert);
}, timeout);
}
if (!this.$rootScope.$$phase) {
this.$rootScope.$digest();
}
return newAlert;
}
clear(alert) {
this.list = _.without(this.list, alert);
}
clearAll() {
this.list = [];
set() {
console.log('old depricated alert srv being used');
}
}
// this is just added to not break old plugins that might be using it
coreModule.service('alertSrv', AlertSrv);

View File

@ -9,7 +9,7 @@ export class BackendSrv {
private noBackendCache: boolean;
/** @ngInject */
constructor(private $http, private alertSrv, private $q, private $timeout, private contextSrv) {}
constructor(private $http, private $q, private $timeout, private contextSrv) {}
get(url, params?) {
return this.request({ method: 'GET', url: url, params: params });
@ -49,14 +49,14 @@ export class BackendSrv {
}
if (err.status === 422) {
this.alertSrv.set('Validation failed', data.message, 'warning', 4000);
appEvents.emit('alert-warning', ['Validation failed', data.message]);
throw data;
}
data.severity = 'error';
let severity = 'error';
if (err.status < 500) {
data.severity = 'warning';
severity = 'warning';
}
if (data.message) {
@ -66,7 +66,8 @@ export class BackendSrv {
description = message;
message = 'Error';
}
this.alertSrv.set(message, description, data.severity, 10000);
appEvents.emit('alert-' + severity, [message, description]);
}
throw data;
@ -93,7 +94,7 @@ export class BackendSrv {
if (options.method !== 'GET') {
if (results && results.data.message) {
if (options.showSuccessAlert !== false) {
this.alertSrv.set(results.data.message, '', 'success', 3000);
appEvents.emit('alert-success', [results.data.message]);
}
}
}

View File

@ -9,7 +9,7 @@ describe('backend_srv', () => {
return Promise.resolve({});
};
const _backendSrv = new BackendSrv(_httpBackend, {}, {}, {}, {});
const _backendSrv = new BackendSrv(_httpBackend, {}, {}, {});
describe('when handling errors', () => {
it('should return the http status code', async () => {

View File

@ -83,6 +83,10 @@ function areRowsMatching(columns, row, otherRow) {
export function mergeTablesIntoModel(dst?: TableModel, ...tables: TableModel[]): TableModel {
const model = dst || new TableModel();
if (arguments.length === 1) {
return model;
}
// Single query returns data columns and rows as is
if (arguments.length === 2) {
model.columns = [...tables[0].columns];

View File

@ -0,0 +1,11 @@
import React from 'react';
import { connect } from 'react-redux';
import { store } from '../../store/configureStore';
export function connectWithStore(WrappedComponent, ...args) {
const ConnectedWrappedComponent = connect(...args)(WrappedComponent);
return props => {
return <ConnectedWrappedComponent {...props} store={store} />;
};
}

View File

@ -1,5 +1,8 @@
import _ from 'lodash';
import moment from 'moment';
import { RawTimeRange } from 'app/types/series';
import * as dateMath from './datemath';
const spans = {
@ -129,7 +132,7 @@ export function describeTextRange(expr: any) {
return opt;
}
export function describeTimeRange(range) {
export function describeTimeRange(range: RawTimeRange): string {
const option = rangeIndex[range.from.toString() + ' to ' + range.to.toString()];
if (option) {
return option.display;

View File

@ -9,3 +9,6 @@ import './admin';
import './alerting/NotificationsEditCtrl';
import './alerting/NotificationsListCtrl';
import './manage-dashboards';
import './teams/CreateTeamCtrl';
import './profile/ProfileCtrl';
import './profile/ChangePasswordCtrl';

View File

@ -1,25 +1,32 @@
import './editor_ctrl';
// Libaries
import angular from 'angular';
import _ from 'lodash';
// Components
import './editor_ctrl';
import coreModule from 'app/core/core_module';
// Utils & Services
import { makeRegions, dedupAnnotations } from './events_processing';
// Types
import { DashboardModel } from '../dashboard/dashboard_model';
export class AnnotationsSrv {
globalAnnotationsPromise: any;
alertStatesPromise: any;
datasourcePromises: any;
/** @ngInject */
constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) {
$rootScope.onAppEvent('refresh', this.clearCache.bind(this), $rootScope);
$rootScope.onAppEvent('dashboard-initialized', this.clearCache.bind(this), $rootScope);
}
constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) {}
clearCache() {
this.globalAnnotationsPromise = null;
this.alertStatesPromise = null;
this.datasourcePromises = null;
init(dashboard: DashboardModel) {
// clear promises on refresh events
dashboard.on('refresh', () => {
this.globalAnnotationsPromise = null;
this.alertStatesPromise = null;
this.datasourcePromises = null;
});
}
getAnnotations(options) {

View File

@ -1,4 +1,4 @@
import React from 'react';
import React from 'react';
import { shallow } from 'enzyme';
import { Props, ApiKeysPage } from './ApiKeysPage';
import { NavModel, ApiKey } from 'app/types';
@ -14,6 +14,7 @@ const setup = (propOverrides?: object) => {
deleteApiKey: jest.fn(),
setSearchQuery: jest.fn(),
addApiKey: jest.fn(),
apiKeysCount: 0,
};
Object.assign(props, propOverrides);
@ -28,14 +29,19 @@ const setup = (propOverrides?: object) => {
};
describe('Render', () => {
it('should render component', () => {
const { wrapper } = setup();
it('should render API keys table if there are any keys', () => {
const { wrapper } = setup({
apiKeys: getMultipleMockKeys(5),
apiKeysCount: 5,
});
expect(wrapper).toMatchSnapshot();
});
it('should render API keys table', () => {
it('should render CTA if there are no API keys', () => {
const { wrapper } = setup({
apiKeys: getMultipleMockKeys(5),
apiKeys: getMultipleMockKeys(0),
apiKeysCount: 0,
hasFetched: true,
});

View File

@ -1,17 +1,19 @@
import React, { PureComponent } from 'react';
import React, { PureComponent } from 'react';
import ReactDOMServer from 'react-dom/server';
import { connect } from 'react-redux';
import { hot } from 'react-hot-loader';
import { NavModel, ApiKey, NewApiKey, OrgRole } from 'app/types';
import { getNavModel } from 'app/core/selectors/navModel';
import { getApiKeys } from './state/selectors';
import { getApiKeys, getApiKeysCount } from './state/selectors';
import { loadApiKeys, deleteApiKey, setSearchQuery, addApiKey } from './state/actions';
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import SlideDown from 'app/core/components/Animations/SlideDown';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import SlideDown from 'app/core/components/Animations/SlideDown';
import ApiKeysAddedModal from './ApiKeysAddedModal';
import config from 'app/core/config';
import appEvents from 'app/core/app_events';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import DeleteButton from 'app/core/components/DeleteButton/DeleteButton';
export interface Props {
navModel: NavModel;
@ -22,6 +24,7 @@ export interface Props {
deleteApiKey: typeof deleteApiKey;
setSearchQuery: typeof setSearchQuery;
addApiKey: typeof addApiKey;
apiKeysCount: number;
}
export interface State {
@ -82,6 +85,7 @@ export class ApiKeysPage extends PureComponent<Props, any> {
return {
...prevState,
newApiKey: initialApiKeyState,
isAdding: false,
};
});
};
@ -101,115 +105,152 @@ export class ApiKeysPage extends PureComponent<Props, any> {
});
};
renderTable() {
const { apiKeys } = this.props;
return [
<h3 key="header" className="page-heading">
Existing Keys
</h3>,
<table key="table" className="filter-table">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th style={{ width: '34px' }} />
</tr>
</thead>
{apiKeys.length > 0 && (
<tbody>
{apiKeys.map(key => {
return (
<tr key={key.id}>
<td>{key.name}</td>
<td>{key.role}</td>
<td>
<a onClick={() => this.onDeleteApiKey(key)} className="btn btn-danger btn-mini">
<i className="fa fa-remove" />
</a>
</td>
</tr>
);
})}
</tbody>
renderEmptyList() {
const { isAdding } = this.state;
return (
<div className="page-container page-body">
{!isAdding && (
<EmptyListCTA
model={{
title: "You haven't added any API Keys yet.",
buttonIcon: 'fa fa-plus',
buttonLink: '#',
onClick: this.onToggleAdding,
buttonTitle: ' New API Key',
proTip: 'Remember you can provide view-only API access to other applications.',
proTipLink: '',
proTipLinkTitle: '',
proTipTarget: '_blank',
}}
/>
)}
</table>,
];
{this.renderAddApiKeyForm()}
</div>
);
}
renderAddApiKeyForm() {
const { newApiKey, isAdding } = this.state;
return (
<SlideDown in={isAdding}>
<div className="cta-form">
<button className="cta-form__close btn btn-transparent" onClick={this.onToggleAdding}>
<i className="fa fa-close" />
</button>
<h5>Add API Key</h5>
<form className="gf-form-group" onSubmit={this.onAddApiKey}>
<div className="gf-form-inline">
<div className="gf-form max-width-21">
<span className="gf-form-label">Key name</span>
<input
type="text"
className="gf-form-input"
value={newApiKey.name}
placeholder="Name"
onChange={evt => this.onApiKeyStateUpdate(evt, ApiKeyStateProps.Name)}
/>
</div>
<div className="gf-form">
<span className="gf-form-label">Role</span>
<span className="gf-form-select-wrapper">
<select
className="gf-form-input gf-size-auto"
value={newApiKey.role}
onChange={evt => this.onApiKeyStateUpdate(evt, ApiKeyStateProps.Role)}
>
{Object.keys(OrgRole).map(role => {
return (
<option key={role} label={role} value={role}>
{role}
</option>
);
})}
</select>
</span>
</div>
<div className="gf-form">
<button className="btn gf-form-btn btn-success">Add</button>
</div>
</div>
</form>
</div>
</SlideDown>
);
}
renderApiKeyList() {
const { isAdding } = this.state;
const { apiKeys, searchQuery } = this.props;
return (
<div className="page-container page-body">
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<label className="gf-form--has-input-icon gf-form--grow">
<input
type="text"
className="gf-form-input"
placeholder="Search keys"
value={searchQuery}
onChange={this.onSearchQueryChange}
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
</div>
<div className="page-action-bar__spacer" />
<button className="btn btn-success pull-right" onClick={this.onToggleAdding} disabled={isAdding}>
<i className="fa fa-plus" /> Add API Key
</button>
</div>
{this.renderAddApiKeyForm()}
<h3 className="page-heading">Existing Keys</h3>
<table className="filter-table">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th style={{ width: '34px' }} />
</tr>
</thead>
{apiKeys.length > 0 ? (
<tbody>
{apiKeys.map(key => {
return (
<tr key={key.id}>
<td>{key.name}</td>
<td>{key.role}</td>
<td>
<DeleteButton onConfirmDelete={() => this.onDeleteApiKey(key)} />
</td>
</tr>
);
})}
</tbody>
) : null}
</table>
</div>
);
}
render() {
const { newApiKey, isAdding } = this.state;
const { hasFetched, navModel, searchQuery } = this.props;
const { hasFetched, navModel, apiKeysCount } = this.props;
return (
<div>
<PageHeader model={navModel} />
<div className="page-container page-body">
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<label className="gf-form--has-input-icon gf-form--grow">
<input
type="text"
className="gf-form-input"
placeholder="Search keys"
value={searchQuery}
onChange={this.onSearchQueryChange}
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
</div>
<div className="page-action-bar__spacer" />
<button className="btn btn-success pull-right" onClick={this.onToggleAdding} disabled={isAdding}>
<i className="fa fa-plus" /> Add API Key
</button>
</div>
<SlideDown in={isAdding}>
<div className="cta-form">
<button className="cta-form__close btn btn-transparent" onClick={this.onToggleAdding}>
<i className="fa fa-close" />
</button>
<h5>Add API Key</h5>
<form className="gf-form-group" onSubmit={this.onAddApiKey}>
<div className="gf-form-inline">
<div className="gf-form max-width-21">
<span className="gf-form-label">Key name</span>
<input
type="text"
className="gf-form-input"
value={newApiKey.name}
placeholder="Name"
onChange={evt => this.onApiKeyStateUpdate(evt, ApiKeyStateProps.Name)}
/>
</div>
<div className="gf-form">
<span className="gf-form-label">Role</span>
<span className="gf-form-select-wrapper">
<select
className="gf-form-input gf-size-auto"
value={newApiKey.role}
onChange={evt => this.onApiKeyStateUpdate(evt, ApiKeyStateProps.Role)}
>
{Object.keys(OrgRole).map(role => {
return (
<option key={role} label={role} value={role}>
{role}
</option>
);
})}
</select>
</span>
</div>
<div className="gf-form">
<button className="btn gf-form-btn btn-success">Add</button>
</div>
</div>
</form>
</div>
</SlideDown>
{hasFetched ? this.renderTable() : <PageLoader pageName="Api keys" />}
</div>
{hasFetched ? (
apiKeysCount > 0 ? (
this.renderApiKeyList()
) : (
this.renderEmptyList()
)
) : (
<PageLoader pageName="Api keys" />
)}
</div>
);
}
@ -220,6 +261,7 @@ function mapStateToProps(state) {
navModel: getNavModel(state.navIndex, 'apikeys'),
apiKeys: getApiKeys(state.apiKeys),
searchQuery: state.apiKeys.searchQuery,
apiKeysCount: getApiKeysCount(state.apiKeys),
hasFetched: state.apiKeys.hasFetched,
};
}

View File

@ -1,276 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render API keys table 1`] = `
exports[`Render should render API keys table if there are any keys 1`] = `
<div>
<PageHeader
model={Object {}}
/>
<div
className="page-container page-body"
>
<div
className="page-action-bar"
>
<div
className="gf-form gf-form--grow"
>
<label
className="gf-form--has-input-icon gf-form--grow"
>
<input
className="gf-form-input"
onChange={[Function]}
placeholder="Search keys"
type="text"
value=""
/>
<i
className="gf-form-input-icon fa fa-search"
/>
</label>
</div>
<div
className="page-action-bar__spacer"
/>
<button
className="btn btn-success pull-right"
disabled={false}
onClick={[Function]}
>
<i
className="fa fa-plus"
/>
Add API Key
</button>
</div>
<Component
in={false}
>
<div
className="cta-form"
>
<button
className="cta-form__close btn btn-transparent"
onClick={[Function]}
>
<i
className="fa fa-close"
/>
</button>
<h5>
Add API Key
</h5>
<form
className="gf-form-group"
onSubmit={[Function]}
>
<div
className="gf-form-inline"
>
<div
className="gf-form max-width-21"
>
<span
className="gf-form-label"
>
Key name
</span>
<input
className="gf-form-input"
onChange={[Function]}
placeholder="Name"
type="text"
value=""
/>
</div>
<div
className="gf-form"
>
<span
className="gf-form-label"
>
Role
</span>
<span
className="gf-form-select-wrapper"
>
<select
className="gf-form-input gf-size-auto"
onChange={[Function]}
value="Viewer"
>
<option
key="Viewer"
label="Viewer"
value="Viewer"
>
Viewer
</option>
<option
key="Editor"
label="Editor"
value="Editor"
>
Editor
</option>
<option
key="Admin"
label="Admin"
value="Admin"
>
Admin
</option>
</select>
</span>
</div>
<div
className="gf-form"
>
<button
className="btn gf-form-btn btn-success"
>
Add
</button>
</div>
</div>
</form>
</div>
</Component>
<h3
className="page-heading"
key="header"
>
Existing Keys
</h3>
<table
className="filter-table"
key="table"
>
<thead>
<tr>
<th>
Name
</th>
<th>
Role
</th>
<th
style={
Object {
"width": "34px",
}
}
/>
</tr>
</thead>
<tbody>
<tr
key="1"
>
<td>
test-1
</td>
<td>
Viewer
</td>
<td>
<a
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</a>
</td>
</tr>
<tr
key="2"
>
<td>
test-2
</td>
<td>
Viewer
</td>
<td>
<a
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</a>
</td>
</tr>
<tr
key="3"
>
<td>
test-3
</td>
<td>
Viewer
</td>
<td>
<a
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</a>
</td>
</tr>
<tr
key="4"
>
<td>
test-4
</td>
<td>
Viewer
</td>
<td>
<a
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</a>
</td>
</tr>
<tr
key="5"
>
<td>
test-5
</td>
<td>
Viewer
</td>
<td>
<a
className="btn btn-danger btn-mini"
onClick={[Function]}
>
<i
className="fa fa-remove"
/>
</a>
</td>
</tr>
</tbody>
</table>
</div>
<PageLoader
pageName="Api keys"
/>
</div>
`;
exports[`Render should render component 1`] = `
exports[`Render should render CTA if there are no API keys 1`] = `
<div>
<PageHeader
model={Object {}}
@ -278,41 +19,21 @@ exports[`Render should render component 1`] = `
<div
className="page-container page-body"
>
<div
className="page-action-bar"
>
<div
className="gf-form gf-form--grow"
>
<label
className="gf-form--has-input-icon gf-form--grow"
>
<input
className="gf-form-input"
onChange={[Function]}
placeholder="Search keys"
type="text"
value=""
/>
<i
className="gf-form-input-icon fa fa-search"
/>
</label>
</div>
<div
className="page-action-bar__spacer"
/>
<button
className="btn btn-success pull-right"
disabled={false}
onClick={[Function]}
>
<i
className="fa fa-plus"
/>
Add API Key
</button>
</div>
<EmptyListCTA
model={
Object {
"buttonIcon": "fa fa-plus",
"buttonLink": "#",
"buttonTitle": " New API Key",
"onClick": [Function],
"proTip": "Remember you can provide view-only API access to other applications.",
"proTipLink": "",
"proTipLinkTitle": "",
"proTipTarget": "_blank",
"title": "You haven't added any API Keys yet.",
}
}
/>
<Component
in={false}
>
@ -406,9 +127,6 @@ exports[`Render should render component 1`] = `
</form>
</div>
</Component>
<PageLoader
pageName="Api keys"
/>
</div>
</div>
`;

View File

@ -1,5 +1,7 @@
import { ApiKeysState } from 'app/types';
export const getApiKeysCount = (state: ApiKeysState) => state.keys.length;
export const getApiKeys = (state: ApiKeysState) => {
const regex = RegExp(state.searchQuery, 'i');

View File

@ -1,6 +1,12 @@
// Utils
import config from 'app/core/config';
import appEvents from 'app/core/app_events';
import coreModule from 'app/core/core_module';
// Services
import { AnnotationsSrv } from '../annotations/annotations_srv';
// Types
import { DashboardModel } from './dashboard_model';
import { PanelModel } from './panel_model';
@ -21,6 +27,7 @@ export class DashboardCtrl {
private dashboardSrv,
private unsavedChangesSrv,
private dashboardViewStateSrv,
private annotationsSrv: AnnotationsSrv,
public playlistSrv
) {
// temp hack due to way dashboards are loaded
@ -49,6 +56,7 @@ export class DashboardCtrl {
// init services
this.timeSrv.init(dashboard);
this.alertingSrv.init(dashboard, data.alerts);
this.annotationsSrv.init(dashboard);
// template values service needs to initialize completely before
// the rest of the dashboard can load
@ -72,7 +80,7 @@ export class DashboardCtrl {
this.keybindingSrv.setupDashboardBindings(this.$scope, dashboard);
this.setWindowTitleAndTheme();
this.$scope.appEvent('dashboard-initialized', dashboard);
appEvents.emit('dashboard-initialized', dashboard);
})
.catch(this.onInitFailed.bind(this, 'Dashboard init failed', true));
}

View File

@ -21,15 +21,14 @@ function GridWrapper({
className,
isResizable,
isDraggable,
isFullscreen,
}) {
if (size.width === 0) {
console.log('size is zero!');
}
const width = size.width > 0 ? size.width : lastGridWidth;
if (width !== lastGridWidth) {
onWidthChange();
lastGridWidth = width;
if (!isFullscreen && Math.abs(width - lastGridWidth) > 8) {
onWidthChange();
lastGridWidth = width;
}
}
return (
@ -197,6 +196,7 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
onDragStop={this.onDragStop}
onResize={this.onResize}
onResizeStop={this.onResizeStop}
isFullscreen={this.props.dashboard.meta.fullscreen}
>
{this.renderPanels()}
</SizedReactLayoutGrid>

View File

@ -133,6 +133,7 @@ export class PanelModel {
}
destroy() {
this.events.emit('panel-teardown');
this.events.removeAllListeners();
}
}

View File

@ -1,5 +1,4 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import Tooltip from 'app/core/components/Tooltip/Tooltip';
import SlideDown from 'app/core/components/Animations/SlideDown';
import { StoreState, FolderInfo } from 'app/types';
@ -13,7 +12,7 @@ import {
import PermissionList from 'app/core/components/PermissionList/PermissionList';
import AddPermission from 'app/core/components/PermissionList/AddPermission';
import PermissionsInfo from 'app/core/components/PermissionList/PermissionsInfo';
import { store } from 'app/store/configureStore';
import { connectWithStore } from '../../../core/utils/connectWithReduxStore';
export interface Props {
dashboardId: number;
@ -95,13 +94,6 @@ export class DashboardPermissions extends PureComponent<Props, State> {
}
}
function connectWithStore(WrappedComponent, ...args) {
const ConnectedWrappedComponent = connect(...args)(WrappedComponent);
return props => {
return <ConnectedWrappedComponent {...props} store={store} />;
};
}
const mapStateToProps = (state: StoreState) => ({
permissions: state.dashboard.permissions,
});

View File

@ -13,6 +13,7 @@ import {
export enum ActionTypes {
LoadDashboardPermissions = 'LOAD_DASHBOARD_PERMISSIONS',
LoadStarredDashboards = 'LOAD_STARRED_DASHBOARDS',
}
export interface LoadDashboardPermissionsAction {
@ -20,7 +21,12 @@ export interface LoadDashboardPermissionsAction {
payload: DashboardAcl[];
}
export type Action = LoadDashboardPermissionsAction;
export interface LoadStarredDashboardsAction {
type: ActionTypes.LoadStarredDashboards;
payload: DashboardAcl[];
}
export type Action = LoadDashboardPermissionsAction | LoadStarredDashboardsAction;
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, any>;

View File

@ -1,6 +1,6 @@
import { Action, ActionTypes } from './actions';
import { OrgRole, PermissionLevel, DashboardState } from 'app/types';
import { inititalState, dashboardReducer } from './reducers';
import { initialState, dashboardReducer } from './reducers';
describe('dashboard reducer', () => {
describe('loadDashboardPermissions', () => {
@ -14,7 +14,7 @@ describe('dashboard reducer', () => {
{ id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit },
],
};
state = dashboardReducer(inititalState, action);
state = dashboardReducer(initialState, action);
});
it('should add permissions to state', async () => {

View File

@ -2,11 +2,11 @@ import { DashboardState } from 'app/types';
import { Action, ActionTypes } from './actions';
import { processAclItems } from 'app/core/utils/acl';
export const inititalState: DashboardState = {
export const initialState: DashboardState = {
permissions: [],
};
export const dashboardReducer = (state = inititalState, action: Action): DashboardState => {
export const dashboardReducer = (state = initialState, action: Action): DashboardState => {
switch (action.type) {
case ActionTypes.LoadDashboardPermissions:
return {

View File

@ -11,7 +11,7 @@ const template = `
`;
/** @ngInject */
function uploadDashboardDirective(timer, alertSrv, $location) {
function uploadDashboardDirective(timer, $location) {
return {
restrict: 'E',
template: template,
@ -59,7 +59,7 @@ function uploadDashboardDirective(timer, alertSrv, $location) {
// Something
elem[0].addEventListener('change', file_selected, false);
} else {
alertSrv.set('Oops', 'Sorry, the HTML5 File APIs are not fully supported in this browser.', 'error');
appEvents.emit('alert-error', ['Oops', 'The HTML5 File APIs are not fully supported in this browser']);
}
},
};

View File

@ -1,5 +1,5 @@
import React, { SFC } from 'react';
import { Label } from '../../../core/components/Forms/Forms';
import { Label } from 'app/core/components/Label/Label';
export interface Props {
dataSourceName: string;

View File

@ -0,0 +1,34 @@
import React, { Component } from 'react';
export default class ErrorBoundary extends Component<{}, any> {
constructor(props) {
super(props);
this.state = { error: null, errorInfo: null };
}
componentDidCatch(error, errorInfo) {
// Catch errors in any components below and re-render with error message
this.setState({
error: error,
errorInfo: errorInfo,
});
}
render() {
if (this.state.errorInfo) {
// Error path
return (
<div className="explore-container">
<h3>An unexpected error happened.</h3>
<details style={{ whiteSpace: 'pre-wrap' }}>
{this.state.error && this.state.error.toString()}
<br />
{this.state.errorInfo.componentStack}
</details>
</div>
);
}
// Normally, just render children
return this.props.children;
}
}

View File

@ -3,15 +3,8 @@ import { hot } from 'react-hot-loader';
import Select from 'react-select';
import _ from 'lodash';
import {
ExploreState,
ExploreUrlState,
HistoryItem,
Query,
QueryTransaction,
Range,
ResultType,
} from 'app/types/explore';
import { ExploreState, ExploreUrlState, HistoryItem, Query, QueryTransaction, ResultType } from 'app/types/explore';
import { RawTimeRange } from 'app/types/series';
import kbn from 'app/core/utils/kbn';
import colors from 'app/core/utils/colors';
import store from 'app/core/store';
@ -28,8 +21,11 @@ import QueryRows from './QueryRows';
import Graph from './Graph';
import Logs from './Logs';
import Table from './Table';
import ErrorBoundary from './ErrorBoundary';
import TimePicker from './TimePicker';
import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
import { DataSource } from 'app/types/datasources';
import { mergeStreams } from 'app/core/logs_model';
const MAX_HISTORY_ITEMS = 100;
@ -154,12 +150,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
}
}
componentDidCatch(error) {
this.setState({ datasourceError: error });
console.error(error);
}
async setDatasource(datasource) {
async setDatasource(datasource: DataSource) {
const supportsGraph = datasource.meta.metrics;
const supportsLogs = datasource.meta.logs;
const supportsTable = datasource.meta.metrics;
@ -170,7 +161,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
const testResult = await datasource.testDatasource();
datasourceError = testResult.status === 'success' ? null : testResult.message;
} catch (error) {
datasourceError = (error && error.statusText) || error;
datasourceError = (error && error.statusText) || 'Network error';
}
const historyKey = `grafana.explore.history.${datasourceId}`;
@ -187,8 +178,12 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
query: this.queryExpressions[i],
}));
// Custom components
const StartPage = datasource.pluginExports.ExploreStartPage;
this.setState(
{
StartPage,
datasource,
datasourceError,
history,
@ -278,10 +273,9 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
}
};
onChangeTime = nextRange => {
const range = {
from: nextRange.from,
to: nextRange.to,
onChangeTime = (nextRange: RawTimeRange) => {
const range: RawTimeRange = {
...nextRange,
};
this.setState({ range }, () => this.onSubmit());
};
@ -342,6 +336,13 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
);
};
// Use this in help pages to set page to a single query
onClickQuery = query => {
const nextQueries = [{ query, key: generateQueryKey() }];
this.queryExpressions = nextQueries.map(q => q.query);
this.setState({ queries: nextQueries }, this.onSubmit);
};
onClickSplit = () => {
const { onChangeSplit } = this.props;
if (onChangeSplit) {
@ -373,9 +374,10 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
this.onModifyQueries({ type: 'ADD_FILTER', key: columnKey, value: rowValue });
};
onModifyQueries = (action: object, index?: number) => {
onModifyQueries = (action, index?: number) => {
const { datasource } = this.state;
if (datasource && datasource.modifyQuery) {
const preventSubmit = action.preventSubmit;
this.setState(
state => {
const { queries, queryTransactions } = state;
@ -391,16 +393,26 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
nextQueryTransactions = [];
} else {
// Modify query only at index
nextQueries = [
...queries.slice(0, index),
{
key: generateQueryKey(index),
query: datasource.modifyQuery(this.queryExpressions[index], action),
},
...queries.slice(index + 1),
];
// Discard transactions related to row query
nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
nextQueries = queries.map((q, i) => {
// Synchronise all queries with local query cache to ensure consistency
q.query = this.queryExpressions[i];
return i === index
? {
key: generateQueryKey(index),
query: datasource.modifyQuery(q.query, action),
}
: q;
});
nextQueryTransactions = queryTransactions
// Consume the hint corresponding to the action
.map(qt => {
if (qt.hints != null && qt.rowIndex === index) {
qt.hints = qt.hints.filter(hint => hint.fix.action !== action);
}
return qt;
})
// Preserve previous row query transaction to keep results visible if next query is incomplete
.filter(qt => preventSubmit || qt.rowIndex !== index);
}
this.queryExpressions = nextQueries.map(q => q.query);
return {
@ -408,7 +420,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
queryTransactions: nextQueryTransactions,
};
},
() => this.onSubmit()
// Accepting certain fixes do not result in a well-formed query which should not be submitted
!preventSubmit ? () => this.onSubmit() : null
);
}
};
@ -459,7 +472,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
) {
const { datasource, range } = this.state;
const resolution = this.el.offsetWidth;
const absoluteRange = {
const absoluteRange: RawTimeRange = {
from: parseDate(range.from, false),
to: parseDate(range.to, true),
};
@ -474,7 +487,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
];
// Clone range for query request
const queryRange: Range = { ...range };
const queryRange: RawTimeRange = { ...range };
return {
interval,
@ -572,13 +585,28 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
});
}
failQueryTransaction(transactionId: string, error: string, datasourceId: string) {
failQueryTransaction(transactionId: string, response: any, datasourceId: string) {
const { datasource } = this.state;
if (datasource.meta.id !== datasourceId) {
// Navigated away, queries did not matter
return;
}
console.error(response);
let error: string | JSX.Element = response;
if (response.data) {
error = response.data.error;
if (response.data.response) {
error = (
<>
<span>{response.data.error}</span>
<details>{response.data.response}</details>
</>
);
}
}
this.setState(state => {
// Transaction might have been discarded
if (!state.queryTransactions.find(qt => qt.id === transactionId)) {
@ -625,9 +653,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
this.setState({ graphRange: transaction.options.range });
} catch (response) {
console.error(response);
const queryError = response.data ? response.data.error : response;
this.failQueryTransaction(transaction.id, queryError, datasourceId);
this.failQueryTransaction(transaction.id, response, datasourceId);
}
} else {
this.discardTransactions(rowIndex);
@ -657,9 +683,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
const results = res.data[0];
this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
} catch (response) {
console.error(response);
const queryError = response.data ? response.data.error : response;
this.failQueryTransaction(transaction.id, queryError, datasourceId);
this.failQueryTransaction(transaction.id, response, datasourceId);
}
} else {
this.discardTransactions(rowIndex);
@ -685,9 +709,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
const results = res.data;
this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
} catch (response) {
console.error(response);
const queryError = response.data ? response.data.error : response;
this.failQueryTransaction(transaction.id, queryError, datasourceId);
this.failQueryTransaction(transaction.id, response, datasourceId);
}
} else {
this.discardTransactions(rowIndex);
@ -695,11 +717,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
});
}
request = url => {
const { datasource } = this.state;
return datasource.metadataRequest(url);
};
cloneState(): ExploreState {
// Copy state, but copy queries including modifications
return {
@ -717,6 +734,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
render() {
const { position, split } = this.props;
const {
StartPage,
datasource,
datasourceError,
datasourceLoading,
@ -744,17 +762,20 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
const graphLoading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done);
const tableLoading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done);
const logsLoading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done);
// TODO don't recreate those on each re-render
const graphResult = _.flatten(
queryTransactions.filter(qt => qt.resultType === 'Graph' && qt.done && qt.result).map(qt => qt.result)
);
const tableResult = mergeTablesIntoModel(
new TableModel(),
...queryTransactions.filter(qt => qt.resultType === 'Table' && qt.done).map(qt => qt.result)
...queryTransactions.filter(qt => qt.resultType === 'Table' && qt.done && qt.result).map(qt => qt.result)
);
const logsResult = _.flatten(
queryTransactions.filter(qt => qt.resultType === 'Logs' && qt.done).map(qt => qt.result)
const logsResult = mergeStreams(
queryTransactions.filter(qt => qt.resultType === 'Logs' && qt.done && qt.result).map(qt => qt.result)
);
const loading = queryTransactions.some(qt => !qt.done);
const showStartPages = StartPage && queryTransactions.length === 0;
const viewModeCount = [supportsGraph, supportsLogs, supportsTable].filter(m => m).length;
return (
<div className={exploreClass} ref={this.getRef}>
@ -831,53 +852,61 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
{datasource && !datasourceError ? (
<div className="explore-container">
<QueryRows
datasource={datasource}
history={history}
queries={queries}
request={this.request}
onAddQueryRow={this.onAddQueryRow}
onChangeQuery={this.onChangeQuery}
onClickHintFix={this.onModifyQueries}
onExecuteQuery={this.onSubmit}
onRemoveQueryRow={this.onRemoveQueryRow}
supportsLogs={supportsLogs}
transactions={queryTransactions}
/>
<div className="result-options">
{supportsGraph ? (
<button className={`btn toggle-btn ${graphButtonActive}`} onClick={this.onClickGraphButton}>
Graph
</button>
) : null}
{supportsTable ? (
<button className={`btn toggle-btn ${tableButtonActive}`} onClick={this.onClickTableButton}>
Table
</button>
) : null}
{supportsLogs ? (
<button className={`btn toggle-btn ${logsButtonActive}`} onClick={this.onClickLogsButton}>
Logs
</button>
) : null}
</div>
<main className="m-t-2">
{supportsGraph &&
showingGraph && (
<Graph
data={graphResult}
height={graphHeight}
loading={graphLoading}
id={`explore-graph-${position}`}
range={graphRange}
split={split}
/>
<ErrorBoundary>
{showStartPages && <StartPage onClickQuery={this.onClickQuery} />}
{!showStartPages && (
<>
{viewModeCount > 1 && (
<div className="result-options">
{supportsGraph ? (
<button className={`btn toggle-btn ${graphButtonActive}`} onClick={this.onClickGraphButton}>
Graph
</button>
) : null}
{supportsTable ? (
<button className={`btn toggle-btn ${tableButtonActive}`} onClick={this.onClickTableButton}>
Table
</button>
) : null}
{supportsLogs ? (
<button className={`btn toggle-btn ${logsButtonActive}`} onClick={this.onClickLogsButton}>
Logs
</button>
) : null}
</div>
)}
{supportsGraph &&
showingGraph && (
<Graph
data={graphResult}
height={graphHeight}
loading={graphLoading}
id={`explore-graph-${position}`}
range={graphRange}
split={split}
/>
)}
{supportsTable && showingTable ? (
<div className="panel-container m-t-2">
<Table data={tableResult} loading={tableLoading} onClickCell={this.onClickTableCell} />
</div>
) : null}
{supportsLogs && showingLogs ? <Logs data={logsResult} loading={logsLoading} /> : null}
</>
)}
{supportsTable && showingTable ? (
<div className="panel-container m-t-2">
<Table data={tableResult} loading={tableLoading} onClickCell={this.onClickTableCell} />
</div>
) : null}
{supportsLogs && showingLogs ? <Logs data={logsResult} loading={logsLoading} /> : null}
</ErrorBoundary>
</main>
</div>
) : null}

View File

@ -6,7 +6,7 @@ import { withSize } from 'react-sizeme';
import 'vendor/flot/jquery.flot';
import 'vendor/flot/jquery.flot.time';
import { Range } from 'app/types/explore';
import { RawTimeRange } from 'app/types/series';
import * as dateMath from 'app/core/utils/datemath';
import TimeSeries from 'app/core/time_series2';
@ -76,7 +76,7 @@ interface GraphProps {
height?: string; // e.g., '200px'
id?: string;
loading?: boolean;
range: Range;
range: RawTimeRange;
split?: boolean;
size?: { width: number; height: number };
}
@ -169,7 +169,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
return (
<div className="panel-container">
{loading && <div className="explore-graph__loader" />}
{loading && <div className="explore-panel__loader" />}
{this.props.data &&
this.props.data.length > MAX_NUMBER_OF_TIME_SERIES &&
!this.state.showAllTimeSeries && (

View File

@ -10,37 +10,33 @@ interface LogsProps {
loading: boolean;
}
const EXAMPLE_QUERY = '{job="default/prometheus"}';
export default class Logs extends PureComponent<LogsProps, {}> {
render() {
const { className = '', data } = this.props;
const { className = '', data, loading = false } = this.props;
const hasData = data && data.rows && data.rows.length > 0;
return (
<div className={`${className} logs`}>
{hasData ? (
<div className="logs-entries panel-container">
{data.rows.map(row => (
<Fragment key={row.key}>
<div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''} />
<div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>
<div>
<Highlighter
textToHighlight={row.entry}
searchWords={row.searchWords}
findChunks={findHighlightChunksInText}
highlightClassName="logs-row-match-highlight"
/>
</div>
</Fragment>
))}
<div className="panel-container">
{loading && <div className="explore-panel__loader" />}
<div className="logs-entries">
{hasData &&
data.rows.map(row => (
<Fragment key={row.key}>
<div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''} />
<div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>
<div>
<Highlighter
textToHighlight={row.entry}
searchWords={row.searchWords}
findChunks={findHighlightChunksInText}
highlightClassName="logs-row-match-highlight"
/>
</div>
</Fragment>
))}
</div>
) : null}
{!hasData ? (
<div className="panel-container">
Enter a query like <code>{EXAMPLE_QUERY}</code>
</div>
) : null}
{!loading && !hasData && 'No data was returned.'}
</div>
</div>
);
}

View File

@ -0,0 +1,72 @@
import PlaceholdersBuffer from './PlaceholdersBuffer';
describe('PlaceholdersBuffer', () => {
it('does nothing if no placeholders are defined', () => {
const text = 'metric';
const buffer = new PlaceholdersBuffer(text);
expect(buffer.hasPlaceholders()).toBe(false);
expect(buffer.toString()).toBe(text);
expect(buffer.getNextMoveOffset()).toBe(0);
});
it('respects the traversal order of placeholders', () => {
const text = 'sum($2 offset $1) by ($3)';
const buffer = new PlaceholdersBuffer(text);
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('sum( offset ) by ()');
expect(buffer.getNextMoveOffset()).toBe(12);
buffer.setNextPlaceholderValue('1h');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('sum( offset 1h) by ()');
expect(buffer.getNextMoveOffset()).toBe(-10);
buffer.setNextPlaceholderValue('metric');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('sum(metric offset 1h) by ()');
expect(buffer.getNextMoveOffset()).toBe(16);
buffer.setNextPlaceholderValue('label');
expect(buffer.hasPlaceholders()).toBe(false);
expect(buffer.toString()).toBe('sum(metric offset 1h) by (label)');
expect(buffer.getNextMoveOffset()).toBe(0);
});
it('respects the traversal order of adjacent placeholders', () => {
const text = '$1$3$2$4';
const buffer = new PlaceholdersBuffer(text);
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('');
expect(buffer.getNextMoveOffset()).toBe(0);
buffer.setNextPlaceholderValue('1');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('1');
expect(buffer.getNextMoveOffset()).toBe(0);
buffer.setNextPlaceholderValue('2');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('12');
expect(buffer.getNextMoveOffset()).toBe(-1);
buffer.setNextPlaceholderValue('3');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('132');
expect(buffer.getNextMoveOffset()).toBe(1);
buffer.setNextPlaceholderValue('4');
expect(buffer.hasPlaceholders()).toBe(false);
expect(buffer.toString()).toBe('1324');
expect(buffer.getNextMoveOffset()).toBe(0);
});
});

View File

@ -0,0 +1,112 @@
/**
* Provides a stateful means of managing placeholders in text.
*
* Placeholders are numbers prefixed with the `$` character (e.g. `$1`).
* Each number value represents the order in which a placeholder should
* receive focus if multiple placeholders exist.
*
* Example scenario given `sum($3 offset $1) by($2)`:
* 1. `sum( offset |) by()`
* 2. `sum( offset 1h) by(|)`
* 3. `sum(| offset 1h) by (label)`
*/
export default class PlaceholdersBuffer {
private nextMoveOffset: number;
private orders: number[];
private parts: string[];
constructor(text: string) {
const result = this.parse(text);
const nextPlaceholderIndex = result.orders.length ? result.orders[0] : 0;
this.nextMoveOffset = this.getOffsetBetween(result.parts, 0, nextPlaceholderIndex);
this.orders = result.orders;
this.parts = result.parts;
}
clearPlaceholders() {
this.nextMoveOffset = 0;
this.orders = [];
}
getNextMoveOffset(): number {
return this.nextMoveOffset;
}
hasPlaceholders(): boolean {
return this.orders.length > 0;
}
setNextPlaceholderValue(value: string) {
if (this.orders.length === 0) {
return;
}
const currentPlaceholderIndex = this.orders[0];
this.parts[currentPlaceholderIndex] = value;
this.orders = this.orders.slice(1);
if (this.orders.length === 0) {
this.nextMoveOffset = 0;
return;
}
const nextPlaceholderIndex = this.orders[0];
// Case should never happen but handle it gracefully in case
if (currentPlaceholderIndex === nextPlaceholderIndex) {
this.nextMoveOffset = 0;
return;
}
const backwardMove = currentPlaceholderIndex > nextPlaceholderIndex;
const indices = backwardMove
? { start: nextPlaceholderIndex + 1, end: currentPlaceholderIndex + 1 }
: { start: currentPlaceholderIndex + 1, end: nextPlaceholderIndex };
this.nextMoveOffset = (backwardMove ? -1 : 1) * this.getOffsetBetween(this.parts, indices.start, indices.end);
}
toString(): string {
return this.parts.join('');
}
private getOffsetBetween(parts: string[], startIndex: number, endIndex: number) {
return parts.slice(startIndex, endIndex).reduce((offset, part) => offset + part.length, 0);
}
private parse(text: string): ParseResult {
const placeholderRegExp = /\$(\d+)/g;
const parts = [];
const orders = [];
let textOffset = 0;
while (true) {
const match = placeholderRegExp.exec(text);
if (!match) {
break;
}
const part = text.slice(textOffset, match.index);
parts.push(part);
// Accounts for placeholders at text boundaries
if (part !== '') {
parts.push('');
}
const order = parseInt(match[1], 10);
orders.push({ index: parts.length - 1, order });
textOffset += part.length + match.length;
}
// Ensures string serialisation still works if no placeholders were parsed
// and also accounts for the remainder of text with placeholders
parts.push(text.slice(textOffset));
return {
// Placeholder values do not necessarily appear sequentially so sort the
// indices to traverse in priority order
orders: orders.sort((o1, o2) => o1.order - o2.order).map(o => o.index),
parts,
};
}
}
type ParseResult = {
/**
* Indices to placeholder items in `parts` in traversal order.
*/
orders: number[];
/**
* Parts comprising the original text with placeholders occupying distinct items.
*/
parts: string[];
};

View File

@ -1,610 +0,0 @@
import _ from 'lodash';
import moment from 'moment';
import React from 'react';
import { Value } from 'slate';
import Cascader from 'rc-cascader';
import PluginPrism from 'slate-prism';
import Prism from 'prismjs';
// dom also includes Element polyfills
import { getNextCharacter, getPreviousCousin } from './utils/dom';
import PrismPromql, { FUNCTIONS } from './slate-plugins/prism/promql';
import BracesPlugin from './slate-plugins/braces';
import RunnerPlugin from './slate-plugins/runner';
import { processLabels, RATE_RANGES, cleanText, parseSelector } from './utils/prometheus';
import TypeaheadField, {
Suggestion,
SuggestionGroup,
TypeaheadInput,
TypeaheadFieldState,
TypeaheadOutput,
} from './QueryField';
const DEFAULT_KEYS = ['job', 'instance'];
const EMPTY_SELECTOR = '{}';
const HISTOGRAM_GROUP = '__histograms__';
const HISTOGRAM_SELECTOR = '{le!=""}'; // Returns all timeseries for histograms
const HISTORY_ITEM_COUNT = 5;
const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
const METRIC_MARK = 'metric';
const PRISM_SYNTAX = 'promql';
export const RECORDING_RULES_GROUP = '__recording_rules__';
export const wrapLabel = (label: string) => ({ label });
export const setFunctionMove = (suggestion: Suggestion): Suggestion => {
suggestion.move = -1;
return suggestion;
};
// Syntax highlighting
Prism.languages[PRISM_SYNTAX] = PrismPromql;
function setPrismTokens(language, field, values, alias = 'variable') {
Prism.languages[language][field] = {
alias,
pattern: new RegExp(`(?:^|\\s)(${values.join('|')})(?:$|\\s)`),
};
}
export function addHistoryMetadata(item: Suggestion, history: any[]): Suggestion {
const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF;
const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label);
const count = historyForItem.length;
const recent = historyForItem[0];
let hint = `Queried ${count} times in the last 24h.`;
if (recent) {
const lastQueried = moment(recent.ts).fromNow();
hint = `${hint} Last queried ${lastQueried}.`;
}
return {
...item,
documentation: hint,
};
}
export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] {
// Filter out recording rules and insert as first option
const ruleRegex = /:\w+:/;
const ruleNames = metrics.filter(metric => ruleRegex.test(metric));
const rulesOption = {
label: 'Recording rules',
value: RECORDING_RULES_GROUP,
children: ruleNames
.slice()
.sort()
.map(name => ({ label: name, value: name })),
};
const options = ruleNames.length > 0 ? [rulesOption] : [];
const metricsOptions = _.chain(metrics)
.filter(metric => !ruleRegex.test(metric))
.groupBy(metric => metric.split(delimiter)[0])
.map((metricsForPrefix: string[], prefix: string): CascaderOption => {
const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix;
const children = prefixIsMetric ? [] : metricsForPrefix.sort().map(m => ({ label: m, value: m }));
return {
children,
label: prefix,
value: prefix,
};
})
.sortBy('label')
.value();
return [...options, ...metricsOptions];
}
export function willApplySuggestion(
suggestion: string,
{ typeaheadContext, typeaheadText }: TypeaheadFieldState
): string {
// Modify suggestion based on context
switch (typeaheadContext) {
case 'context-labels': {
const nextChar = getNextCharacter();
if (!nextChar || nextChar === '}' || nextChar === ',') {
suggestion += '=';
}
break;
}
case 'context-label-values': {
// Always add quotes and remove existing ones instead
if (!typeaheadText.match(/^(!?=~?"|")/)) {
suggestion = `"${suggestion}`;
}
if (getNextCharacter() !== '"') {
suggestion = `${suggestion}"`;
}
break;
}
default:
}
return suggestion;
}
interface CascaderOption {
label: string;
value: string;
children?: CascaderOption[];
disabled?: boolean;
}
interface PromQueryFieldProps {
error?: string;
hint?: any;
histogramMetrics?: string[];
history?: any[];
initialQuery?: string | null;
labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
metrics?: string[];
metricsByPrefix?: CascaderOption[];
onClickHintFix?: (action: any) => void;
onPressEnter?: () => void;
onQueryChange?: (value: string, override?: boolean) => void;
portalOrigin?: string;
request?: (url: string) => any;
supportsLogs?: boolean; // To be removed after Logging gets its own query field
}
interface PromQueryFieldState {
histogramMetrics: string[];
labelKeys: { [index: string]: string[] }; // metric -> [labelKey,...]
labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
logLabelOptions: any[];
metrics: string[];
metricsOptions: any[];
metricsByPrefix: CascaderOption[];
syntaxLoaded: boolean;
}
interface PromTypeaheadInput {
text: string;
prefix: string;
wrapperClasses: string[];
labelKey?: string;
value?: Value;
}
class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryFieldState> {
plugins: any[];
constructor(props: PromQueryFieldProps, context) {
super(props, context);
this.plugins = [
BracesPlugin(),
RunnerPlugin({ handler: props.onPressEnter }),
PluginPrism({
onlyIn: node => node.type === 'code_block',
getSyntax: node => 'promql',
}),
];
this.state = {
histogramMetrics: props.histogramMetrics || [],
labelKeys: props.labelKeys || {},
labelValues: props.labelValues || {},
logLabelOptions: [],
metrics: props.metrics || [],
metricsByPrefix: props.metricsByPrefix || [],
metricsOptions: [],
syntaxLoaded: false,
};
}
componentDidMount() {
// Temporarily reused by logging
const { supportsLogs } = this.props;
if (supportsLogs) {
this.fetchLogLabels();
} else {
// Usual actions
this.fetchMetricNames();
this.fetchHistogramMetrics();
}
}
onChangeLogLabels = (values: string[], selectedOptions: CascaderOption[]) => {
let query;
if (selectedOptions.length === 1) {
if (selectedOptions[0].children.length === 0) {
query = selectedOptions[0].value;
} else {
// Ignore click on group
return;
}
} else {
const key = selectedOptions[0].value;
const value = selectedOptions[1].value;
query = `{${key}="${value}"}`;
}
this.onChangeQuery(query, true);
};
onChangeMetrics = (values: string[], selectedOptions: CascaderOption[]) => {
let query;
if (selectedOptions.length === 1) {
if (selectedOptions[0].children.length === 0) {
query = selectedOptions[0].value;
} else {
// Ignore click on group
return;
}
} else {
const prefix = selectedOptions[0].value;
const metric = selectedOptions[1].value;
if (prefix === HISTOGRAM_GROUP) {
query = `histogram_quantile(0.95, sum(rate(${metric}[5m])) by (le))`;
} else {
query = metric;
}
}
this.onChangeQuery(query, true);
};
onChangeQuery = (value: string, override?: boolean) => {
// Send text change to parent
const { onQueryChange } = this.props;
if (onQueryChange) {
onQueryChange(value, override);
}
};
onClickHintFix = () => {
const { hint, onClickHintFix } = this.props;
if (onClickHintFix && hint && hint.fix) {
onClickHintFix(hint.fix.action);
}
};
onReceiveMetrics = () => {
const { histogramMetrics, metrics, metricsByPrefix } = this.state;
if (!metrics) {
return;
}
// Update global prism config
setPrismTokens(PRISM_SYNTAX, METRIC_MARK, metrics);
// Build metrics tree
const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
const metricsOptions = [
{ label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions },
...metricsByPrefix,
];
this.setState({ metricsOptions, syntaxLoaded: true });
};
onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
const { prefix, text, value, wrapperNode } = typeahead;
// Get DOM-dependent context
const wrapperClasses = Array.from(wrapperNode.classList);
const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
const labelKey = labelKeyNode && labelKeyNode.textContent;
const nextChar = getNextCharacter();
const result = this.getTypeahead({ text, value, prefix, wrapperClasses, labelKey });
console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
return result;
};
// Keep this DOM-free for testing
getTypeahead({ prefix, wrapperClasses, text }: PromTypeaheadInput): TypeaheadOutput {
// Syntax spans have 3 classes by default. More indicate a recognized token
const tokenRecognized = wrapperClasses.length > 3;
// Determine candidates by CSS context
if (_.includes(wrapperClasses, 'context-range')) {
// Suggestions for metric[|]
return this.getRangeTypeahead();
} else if (_.includes(wrapperClasses, 'context-labels')) {
// Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
return this.getLabelTypeahead.apply(this, arguments);
} else if (_.includes(wrapperClasses, 'context-aggregation')) {
return this.getAggregationTypeahead.apply(this, arguments);
} else if (
// Show default suggestions in a couple of scenarios
(prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token
(prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace
text.match(/[+\-*/^%]/) // Anything after binary operator
) {
return this.getEmptyTypeahead();
}
return {
suggestions: [],
};
}
getEmptyTypeahead(): TypeaheadOutput {
const { history } = this.props;
const { metrics } = this.state;
const suggestions: SuggestionGroup[] = [];
if (history && history.length > 0) {
const historyItems = _.chain(history)
.uniqBy('query')
.take(HISTORY_ITEM_COUNT)
.map(h => h.query)
.map(wrapLabel)
.map(item => addHistoryMetadata(item, history))
.value();
suggestions.push({
prefixMatch: true,
skipSort: true,
label: 'History',
items: historyItems,
});
}
suggestions.push({
prefixMatch: true,
label: 'Functions',
items: FUNCTIONS.map(setFunctionMove),
});
if (metrics) {
suggestions.push({
label: 'Metrics',
items: metrics.map(wrapLabel),
});
}
return { suggestions };
}
getRangeTypeahead(): TypeaheadOutput {
return {
context: 'context-range',
suggestions: [
{
label: 'Range vector',
items: [...RATE_RANGES].map(wrapLabel),
},
],
};
}
getAggregationTypeahead({ value }: PromTypeaheadInput): TypeaheadOutput {
let refresher: Promise<any> = null;
const suggestions: SuggestionGroup[] = [];
// sum(foo{bar="1"}) by (|)
const line = value.anchorBlock.getText();
const cursorOffset: number = value.anchorOffset;
// sum(foo{bar="1"}) by (
const leftSide = line.slice(0, cursorOffset);
const openParensAggregationIndex = leftSide.lastIndexOf('(');
const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('(');
const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex;
// foo{bar="1"}
const selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex);
const selector = parseSelector(selectorString, selectorString.length - 2).selector;
const labelKeys = this.state.labelKeys[selector];
if (labelKeys) {
suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
} else {
refresher = this.fetchSeriesLabels(selector);
}
return {
refresher,
suggestions,
context: 'context-aggregation',
};
}
getLabelTypeahead({ text, wrapperClasses, labelKey, value }: PromTypeaheadInput): TypeaheadOutput {
let context: string;
let refresher: Promise<any> = null;
const suggestions: SuggestionGroup[] = [];
const line = value.anchorBlock.getText();
const cursorOffset: number = value.anchorOffset;
// Get normalized selector
let selector;
let parsedSelector;
try {
parsedSelector = parseSelector(line, cursorOffset);
selector = parsedSelector.selector;
} catch {
selector = EMPTY_SELECTOR;
}
const containsMetric = selector.indexOf('__name__=') > -1;
const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) {
// Label values
if (labelKey && this.state.labelValues[selector] && this.state.labelValues[selector][labelKey]) {
const labelValues = this.state.labelValues[selector][labelKey];
context = 'context-label-values';
suggestions.push({
label: `Label values for "${labelKey}"`,
items: labelValues.map(wrapLabel),
});
}
} else {
// Label keys
const labelKeys = this.state.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
if (labelKeys) {
const possibleKeys = _.difference(labelKeys, existingKeys);
if (possibleKeys.length > 0) {
context = 'context-labels';
suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) });
}
}
}
// Query labels for selector
// Temporarily add skip for logging
if (selector && !this.state.labelValues[selector] && !this.props.supportsLogs) {
if (selector === EMPTY_SELECTOR) {
// Query label values for default labels
refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key)));
} else {
refresher = this.fetchSeriesLabels(selector, !containsMetric);
}
}
return { context, refresher, suggestions };
}
request = url => {
if (this.props.request) {
return this.props.request(url);
}
return fetch(url);
};
fetchHistogramMetrics() {
this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true, () => {
const histogramSeries = this.state.labelValues[HISTOGRAM_SELECTOR];
if (histogramSeries && histogramSeries['__name__']) {
const histogramMetrics = histogramSeries['__name__'].slice().sort();
this.setState({ histogramMetrics }, this.onReceiveMetrics);
}
});
}
// Temporarily here while reusing this field for logging
async fetchLogLabels() {
const url = '/api/prom/label';
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const labelKeys = body.data.slice().sort();
const labelKeysBySelector = {
...this.state.labelKeys,
[EMPTY_SELECTOR]: labelKeys,
};
const labelValuesByKey = {};
const logLabelOptions = [];
for (const key of labelKeys) {
const valuesUrl = `/api/prom/label/${key}/values`;
const res = await this.request(valuesUrl);
const body = await (res.data || res.json());
const values = body.data.slice().sort();
labelValuesByKey[key] = values;
logLabelOptions.push({
label: key,
value: key,
children: values.map(value => ({ label: value, value })),
});
}
const labelValues = { [EMPTY_SELECTOR]: labelValuesByKey };
this.setState({ labelKeys: labelKeysBySelector, labelValues, logLabelOptions });
} catch (e) {
console.error(e);
}
}
async fetchLabelValues(key: string) {
const url = `/api/v1/label/${key}/values`;
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const exisingValues = this.state.labelValues[EMPTY_SELECTOR];
const values = {
...exisingValues,
[key]: body.data,
};
const labelValues = {
...this.state.labelValues,
[EMPTY_SELECTOR]: values,
};
this.setState({ labelValues });
} catch (e) {
console.error(e);
}
}
async fetchSeriesLabels(name: string, withName?: boolean, callback?: () => void) {
const url = `/api/v1/series?match[]=${name}`;
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const { keys, values } = processLabels(body.data, withName);
const labelKeys = {
...this.state.labelKeys,
[name]: keys,
};
const labelValues = {
...this.state.labelValues,
[name]: values,
};
this.setState({ labelKeys, labelValues }, callback);
} catch (e) {
console.error(e);
}
}
async fetchMetricNames() {
const url = '/api/v1/label/__name__/values';
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const metrics = body.data;
const metricsByPrefix = groupMetricsByPrefix(metrics);
this.setState({ metrics, metricsByPrefix }, this.onReceiveMetrics);
} catch (error) {
console.error(error);
}
}
render() {
const { error, hint, initialQuery, supportsLogs } = this.props;
const { logLabelOptions, metricsOptions, syntaxLoaded } = this.state;
return (
<div className="prom-query-field">
<div className="prom-query-field-tools">
{supportsLogs ? (
<Cascader options={logLabelOptions} onChange={this.onChangeLogLabels}>
<button className="btn navbar-button navbar-button--tight">Log labels</button>
</Cascader>
) : (
<Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
<button className="btn navbar-button navbar-button--tight">Metrics</button>
</Cascader>
)}
</div>
<div className="prom-query-field-wrapper">
<div className="slate-query-field-wrapper">
<TypeaheadField
additionalPlugins={this.plugins}
cleanText={cleanText}
initialValue={initialQuery}
onTypeahead={this.onTypeahead}
onWillApplySuggestion={willApplySuggestion}
onValueChanged={this.onChangeQuery}
placeholder="Enter a PromQL query"
portalOrigin="prometheus"
syntaxLoaded={syntaxLoaded}
/>
</div>
{error ? <div className="prom-query-field-info text-error">{error}</div> : null}
{hint ? (
<div className="prom-query-field-info text-warning">
{hint.label}{' '}
{hint.fix ? (
<a className="text-link muted" onClick={this.onClickHintFix}>
{hint.fix.label}
</a>
) : null}
</div>
) : null}
</div>
</div>
);
}
}
export default PromQueryField;

View File

@ -5,96 +5,29 @@ import { Change, Value } from 'slate';
import { Editor } from 'slate-react';
import Plain from 'slate-plain-serializer';
import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore';
import ClearPlugin from './slate-plugins/clear';
import NewlinePlugin from './slate-plugins/newline';
import Typeahead from './Typeahead';
import { makeFragment, makeValue } from './Value';
import PlaceholdersBuffer from './PlaceholdersBuffer';
export const TYPEAHEAD_DEBOUNCE = 100;
function getSuggestionByIndex(suggestions: SuggestionGroup[], index: number): Suggestion {
function getSuggestionByIndex(suggestions: CompletionItemGroup[], index: number): CompletionItem {
// Flatten suggestion groups
const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []);
const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length;
return flattenedSuggestions[correctedIndex];
}
function hasSuggestions(suggestions: SuggestionGroup[]): boolean {
function hasSuggestions(suggestions: CompletionItemGroup[]): boolean {
return suggestions && suggestions.length > 0;
}
export interface Suggestion {
/**
* The label of this completion item. By default
* this is also the text that is inserted when selecting
* this completion.
*/
label: string;
/**
* The kind of this completion item. Based on the kind
* an icon is chosen by the editor.
*/
kind?: string;
/**
* A human-readable string with additional information
* about this item, like type or symbol information.
*/
detail?: string;
/**
* A human-readable string, can be Markdown, that represents a doc-comment.
*/
documentation?: string;
/**
* A string that should be used when comparing this item
* with other items. When `falsy` the `label` is used.
*/
sortText?: string;
/**
* A string that should be used when filtering a set of
* completion items. When `falsy` the `label` is used.
*/
filterText?: string;
/**
* A string or snippet that should be inserted in a document when selecting
* this completion. When `falsy` the `label` is used.
*/
insertText?: string;
/**
* Delete number of characters before the caret position,
* by default the letters from the beginning of the word.
*/
deleteBackwards?: number;
/**
* Number of steps to move after the insertion, can be negative.
*/
move?: number;
}
export interface SuggestionGroup {
/**
* Label that will be displayed for all entries of this group.
*/
label: string;
/**
* List of suggestions of this group.
*/
items: Suggestion[];
/**
* If true, match only by prefix (and not mid-word).
*/
prefixMatch?: boolean;
/**
* If true, do not filter items in this group based on the search.
*/
skipFilter?: boolean;
/**
* If true, do not sort items.
*/
skipSort?: boolean;
}
interface TypeaheadFieldProps {
interface QueryFieldProps {
additionalPlugins?: any[];
cleanText?: (text: string) => string;
initialValue: string | null;
@ -102,15 +35,15 @@ interface TypeaheadFieldProps {
onFocus?: () => void;
onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput;
onValueChanged?: (value: Value) => void;
onWillApplySuggestion?: (suggestion: string, state: TypeaheadFieldState) => string;
onWillApplySuggestion?: (suggestion: string, state: QueryFieldState) => string;
placeholder?: string;
portalOrigin?: string;
syntax?: string;
syntaxLoaded?: boolean;
}
export interface TypeaheadFieldState {
suggestions: SuggestionGroup[];
export interface QueryFieldState {
suggestions: CompletionItemGroup[];
typeaheadContext: string | null;
typeaheadIndex: number;
typeaheadPrefix: string;
@ -127,22 +60,19 @@ export interface TypeaheadInput {
wrapperNode: Element;
}
export interface TypeaheadOutput {
context?: string;
refresher?: Promise<{}>;
suggestions: SuggestionGroup[];
}
class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadFieldState> {
export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldState> {
menuEl: HTMLElement | null;
placeholdersBuffer: PlaceholdersBuffer;
plugins: any[];
resetTimer: any;
constructor(props, context) {
super(props, context);
this.placeholdersBuffer = new PlaceholdersBuffer(props.initialValue || '');
// Base plugins
this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins];
this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins].filter(p => p);
this.state = {
suggestions: [],
@ -150,7 +80,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
typeaheadIndex: 0,
typeaheadPrefix: '',
typeaheadText: '',
value: makeValue(props.initialValue || '', props.syntax),
value: makeValue(this.placeholdersBuffer.toString(), props.syntax),
};
}
@ -172,15 +102,17 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
}
}
componentWillReceiveProps(nextProps: TypeaheadFieldProps) {
componentWillReceiveProps(nextProps: QueryFieldProps) {
if (nextProps.syntaxLoaded && !this.props.syntaxLoaded) {
// Need a bogus edit to re-render the editor after syntax has fully loaded
this.onChange(
this.state.value
.change()
.insertText(' ')
.deleteBackward()
);
const change = this.state.value
.change()
.insertText(' ')
.deleteBackward();
if (this.placeholdersBuffer.hasPlaceholders()) {
change.move(this.placeholdersBuffer.getNextMoveOffset()).focus();
}
this.onChange(change);
}
}
@ -293,7 +225,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
}
}, TYPEAHEAD_DEBOUNCE);
applyTypeahead(change: Change, suggestion: Suggestion): Change {
applyTypeahead(change: Change, suggestion: CompletionItem): Change {
const { cleanText, onWillApplySuggestion, syntax } = this.props;
const { typeaheadPrefix, typeaheadText } = this.state;
let suggestionText = suggestion.insertText || suggestion.label;
@ -363,7 +295,17 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
}
const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex);
this.applyTypeahead(change, suggestion);
const nextChange = this.applyTypeahead(change, suggestion);
const insertTextOperation = nextChange.operations.find(operation => operation.type === 'insert_text');
if (insertTextOperation) {
const suggestionText = insertTextOperation.text;
this.placeholdersBuffer.setNextPlaceholderValue(suggestionText);
if (this.placeholdersBuffer.hasPlaceholders()) {
nextChange.move(this.placeholdersBuffer.getNextMoveOffset()).focus();
}
}
return true;
}
break;
@ -410,6 +352,8 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
// If we dont wait here, menu clicks wont work because the menu
// will be gone.
this.resetTimer = setTimeout(this.resetTypeahead, 100);
// Disrupting placeholder entry wipes all remaining placeholders needing input
this.placeholdersBuffer.clearPlaceholders();
if (onBlur) {
onBlur();
}
@ -422,7 +366,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
}
};
onClickMenu = (item: Suggestion) => {
onClickMenu = (item: CompletionItem) => {
// Manually triggering change
const change = this.applyTypeahead(this.state.value.change(), item);
this.onChange(change);
@ -490,19 +434,21 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
render() {
return (
<div className="slate-query-field">
{this.renderMenu()}
<Editor
autoCorrect={false}
onBlur={this.handleBlur}
onKeyDown={this.onKeyDown}
onChange={this.onChange}
onFocus={this.handleFocus}
placeholder={this.props.placeholder}
plugins={this.plugins}
spellCheck={false}
value={this.state.value}
/>
<div className="slate-query-field-wrapper">
<div className="slate-query-field">
{this.renderMenu()}
<Editor
autoCorrect={false}
onBlur={this.handleBlur}
onKeyDown={this.onKeyDown}
onChange={this.onChange}
onFocus={this.handleFocus}
placeholder={this.props.placeholder}
plugins={this.plugins}
spellCheck={false}
value={this.state.value}
/>
</div>
</div>
);
}

View File

@ -1,12 +1,12 @@
import React, { PureComponent } from 'react';
import { QueryTransaction } from 'app/types/explore';
import { QueryTransaction, HistoryItem, Query, QueryHint } from 'app/types/explore';
// TODO make this datasource-plugin-dependent
import QueryField from './PromQueryField';
import QueryTransactions from './QueryTransactions';
import DefaultQueryField from './QueryField';
import QueryTransactionStatus from './QueryTransactionStatus';
import { DataSource } from 'app/types';
function getFirstHintFromTransactions(transactions: QueryTransaction[]) {
function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint {
const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
if (transaction) {
return transaction.hints[0];
@ -14,7 +14,28 @@ function getFirstHintFromTransactions(transactions: QueryTransaction[]) {
return undefined;
}
class QueryRow extends PureComponent<any, {}> {
interface QueryRowEventHandlers {
onAddQueryRow: (index: number) => void;
onChangeQuery: (value: string, index: number, override?: boolean) => void;
onClickHintFix: (action: object, index?: number) => void;
onExecuteQuery: () => void;
onRemoveQueryRow: (index: number) => void;
}
interface QueryRowCommonProps {
className?: string;
datasource: DataSource;
history: HistoryItem[];
transactions: QueryTransaction[];
}
type QueryRowProps = QueryRowCommonProps &
QueryRowEventHandlers & {
index: number;
query: string;
};
class QueryRow extends PureComponent<QueryRowProps> {
onChangeQuery = (value, override?: boolean) => {
const { index, onChangeQuery } = this.props;
if (onChangeQuery) {
@ -55,17 +76,19 @@ class QueryRow extends PureComponent<any, {}> {
};
render() {
const { history, query, request, supportsLogs, transactions } = this.props;
const transactionWithError = transactions.find(t => t.error);
const { datasource, history, query, transactions } = this.props;
const transactionWithError = transactions.find(t => t.error !== undefined);
const hint = getFirstHintFromTransactions(transactions);
const queryError = transactionWithError ? transactionWithError.error : null;
const QueryField = datasource.pluginExports.ExploreQueryField || DefaultQueryField;
return (
<div className="query-row">
<div className="query-row-status">
<QueryTransactions transactions={transactions} />
<QueryTransactionStatus transactions={transactions} />
</div>
<div className="query-row-field">
<QueryField
datasource={datasource}
error={queryError}
hint={hint}
initialQuery={query}
@ -73,8 +96,6 @@ class QueryRow extends PureComponent<any, {}> {
onClickHintFix={this.onClickHintFix}
onPressEnter={this.onPressEnter}
onQueryChange={this.onChangeQuery}
request={request}
supportsLogs={supportsLogs}
/>
</div>
<div className="query-row-tools">
@ -93,9 +114,14 @@ class QueryRow extends PureComponent<any, {}> {
}
}
export default class QueryRows extends PureComponent<any, {}> {
type QueryRowsProps = QueryRowCommonProps &
QueryRowEventHandlers & {
queries: Query[];
};
export default class QueryRows extends PureComponent<QueryRowsProps> {
render() {
const { className = '', queries, queryHints, transactions, ...handlers } = this.props;
const { className = '', queries, transactions, ...handlers } = this.props;
return (
<div className={className}>
{queries.map((q, index) => (

View File

@ -1,17 +1,17 @@
import React, { PureComponent } from 'react';
import { QueryTransaction as QueryTransactionModel } from 'app/types/explore';
import { QueryTransaction } from 'app/types/explore';
import ElapsedTime from './ElapsedTime';
function formatLatency(value) {
return `${(value / 1000).toFixed(1)}s`;
}
interface QueryTransactionProps {
transaction: QueryTransactionModel;
interface QueryTransactionStatusItemProps {
transaction: QueryTransaction;
}
class QueryTransaction extends PureComponent<QueryTransactionProps> {
class QueryTransactionStatusItem extends PureComponent<QueryTransactionStatusItemProps> {
render() {
const { transaction } = this.props;
const className = transaction.done ? 'query-transaction' : 'query-transaction query-transaction--loading';
@ -26,16 +26,16 @@ class QueryTransaction extends PureComponent<QueryTransactionProps> {
}
}
interface QueryTransactionsProps {
transactions: QueryTransactionModel[];
interface QueryTransactionStatusProps {
transactions: QueryTransaction[];
}
export default class QueryTransactions extends PureComponent<QueryTransactionsProps> {
export default class QueryTransactionStatus extends PureComponent<QueryTransactionStatusProps> {
render() {
const { transactions } = this.props;
return (
<div className="query-transactions">
{transactions.map((t, i) => <QueryTransaction key={`${t.query}:${t.resultType}`} transaction={t} />)}
{transactions.map((t, i) => <QueryTransactionStatusItem key={`${t.query}:${t.resultType}`} transaction={t} />)}
</div>
);
}

View File

@ -21,10 +21,16 @@ function prepareRows(rows, columnNames) {
export default class Table extends PureComponent<TableProps> {
getCellProps = (state, rowInfo, column) => {
return {
onClick: () => {
const columnKey = column.Header;
const rowValue = rowInfo.row[columnKey];
this.props.onClickCell(columnKey, rowValue);
onClick: (e: React.SyntheticEvent) => {
// Only handle click on link, not the cell
if (e.target) {
const link = e.target as HTMLElement;
if (link.className === 'link') {
const columnKey = column.Header;
const rowValue = rowInfo.row[columnKey];
this.props.onClickCell(columnKey, rowValue);
}
}
},
};
};

View File

@ -33,8 +33,8 @@ describe('<TimePicker />', () => {
to: '1000',
};
const rangeString = rangeUtil.describeTimeRange({
from: parseTime(range.from),
to: parseTime(range.to),
from: parseTime(range.from, true),
to: parseTime(range.to, true),
});
const wrapper = shallow(<TimePicker range={range} isUtc isOpen />);
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:00');
@ -50,8 +50,8 @@ describe('<TimePicker />', () => {
to: '4000',
};
const rangeString = rangeUtil.describeTimeRange({
from: parseTime(range.from),
to: parseTime(range.to),
from: parseTime(range.from, true),
to: parseTime(range.to, true),
});
const onChangeTime = sinon.spy();

View File

@ -3,6 +3,7 @@ import moment from 'moment';
import * as dateMath from 'app/core/utils/datemath';
import * as rangeUtil from 'app/core/utils/rangeutil';
import { RawTimeRange } from 'app/types/series';
const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss';
export const DEFAULT_RANGE = {
@ -10,77 +11,104 @@ export const DEFAULT_RANGE = {
to: 'now',
};
export function parseTime(value, isUtc = false, asString = false) {
/**
* Return a human-editable string of either relative (inludes "now") or absolute local time (in the shape of DATE_FORMAT).
* @param value Epoch or relative time
*/
export function parseTime(value: string, isUtc = false): string {
if (value.indexOf('now') !== -1) {
return value;
}
if (!isNaN(value)) {
const epoch = parseInt(value, 10);
const m = isUtc ? moment.utc(epoch) : moment(epoch);
return asString ? m.format(DATE_FORMAT) : m;
let time: any = value;
// Possible epoch
if (!isNaN(time)) {
time = parseInt(time, 10);
}
return undefined;
time = isUtc ? moment.utc(time) : moment(time);
return time.format(DATE_FORMAT);
}
export default class TimePicker extends PureComponent<any, any> {
interface TimePickerProps {
isOpen?: boolean;
isUtc?: boolean;
range?: RawTimeRange;
onChangeTime?: (Range) => void;
}
interface TimePickerState {
isOpen: boolean;
isUtc: boolean;
rangeString: string;
refreshInterval: string;
// Input-controlled text, keep these in a shape that is human-editable
fromRaw: string;
toRaw: string;
}
export default class TimePicker extends PureComponent<TimePickerProps, TimePickerState> {
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 from = props.range ? props.range.from : DEFAULT_RANGE.from;
const to = props.range ? props.range.to : DEFAULT_RANGE.to;
// Ensure internal format
const fromRaw = parseTime(from, props.isUtc);
const toRaw = parseTime(to, props.isUtc);
const range = {
from: parseTime(fromRaw),
to: parseTime(toRaw),
from: fromRaw,
to: toRaw,
};
this.state = {
fromRaw: parseTime(fromRaw, props.isUtc, true),
fromRaw,
toRaw,
isOpen: props.isOpen,
isUtc: props.isUtc,
rangeString: rangeUtil.describeTimeRange(range),
refreshInterval: '',
toRaw: parseTime(toRaw, props.isUtc, true),
};
}
move(direction) {
move(direction: number) {
const { onChangeTime } = this.props;
const { fromRaw, toRaw } = this.state;
const range = {
from: dateMath.parse(fromRaw, false),
to: dateMath.parse(toRaw, true),
};
const from = dateMath.parse(fromRaw, false);
const to = dateMath.parse(toRaw, true);
const timespan = (to.valueOf() - from.valueOf()) / 2;
const timespan = (range.to.valueOf() - range.from.valueOf()) / 2;
let to, from;
let nextTo, nextFrom;
if (direction === -1) {
to = range.to.valueOf() - timespan;
from = range.from.valueOf() - timespan;
nextTo = to.valueOf() - timespan;
nextFrom = 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();
nextTo = to.valueOf() + timespan;
nextFrom = from.valueOf() + timespan;
if (nextTo > Date.now() && to < Date.now()) {
nextTo = Date.now();
nextFrom = from.valueOf();
}
} else {
to = range.to.valueOf();
from = range.from.valueOf();
nextTo = to.valueOf();
nextFrom = from.valueOf();
}
const rangeString = rangeUtil.describeTimeRange(range);
// No need to convert to UTC again
to = moment(to);
from = moment(from);
const nextRange = {
from: moment(nextFrom),
to: moment(nextTo),
};
this.setState(
{
rangeString,
fromRaw: from.format(DATE_FORMAT),
toRaw: to.format(DATE_FORMAT),
rangeString: rangeUtil.describeTimeRange(nextRange),
fromRaw: nextRange.from.format(DATE_FORMAT),
toRaw: nextRange.to.format(DATE_FORMAT),
},
() => {
onChangeTime({ to, from });
onChangeTime(nextRange);
}
);
}
@ -99,16 +127,19 @@ export default class TimePicker extends PureComponent<any, any> {
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);
let range;
this.setState(
{
isOpen: false,
rangeString,
state => {
const { toRaw, fromRaw } = this.state;
range = {
from: dateMath.parse(fromRaw, false),
to: dateMath.parse(toRaw, true),
};
const rangeString = rangeUtil.describeTimeRange(range);
return {
isOpen: false,
rangeString,
};
},
() => {
if (onChangeTime) {

View File

@ -1,7 +1,7 @@
import React from 'react';
import Highlighter from 'react-highlight-words';
import { Suggestion, SuggestionGroup } from './QueryField';
import { CompletionItem, CompletionItemGroup } from 'app/types/explore';
function scrollIntoView(el: HTMLElement) {
if (!el || !el.offsetParent) {
@ -15,12 +15,12 @@ function scrollIntoView(el: HTMLElement) {
interface TypeaheadItemProps {
isSelected: boolean;
item: Suggestion;
item: CompletionItem;
onClickItem: (Suggestion) => void;
prefix?: string;
}
class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
class TypeaheadItem extends React.PureComponent<TypeaheadItemProps> {
el: HTMLElement;
componentDidUpdate(prevProps) {
@ -53,14 +53,14 @@ class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
}
interface TypeaheadGroupProps {
items: Suggestion[];
items: CompletionItem[];
label: string;
onClickItem: (Suggestion) => void;
selected: Suggestion;
onClickItem: (CompletionItem) => void;
selected: CompletionItem;
prefix?: string;
}
class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> {
class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps> {
render() {
const { items, label, selected, onClickItem, prefix } = this.props;
return (
@ -85,13 +85,13 @@ class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> {
}
interface TypeaheadProps {
groupedItems: SuggestionGroup[];
groupedItems: CompletionItemGroup[];
menuRef: any;
selectedItem: Suggestion | null;
selectedItem: CompletionItem | null;
onClickItem: (Suggestion) => void;
prefix?: string;
}
class Typeahead extends React.PureComponent<TypeaheadProps, {}> {
class Typeahead extends React.PureComponent<TypeaheadProps> {
render() {
const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props;
return (

View File

@ -7,6 +7,7 @@ import { serializeStateToUrlParam, parseUrlState } from 'app/core/utils/explore'
import { StoreState } from 'app/types';
import { ExploreState } from 'app/types/explore';
import ErrorBoundary from './ErrorBoundary';
import Explore from './Explore';
interface WrapperProps {
@ -61,28 +62,33 @@ export class Wrapper extends Component<WrapperProps, WrapperState> {
const { split, splitState } = this.state;
const urlStateLeft = parseUrlState(this.urlStates[STATE_KEY_LEFT]);
const urlStateRight = parseUrlState(this.urlStates[STATE_KEY_RIGHT]);
return (
<div className="explore-wrapper">
<Explore
datasourceSrv={datasourceSrv}
onChangeSplit={this.onChangeSplit}
onSaveState={this.onSaveState}
position="left"
split={split}
stateKey={STATE_KEY_LEFT}
urlState={urlStateLeft}
/>
{split && (
<ErrorBoundary>
<Explore
datasourceSrv={datasourceSrv}
onChangeSplit={this.onChangeSplit}
onSaveState={this.onSaveState}
position="right"
position="left"
split={split}
splitState={splitState}
stateKey={STATE_KEY_RIGHT}
urlState={urlStateRight}
stateKey={STATE_KEY_LEFT}
urlState={urlStateLeft}
/>
</ErrorBoundary>
{split && (
<ErrorBoundary>
<Explore
datasourceSrv={datasourceSrv}
onChangeSplit={this.onChangeSplit}
onSaveState={this.onSaveState}
position="right"
split={split}
splitState={splitState}
stateKey={STATE_KEY_RIGHT}
urlState={urlStateRight}
/>
</ErrorBoundary>
)}
</div>
);

View File

@ -0,0 +1,45 @@
import React from 'react';
import { shallow } from 'enzyme';
import { OrgDetailsPage, Props } from './OrgDetailsPage';
import { NavModel, Organization, OrganizationPreferences } from '../../types';
const setup = (propOverrides?: object) => {
const props: Props = {
preferences: {} as OrganizationPreferences,
organization: {} as Organization,
navModel: {} as NavModel,
loadOrganization: jest.fn(),
loadOrganizationPreferences: jest.fn(),
loadStarredDashboards: jest.fn(),
setOrganizationName: jest.fn(),
updateOrganization: jest.fn(),
};
Object.assign(props, propOverrides);
return shallow(<OrgDetailsPage {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
it('should render organization and preferences', () => {
const wrapper = setup({
organization: {
name: 'Cool org',
id: 1,
},
preferences: {
homeDashboardId: 1,
theme: 'Default',
timezone: 'Default',
},
});
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -0,0 +1,85 @@
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import PageHeader from '../../core/components/PageHeader/PageHeader';
import PageLoader from '../../core/components/PageLoader/PageLoader';
import OrgProfile from './OrgProfile';
import OrgPreferences from './OrgPreferences';
import {
loadOrganization,
loadOrganizationPreferences,
setOrganizationName,
updateOrganization,
} from './state/actions';
import { loadStarredDashboards } from '../../core/actions/user';
import { NavModel, Organization, OrganizationPreferences, StoreState } from 'app/types';
import { getNavModel } from '../../core/selectors/navModel';
export interface Props {
navModel: NavModel;
organization: Organization;
preferences: OrganizationPreferences;
loadOrganization: typeof loadOrganization;
loadOrganizationPreferences: typeof loadOrganizationPreferences;
loadStarredDashboards: typeof loadStarredDashboards;
setOrganizationName: typeof setOrganizationName;
updateOrganization: typeof updateOrganization;
}
export class OrgDetailsPage extends PureComponent<Props> {
async componentDidMount() {
await this.props.loadStarredDashboards();
await this.props.loadOrganization();
await this.props.loadOrganizationPreferences();
}
onOrgNameChange = name => {
this.props.setOrganizationName(name);
};
onUpdateOrganization = () => {
this.props.updateOrganization();
};
render() {
const { navModel, organization, preferences } = this.props;
return (
<div>
<PageHeader model={navModel} />
<div className="page-container page-body">
{Object.keys(organization).length === 0 || Object.keys(preferences).length === 0 ? (
<PageLoader pageName="Organization" />
) : (
<div>
<OrgProfile
onOrgNameChange={name => this.onOrgNameChange(name)}
onSubmit={this.onUpdateOrganization}
orgName={organization.name}
/>
<OrgPreferences />
</div>
)}
</div>
</div>
);
}
}
function mapStateToProps(state: StoreState) {
return {
navModel: getNavModel(state.navIndex, 'org-settings'),
organization: state.organization.organization,
preferences: state.organization.preferences,
};
}
const mapDispatchToProps = {
loadOrganization,
loadOrganizationPreferences,
loadStarredDashboards,
setOrganizationName,
updateOrganization,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(OrgDetailsPage));

View File

@ -0,0 +1,28 @@
import React from 'react';
import { shallow } from 'enzyme';
import { OrgPreferences, Props } from './OrgPreferences';
const setup = () => {
const props: Props = {
preferences: {
homeDashboardId: 1,
timezone: 'UTC',
theme: 'Default',
},
starredDashboards: [{ id: 1, title: 'Standard dashboard', url: '', uri: '', uid: '', type: '', tags: [] }],
setOrganizationTimezone: jest.fn(),
setOrganizationTheme: jest.fn(),
setOrganizationHomeDashboard: jest.fn(),
updateOrganizationPreferences: jest.fn(),
};
return shallow(<OrgPreferences {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -0,0 +1,113 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { Label } from '../../core/components/Label/Label';
import SimplePicker from '../../core/components/Picker/SimplePicker';
import { DashboardSearchHit, OrganizationPreferences } from 'app/types';
import {
setOrganizationHomeDashboard,
setOrganizationTheme,
setOrganizationTimezone,
updateOrganizationPreferences,
} from './state/actions';
export interface Props {
preferences: OrganizationPreferences;
starredDashboards: DashboardSearchHit[];
setOrganizationHomeDashboard: typeof setOrganizationHomeDashboard;
setOrganizationTheme: typeof setOrganizationTheme;
setOrganizationTimezone: typeof setOrganizationTimezone;
updateOrganizationPreferences: typeof updateOrganizationPreferences;
}
const themes = [{ value: '', text: 'Default' }, { value: 'dark', text: 'Dark' }, { value: 'light', text: 'Light' }];
const timezones = [
{ value: '', text: 'Default' },
{ value: 'browser', text: 'Local browser time' },
{ value: 'utc', text: 'UTC' },
];
export class OrgPreferences extends PureComponent<Props> {
onSubmitForm = event => {
event.preventDefault();
this.props.updateOrganizationPreferences();
};
render() {
const {
preferences,
starredDashboards,
setOrganizationHomeDashboard,
setOrganizationTimezone,
setOrganizationTheme,
} = this.props;
starredDashboards.unshift({ id: 0, title: 'Default', tags: [], type: '', uid: '', uri: '', url: '' });
return (
<form className="section gf-form-group" onSubmit={this.onSubmitForm}>
<h3 className="page-heading">Preferences</h3>
<div className="gf-form">
<span className="gf-form-label width-11">UI Theme</span>
<SimplePicker
defaultValue={themes.find(theme => theme.value === preferences.theme)}
options={themes}
getOptionValue={i => i.value}
getOptionLabel={i => i.text}
onSelected={theme => setOrganizationTheme(theme.value)}
width={20}
/>
</div>
<div className="gf-form">
<Label
width={11}
tooltip="Not finding dashboard you want? Star it first, then it should appear in this select box."
>
Home Dashboard
</Label>
<SimplePicker
defaultValue={starredDashboards.find(dashboard => dashboard.id === preferences.homeDashboardId)}
getOptionValue={i => i.id}
getOptionLabel={i => i.title}
onSelected={(dashboard: DashboardSearchHit) => setOrganizationHomeDashboard(dashboard.id)}
options={starredDashboards}
placeholder="Chose default dashboard"
width={20}
/>
</div>
<div className="gf-form">
<label className="gf-form-label width-11">Timezone</label>
<SimplePicker
defaultValue={timezones.find(timezone => timezone.value === preferences.timezone)}
getOptionValue={i => i.value}
getOptionLabel={i => i.text}
onSelected={timezone => setOrganizationTimezone(timezone.value)}
options={timezones}
width={20}
/>
</div>
<div className="gf-form-button-row">
<button type="submit" className="btn btn-success">
Save
</button>
</div>
</form>
);
}
}
function mapStateToProps(state) {
return {
preferences: state.organization.preferences,
starredDashboards: state.user.starredDashboards,
};
}
const mapDispatchToProps = {
setOrganizationHomeDashboard,
setOrganizationTimezone,
setOrganizationTheme,
updateOrganizationPreferences,
};
export default connect(mapStateToProps, mapDispatchToProps)(OrgPreferences);

View File

@ -0,0 +1,21 @@
import React from 'react';
import { shallow } from 'enzyme';
import OrgProfile, { Props } from './OrgProfile';
const setup = () => {
const props: Props = {
orgName: 'Main org',
onSubmit: jest.fn(),
onOrgNameChange: jest.fn(),
};
return shallow(<OrgProfile {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -0,0 +1,44 @@
import React, { SFC } from 'react';
export interface Props {
orgName: string;
onSubmit: () => void;
onOrgNameChange: (orgName: string) => void;
}
const OrgProfile: SFC<Props> = ({ onSubmit, onOrgNameChange, orgName }) => {
return (
<div>
<h3 className="page-sub-heading">Organization profile</h3>
<form
name="orgForm"
className="gf-form-group"
onSubmit={event => {
event.preventDefault();
onSubmit();
}}
>
<div className="gf-form-inline">
<div className="gf-form max-width-28">
<span className="gf-form-label">Organization name</span>
<input
className="gf-form-input"
type="text"
onChange={event => {
onOrgNameChange(event.target.value);
}}
value={orgName}
/>
</div>
</div>
<div className="gf-form-button-row">
<button type="submit" className="btn btn-success">
Save
</button>
</div>
</form>
</div>
);
};
export default OrgProfile;

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