mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into panel-edit-ux
This commit is contained in:
commit
2f4ae1e2d8
@ -319,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: |
|
||||
@ -361,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: |
|
||||
@ -369,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'
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,6 +8,7 @@ awsconfig
|
||||
/dist
|
||||
/public/build
|
||||
/public/views/index.html
|
||||
/public/views/error.html
|
||||
/emails/dist
|
||||
/public_gen
|
||||
/public/vendor/npm
|
||||
|
@ -4,16 +4,20 @@
|
||||
|
||||
* **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)
|
||||
* **Alerting**: Increaste default duration for queries [#13945](https://github.com/grafana/grafana/pull/13945)
|
||||
|
||||
### Breaking changes
|
||||
|
||||
|
10
Makefile
10
Makefile
@ -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
|
||||
|
9
build.go
9
build.go
@ -471,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()
|
||||
}
|
||||
|
||||
@ -518,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 {
|
||||
|
@ -557,3 +557,7 @@ callback_url =
|
||||
|
||||
[panels]
|
||||
enable_alpha = false
|
||||
|
||||
[enterprise]
|
||||
license_path =
|
||||
|
||||
|
@ -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 =
|
||||
|
||||
|
@ -60,7 +60,8 @@ Here is a minimal policy example:
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudwatch:ListMetrics",
|
||||
"cloudwatch:GetMetricStatistics"
|
||||
"cloudwatch:GetMetricStatistics",
|
||||
"cloudwatch:GetMetricData"
|
||||
],
|
||||
"Resource": "*"
|
||||
},
|
||||
|
@ -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.
|
||||
|
@ -18,7 +18,7 @@ Grafana v5.3 brings new features, many enhancements and bug fixes. This article
|
||||
- [TV mode]({{< relref "#tv-and-kiosk-mode" >}}) is improved and more accessible
|
||||
- [Alerting]({{< relref "#notification-reminders" >}}) with notification reminders
|
||||
- [Postgres]({{< relref "#postgres-query-builder" >}}) gets a new query builder!
|
||||
- [OAuth]({{< relref "#improved-oauth-support-for-gitlab" >}}) support for Gitlab is improved
|
||||
- [OAuth]({{< relref "#improved-oauth-support-for-gitlab" >}}) support for GitLab is improved
|
||||
- [Annotations]({{< relref "#annotations" >}}) with template variable filtering
|
||||
- [Variables]({{< relref "#variables" >}}) with free text support
|
||||
|
||||
@ -69,9 +69,9 @@ Grafana 5.3 comes with a new graphical query builder for Postgres. This brings P
|
||||
|
||||
{{< docs-imagebox img="/img/docs/v53/postgres_query_still.png" class="docs-image--no-shadow" animated-gif="/img/docs/v53/postgres_query.gif" >}}
|
||||
|
||||
## Improved OAuth Support for Gitlab
|
||||
## Improved OAuth Support for GitLab
|
||||
|
||||
Grafana 5.3 comes with a new OAuth integration for Gitlab that enables configuration to only allow users that are a member of certain Gitlab groups to authenticate. This makes it possible to use Gitlab OAuth with Grafana in a shared environment without giving everyone access to Grafana.
|
||||
Grafana 5.3 comes with a new OAuth integration for GitLab that enables configuration to only allow users that are a member of certain GitLab groups to authenticate. This makes it possible to use GitLab OAuth with Grafana in a shared environment without giving everyone access to Grafana.
|
||||
Learn how to enable and configure it in the [documentation](/auth/gitlab/).
|
||||
|
||||
## Annotations
|
||||
|
@ -290,7 +290,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||
"sendReminder": true,
|
||||
"frequency": "15m",
|
||||
"settings": {
|
||||
"addresses: "carl@grafana.com;dev@grafana.com"
|
||||
"addresses": "carl@grafana.com;dev@grafana.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
@ -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",
|
||||
|
@ -8,3 +8,5 @@ docker build \
|
||||
--tag "${_docker_repo}:${_grafana_tag}"\
|
||||
--no-cache=true \
|
||||
.
|
||||
|
||||
docker push "${_docker_repo}:${_grafana_tag}"
|
||||
|
@ -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)
|
||||
|
||||
|
@ -14,6 +14,7 @@ type IndexViewData struct {
|
||||
NewGrafanaVersionExists bool
|
||||
NewGrafanaVersion string
|
||||
AppName string
|
||||
AppNameBodyClass string
|
||||
}
|
||||
|
||||
type PluginCss struct {
|
||||
|
@ -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 ""
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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()
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
||||
}
|
||||
|
||||
|
@ -18,6 +18,7 @@ import (
|
||||
)
|
||||
|
||||
func TestMiddlewareContext(t *testing.T) {
|
||||
setting.ERR_TEMPLATE_NAME = "error-template"
|
||||
|
||||
Convey("Given the grafana middleware", t, func() {
|
||||
middlewareScenario("middleware should add context to injector", func(sc *scenarioContext) {
|
||||
|
@ -138,7 +138,7 @@ func Recovery() macaron.Handler {
|
||||
|
||||
c.JSON(500, resp)
|
||||
} else {
|
||||
c.HTML(500, "error")
|
||||
c.HTML(500, setting.ERR_TEMPLATE_NAME)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
@ -8,11 +8,14 @@ import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/session"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"gopkg.in/macaron.v1"
|
||||
)
|
||||
|
||||
func TestRecoveryMiddleware(t *testing.T) {
|
||||
setting.ERR_TEMPLATE_NAME = "error-template"
|
||||
|
||||
Convey("Given an api route that panics", t, func() {
|
||||
apiURL := "/api/whatever"
|
||||
recoveryScenario("recovery middleware should return json", apiURL, func(sc *scenarioContext) {
|
||||
@ -50,6 +53,7 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) {
|
||||
sc := &scenarioContext{
|
||||
url: url,
|
||||
}
|
||||
|
||||
viewsPath, _ := filepath.Abs("../../public/views")
|
||||
|
||||
sc.m = macaron.New()
|
||||
|
@ -36,7 +36,7 @@ func (ctx *ReqContext) Handle(status int, title string, err error) {
|
||||
ctx.Data["AppSubUrl"] = setting.AppSubUrl
|
||||
ctx.Data["Theme"] = "dark"
|
||||
|
||||
ctx.HTML(status, "error")
|
||||
ctx.HTML(status, setting.ERR_TEMPLATE_NAME)
|
||||
}
|
||||
|
||||
func (ctx *ReqContext) JsonOK(message string) {
|
||||
|
@ -52,6 +52,24 @@ func TestSimpleReducer(t *testing.T) {
|
||||
So(result, ShouldEqual, float64(1))
|
||||
})
|
||||
|
||||
Convey("median should ignore null values", func() {
|
||||
reducer := NewSimpleReducer("median")
|
||||
series := &tsdb.TimeSeries{
|
||||
Name: "test time serie",
|
||||
}
|
||||
|
||||
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), 1))
|
||||
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), 2))
|
||||
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), 3))
|
||||
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(float64(1)), 4))
|
||||
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(float64(2)), 5))
|
||||
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(float64(3)), 6))
|
||||
|
||||
result := reducer.Reduce(series)
|
||||
So(result.Valid, ShouldEqual, true)
|
||||
So(result.Float64, ShouldEqual, float64(2))
|
||||
})
|
||||
|
||||
Convey("avg", func() {
|
||||
result := testReducer("avg", 1, 2, 3)
|
||||
So(result, ShouldEqual, float64(2))
|
||||
|
@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
captionLengthLimit = 200
|
||||
captionLengthLimit = 1024
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -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")
|
||||
|
@ -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,15 @@ 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 (
|
||||
ERR_TEMPLATE_NAME = "error"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -49,6 +52,7 @@ var (
|
||||
// build
|
||||
BuildVersion string
|
||||
BuildCommit string
|
||||
BuildBranch string
|
||||
BuildStamp int64
|
||||
IsEnterprise bool
|
||||
ApplicationName string
|
||||
@ -209,12 +213,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 +535,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 +717,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
|
||||
}
|
||||
|
||||
|
28
public/app/core/actions/user.ts
Normal file
28
public/app/core/actions/user.ts
Normal 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));
|
||||
};
|
||||
}
|
@ -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],
|
||||
}}
|
||||
>
|
||||
|
@ -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',
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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}>
|
||||
@ -19,4 +20,3 @@ export const Label: SFC<Props> = props => {
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
|
46
public/app/core/components/Picker/SimplePicker.tsx
Normal file
46
public/app/core/components/Picker/SimplePicker.tsx
Normal 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;
|
@ -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 (
|
||||
|
@ -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']);
|
||||
|
@ -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',
|
||||
]);
|
@ -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';
|
||||
|
@ -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 };
|
||||
}
|
||||
|
@ -1,9 +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,
|
||||
};
|
||||
|
15
public/app/core/reducers/user.ts
Normal file
15
public/app/core/reducers/user.ts
Normal 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;
|
||||
};
|
@ -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];
|
||||
|
@ -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;
|
||||
|
@ -166,7 +166,7 @@ export class AlertTabCtrl {
|
||||
|
||||
alert.noDataState = alert.noDataState || config.alertingNoDataOrNullValues;
|
||||
alert.executionErrorState = alert.executionErrorState || config.alertingErrorOrTimeout;
|
||||
alert.frequency = alert.frequency || '60s';
|
||||
alert.frequency = alert.frequency || '1m';
|
||||
alert.handler = alert.handler || 1;
|
||||
alert.notifications = alert.notifications || [];
|
||||
|
||||
@ -217,7 +217,7 @@ export class AlertTabCtrl {
|
||||
buildDefaultCondition() {
|
||||
return {
|
||||
type: 'query',
|
||||
query: { params: ['A', '5m', 'now'] },
|
||||
query: { params: ['A', '15m', 'now'] },
|
||||
reducer: { type: 'avg', params: [] },
|
||||
evaluator: { type: 'gt', params: [null] },
|
||||
operator: { type: 'and' },
|
||||
|
@ -9,3 +9,6 @@ import './admin';
|
||||
import './alerting/NotificationsEditCtrl';
|
||||
import './alerting/NotificationsListCtrl';
|
||||
import './manage-dashboards';
|
||||
import './teams/CreateTeamCtrl';
|
||||
import './profile/ProfileCtrl';
|
||||
import './profile/ChangePasswordCtrl';
|
||||
|
@ -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,
|
||||
});
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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>
|
||||
`;
|
||||
|
@ -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');
|
||||
|
||||
|
@ -133,6 +133,7 @@ export class PanelModel {
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.events.emit('panel-teardown');
|
||||
this.events.removeAllListeners();
|
||||
}
|
||||
}
|
||||
|
@ -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>;
|
||||
|
||||
|
@ -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 () => {
|
||||
|
@ -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 {
|
||||
|
34
public/app/features/explore/ErrorBoundary.tsx
Normal file
34
public/app/features/explore/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
@ -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) {
|
||||
@ -471,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),
|
||||
};
|
||||
@ -486,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,
|
||||
@ -584,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)) {
|
||||
@ -637,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);
|
||||
@ -669,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);
|
||||
@ -697,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);
|
||||
@ -724,6 +734,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
render() {
|
||||
const { position, split } = this.props;
|
||||
const {
|
||||
StartPage,
|
||||
datasource,
|
||||
datasourceError,
|
||||
datasourceLoading,
|
||||
@ -751,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}>
|
||||
@ -846,45 +860,53 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
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}
|
||||
|
@ -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 && (
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -27,7 +27,7 @@ function hasSuggestions(suggestions: CompletionItemGroup[]): boolean {
|
||||
return suggestions && suggestions.length > 0;
|
||||
}
|
||||
|
||||
interface TypeaheadFieldProps {
|
||||
interface QueryFieldProps {
|
||||
additionalPlugins?: any[];
|
||||
cleanText?: (text: string) => string;
|
||||
initialValue: string | null;
|
||||
@ -35,14 +35,14 @@ 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 {
|
||||
export interface QueryFieldState {
|
||||
suggestions: CompletionItemGroup[];
|
||||
typeaheadContext: string | null;
|
||||
typeaheadIndex: number;
|
||||
@ -60,7 +60,7 @@ export interface TypeaheadInput {
|
||||
wrapperNode: Element;
|
||||
}
|
||||
|
||||
class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadFieldState> {
|
||||
export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldState> {
|
||||
menuEl: HTMLElement | null;
|
||||
placeholdersBuffer: PlaceholdersBuffer;
|
||||
plugins: any[];
|
||||
@ -72,7 +72,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
|
||||
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: [],
|
||||
@ -102,7 +102,7 @@ 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
|
||||
const change = this.state.value
|
||||
@ -434,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>
|
||||
);
|
||||
}
|
||||
|
@ -2,9 +2,9 @@ import React, { PureComponent } from 'react';
|
||||
|
||||
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[]): QueryHint {
|
||||
const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
|
||||
@ -24,10 +24,8 @@ interface QueryRowEventHandlers {
|
||||
|
||||
interface QueryRowCommonProps {
|
||||
className?: string;
|
||||
datasource: any;
|
||||
datasource: DataSource;
|
||||
history: HistoryItem[];
|
||||
// Temporarily
|
||||
supportsLogs?: boolean;
|
||||
transactions: QueryTransaction[];
|
||||
}
|
||||
|
||||
@ -78,14 +76,15 @@ class QueryRow extends PureComponent<QueryRowProps> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { datasource, history, query, supportsLogs, transactions } = this.props;
|
||||
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
|
||||
@ -97,7 +96,6 @@ class QueryRow extends PureComponent<QueryRowProps> {
|
||||
onClickHintFix={this.onClickHintFix}
|
||||
onPressEnter={this.onPressEnter}
|
||||
onQueryChange={this.onChangeQuery}
|
||||
supportsLogs={supportsLogs}
|
||||
/>
|
||||
</div>
|
||||
<div className="query-row-tools">
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -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();
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
);
|
||||
|
45
public/app/features/org/OrgDetailsPage.test.tsx
Normal file
45
public/app/features/org/OrgDetailsPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
85
public/app/features/org/OrgDetailsPage.tsx
Normal file
85
public/app/features/org/OrgDetailsPage.tsx
Normal 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));
|
28
public/app/features/org/OrgPreferences.test.tsx
Normal file
28
public/app/features/org/OrgPreferences.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
113
public/app/features/org/OrgPreferences.tsx
Normal file
113
public/app/features/org/OrgPreferences.tsx
Normal 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);
|
21
public/app/features/org/OrgProfile.test.tsx
Normal file
21
public/app/features/org/OrgProfile.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
44
public/app/features/org/OrgProfile.tsx
Normal file
44
public/app/features/org/OrgProfile.tsx
Normal 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;
|
@ -0,0 +1,36 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<div>
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
/>
|
||||
<div
|
||||
className="page-container page-body"
|
||||
>
|
||||
<PageLoader
|
||||
pageName="Organization"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Render should render organization and preferences 1`] = `
|
||||
<div>
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
/>
|
||||
<div
|
||||
className="page-container page-body"
|
||||
>
|
||||
<div>
|
||||
<OrgProfile
|
||||
onOrgNameChange={[Function]}
|
||||
onSubmit={[Function]}
|
||||
orgName="Cool org"
|
||||
/>
|
||||
<Connect(OrgPreferences) />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
@ -0,0 +1,136 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<form
|
||||
className="section gf-form-group"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<h3
|
||||
className="page-heading"
|
||||
>
|
||||
Preferences
|
||||
</h3>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<span
|
||||
className="gf-form-label width-11"
|
||||
>
|
||||
UI Theme
|
||||
</span>
|
||||
<SimplePicker
|
||||
getOptionLabel={[Function]}
|
||||
getOptionValue={[Function]}
|
||||
onSelected={[Function]}
|
||||
options={
|
||||
Array [
|
||||
Object {
|
||||
"text": "Default",
|
||||
"value": "",
|
||||
},
|
||||
Object {
|
||||
"text": "Dark",
|
||||
"value": "dark",
|
||||
},
|
||||
Object {
|
||||
"text": "Light",
|
||||
"value": "light",
|
||||
},
|
||||
]
|
||||
}
|
||||
width={20}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<Component
|
||||
tooltip="Not finding dashboard you want? Star it first, then it should appear in this select box."
|
||||
width={11}
|
||||
>
|
||||
Home Dashboard
|
||||
</Component>
|
||||
<SimplePicker
|
||||
defaultValue={
|
||||
Object {
|
||||
"id": 1,
|
||||
"tags": Array [],
|
||||
"title": "Standard dashboard",
|
||||
"type": "",
|
||||
"uid": "",
|
||||
"uri": "",
|
||||
"url": "",
|
||||
}
|
||||
}
|
||||
getOptionLabel={[Function]}
|
||||
getOptionValue={[Function]}
|
||||
onSelected={[Function]}
|
||||
options={
|
||||
Array [
|
||||
Object {
|
||||
"id": 0,
|
||||
"tags": Array [],
|
||||
"title": "Default",
|
||||
"type": "",
|
||||
"uid": "",
|
||||
"uri": "",
|
||||
"url": "",
|
||||
},
|
||||
Object {
|
||||
"id": 1,
|
||||
"tags": Array [],
|
||||
"title": "Standard dashboard",
|
||||
"type": "",
|
||||
"uid": "",
|
||||
"uri": "",
|
||||
"url": "",
|
||||
},
|
||||
]
|
||||
}
|
||||
placeholder="Chose default dashboard"
|
||||
width={20}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<label
|
||||
className="gf-form-label width-11"
|
||||
>
|
||||
Timezone
|
||||
</label>
|
||||
<SimplePicker
|
||||
getOptionLabel={[Function]}
|
||||
getOptionValue={[Function]}
|
||||
onSelected={[Function]}
|
||||
options={
|
||||
Array [
|
||||
Object {
|
||||
"text": "Default",
|
||||
"value": "",
|
||||
},
|
||||
Object {
|
||||
"text": "Local browser time",
|
||||
"value": "browser",
|
||||
},
|
||||
Object {
|
||||
"text": "UTC",
|
||||
"value": "utc",
|
||||
},
|
||||
]
|
||||
}
|
||||
width={20}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form-button-row"
|
||||
>
|
||||
<button
|
||||
className="btn btn-success"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
@ -0,0 +1,46 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<div>
|
||||
<h3
|
||||
className="page-sub-heading"
|
||||
>
|
||||
Organization profile
|
||||
</h3>
|
||||
<form
|
||||
className="gf-form-group"
|
||||
name="orgForm"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<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"
|
||||
onChange={[Function]}
|
||||
type="text"
|
||||
value="Main org"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form-button-row"
|
||||
>
|
||||
<button
|
||||
className="btn btn-success"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
@ -1,8 +1,3 @@
|
||||
import './profile_ctrl';
|
||||
import './select_org_ctrl';
|
||||
import './change_password_ctrl';
|
||||
import './new_org_ctrl';
|
||||
import './user_invite_ctrl';
|
||||
import './create_team_ctrl';
|
||||
import './org_details_ctrl';
|
||||
import './prefs_control';
|
||||
import './SelectOrgCtrl';
|
||||
import './NewOrgCtrl';
|
||||
import './UserInviteCtrl';
|
||||
|
@ -1,38 +0,0 @@
|
||||
import angular from 'angular';
|
||||
|
||||
export class OrgDetailsCtrl {
|
||||
/** @ngInject */
|
||||
constructor($scope, $http, backendSrv, contextSrv, navModelSrv) {
|
||||
$scope.init = () => {
|
||||
$scope.getOrgInfo();
|
||||
$scope.navModel = navModelSrv.getNav('cfg', 'org-settings', 0);
|
||||
};
|
||||
|
||||
$scope.getOrgInfo = () => {
|
||||
backendSrv.get('/api/org').then(org => {
|
||||
$scope.org = org;
|
||||
$scope.address = org.address;
|
||||
contextSrv.user.orgName = org.name;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.update = () => {
|
||||
if (!$scope.orgForm.$valid) {
|
||||
return;
|
||||
}
|
||||
const data = { name: $scope.org.name };
|
||||
backendSrv.put('/api/org', data).then($scope.getOrgInfo);
|
||||
};
|
||||
|
||||
$scope.updateAddress = () => {
|
||||
if (!$scope.addressForm.$valid) {
|
||||
return;
|
||||
}
|
||||
backendSrv.put('/api/org/address', $scope.address).then($scope.getOrgInfo);
|
||||
};
|
||||
|
||||
$scope.init();
|
||||
}
|
||||
}
|
||||
|
||||
angular.module('grafana.controllers').controller('OrgDetailsCtrl', OrgDetailsCtrl);
|
@ -1,21 +0,0 @@
|
||||
<page-header model="navModel"></page-header>
|
||||
|
||||
<div class="page-container page-body">
|
||||
<h3 class="page-sub-heading">Organization profile</h3>
|
||||
|
||||
<form name="orgForm" class="gf-form-group">
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form max-width-28">
|
||||
<span class="gf-form-label">Organization name</span>
|
||||
<input class="gf-form-input" type="text" required ng-model="org.name">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-button-row">
|
||||
<button type="submit" class="btn btn-success" ng-click="update()">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
<prefs-control mode="org"></prefs-control>
|
||||
</div>
|
||||
|
||||
|
@ -1,92 +0,0 @@
|
||||
import config from 'app/core/config';
|
||||
import coreModule from 'app/core/core_module';
|
||||
|
||||
export class PrefsControlCtrl {
|
||||
prefs: any;
|
||||
oldTheme: any;
|
||||
prefsForm: any;
|
||||
mode: string;
|
||||
|
||||
timezones: any = [
|
||||
{ value: '', text: 'Default' },
|
||||
{ value: 'browser', text: 'Local browser time' },
|
||||
{ value: 'utc', text: 'UTC' },
|
||||
];
|
||||
themes: any = [{ value: '', text: 'Default' }, { value: 'dark', text: 'Dark' }, { value: 'light', text: 'Light' }];
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private backendSrv, private $location) {}
|
||||
|
||||
$onInit() {
|
||||
return this.backendSrv.get(`/api/${this.mode}/preferences`).then(prefs => {
|
||||
this.prefs = prefs;
|
||||
this.oldTheme = prefs.theme;
|
||||
});
|
||||
}
|
||||
|
||||
updatePrefs() {
|
||||
if (!this.prefsForm.$valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cmd = {
|
||||
theme: this.prefs.theme,
|
||||
timezone: this.prefs.timezone,
|
||||
homeDashboardId: this.prefs.homeDashboardId,
|
||||
};
|
||||
|
||||
this.backendSrv.put(`/api/${this.mode}/preferences`, cmd).then(() => {
|
||||
window.location.href = config.appSubUrl + this.$location.path();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const template = `
|
||||
<form name="ctrl.prefsForm" class="section gf-form-group">
|
||||
<h3 class="page-heading">Preferences</h3>
|
||||
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-11">UI Theme</span>
|
||||
<div class="gf-form-select-wrapper max-width-20">
|
||||
<select class="gf-form-input" ng-model="ctrl.prefs.theme" ng-options="f.value as f.text for f in ctrl.themes"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-11">
|
||||
Home Dashboard
|
||||
<info-popover mode="right-normal">
|
||||
Not finding dashboard you want? Star it first, then it should appear in this select box.
|
||||
</info-popover>
|
||||
</span>
|
||||
<dashboard-selector class="gf-form-select-wrapper max-width-20" model="ctrl.prefs.homeDashboardId">
|
||||
</dashboard-selector>
|
||||
</div>
|
||||
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-11">Timezone</label>
|
||||
<div class="gf-form-select-wrapper max-width-20">
|
||||
<select class="gf-form-input" ng-model="ctrl.prefs.timezone" ng-options="f.value as f.text for f in ctrl.timezones"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-button-row">
|
||||
<button type="submit" class="btn btn-success" ng-click="ctrl.updatePrefs()">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
export function prefsControlDirective() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
controller: PrefsControlCtrl,
|
||||
bindToController: true,
|
||||
controllerAs: 'ctrl',
|
||||
template: template,
|
||||
scope: {
|
||||
mode: '@',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
coreModule.directive('prefsControl', prefsControlDirective);
|
118
public/app/features/org/state/actions.ts
Normal file
118
public/app/features/org/state/actions.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { ThunkAction } from 'redux-thunk';
|
||||
import { Organization, OrganizationPreferences, StoreState } from 'app/types';
|
||||
import { getBackendSrv } from '../../../core/services/backend_srv';
|
||||
|
||||
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, any>;
|
||||
|
||||
export enum ActionTypes {
|
||||
LoadOrganization = 'LOAD_ORGANISATION',
|
||||
LoadPreferences = 'LOAD_PREFERENCES',
|
||||
SetOrganizationName = 'SET_ORGANIZATION_NAME',
|
||||
SetOrganizationTheme = 'SET_ORGANIZATION_THEME',
|
||||
SetOrganizationHomeDashboard = 'SET_ORGANIZATION_HOME_DASHBOARD',
|
||||
SetOrganizationTimezone = 'SET_ORGANIZATION_TIMEZONE',
|
||||
}
|
||||
|
||||
interface LoadOrganizationAction {
|
||||
type: ActionTypes.LoadOrganization;
|
||||
payload: Organization;
|
||||
}
|
||||
|
||||
interface LoadPreferencesAction {
|
||||
type: ActionTypes.LoadPreferences;
|
||||
payload: OrganizationPreferences;
|
||||
}
|
||||
|
||||
interface SetOrganizationNameAction {
|
||||
type: ActionTypes.SetOrganizationName;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
interface SetOrganizationThemeAction {
|
||||
type: ActionTypes.SetOrganizationTheme;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
interface SetOrganizationHomeDashboardAction {
|
||||
type: ActionTypes.SetOrganizationHomeDashboard;
|
||||
payload: number;
|
||||
}
|
||||
|
||||
interface SetOrganizationTimezoneAction {
|
||||
type: ActionTypes.SetOrganizationTimezone;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
const organisationLoaded = (organisation: Organization) => ({
|
||||
type: ActionTypes.LoadOrganization,
|
||||
payload: organisation,
|
||||
});
|
||||
|
||||
const preferencesLoaded = (preferences: OrganizationPreferences) => ({
|
||||
type: ActionTypes.LoadPreferences,
|
||||
payload: preferences,
|
||||
});
|
||||
|
||||
export const setOrganizationName = (orgName: string) => ({
|
||||
type: ActionTypes.SetOrganizationName,
|
||||
payload: orgName,
|
||||
});
|
||||
|
||||
export const setOrganizationTheme = (theme: string) => ({
|
||||
type: ActionTypes.SetOrganizationTheme,
|
||||
payload: theme,
|
||||
});
|
||||
|
||||
export const setOrganizationHomeDashboard = (id: number) => ({
|
||||
type: ActionTypes.SetOrganizationHomeDashboard,
|
||||
payload: id,
|
||||
});
|
||||
|
||||
export const setOrganizationTimezone = (timezone: string) => ({
|
||||
type: ActionTypes.SetOrganizationTimezone,
|
||||
payload: timezone,
|
||||
});
|
||||
|
||||
export type Action =
|
||||
| LoadOrganizationAction
|
||||
| LoadPreferencesAction
|
||||
| SetOrganizationNameAction
|
||||
| SetOrganizationThemeAction
|
||||
| SetOrganizationHomeDashboardAction
|
||||
| SetOrganizationTimezoneAction;
|
||||
|
||||
export function loadOrganization(): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
const organisationResponse = await getBackendSrv().get('/api/org');
|
||||
dispatch(organisationLoaded(organisationResponse));
|
||||
|
||||
return organisationResponse;
|
||||
};
|
||||
}
|
||||
|
||||
export function loadOrganizationPreferences(): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
const preferencesResponse = await getBackendSrv().get('/api/org/preferences');
|
||||
dispatch(preferencesLoaded(preferencesResponse));
|
||||
};
|
||||
}
|
||||
|
||||
export function updateOrganization() {
|
||||
return async (dispatch, getStore) => {
|
||||
const organization = getStore().organization.organization;
|
||||
|
||||
await getBackendSrv().put('/api/org', { name: organization.name });
|
||||
|
||||
dispatch(loadOrganization());
|
||||
};
|
||||
}
|
||||
|
||||
export function updateOrganizationPreferences() {
|
||||
return async (dispatch, getStore) => {
|
||||
const preferences = getStore().organization.preferences;
|
||||
|
||||
await getBackendSrv().put('/api/org/preferences', preferences);
|
||||
|
||||
window.location.reload();
|
||||
};
|
||||
}
|
35
public/app/features/org/state/reducers.ts
Normal file
35
public/app/features/org/state/reducers.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { Organization, OrganizationPreferences, OrganizationState } from 'app/types';
|
||||
import { Action, ActionTypes } from './actions';
|
||||
|
||||
const initialState: OrganizationState = {
|
||||
organization: {} as Organization,
|
||||
preferences: {} as OrganizationPreferences,
|
||||
};
|
||||
|
||||
const organizationReducer = (state = initialState, action: Action): OrganizationState => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.LoadOrganization:
|
||||
return { ...state, organization: action.payload };
|
||||
|
||||
case ActionTypes.LoadPreferences:
|
||||
return { ...state, preferences: action.payload };
|
||||
|
||||
case ActionTypes.SetOrganizationName:
|
||||
return { ...state, organization: { ...state.organization, name: action.payload } };
|
||||
|
||||
case ActionTypes.SetOrganizationTheme:
|
||||
return { ...state, preferences: { ...state.preferences, theme: action.payload } };
|
||||
|
||||
case ActionTypes.SetOrganizationHomeDashboard:
|
||||
return { ...state, preferences: { ...state.preferences, homeDashboardId: action.payload } };
|
||||
|
||||
case ActionTypes.SetOrganizationTimezone:
|
||||
return { ...state, preferences: { ...state.preferences, timezone: action.payload } };
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export default {
|
||||
organization: organizationReducer,
|
||||
};
|
@ -48,11 +48,6 @@ export class PanelCtrl {
|
||||
}
|
||||
|
||||
$scope.$on('component-did-mount', () => this.panelDidMount());
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
this.events.emit('panel-teardown');
|
||||
this.events.removeAllListeners();
|
||||
});
|
||||
}
|
||||
|
||||
panelDidMount() {
|
||||
|
@ -8,9 +8,10 @@ import { importPluginModule } from './plugin_loader';
|
||||
|
||||
// Types
|
||||
import { DataSourceApi } from 'app/types/series';
|
||||
import { DataSource } from 'app/types';
|
||||
|
||||
export class DatasourceSrv {
|
||||
datasources: any;
|
||||
datasources: { [name: string]: DataSource };
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private $q, private $injector, private $rootScope, private templateSrv) {
|
||||
@ -61,9 +62,10 @@ export class DatasourceSrv {
|
||||
throw new Error('Plugin module is missing Datasource constructor');
|
||||
}
|
||||
|
||||
const instance = this.$injector.instantiate(plugin.Datasource, { instanceSettings: dsConfig });
|
||||
const instance: DataSource = this.$injector.instantiate(plugin.Datasource, { instanceSettings: dsConfig });
|
||||
instance.meta = pluginDef;
|
||||
instance.name = name;
|
||||
instance.pluginExports = plugin;
|
||||
this.datasources[name] = instance;
|
||||
deferred.resolve(instance);
|
||||
})
|
||||
|
@ -74,7 +74,7 @@ export class TeamMembers extends PureComponent<Props, State> {
|
||||
</td>
|
||||
<td>{member.login}</td>
|
||||
<td>{member.email}</td>
|
||||
{syncEnabled ? this.renderLabels(member.labels) : ''}
|
||||
{syncEnabled ? this.renderLabels(member.labels) : null}
|
||||
<td className="text-right">
|
||||
<DeleteButton onConfirmDelete={() => this.onRemoveMember(member)} />
|
||||
</td>
|
||||
|
@ -2,7 +2,7 @@
|
||||
<div class="login-content">
|
||||
<div class="login-branding">
|
||||
<img class="logo-icon" src="public/img/grafana_icon.svg" alt="Grafana" />
|
||||
<i class="icon-gf icon-gf-grafana_wordmark"></i>
|
||||
<div class="logo-wordmark" />
|
||||
</div>
|
||||
<div class="login-outer-box">
|
||||
<div class="login-inner-box" id="login-view">
|
||||
|
@ -19,7 +19,7 @@
|
||||
</div>
|
||||
</form>
|
||||
<div ng-show="mode === 'email-sent'">
|
||||
An email with a reset link as been sent to the email address. <br>
|
||||
An email with a reset link has been sent to the email address. <br>
|
||||
You should receive it shortly.
|
||||
<div class="p-t-1">
|
||||
<a href="login" class="btn btn-success p-t-1">
|
||||
|
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
const CHEAT_SHEET_ITEMS = [
|
||||
{
|
||||
title: 'Logs From a Job',
|
||||
expression: '{job="default/prometheus"}',
|
||||
label: 'Returns all log lines emitted by instances of this job.',
|
||||
},
|
||||
{
|
||||
title: 'Search For Text',
|
||||
expression: '{app="cassandra"} Maximum memory usage',
|
||||
label: 'Returns all log lines for the selector and highlights the given text in the results.',
|
||||
},
|
||||
];
|
||||
|
||||
export default (props: any) => (
|
||||
<div>
|
||||
<h1>Logging Cheat Sheet</h1>
|
||||
{CHEAT_SHEET_ITEMS.map(item => (
|
||||
<div className="cheat-sheet-item" key={item.expression}>
|
||||
<div className="cheat-sheet-item__title">{item.title}</div>
|
||||
<div className="cheat-sheet-item__expression" onClick={e => props.onClickQuery(item.expression)}>
|
||||
<code>{item.expression}</code>
|
||||
</div>
|
||||
<div className="cheat-sheet-item__label">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
@ -0,0 +1,227 @@
|
||||
import _ from 'lodash';
|
||||
import React from 'react';
|
||||
import Cascader from 'rc-cascader';
|
||||
import PluginPrism from 'slate-prism';
|
||||
import Prism from 'prismjs';
|
||||
|
||||
import { TypeaheadOutput } from 'app/types/explore';
|
||||
|
||||
// dom also includes Element polyfills
|
||||
import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom';
|
||||
import BracesPlugin from 'app/features/explore/slate-plugins/braces';
|
||||
import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
|
||||
import TypeaheadField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField';
|
||||
|
||||
const PRISM_SYNTAX = 'promql';
|
||||
|
||||
export function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: QueryFieldState): 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 LoggingQueryFieldProps {
|
||||
datasource: any;
|
||||
error?: string | JSX.Element;
|
||||
hint?: any;
|
||||
history?: any[];
|
||||
initialQuery?: string | null;
|
||||
onClickHintFix?: (action: any) => void;
|
||||
onPressEnter?: () => void;
|
||||
onQueryChange?: (value: string, override?: boolean) => void;
|
||||
}
|
||||
|
||||
interface LoggingQueryFieldState {
|
||||
logLabelOptions: any[];
|
||||
syntaxLoaded: boolean;
|
||||
}
|
||||
|
||||
class LoggingQueryField extends React.PureComponent<LoggingQueryFieldProps, LoggingQueryFieldState> {
|
||||
plugins: any[];
|
||||
languageProvider: any;
|
||||
|
||||
constructor(props: LoggingQueryFieldProps, context) {
|
||||
super(props, context);
|
||||
|
||||
if (props.datasource.languageProvider) {
|
||||
this.languageProvider = props.datasource.languageProvider;
|
||||
}
|
||||
|
||||
this.plugins = [
|
||||
BracesPlugin(),
|
||||
RunnerPlugin({ handler: props.onPressEnter }),
|
||||
PluginPrism({
|
||||
onlyIn: node => node.type === 'code_block',
|
||||
getSyntax: node => 'promql',
|
||||
}),
|
||||
];
|
||||
|
||||
this.state = {
|
||||
logLabelOptions: [],
|
||||
syntaxLoaded: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.languageProvider) {
|
||||
this.languageProvider
|
||||
.start()
|
||||
.then(remaining => {
|
||||
remaining.map(task => task.then(this.onReceiveMetrics).catch(() => {}));
|
||||
})
|
||||
.then(() => this.onReceiveMetrics());
|
||||
}
|
||||
}
|
||||
|
||||
loadOptions = (selectedOptions: CascaderOption[]) => {
|
||||
const targetOption = selectedOptions[selectedOptions.length - 1];
|
||||
|
||||
this.setState(state => {
|
||||
const nextOptions = state.logLabelOptions.map(option => {
|
||||
if (option.value === targetOption.value) {
|
||||
return {
|
||||
...option,
|
||||
loading: true,
|
||||
};
|
||||
}
|
||||
return option;
|
||||
});
|
||||
return { logLabelOptions: nextOptions };
|
||||
});
|
||||
|
||||
this.languageProvider
|
||||
.fetchLabelValues(targetOption.value)
|
||||
.then(this.onReceiveMetrics)
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
onChangeLogLabels = (values: string[], selectedOptions: CascaderOption[]) => {
|
||||
if (selectedOptions.length === 2) {
|
||||
const key = selectedOptions[0].value;
|
||||
const value = selectedOptions[1].value;
|
||||
const query = `{${key}="${value}"}`;
|
||||
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 = () => {
|
||||
Prism.languages[PRISM_SYNTAX] = this.languageProvider.getSyntax();
|
||||
const { logLabelOptions } = this.languageProvider;
|
||||
this.setState({
|
||||
logLabelOptions,
|
||||
syntaxLoaded: true,
|
||||
});
|
||||
};
|
||||
|
||||
onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
|
||||
if (!this.languageProvider) {
|
||||
return { suggestions: [] };
|
||||
}
|
||||
|
||||
const { history } = this.props;
|
||||
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.languageProvider.provideCompletionItems(
|
||||
{ text, value, prefix, wrapperClasses, labelKey },
|
||||
{ history }
|
||||
);
|
||||
|
||||
console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { error, hint, initialQuery } = this.props;
|
||||
const { logLabelOptions, syntaxLoaded } = this.state;
|
||||
const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined;
|
||||
const chooserText = syntaxLoaded ? 'Log labels' : 'Loading labels...';
|
||||
|
||||
return (
|
||||
<div className="prom-query-field">
|
||||
<div className="prom-query-field-tools">
|
||||
<Cascader options={logLabelOptions} onChange={this.onChangeLogLabels} loadData={this.loadOptions}>
|
||||
<button className="btn navbar-button navbar-button--tight" disabled={!syntaxLoaded}>
|
||||
{chooserText}
|
||||
</button>
|
||||
</Cascader>
|
||||
</div>
|
||||
<div className="prom-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}
|
||||
/>
|
||||
{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 LoggingQueryField;
|
@ -0,0 +1,60 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import LoggingCheatSheet from './LoggingCheatSheet';
|
||||
|
||||
const TAB_MENU_ITEMS = [
|
||||
{
|
||||
text: 'Start',
|
||||
id: 'start',
|
||||
icon: 'fa fa-rocket',
|
||||
},
|
||||
];
|
||||
|
||||
export default class LoggingStartPage extends PureComponent<any, { active: string }> {
|
||||
state = {
|
||||
active: 'start',
|
||||
};
|
||||
|
||||
onClickTab = active => {
|
||||
this.setState({ active });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { active } = this.state;
|
||||
const customCss = '';
|
||||
|
||||
return (
|
||||
<div style={{ margin: '45px 0', border: '1px solid #ddd', borderRadius: 5 }}>
|
||||
<div className="page-header-canvas">
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<nav>
|
||||
<ul className={`gf-tabs ${customCss}`}>
|
||||
{TAB_MENU_ITEMS.map((tab, idx) => {
|
||||
const tabClasses = classNames({
|
||||
'gf-tabs-link': true,
|
||||
active: tab.id === active,
|
||||
});
|
||||
|
||||
return (
|
||||
<li className="gf-tabs-item" key={tab.id}>
|
||||
<a className={tabClasses} onClick={() => this.onClickTab(tab.id)}>
|
||||
<i className={tab.icon} />
|
||||
{tab.text}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-container page-body">
|
||||
{active === 'start' && <LoggingCheatSheet onClickQuery={this.props.onClickQuery} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@ import _ from 'lodash';
|
||||
|
||||
import * as dateMath from 'app/core/utils/datemath';
|
||||
|
||||
import LanguageProvider from './language_provider';
|
||||
import { processStreams } from './result_transformer';
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
@ -48,8 +49,12 @@ function serializeParams(data: any) {
|
||||
}
|
||||
|
||||
export default class LoggingDatasource {
|
||||
languageProvider: LanguageProvider;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private instanceSettings, private backendSrv, private templateSrv) {}
|
||||
constructor(private instanceSettings, private backendSrv, private templateSrv) {
|
||||
this.languageProvider = new LanguageProvider(this);
|
||||
}
|
||||
|
||||
_request(apiUrl: string, data?, options?: any) {
|
||||
const baseUrl = this.instanceSettings.url;
|
||||
|
215
public/app/plugins/datasource/logging/language_provider.ts
Normal file
215
public/app/plugins/datasource/logging/language_provider.ts
Normal file
@ -0,0 +1,215 @@
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
|
||||
import {
|
||||
CompletionItem,
|
||||
CompletionItemGroup,
|
||||
LanguageProvider,
|
||||
TypeaheadInput,
|
||||
TypeaheadOutput,
|
||||
} from 'app/types/explore';
|
||||
|
||||
import { parseSelector } from 'app/plugins/datasource/prometheus/language_utils';
|
||||
import PromqlSyntax from 'app/plugins/datasource/prometheus/promql';
|
||||
|
||||
const DEFAULT_KEYS = ['job', 'namespace'];
|
||||
const EMPTY_SELECTOR = '{}';
|
||||
const HISTORY_ITEM_COUNT = 10;
|
||||
const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
|
||||
|
||||
const wrapLabel = (label: string) => ({ label });
|
||||
|
||||
export function addHistoryMetadata(item: CompletionItem, history: any[]): CompletionItem {
|
||||
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 default class LoggingLanguageProvider extends LanguageProvider {
|
||||
labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
|
||||
labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
|
||||
logLabelOptions: any[];
|
||||
started: boolean;
|
||||
|
||||
constructor(datasource: any, initialValues?: any) {
|
||||
super();
|
||||
|
||||
this.datasource = datasource;
|
||||
this.labelKeys = {};
|
||||
this.labelValues = {};
|
||||
this.started = false;
|
||||
|
||||
Object.assign(this, initialValues);
|
||||
}
|
||||
// Strip syntax chars
|
||||
cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
|
||||
|
||||
getSyntax() {
|
||||
return PromqlSyntax;
|
||||
}
|
||||
|
||||
request = url => {
|
||||
return this.datasource.metadataRequest(url);
|
||||
};
|
||||
|
||||
start = () => {
|
||||
if (!this.started) {
|
||||
this.started = true;
|
||||
return this.fetchLogLabels();
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
};
|
||||
|
||||
// Keep this DOM-free for testing
|
||||
provideCompletionItems({ prefix, wrapperClasses, text }: TypeaheadInput, context?: any): 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-labels')) {
|
||||
// Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
|
||||
return this.getLabelCompletionItems.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.getEmptyCompletionItems(context || {});
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: [],
|
||||
};
|
||||
}
|
||||
|
||||
getEmptyCompletionItems(context: any): TypeaheadOutput {
|
||||
const { history } = context;
|
||||
const suggestions: CompletionItemGroup[] = [];
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
getLabelCompletionItems({ text, wrapperClasses, labelKey, value }: TypeaheadInput): TypeaheadOutput {
|
||||
let context: string;
|
||||
let refresher: Promise<any> = null;
|
||||
const suggestions: CompletionItemGroup[] = [];
|
||||
const line = value.anchorBlock.getText();
|
||||
const cursorOffset: number = value.anchorOffset;
|
||||
|
||||
// Use EMPTY_SELECTOR until series API is implemented for facetting
|
||||
const selector = EMPTY_SELECTOR;
|
||||
let parsedSelector;
|
||||
try {
|
||||
parsedSelector = parseSelector(line, cursorOffset);
|
||||
} catch {}
|
||||
const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
|
||||
|
||||
if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) {
|
||||
// Label values
|
||||
if (labelKey && this.labelValues[selector]) {
|
||||
const labelValues = this.labelValues[selector][labelKey];
|
||||
if (labelValues) {
|
||||
context = 'context-label-values';
|
||||
suggestions.push({
|
||||
label: `Label values for "${labelKey}"`,
|
||||
items: labelValues.map(wrapLabel),
|
||||
});
|
||||
} else {
|
||||
refresher = this.fetchLabelValues(labelKey);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Label keys
|
||||
const labelKeys = this.labelKeys[selector] || DEFAULT_KEYS;
|
||||
if (labelKeys) {
|
||||
const possibleKeys = _.difference(labelKeys, existingKeys);
|
||||
if (possibleKeys.length > 0) {
|
||||
context = 'context-labels';
|
||||
suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { context, refresher, suggestions };
|
||||
}
|
||||
|
||||
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();
|
||||
this.labelKeys = {
|
||||
...this.labelKeys,
|
||||
[EMPTY_SELECTOR]: labelKeys,
|
||||
};
|
||||
this.logLabelOptions = labelKeys.map(key => ({ label: key, value: key, isLeaf: false }));
|
||||
|
||||
// Pre-load values for default labels
|
||||
return labelKeys.filter(key => DEFAULT_KEYS.indexOf(key) > -1).map(key => this.fetchLabelValues(key));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async fetchLabelValues(key: string) {
|
||||
const url = `/api/prom/label/${key}/values`;
|
||||
try {
|
||||
const res = await this.request(url);
|
||||
const body = await (res.data || res.json());
|
||||
const values = body.data.slice().sort();
|
||||
|
||||
// Add to label options
|
||||
this.logLabelOptions = this.logLabelOptions.map(keyOption => {
|
||||
if (keyOption.value === key) {
|
||||
return {
|
||||
...keyOption,
|
||||
children: values.map(value => ({ label: value, value })),
|
||||
};
|
||||
}
|
||||
return keyOption;
|
||||
});
|
||||
|
||||
// Add to key map
|
||||
const exisingValues = this.labelValues[EMPTY_SELECTOR];
|
||||
const nextValues = {
|
||||
...exisingValues,
|
||||
[key]: values,
|
||||
};
|
||||
this.labelValues = {
|
||||
...this.labelValues,
|
||||
[EMPTY_SELECTOR]: nextValues,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,7 +1,15 @@
|
||||
import Datasource from './datasource';
|
||||
|
||||
import LoggingStartPage from './components/LoggingStartPage';
|
||||
import LoggingQueryField from './components/LoggingQueryField';
|
||||
|
||||
export class LoggingConfigCtrl {
|
||||
static templateUrl = 'partials/config.html';
|
||||
}
|
||||
|
||||
export { Datasource, LoggingConfigCtrl as ConfigCtrl };
|
||||
export {
|
||||
Datasource,
|
||||
LoggingConfigCtrl as ConfigCtrl,
|
||||
LoggingQueryField as ExploreQueryField,
|
||||
LoggingStartPage as ExploreStartPage,
|
||||
};
|
||||
|
@ -1,24 +1,27 @@
|
||||
import _ from 'lodash';
|
||||
import ResponseParser from './response_parser';
|
||||
import MysqlQuery from 'app/plugins/datasource/mysql/mysql_query';
|
||||
|
||||
export class MysqlDatasource {
|
||||
id: any;
|
||||
name: any;
|
||||
responseParser: ResponseParser;
|
||||
queryModel: MysqlQuery;
|
||||
interval: string;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(instanceSettings, private backendSrv, private $q, private templateSrv) {
|
||||
constructor(instanceSettings, private backendSrv, private $q, private templateSrv, private timeSrv) {
|
||||
this.name = instanceSettings.name;
|
||||
this.id = instanceSettings.id;
|
||||
this.responseParser = new ResponseParser(this.$q);
|
||||
this.queryModel = new MysqlQuery({});
|
||||
this.interval = (instanceSettings.jsonData || {}).timeInterval;
|
||||
}
|
||||
|
||||
interpolateVariable(value, variable) {
|
||||
interpolateVariable = (value, variable) => {
|
||||
if (typeof value === 'string') {
|
||||
if (variable.multi || variable.includeAll) {
|
||||
return "'" + value.replace(/'/g, `''`) + "'";
|
||||
return this.queryModel.quoteLiteral(value);
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
@ -28,27 +31,25 @@ export class MysqlDatasource {
|
||||
return value;
|
||||
}
|
||||
|
||||
const quotedValues = _.map(value, val => {
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "'" + val.replace(/'/g, `''`) + "'";
|
||||
const quotedValues = _.map(value, v => {
|
||||
return this.queryModel.quoteLiteral(v);
|
||||
});
|
||||
return quotedValues.join(',');
|
||||
}
|
||||
};
|
||||
|
||||
query(options) {
|
||||
const queries = _.filter(options.targets, item => {
|
||||
return item.hide !== true;
|
||||
}).map(item => {
|
||||
const queries = _.filter(options.targets, target => {
|
||||
return target.hide !== true;
|
||||
}).map(target => {
|
||||
const queryModel = new MysqlQuery(target, this.templateSrv, options.scopedVars);
|
||||
|
||||
return {
|
||||
refId: item.refId,
|
||||
refId: target.refId,
|
||||
intervalMs: options.intervalMs,
|
||||
maxDataPoints: options.maxDataPoints,
|
||||
datasourceId: this.id,
|
||||
rawSql: this.templateSrv.replace(item.rawSql, options.scopedVars, this.interpolateVariable),
|
||||
format: item.format,
|
||||
rawSql: queryModel.render(this.interpolateVariable),
|
||||
format: target.format,
|
||||
};
|
||||
});
|
||||
|
||||
@ -109,8 +110,11 @@ export class MysqlDatasource {
|
||||
format: 'table',
|
||||
};
|
||||
|
||||
const range = this.timeSrv.timeRange();
|
||||
const data = {
|
||||
queries: [interpolatedQuery],
|
||||
from: range.from.valueOf().toString(),
|
||||
to: range.to.valueOf().toString(),
|
||||
};
|
||||
|
||||
if (optionalOptions && optionalOptions.range && optionalOptions.range.from) {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user