Merge branch 'master' into polish/improve-add-new-cell

pull/10616/head
Alex Paxton 2018-03-29 17:18:24 -07:00 committed by GitHub
commit fca7e5faf4
73 changed files with 3053 additions and 980 deletions

View File

@ -10,11 +10,7 @@
### Bug Fixes
## v1.4.3.0 [unreleased]
### UI Improvements
### Bug Fixes
1. [#2950](https://github.com/influxdata/chronograf/pull/2094): Always save template variables on first edit
## v1.4.3.0 [2018-3-28]

View File

@ -1,9 +1,9 @@
Contributing to Chronograf
==========================
# Contributing to Chronograf
## Bug reports
Bug reports
---------------
Before you file an issue, please search existing issues in case it has already been filed, or perhaps even fixed. If you file an issue, please include the following.
* Full details of your operating system (or distribution) e.g. 64-bit Ubuntu 14.04.
* The version of Chronograf you are running
* Whether you installed it using a pre-built package, or built it from source.
@ -13,77 +13,65 @@ Before you file an issue, please search existing issues in case it has already b
Remember the golden rule of bug reports: **The easier you make it for us to reproduce the problem, the faster it will get fixed.**
If you have never written a bug report before, or if you want to brush up on your bug reporting skills, we recommend reading [Simon Tatham's essay "How to Report Bugs Effectively."](http://www.chiark.greenend.org.uk/~sgtatham/bugs.html)
Please note that issues are *not the place to file general questions* such as "How do I use Chronograf?" Questions of this nature should be sent to the [InfluxDB Google Group](https://groups.google.com/forum/#!forum/influxdb), not filed as issues. Issues like this will be closed.
Please note that issues are _not the place to file general questions_ such as "How do I use Chronograf?" Questions of this nature should be sent to the [InfluxDB Google Group](https://groups.google.com/forum/#!forum/influxdb), not filed as issues. Issues like this will be closed.
## Feature requests
Feature requests
----------------
We really like to receive feature requests, as it helps us prioritize our work. Please be clear about your requirements, as incomplete feature requests may simply be closed if we don't understand what you would like to see added to Chronograf.
Contributing to the source code
-------------------------------
## Contributing to the source code
Chronograf is built using Go for its API backend and serving the front-end assets, and uses Dep for dependency management. The front-end visualization is built with React (JavaScript) and uses Yarn for dependency management. The assumption is that all your Go development are done in `$GOPATH/src`. `GOPATH` can be any directory under which Chronograf and all its dependencies will be cloned. For full details on the project structure, follow along below.
Submitting a pull request
-------------------------
To submit a pull request you should fork the Chronograf repository, and make your change on a feature branch of your fork. Then generate a pull request from your branch against *master* of the Chronograf repository. Include in your pull request details of your change -- the why *and* the how -- as well as the testing your performed. Also, be sure to run the test suite with your change in place. Changes that cause tests to fail cannot be merged.
## Submitting a pull request
To submit a pull request you should fork the Chronograf repository, and make your change on a feature branch of your fork. Then generate a pull request from your branch against _master_ of the Chronograf repository. Include in your pull request details of your change -- the why _and_ the how -- as well as the testing your performed. Also, be sure to run the test suite with your change in place. Changes that cause tests to fail cannot be merged.
There will usually be some back and forth as we finalize the change, but once that completes it may be merged.
To assist in review for the PR, please add the following to your pull request comment:
```md
- [ ] CHANGELOG.md updated
- [ ] Rebased/mergable
- [ ] Tests pass
- [ ] Sign [CLA](https://influxdata.com/community/cla/) (if not already signed)
* [ ] CHANGELOG.md updated
* [ ] Rebased/mergable
* [ ] Tests pass
* [ ] Sign [CLA](https://influxdata.com/community/cla/) (if not already signed)
```
Signing the CLA
---------------
## Signing the CLA
If you are going to be contributing back to Chronograf please take a second to sign our CLA, which can be found
[on our website](https://influxdata.com/community/cla/).
Installing & Using Yarn
--------------
## Installing & Using Yarn
You'll need to install Yarn to manage the frontend (JavaScript) dependencies.
* [Install Yarn](https://yarnpkg.com/en/docs/install)
To add a dependency via Yarn, for example, run `yarn add <dependency>` from within the `/chronograf/ui` directory.
Installing Go
-------------
Chronograf requires Go 1.7.1 or higher.
## Installing Go
At InfluxData we find gvm, a Go version manager, useful for installing and managing Go. For instructions
on how to install it see [the gvm page on github](https://github.com/moovweb/gvm).
Chronograf requires Go 1.10 or higher.
After installing gvm you can install and set the default go version by
running the following:
## Installing & Using Dep
```bash
gvm install go1.7.5
gvm use go1.7.5 --default
```
Installing & Using Dep
--------------
You'll need to install Dep to manage the backend (Go) dependencies.
* [Install Dep](https://github.com/golang/dep)
To add a dependency via Dep, for example, run `dep ensure -add <dependency>` from within the `/chronograf` directory. _Note that as of this writing, `dep ensure` will modify many extraneous vendor files, so you'll need to run `dep prune` to clean this up before committing your changes. Apparently, the next version of `dep` will take care of this step for you._
Revision Control Systems
------------------------
Go has the ability to import remote packages via revision control systems with the `go get` command. To ensure that you can retrieve any remote package, be sure to install the following rcs software to your system.
Currently the project only depends on `git` and `mercurial`.
## Revision Control Systems
Go has the ability to import remote packages via revision control systems with the `go get` command. To ensure that you can retrieve any remote package, be sure to install the following rcs software to your system.
Currently the project only depends on `git`.
* [Install Git](http://git-scm.com/book/en/Getting-Started-Installing-Git)
* [Install Mercurial](http://mercurial.selenic.com/wiki/Download)
Getting the source
------------------
## Getting the source
Setup the project structure and fetch the repo like so:
```bash
@ -94,8 +82,8 @@ Setup the project structure and fetch the repo like so:
You can add the line `export GOPATH=$HOME/go` to your bash/zsh file to be set for every shell instead of having to manually run it everytime.
Cloning a fork
--------------
## Cloning a fork
If you wish to work with fork of Chronograf, your own fork for example, you must still follow the directory structure above. But instead of cloning the main repo, instead clone your fork. Follow the steps below to work with a fork:
```bash
@ -107,8 +95,8 @@ If you wish to work with fork of Chronograf, your own fork for example, you must
Retaining the directory structure `$GOPATH/src/github.com/influxdata` is necessary so that Go imports work correctly.
Build and Test
--------------
## Build and Test
Make sure you have `go` and `yarn` installed and the project structure as shown above. We provide a `Makefile` to get up and running quickly, so all you'll need to do is run the following:
```bash
@ -125,6 +113,6 @@ To run the tests, execute the following command:
make test
```
Continuous Integration testing
-----
## Continuous Integration testing
Chronograf uses CircleCI for continuous integration testing. To see how the code is built and tested, check out [this file](https://github.com/influxdata/chronograf/blob/master/Makefile). It closely follows the build and test process outlined above. You can see the exact version of Go Chronograf uses for testing by consulting that file.

188
Gopkg.lock generated
View File

@ -11,6 +11,12 @@
packages = ["."]
revision = "3ec0642a7fb6488f65b06f9040adc67e3990296a"
[[projects]]
branch = "master"
name = "github.com/beorn7/perks"
packages = ["quantile"]
revision = "3a771d992973f24aa725d07868b467d1ddfceafb"
[[projects]]
name = "github.com/boltdb/bolt"
packages = ["."]
@ -39,8 +45,38 @@
[[projects]]
name = "github.com/gogo/protobuf"
packages = ["gogoproto","jsonpb","plugin/compare","plugin/defaultcheck","plugin/description","plugin/embedcheck","plugin/enumstringer","plugin/equal","plugin/face","plugin/gostring","plugin/marshalto","plugin/oneofcheck","plugin/populate","plugin/size","plugin/stringer","plugin/testgen","plugin/union","plugin/unmarshal","proto","protoc-gen-gogo","protoc-gen-gogo/descriptor","protoc-gen-gogo/generator","protoc-gen-gogo/grpc","protoc-gen-gogo/plugin","vanity","vanity/command"]
revision = "6abcf94fd4c97dcb423fdafd42fe9f96ca7e421b"
packages = [
"codec",
"gogoproto",
"jsonpb",
"plugin/compare",
"plugin/defaultcheck",
"plugin/description",
"plugin/embedcheck",
"plugin/enumstringer",
"plugin/equal",
"plugin/face",
"plugin/gostring",
"plugin/marshalto",
"plugin/oneofcheck",
"plugin/populate",
"plugin/size",
"plugin/stringer",
"plugin/testgen",
"plugin/union",
"plugin/unmarshal",
"proto",
"protoc-gen-gogo",
"protoc-gen-gogo/descriptor",
"protoc-gen-gogo/generator",
"protoc-gen-gogo/grpc",
"protoc-gen-gogo/plugin",
"sortkeys",
"types",
"vanity",
"vanity/command"
]
revision = "49944b4a4b085da44c43d4b233ea40787396371f"
[[projects]]
name = "github.com/golang/protobuf"
@ -50,7 +86,13 @@
[[projects]]
name = "github.com/google/go-cmp"
packages = ["cmp","cmp/cmpopts","cmp/internal/diff","cmp/internal/function","cmp/internal/value"]
packages = [
"cmp",
"cmp/cmpopts",
"cmp/internal/diff",
"cmp/internal/function",
"cmp/internal/value"
]
revision = "8099a9787ce5dc5984ed879a3bda47dc730a8e97"
version = "v0.1.0"
@ -65,22 +107,79 @@
packages = ["query"]
revision = "53e6ce116135b80d037921a7fdd5138cf32d7a8a"
[[projects]]
name = "github.com/influxdata/ifql"
packages = [
".",
"ast",
"compiler",
"complete",
"functions",
"interpreter",
"parser",
"query",
"query/control",
"query/execute",
"query/execute/storage",
"query/plan",
"semantic"
]
revision = "9445c4494d4421db2ab1aaaf6f4069446f11752e"
[[projects]]
name = "github.com/influxdata/influxdb"
packages = ["influxql","influxql/internal","influxql/neldermead","models","pkg/escape"]
packages = [
"influxql",
"influxql/internal",
"influxql/neldermead",
"models",
"pkg/escape"
]
revision = "cd9363b52cac452113b95554d98a6be51beda24e"
version = "v1.1.5"
[[projects]]
name = "github.com/influxdata/kapacitor"
packages = ["client/v1","pipeline","pipeline/tick","services/k8s/client","tick","tick/ast","tick/stateful","udf/agent"]
packages = [
"client/v1",
"pipeline",
"pipeline/tick",
"services/k8s/client",
"tick",
"tick/ast",
"tick/stateful",
"udf/agent"
]
revision = "6de30070b39afde111fea5e041281126fe8aae31"
[[projects]]
branch = "master"
name = "github.com/influxdata/tdigest"
packages = ["."]
revision = "617b83f940fd9acd207f712561a8a0590277fb38"
[[projects]]
name = "github.com/influxdata/usage-client"
packages = ["v1"]
revision = "6d3895376368aa52a3a81d2a16e90f0f52371967"
[[projects]]
branch = "master"
name = "github.com/influxdata/yamux"
packages = ["."]
revision = "1f58ded512de5feabbe30b60c7d33a7a896c5f16"
[[projects]]
branch = "master"
name = "github.com/influxdata/yarpc"
packages = [
".",
"codes",
"status",
"yarpcproto"
]
revision = "f0da2db138cad2fb425541938fc28dd5a5bc6918"
[[projects]]
name = "github.com/jessevdk/go-flags"
packages = ["."]
@ -91,12 +190,60 @@
packages = ["."]
revision = "46eb4c183bfc1ebb527d9d19bcded39476302eb8"
[[projects]]
name = "github.com/matttproud/golang_protobuf_extensions"
packages = ["pbutil"]
revision = "3247c84500bff8d9fb6d579d800f20b3e091582c"
version = "v1.0.0"
[[projects]]
name = "github.com/opentracing/opentracing-go"
packages = [
".",
"log"
]
revision = "1949ddbfd147afd4d964a9f00b24eb291e0e7c38"
version = "v1.0.2"
[[projects]]
name = "github.com/pkg/errors"
packages = ["."]
revision = "645ef00459ed84a119197bfb8d8205042c6df63d"
version = "v0.8.0"
[[projects]]
name = "github.com/prometheus/client_golang"
packages = ["prometheus"]
revision = "c5b7fccd204277076155f10851dad72b76a49317"
version = "v0.8.0"
[[projects]]
branch = "master"
name = "github.com/prometheus/client_model"
packages = ["go"]
revision = "99fa1f4be8e564e8a6b613da7fa6f46c9edafc6c"
[[projects]]
branch = "master"
name = "github.com/prometheus/common"
packages = [
"expfmt",
"internal/bitbucket.org/ww/goautoneg",
"model"
]
revision = "e4aa40a9169a88835b849a6efb71e05dc04b88f0"
[[projects]]
branch = "master"
name = "github.com/prometheus/procfs"
packages = [
".",
"internal/util",
"nfs",
"xfs"
]
revision = "780932d4fbbe0e69b84c34c20f5c8d0981e109ea"
[[projects]]
name = "github.com/satori/go.uuid"
packages = ["."]
@ -115,12 +262,20 @@
[[projects]]
name = "golang.org/x/net"
packages = ["context","context/ctxhttp"]
packages = [
"context",
"context/ctxhttp"
]
revision = "749a502dd1eaf3e5bfd4f8956748c502357c0bbe"
[[projects]]
name = "golang.org/x/oauth2"
packages = [".","github","heroku","internal"]
packages = [
".",
"github",
"heroku",
"internal"
]
revision = "2f32c3ac0fa4fb807a0fcefb0b6f2468a0d99bd0"
[[projects]]
@ -131,18 +286,31 @@
[[projects]]
name = "google.golang.org/api"
packages = ["gensupport","googleapi","googleapi/internal/uritemplates","oauth2/v2"]
packages = [
"gensupport",
"googleapi",
"googleapi/internal/uritemplates",
"oauth2/v2"
]
revision = "bc20c61134e1d25265dd60049f5735381e79b631"
[[projects]]
name = "google.golang.org/appengine"
packages = ["internal","internal/base","internal/datastore","internal/log","internal/remote_api","internal/urlfetch","urlfetch"]
packages = [
"internal",
"internal/base",
"internal/datastore",
"internal/log",
"internal/remote_api",
"internal/urlfetch",
"urlfetch"
]
revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a"
version = "v1.0.0"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "a4df1b0953349e64a89581f4b83ac3a2f40e17681e19f8de3cbf828b6375a3ba"
inputs-digest = "3dee5534e81013d8f9d3b8cf80ad614dd8f0168114e63e1f1a5ba60733891e83"
solver-name = "gps-cdcl"
solver-version = 1

View File

@ -26,7 +26,7 @@ required = ["github.com/kevinburke/go-bindata","github.com/gogo/protobuf/proto",
[[constraint]]
name = "github.com/gogo/protobuf"
revision = "6abcf94fd4c97dcb423fdafd42fe9f96ca7e421b"
revision = "49944b4a4b085da44c43d4b233ea40787396371f"
[[constraint]]
name = "github.com/google/go-github"
@ -72,6 +72,9 @@ required = ["github.com/kevinburke/go-bindata","github.com/gogo/protobuf/proto",
name = "github.com/influxdata/influxdb"
version = "~1.1.0"
[[constraint]]
name = "github.com/influxdata/ifql"
revision = "9445c4494d4421db2ab1aaaf6f4069446f11752e"
[[constraint]]
name = "github.com/influxdata/kapacitor"

View File

@ -124,3 +124,6 @@ clean:
ctags:
ctags -R --languages="Go" --exclude=.git --exclude=ui .
lint:
cd ui && yarn prettier

View File

@ -183,7 +183,7 @@ docker pull chronograf:1.4.3.0
### From Source
* Chronograf works with go 1.8.x, node 6.x/7.x, and yarn 0.18+.
* Chronograf works with go 1.10+, node 8.x, and yarn 1.5+.
* Chronograf requires [Kapacitor](https://github.com/influxdata/kapacitor)
1.2.x+ to create and store alerts.

View File

@ -3,7 +3,7 @@ machine:
services:
- docker
environment:
DOCKER_TAG: chronograf-20180207
DOCKER_TAG: chronograf-20180327
dependencies:
override:

View File

@ -20,24 +20,18 @@ RUN pip install boto requests python-jose --upgrade
RUN gem install fpm
# Install node
ENV NODE_VERSION v6.12.3
RUN wget -q https://nodejs.org/dist/latest-v6.x/node-${NODE_VERSION}-linux-x64.tar.gz; \
ENV NODE_VERSION v8.10.0
RUN wget -q https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz; \
tar -xvf node-${NODE_VERSION}-linux-x64.tar.gz -C / --strip-components=1; \
rm -f node-${NODE_VERSION}-linux-x64.tar.gz
# Update npm
RUN cd $(npm root -g)/npm \
&& npm install fs-extra \
&& sed -i -e s/graceful-fs/fs-extra/ -e s/fs.rename/fs.move/ ./lib/utils/rename.js
RUN npm install npm -g
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list && \
apt-get update && sudo apt-get install yarn
# Install go
ENV GOPATH /root/go
ENV GO_VERSION 1.9.4
ENV GO_VERSION 1.10
ENV GO_ARCH amd64
RUN wget https://storage.googleapis.com/golang/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz; \
tar -C /usr/local/ -xf /go${GO_VERSION}.linux-${GO_ARCH}.tar.gz ; \

View File

@ -2715,6 +2715,11 @@ func TestServer(t *testing.T) {
"logout": "/oauth/logout",
"external": {
"statusFeed": ""
},
"ifql": {
"ast": "/chronograf/v1/ifql/ast",
"self": "/chronograf/v1/ifql",
"suggestions": "/chronograf/v1/ifql/suggestions"
}
}
`,
@ -2798,6 +2803,11 @@ func TestServer(t *testing.T) {
"logout": "/oauth/logout",
"external": {
"statusFeed": ""
},
"ifql": {
"ast": "/chronograf/v1/ifql/ast",
"self": "/chronograf/v1/ifql",
"suggestions": "/chronograf/v1/ifql/suggestions"
}
}
`,

99
server/ifql.go Normal file
View File

@ -0,0 +1,99 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"github.com/bouk/httprouter"
"github.com/influxdata/ifql"
"github.com/influxdata/ifql/parser"
)
// SuggestionsResponse provides a list of available IFQL functions
type SuggestionsResponse struct {
Functions []SuggestionResponse `json:"funcs"`
}
// SuggestionResponse provides the parameters available for a given IFQL function
type SuggestionResponse struct {
Name string `json:"name"`
Params map[string]string `json:"params"`
}
type ifqlLinks struct {
Self string `json:"self"` // Self link mapping to this resource
Suggestions string `json:"suggestions"` // URL for ifql builder function suggestions
}
type ifqlResponse struct {
Links ifqlLinks `json:"links"`
}
// IFQL returns a list of links for the IFQL API
func (s *Service) IFQL(w http.ResponseWriter, r *http.Request) {
httpAPIIFQL := "/chronograf/v1/ifql"
res := ifqlResponse{
Links: ifqlLinks{
Self: fmt.Sprintf("%s", httpAPIIFQL),
Suggestions: fmt.Sprintf("%s/suggestions", httpAPIIFQL),
},
}
encodeJSON(w, http.StatusOK, res, s.Logger)
}
// IFQLSuggestions returns a list of available IFQL functions for the IFQL Builder
func (s *Service) IFQLSuggestions(w http.ResponseWriter, r *http.Request) {
completer := ifql.DefaultCompleter()
names := completer.FunctionNames()
var functions []SuggestionResponse
for _, name := range names {
suggestion, err := completer.FunctionSuggestion(name)
if err != nil {
Error(w, http.StatusNotFound, err.Error(), s.Logger)
return
}
functions = append(functions, SuggestionResponse{
Name: name,
Params: suggestion.Params,
})
}
res := SuggestionsResponse{Functions: functions}
encodeJSON(w, http.StatusOK, res, s.Logger)
}
// IFQLSuggestion returns the function parameters for the requested function
func (s *Service) IFQLSuggestion(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
name := httprouter.GetParamFromContext(ctx, "name")
completer := ifql.DefaultCompleter()
suggestion, err := completer.FunctionSuggestion(name)
if err != nil {
Error(w, http.StatusNotFound, err.Error(), s.Logger)
}
encodeJSON(w, http.StatusOK, SuggestionResponse{Name: name, Params: suggestion.Params}, s.Logger)
}
type ASTRequest struct {
Body string `json:"body"`
}
func (s *Service) IFQLAST(w http.ResponseWriter, r *http.Request) {
var request ASTRequest
err := json.NewDecoder(r.Body).Decode(&request)
if err != nil {
invalidJSON(w, s.Logger)
}
ast, err := parser.NewAST(request.Body)
if err != nil {
Error(w, http.StatusInternalServerError, err.Error(), s.Logger)
}
encodeJSON(w, http.StatusOK, ast, s.Logger)
}

View File

@ -5,6 +5,12 @@ import (
"net/url"
)
type getIFQLLinksResponse struct {
AST string `json:"ast"`
Self string `json:"self"`
Suggestions string `json:"suggestions"`
}
type getConfigLinksResponse struct {
Self string `json:"self"` // Location of the whole global application configuration
Auth string `json:"auth"` // Location of the auth section of the global application configuration

View File

@ -156,6 +156,12 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
router.PATCH("/chronograf/v1/sources/:id", EnsureEditor(service.UpdateSource))
router.DELETE("/chronograf/v1/sources/:id", EnsureEditor(service.RemoveSource))
// IFQL
router.GET("/chronograf/v1/ifql", EnsureViewer(service.IFQL))
router.POST("/chronograf/v1/ifql/ast", EnsureViewer(service.IFQLAST))
router.GET("/chronograf/v1/ifql/suggestions", EnsureViewer(service.IFQLSuggestions))
router.GET("/chronograf/v1/ifql/suggestions/:name", EnsureViewer(service.IFQLSuggestion))
// Source Proxy to Influx; Has gzip compression around the handler
influx := gziphandler.GzipHandler(http.HandlerFunc(EnsureViewer(service.Influx)))
router.Handler("POST", "/chronograf/v1/sources/:id/proxy", influx)

View File

@ -44,6 +44,7 @@ type getRoutesResponse struct {
Auth []AuthRoute `json:"auth"` // Location of all auth routes.
Logout *string `json:"logout,omitempty"` // Location of the logout route for all auth routes
ExternalLinks getExternalLinksResponse `json:"external"` // All external links for the client to use
IFQL getIFQLLinksResponse `json:"ifql"`
}
// AllRoutes is a handler that returns all links to resources in Chronograf server, as well as
@ -94,6 +95,11 @@ func (a *AllRoutes) ServeHTTP(w http.ResponseWriter, r *http.Request) {
StatusFeed: &a.StatusFeed,
CustomLinks: customLinks,
},
IFQL: getIFQLLinksResponse{
Self: "/chronograf/v1/ifql",
AST: "/chronograf/v1/ifql/ast",
Suggestions: "/chronograf/v1/ifql/suggestions",
},
}
// The JSON response will have no field present for the LogoutLink if there is no logout link.

View File

@ -29,7 +29,7 @@ func TestAllRoutes(t *testing.T) {
if err := json.Unmarshal(body, &routes); err != nil {
t.Error("TestAllRoutes not able to unmarshal JSON response")
}
want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/organizations/default/users","allUsers":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","environment":"/chronograf/v1/env","dashboards":"/chronograf/v1/dashboards","config":{"self":"/chronograf/v1/config","auth":"/chronograf/v1/config/auth"},"auth":[],"external":{"statusFeed":""}}
want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/organizations/default/users","allUsers":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","environment":"/chronograf/v1/env","dashboards":"/chronograf/v1/dashboards","config":{"self":"/chronograf/v1/config","auth":"/chronograf/v1/config/auth"},"auth":[],"external":{"statusFeed":""},"ifql":{"ast":"/chronograf/v1/ifql/ast","self":"/chronograf/v1/ifql","suggestions":"/chronograf/v1/ifql/suggestions"}}
`
if want != string(body) {
t.Errorf("TestAllRoutes\nwanted\n*%s*\ngot\n*%s*", want, string(body))
@ -67,7 +67,7 @@ func TestAllRoutesWithAuth(t *testing.T) {
if err := json.Unmarshal(body, &routes); err != nil {
t.Error("TestAllRoutesWithAuth not able to unmarshal JSON response")
}
want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/organizations/default/users","allUsers":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","environment":"/chronograf/v1/env","dashboards":"/chronograf/v1/dashboards","config":{"self":"/chronograf/v1/config","auth":"/chronograf/v1/config/auth"},"auth":[{"name":"github","label":"GitHub","login":"/oauth/github/login","logout":"/oauth/github/logout","callback":"/oauth/github/callback"}],"logout":"/oauth/logout","external":{"statusFeed":""}}
want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/organizations/default/users","allUsers":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","environment":"/chronograf/v1/env","dashboards":"/chronograf/v1/dashboards","config":{"self":"/chronograf/v1/config","auth":"/chronograf/v1/config/auth"},"auth":[{"name":"github","label":"GitHub","login":"/oauth/github/login","logout":"/oauth/github/logout","callback":"/oauth/github/callback"}],"logout":"/oauth/logout","external":{"statusFeed":""},"ifql":{"ast":"/chronograf/v1/ifql/ast","self":"/chronograf/v1/ifql","suggestions":"/chronograf/v1/ifql/suggestions"}}
`
if want != string(body) {
t.Errorf("TestAllRoutesWithAuth\nwanted\n*%s*\ngot\n*%s*", want, string(body))
@ -100,7 +100,7 @@ func TestAllRoutesWithExternalLinks(t *testing.T) {
if err := json.Unmarshal(body, &routes); err != nil {
t.Error("TestAllRoutesWithExternalLinks not able to unmarshal JSON response")
}
want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/organizations/default/users","allUsers":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","environment":"/chronograf/v1/env","dashboards":"/chronograf/v1/dashboards","config":{"self":"/chronograf/v1/config","auth":"/chronograf/v1/config/auth"},"auth":[],"external":{"statusFeed":"http://pineapple.life/feed.json","custom":[{"name":"cubeapple","url":"https://cube.apple"}]}}
want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/organizations/default/users","allUsers":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","environment":"/chronograf/v1/env","dashboards":"/chronograf/v1/dashboards","config":{"self":"/chronograf/v1/config","auth":"/chronograf/v1/config/auth"},"auth":[],"external":{"statusFeed":"http://pineapple.life/feed.json","custom":[{"name":"cubeapple","url":"https://cube.apple"}]},"ifql":{"ast":"/chronograf/v1/ifql/ast","self":"/chronograf/v1/ifql","suggestions":"/chronograf/v1/ifql/suggestions"}}
`
if want != string(body) {
t.Errorf("TestAllRoutesWithExternalLinks\nwanted\n*%s*\ngot\n*%s*", want, string(body))

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +0,0 @@
import { configure } from '@kadira/storybook';
function loadStories() {
require('../stories');
}
configure(loadStories, module);

View File

@ -1 +0,0 @@
<link href="/style.css" rel="stylesheet">

View File

@ -1,54 +0,0 @@
const path = require('path');
var ExtractTextPlugin = require("extract-text-webpack-plugin");
// you can use this file to add your custom webpack plugins, loaders and anything you like.
// This is just the basic way to add addional webpack configurations.
// For more information refer the docs: https://getstorybook.io/docs/configurations/custom-webpack-config
// IMPORTANT
// When you add this file, we won't add the default configurations which is similar
// to "React Create App". This only has babel loader to load JavaScript.
module.exports = {
debug: true,
devtool: 'source-map',
plugins: [
new ExtractTextPlugin("style.css"),
],
output: {
publicPath: '/',
path: path.resolve(__dirname, '../build'),
filename: '[name].[chunkhash].dev.js',
},
module: {
loaders: [
{
test: /\.scss$/,
loader: ExtractTextPlugin.extract('style-loader', 'css-loader!sass-loader!resolve-url!sass?sourceMap'),
},
{
test: /\.css$/,
loader: ExtractTextPlugin.extract('style-loader', 'css-loader!postcss-loader'),
},
{
test : /\.(ico|png|jpg|ttf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/,
options: {
limit: 100000,
},
loader : 'file-loader',
},
],
},
resolve: {
alias: {
src: path.resolve(__dirname, '..', 'src'),
shared: path.resolve(__dirname, '..', 'src', 'shared'),
style: path.resolve(__dirname, '..', 'src', 'style'),
utils: path.resolve(__dirname, '..', 'src', 'utils'),
},
},
sassLoader: {
includePaths: [path.resolve(__dirname, "node_modules")],
},
postcss: require('../webpack/postcss'),
};

View File

@ -1,66 +0,0 @@
const webpack = require('webpack')
const path = require('path')
module.exports = function(config) {
config.set({
browsers: ['PhantomJS'],
frameworks: ['mocha'],
files: [
'node_modules/babel-polyfill/dist/polyfill.js',
'spec/spec-helper.js',
'spec/index.js',
],
preprocessors: {
'spec/spec-helper.js': ['webpack', 'sourcemap'],
'spec/index.js': ['webpack', 'sourcemap'],
},
// For more detailed reporting on tests, you can add 'verbose' and/or 'progress'.
// This can also be done via the command line with `yarn test -- --reporters=verbose`.
reporters: ['dots'],
webpack: {
devtool: 'inline-source-map',
module: {
loaders: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader',
},
{
test: /\.css/,
exclude: /node_modules/,
loader: 'style-loader!css-loader!postcss-loader',
},
{
test: /\.scss/,
exclude: /node_modules/,
loader: 'style-loader!css-loader!sass-loader',
},
{
// Sinon behaves weirdly with webpack, see https://github.com/webpack/webpack/issues/304
test: /sinon\/pkg\/sinon\.js/,
loader: 'imports?define=>false,require=>false',
},
],
},
externals: {
'react/addons': true,
'react/lib/ExecutionEnvironment': true,
'react/lib/ReactContext': true,
},
resolve: {
alias: {
app: path.resolve(__dirname, 'app'),
src: path.resolve(__dirname, 'src'),
chronograf: path.resolve(__dirname, 'src', 'chronograf'),
shared: path.resolve(__dirname, 'src', 'shared'),
style: path.resolve(__dirname, 'src', 'style'),
utils: path.resolve(__dirname, 'src', 'utils'),
},
},
},
webpackServer: {
noInfo: true, // please don't spam the console when running in karma!
},
})
}

View File

@ -0,0 +1,4 @@
jest.mock('src/utils/ajax', () => require('mocks/utils/ajax'))
export const getSuggestions = jest.fn(() => Promise.resolve([]))
export const getAST = jest.fn(() => Promise.resolve({}))

View File

@ -1 +1 @@
export default jest.fn(() => Promise.resolve())
export default jest.fn(() => Promise.resolve({data: {}}))

View File

@ -201,10 +201,13 @@ export const getDashboardsAsync = () => async dispatch => {
const removeUnselectedTemplateValues = dashboard => {
const templates = dashboard.templates.map(template => {
const values =
template.type === 'csv'
? template.values
: [template.values.find(val => val.selected)] || []
if (template.type === 'csv') {
return template
}
const value = template.values.find(val => val.selected)
const values = value ? [value] : []
return {...template, values}
})
return templates

View File

@ -1,18 +0,0 @@
import React from 'react'
import ClusterError from 'shared/components/ClusterError'
import PanelHeading from 'shared/components/PanelHeading'
import PanelBody from 'shared/components/PanelBody'
import errorCopy from 'hson!shared/copy/errors.hson'
const NoDataNodeError = React.createClass({
render() {
return (
<ClusterError>
<PanelHeading>{errorCopy.noData.head}</PanelHeading>
<PanelBody>{errorCopy.noData.body}</PanelBody>
</ClusterError>
)
},
})
export default NoDataNodeError

View File

@ -1,29 +1,21 @@
import React from 'react'
import PropTypes from 'prop-types'
import React, {PureComponent} from 'react'
import classnames from 'classnames'
const QueryMakerTab = React.createClass({
propTypes: {
isActive: PropTypes.bool,
query: PropTypes.shape({
rawText: PropTypes.string,
}).isRequired,
onSelect: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
queryTabText: PropTypes.string,
queryIndex: PropTypes.number,
},
interface Query {
rawText: string
}
handleSelect() {
this.props.onSelect(this.props.queryIndex)
},
interface Props {
isActive: boolean
query: Query
onSelect: (index: number) => void
onDelete: (index: number) => void
queryTabText: string
queryIndex: number
}
handleDelete(e) {
e.stopPropagation()
this.props.onDelete(this.props.queryIndex)
},
render() {
class QueryMakerTab extends PureComponent<Props> {
public render() {
return (
<div
className={classnames('query-maker--tab', {
@ -39,7 +31,16 @@ const QueryMakerTab = React.createClass({
/>
</div>
)
},
})
}
private handleSelect() {
this.props.onSelect(this.props.queryIndex)
}
private handleDelete(e) {
e.stopPropagation()
this.props.onDelete(this.props.queryIndex)
}
}
export default QueryMakerTab

View File

@ -1,25 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import {withRouter} from 'react-router'
import DataExplorer from './DataExplorer'
const App = React.createClass({
propTypes: {
source: PropTypes.shape({
links: PropTypes.shape({
proxy: PropTypes.string.isRequired,
self: PropTypes.string.isRequired,
}).isRequired,
}).isRequired,
},
render() {
return (
<div className="page">
<DataExplorer source={this.props.source} />
</div>
)
},
})
export default withRouter(App)

View File

@ -0,0 +1,21 @@
import React, {PureComponent} from 'react'
import {withRouter} from 'react-router'
import DataExplorer from './DataExplorer'
import {Source} from 'src/types'
interface Props {
source: Source
}
class DataExplorerPage extends PureComponent<Props> {
public render() {
return (
<div className="page">
<DataExplorer source={this.props.source} />
</div>
)
}
}
export default withRouter(DataExplorerPage)

View File

@ -1,2 +1,2 @@
import App from './containers/App'
export default App
import DataExplorerPage from './containers/DataExplorerPage'
export default DataExplorerPage

35
ui/src/ifql/apis/index.ts Normal file
View File

@ -0,0 +1,35 @@
import AJAX from 'src/utils/ajax'
export const getSuggestions = async (url: string) => {
try {
const {data} = await AJAX({
url,
})
return data.funcs
} catch (error) {
console.error('Could not get suggestions', error)
throw error
}
}
interface ASTRequest {
url: string
body: string
}
export const getAST = async (request: ASTRequest) => {
const {url, body} = request
try {
const {data} = await AJAX({
method: 'POST',
url,
data: {body},
})
return data
} catch (error) {
console.error('Could not parse query', error)
throw error
}
}

75
ui/src/ifql/ast/walker.ts Normal file
View File

@ -0,0 +1,75 @@
// Texas Ranger
import _ from 'lodash'
interface Expression {
expression: object
}
interface AST {
body: Expression[]
}
export default class Walker {
private ast: AST
constructor(ast) {
this.ast = ast
}
public get functions() {
return this.buildFuncNodes(this.walk(this.baseExpression))
}
private reduceArgs = args => {
if (!args) {
return []
}
return args.reduce(
(acc, arg) => [...acc, ...this.getProperties(arg.properties)],
[]
)
}
private walk = currentNode => {
if (_.isEmpty(currentNode)) {
return []
}
let name
let args
if (currentNode.call) {
name = currentNode.call.callee.name
args = currentNode.call.arguments
return [...this.walk(currentNode.argument), {name, args}]
}
name = currentNode.callee.name
args = currentNode.arguments
return [{name, args}]
}
private buildFuncNodes = nodes => {
return nodes.map(node => {
return {
name: node.name,
arguments: this.reduceArgs(node.args),
}
})
}
private getProperties = props => {
return props.map(prop => ({
key: prop.key.name,
value: _.get(
prop,
'value.value',
_.get(prop, 'value.location.source', '')
),
}))
}
private get baseExpression() {
return _.get(this.ast, 'body.0.expression', {})
}
}

View File

@ -0,0 +1,43 @@
import React, {SFC, ChangeEvent, KeyboardEvent} from 'react'
import FancyScrollbar from 'src/shared/components/FancyScrollbar'
import DropdownInput from 'src/shared/components/DropdownInput'
import FuncListItem from 'src/ifql/components/FuncListItem'
interface Props {
inputText: string
isOpen: boolean
onInputChange: (e: ChangeEvent<HTMLInputElement>) => void
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void
onAddNode: (name: string) => void
funcs: string[]
}
const FuncList: SFC<Props> = ({
inputText,
isOpen,
onAddNode,
onKeyDown,
onInputChange,
funcs,
}) => {
return (
<ul className="dropdown-menu funcs">
<DropdownInput
buttonSize="btn-xs"
buttonColor="btn-default"
onFilterChange={onInputChange}
onFilterKeyPress={onKeyDown}
searchTerm={inputText}
/>
<FancyScrollbar autoHide={false} autoHeight={true} maxHeight={240}>
{isOpen &&
funcs.map((func, i) => (
<FuncListItem key={i} name={func} onAddNode={onAddNode} />
))}
</FancyScrollbar>
</ul>
)
}
export default FuncList

View File

@ -0,0 +1,22 @@
import React, {PureComponent} from 'react'
interface Props {
name: string
onAddNode: (name: string) => void
}
export default class FuncListItem extends PureComponent<Props> {
public render() {
const {name} = this.props
return (
<li onClick={this.handleClick} className="dropdown-item func">
<a>{name}</a>
</li>
)
}
private handleClick = () => {
this.props.onAddNode(this.props.name)
}
}

View File

@ -0,0 +1,91 @@
import React, {PureComponent, ChangeEvent, KeyboardEvent} from 'react'
import {ClickOutside} from 'src/shared/components/ClickOutside'
import FuncList from 'src/ifql/components/FuncList'
interface State {
isOpen: boolean
inputText: string
}
interface Props {
funcs: string[]
onAddNode: (name: string) => void
}
export class FuncSelector extends PureComponent<Props, State> {
constructor(props) {
super(props)
this.state = {
isOpen: false,
inputText: '',
}
}
public render() {
const {isOpen, inputText} = this.state
return (
<ClickOutside onClickOutside={this.handleClickOutside}>
<div className={`dropdown dashboard-switcher ${this.openClass}`}>
<button
className="btn btn-square btn-default btn-sm dropdown-toggle"
onClick={this.handleClick}
>
<span className="icon plus" />
</button>
<FuncList
inputText={inputText}
onAddNode={this.handleAddNode}
isOpen={isOpen}
funcs={this.availableFuncs}
onInputChange={this.handleInputChange}
onKeyDown={this.handleKeyDown}
/>
</div>
</ClickOutside>
)
}
private get openClass(): string {
if (this.state.isOpen) {
return 'open'
}
return ''
}
private handleAddNode = (name: string) => {
this.setState({isOpen: false})
this.props.onAddNode(name)
}
private get availableFuncs(): string[] {
return this.props.funcs.filter(f =>
f.toLowerCase().includes(this.state.inputText)
)
}
private handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({inputText: e.target.value})
}
private handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key !== 'Escape') {
return
}
this.setState({inputText: '', isOpen: false})
}
private handleClick = () => {
this.setState({isOpen: !this.state.isOpen})
}
private handleClickOutside = () => {
this.setState({isOpen: false})
}
}
export default FuncSelector

View File

@ -0,0 +1,25 @@
import React, {SFC} from 'react'
interface Arg {
key: string
value: string
}
interface Node {
name: string
arguments: Arg[]
}
interface Props {
node: Node
}
const Node: SFC<Props> = ({node}) => {
return (
<div className="func-node">
<div>{node.name}</div>
</div>
)
}
export default Node

View File

@ -0,0 +1,32 @@
import React, {SFC} from 'react'
import FuncSelector from 'src/ifql/components/FuncSelector'
import Node from 'src/ifql/components/Node'
interface Arg {
key: string
value: string
}
interface NodeProp {
name: string
arguments: Arg[]
}
interface Props {
funcs: string[]
nodes: NodeProp[]
onAddNode: (name: string) => void
}
const TimeMachine: SFC<Props> = ({funcs, nodes, onAddNode}) => {
return (
<div>
<div className="func-node-container">
{nodes.map((n, i) => <Node key={i} node={n} />)}
<FuncSelector funcs={funcs} onAddNode={onAddNode} />
</div>
</div>
)
}
export default TimeMachine

View File

@ -0,0 +1,155 @@
export const ast = {
type: 'File',
start: 0,
end: 22,
loc: {
start: {
line: 1,
column: 0,
},
end: {
line: 1,
column: 22,
},
},
program: {
type: 'Program',
start: 0,
end: 22,
loc: {
start: {
line: 1,
column: 0,
},
end: {
line: 1,
column: 22,
},
},
sourceType: 'module',
body: [
{
type: 'ExpressionStatement',
start: 0,
end: 22,
loc: {
start: {
line: 1,
column: 0,
},
end: {
line: 1,
column: 22,
},
},
expression: {
type: 'CallExpression',
start: 0,
end: 22,
loc: {
start: {
line: 1,
column: 0,
},
end: {
line: 1,
column: 22,
},
},
callee: {
type: 'Identifier',
start: 0,
end: 4,
loc: {
start: {
line: 1,
column: 0,
},
end: {
line: 1,
column: 4,
},
identifierName: 'from',
},
name: 'from',
},
arguments: [
{
type: 'ObjectExpression',
start: 5,
end: 21,
loc: {
start: {
line: 1,
column: 5,
},
end: {
line: 1,
column: 21,
},
},
properties: [
{
type: 'ObjectProperty',
start: 6,
end: 20,
loc: {
start: {
line: 1,
column: 6,
},
end: {
line: 1,
column: 20,
},
},
method: false,
shorthand: false,
computed: false,
key: {
type: 'Identifier',
start: 6,
end: 8,
loc: {
start: {
line: 1,
column: 6,
},
end: {
line: 1,
column: 8,
},
identifierName: 'db',
},
name: 'db',
},
value: {
type: 'StringLiteral',
start: 10,
end: 20,
loc: {
start: {
line: 1,
column: 10,
},
end: {
line: 1,
column: 20,
},
},
extra: {
rawValue: 'telegraf',
raw: 'telegraf',
},
value: 'telegraf',
},
},
],
},
],
},
},
],
directives: [],
},
}

View File

@ -0,0 +1,109 @@
import React, {PureComponent} from 'react'
import {connect} from 'react-redux'
import TimeMachine from 'src/ifql/components/TimeMachine'
import Walker from 'src/ifql/ast/walker'
import {getSuggestions, getAST} from 'src/ifql/apis'
interface Links {
self: string
suggestions: string
ast: string
}
interface Props {
links: Links
}
interface State {
funcs: string[]
ast: object
query: string
}
export class IFQLPage extends PureComponent<Props, State> {
constructor(props) {
super(props)
this.state = {
funcs: [],
ast: null,
query: 'from(db: "telegraf") |> filter() |> range()',
}
}
public async componentDidMount() {
const {links} = this.props
const {suggestions} = links
try {
const results = await getSuggestions(suggestions)
const funcs = results.map(s => s.name)
this.setState({funcs})
} catch (error) {
console.error('Could not get function suggestions: ', error)
}
this.getASTResponse(this.state.query)
}
public render() {
const {funcs} = this.state
return (
<div className="page">
<div className="page-header">
<div className="page-header__container">
<div className="page-header__left">
<h1 className="page-header__title">Time Machine</h1>
</div>
</div>
</div>
<div className="page-contents">
<div className="container-fluid">
<TimeMachine
funcs={funcs}
nodes={this.nodes}
onAddNode={this.handleAddNode}
/>
</div>
</div>
</div>
)
}
private handleAddNode = (name: string) => {
const query = `${this.state.query} |> ${name}()`
this.getASTResponse(query)
}
private get nodes() {
const {ast} = this.state
if (!ast) {
return []
}
const walker = new Walker(ast)
return walker.functions
}
private async getASTResponse(query: string) {
const {links} = this.props
try {
const ast = await getAST({url: links.ast, body: query})
this.setState({ast, query})
} catch (error) {
console.error('Could not parse AST', error)
}
}
}
const mapStateToProps = ({links}) => {
return {links: links.ifql}
}
export default connect(mapStateToProps, null)(IFQLPage)

3
ui/src/ifql/index.ts Normal file
View File

@ -0,0 +1,3 @@
import IFQLPage from 'src/ifql/containers/IFQLPage'
export {IFQLPage}

View File

@ -1,6 +1,6 @@
import 'babel-polyfill'
import React from 'react'
import React, {PureComponent} from 'react'
import {render} from 'react-dom'
import {Provider} from 'react-redux'
import {Router, Route, useRouterHistory} from 'react-router'
@ -32,26 +32,34 @@ import {
} from 'src/kapacitor'
import {AdminChronografPage, AdminInfluxDBPage} from 'src/admin'
import {SourcePage, ManageSources} from 'src/sources'
import NotFound from 'shared/components/NotFound'
import {IFQLPage} from 'src/ifql/index'
import NotFound from 'src/shared/components/NotFound'
import {getLinksAsync} from 'shared/actions/links'
import {getMeAsync} from 'shared/actions/auth'
import {getLinksAsync} from 'src/shared/actions/links'
import {getMeAsync} from 'src/shared/actions/auth'
import {disablePresentationMode} from 'shared/actions/app'
import {errorThrown} from 'shared/actions/errors'
import {notify} from 'shared/actions/notifications'
import {disablePresentationMode} from 'src/shared/actions/app'
import {errorThrown} from 'src/shared/actions/errors'
import {notify} from 'src/shared/actions/notifications'
import 'src/style/chronograf.scss'
import {HEARTBEAT_INTERVAL} from 'shared/constants'
import {HEARTBEAT_INTERVAL} from 'src/shared/constants'
const errorsQueue = []
const rootNode = document.getElementById('react-root')
declare global {
interface Window {
basepath: string
}
}
// Older method used for pre-IE 11 compatibility
const basepath = rootNode.getAttribute('data-basepath') || ''
window.basepath = basepath
const browserHistory = useRouterHistory(createHistory)({
basename: basepath, // this is written in when available by the URL prefixer middleware
})
@ -73,14 +81,22 @@ window.addEventListener('keyup', event => {
const history = syncHistoryWithStore(browserHistory, store)
const Root = React.createClass({
getInitialState() {
return {
interface State {
ready: boolean
}
class Root extends PureComponent<{}, State> {
private getLinks = bindActionCreators(getLinksAsync, dispatch)
private getMe = bindActionCreators(getMeAsync, dispatch)
constructor(props) {
super(props)
this.state = {
ready: false,
}
},
}
async componentWillMount() {
public async componentWillMount() {
this.flushErrorsQueue()
try {
@ -90,45 +106,10 @@ const Root = React.createClass({
} catch (error) {
dispatch(errorThrown(error))
}
},
}
getLinks: bindActionCreators(getLinksAsync, dispatch),
getMe: bindActionCreators(getMeAsync, dispatch),
async checkAuth() {
try {
await this.performHeartbeat({shouldResetMe: true})
} catch (error) {
dispatch(errorThrown(error))
}
},
async performHeartbeat({shouldResetMe = false} = {}) {
await this.getMe({shouldResetMe})
setTimeout(() => {
if (store.getState().auth.me !== null) {
this.performHeartbeat()
}
}, HEARTBEAT_INTERVAL)
},
flushErrorsQueue() {
if (errorsQueue.length) {
errorsQueue.forEach(error => {
if (typeof error === 'object') {
dispatch(notify(error))
} else {
dispatch(errorThrown({status: 0, auth: null}, error, 'warning'))
}
})
}
},
render() {
return !this.state.ready ? ( // eslint-disable-line no-negated-condition
<div className="page-spinner" />
) : (
public render() {
return this.state.ready ? (
<Provider store={store}>
<Router history={history}>
<Route path="/" component={UserIsAuthenticated(CheckSources)} />
@ -163,14 +144,47 @@ const Root = React.createClass({
<Route path="manage-sources" component={ManageSources} />
<Route path="manage-sources/new" component={SourcePage} />
<Route path="manage-sources/:id/edit" component={SourcePage} />
<Route path="ifql" component={IFQLPage} />
</Route>
</Route>
<Route path="*" component={NotFound} />
</Router>
</Provider>
) : (
<div className="page-spinner" />
)
},
})
}
private async performHeartbeat({shouldResetMe = false} = {}) {
await this.getMe({shouldResetMe})
setTimeout(() => {
if (store.getState().auth.me !== null) {
this.performHeartbeat()
}
}, HEARTBEAT_INTERVAL)
}
private flushErrorsQueue() {
if (errorsQueue.length) {
errorsQueue.forEach(error => {
if (typeof error === 'object') {
dispatch(notify(error))
} else {
dispatch(errorThrown({status: 0, auth: null}, error, 'warning'))
}
})
}
}
private async checkAuth() {
try {
await this.performHeartbeat({shouldResetMe: true})
} catch (error) {
dispatch(errorThrown(error))
}
}
}
if (rootNode) {
render(<Root />, rootNode)

View File

@ -1,4 +1,4 @@
import AJAX from 'utils/ajax'
import AJAX from 'src/utils/ajax'
import _ from 'lodash'
import {buildInfluxUrl, proxy} from 'utils/queryUrlGenerator'

View File

@ -0,0 +1,28 @@
import React, {PureComponent, ReactElement} from 'react'
import ReactDOM from 'react-dom'
interface Props {
children: ReactElement<any>
onClickOutside: () => void
}
export class ClickOutside extends PureComponent<Props> {
public componentDidMount() {
document.addEventListener('click', this.handleClickOutside, true)
}
public componentWillUnmount() {
document.removeEventListener('click', this.handleClickOutside, true)
}
public render() {
return React.Children.only(this.props.children)
}
private handleClickOutside = e => {
const domNode = ReactDOM.findDOMNode(this)
if (!domNode || !domNode.contains(e.target)) {
this.props.onClickOutside()
}
}
}

View File

@ -1,9 +1,21 @@
import React from 'react'
import PropTypes from 'prop-types'
import React, {SFC, ChangeEvent, KeyboardEvent} from 'react'
const disabledClass = disabled => (disabled ? ' disabled' : '')
const DropdownInput = ({
type OnFilterChangeHandler = (e: ChangeEvent<HTMLInputElement>) => void
type OnFilterKeyPress = (e: KeyboardEvent<HTMLInputElement>) => void
interface Props {
searchTerm: string
buttonSize: string
buttonColor: string
toggleStyle?: object
disabled?: boolean
onFilterChange: OnFilterChangeHandler
onFilterKeyPress: OnFilterKeyPress
}
const DropdownInput: SFC<Props> = ({
searchTerm,
buttonSize,
buttonColor,
@ -33,15 +45,3 @@ const DropdownInput = ({
)
export default DropdownInput
const {bool, func, shape, string} = PropTypes
DropdownInput.propTypes = {
searchTerm: string,
buttonSize: string,
buttonColor: string,
toggleStyle: shape({}),
disabled: bool,
onFilterChange: func.isRequired,
onFilterKeyPress: func.isRequired,
}

View File

@ -1,6 +1,7 @@
import {SFC} from 'react'
interface Props {
name?: string
children?: any
}

View File

@ -1,27 +0,0 @@
import React from 'react'
import ReactTooltip from 'react-tooltip'
const GraphTips = React.createClass({
render() {
const graphTipsText =
'<h1>Graph Tips:</h1><p><code>Click + Drag</code> Zoom in (X or Y)<br/><code>Shift + Click</code> Pan Graph Window<br/><code>Double Click</code> Reset Graph Window</p><h1>Static Legend Tips:</h1><p><code>Click</code>Focus on single Series<br/><code>Shift + Click</code> Show/Hide single Series</p>'
return (
<div
className="graph-tips"
data-for="graph-tips-tooltip"
data-tip={graphTipsText}
>
<span>?</span>
<ReactTooltip
id="graph-tips-tooltip"
effect="solid"
html={true}
place="bottom"
class="influx-tooltip"
/>
</div>
)
},
})
export default GraphTips

View File

@ -0,0 +1,24 @@
import React, {SFC} from 'react'
import ReactTooltip from 'react-tooltip'
const graphTipsText =
'<h1>Graph Tips:</h1><p><code>Click + Drag</code> Zoom in (X or Y)<br/><code>Shift + Click</code> Pan Graph Window<br/><code>Double Click</code> Reset Graph Window</p><h1>Static Legend Tips:</h1><p><code>Click</code>Focus on single Series<br/><code>Shift + Click</code> Show/Hide single Series</p>'
const GraphTips: SFC = () => (
<div
className="graph-tips"
data-for="graph-tips-tooltip"
data-tip={graphTipsText}
>
<span>?</span>
<ReactTooltip
id="graph-tips-tooltip"
effect="solid"
html={true}
place="bottom"
class="influx-tooltip"
/>
</div>
)
export default GraphTips

View File

@ -1,129 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import Dygraph from './Dygraph'
import shallowCompare from 'react-addons-shallow-compare'
import timeSeriesToDygraph from 'utils/timeSeriesToDygraph'
export default React.createClass({
displayName: 'MiniGraph',
propTypes: {
data: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
title: PropTypes.string,
queryDescription: PropTypes.string,
yRange: PropTypes.arrayOf(PropTypes.number.isRequired),
options: PropTypes.shape({
combineSeries: PropTypes.bool,
}),
},
getDefaultProps() {
return {
options: {},
}
},
shouldComponentUpdate(nextProps, nextState) {
return shallowCompare(this, nextProps, nextState)
},
render() {
const results = timeSeriesToDygraph(this.props.data)
const {fields, timeSeries} = this.props.options.combineSeries
? this.combineSeries(results)
: results
if (!timeSeries.length) {
return null
}
const options = {
labels: fields,
showLabelsOnHighlight: false,
fillGraph: false,
connectSeparatedPoints: true,
axisLineColor: '#23232C',
gridLineColor: '#2E2E38',
gridLineWidth: 1,
strokeWidth: 1.5,
highlightCircleSize: 0,
highlightSeriesOpts: {
strokeWidth: 0,
highlightCircleSize: 0,
},
highlightCallback() {},
legend: 'never',
axes: {
x: {
drawGrid: false,
drawAxis: false,
},
y: {
drawGrid: false,
drawAxis: false,
},
},
title: this.props.title,
rightGap: 0,
yRangePad: 10,
interactionModel: {},
}
const truncPrecision = 100000
const latestValue = timeSeries[timeSeries.length - 1][1]
const truncated = Math.round(latestValue * truncPrecision) / truncPrecision
const statText = (
<div className="cluster-stat--label">
<span>{this.props.queryDescription}</span>
<span>
<strong>{truncated}</strong>
</span>
</div>
)
return (
<div className="cluster-stat">
<Dygraph
containerStyle={{width: '100%', height: '30px'}}
timeSeries={timeSeries}
fields={fields}
options={options}
yRange={this.props.yRange}
/>
{statText}
</div>
)
},
/**
* If we have a series with multiple points, sometimes we want to sum all
* values into a single value (e.g. on the overview page, where we might
* calculate active writes per node, but also across the entire cluster.
*
* [<timestamp>, 5, 10] => [<timestamp>, 15]
*/
combineSeries(results) {
const fields = results.fields.slice(0, 2) // Hack, but good enough for now for the sparklines (which have no labels).
const timeSeries = results.timeSeries
.filter(point => {
// Filter out any points that don't report results for *all* of the series
// we're trying to combine..
//
// e.g. [<timestamp>, null, null, 5] would be removed.
//
// We use `combineSeries` when we want to combine the values for multiple series
// into a single series. It makes sense to only report points where all
// series are represented, so we can accurately take the sum.
return point.slice(1).every(v => v !== null)
})
.map(point => {
const timestamp = point[0]
const total = point.slice(1).reduce((sum, n) => {
return n ? sum + n : sum
}, 0)
return [timestamp, total]
})
return {fields, timeSeries}
},
})

View File

@ -1,33 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import {Link} from 'react-router'
import Authorized, {EDITOR_ROLE} from 'src/auth/Authorized'
const NoKapacitorError = React.createClass({
propTypes: {
source: PropTypes.shape({
id: PropTypes.string.isRequired,
}).isRequired,
},
render() {
const path = `/sources/${this.props.source.id}/kapacitors/new`
return (
<div className="graph-empty">
<p>
The current source does not have an associated Kapacitor instance
<br />
<br />
<Authorized requiredRole={EDITOR_ROLE}>
<Link to={path} className="btn btn-sm btn-primary">
Configure Kapacitor
</Link>
</Authorized>
</p>
</div>
)
},
})
export default NoKapacitorError

View File

@ -0,0 +1,30 @@
import React, {SFC} from 'react'
import {Link} from 'react-router'
import Authorized, {EDITOR_ROLE} from 'src/auth/Authorized'
interface Props {
source: {
id: string
}
}
const NoKapacitorError: SFC<Props> = ({source}) => {
const path = `/sources/${source.id}/kapacitors/new`
return (
<div className="graph-empty">
<p>
The current source does not have an associated Kapacitor instance
<br />
<br />
<Authorized requiredRole={EDITOR_ROLE}>
<Link to={path} className="btn btn-sm btn-primary">
Configure Kapacitor
</Link>
</Authorized>
</p>
</div>
)
}
export default NoKapacitorError

View File

@ -1,18 +0,0 @@
import React from 'react'
const NotFound = React.createClass({
render() {
return (
<div className="container-fluid">
<div className="panel">
<div className="panel-heading text-center">
<h1 className="deluxe">404</h1>
<h4>Bummer! We couldn't find the page you were looking for</h4>
</div>
</div>
</div>
)
},
})
export default NotFound

View File

@ -0,0 +1,13 @@
import React, {SFC} from 'react'
const NotFound: SFC = () => (
<div className="container-fluid">
<div className="panel">
<div className="panel-heading text-center">
<h1 className="deluxe">404</h1>
<h4>Bummer! We couldn't find the page you were looking for</h4>
</div>
</div>
</div>
)
export default NotFound

View File

@ -1,20 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
const {node} = PropTypes
const PanelBody = React.createClass({
propTypes: {
children: node.isRequired,
},
render() {
return (
<div className="panel-body text-center">
<h3 className="deluxe">How to resolve:</h3>
<p>{this.props.children}</p>
</div>
)
},
})
export default PanelBody

View File

@ -1,19 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
const {node} = PropTypes
const PanelHeading = React.createClass({
propTypes: {
children: node.isRequired,
},
render() {
return (
<div className="panel-heading text-center">
<h2 className="deluxe">{this.props.children}</h2>
</div>
)
},
})
export default PanelHeading

View File

@ -1,30 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import classnames from 'classnames'
const {func, bool, string} = PropTypes
const ResizeHandle = React.createClass({
propTypes: {
onHandleStartDrag: func.isRequired,
isDragging: bool.isRequired,
theme: string,
top: string,
},
render() {
const {isDragging, onHandleStartDrag, top, theme} = this.props
return (
<div
className={classnames('resizer--handle', {
dragging: isDragging,
'resizer--malachite': theme === 'kapacitor',
})}
onMouseDown={onHandleStartDrag}
style={{top}}
/>
)
},
})
export default ResizeHandle

View File

@ -0,0 +1,27 @@
import React, {SFC} from 'react'
import classnames from 'classnames'
interface Props {
onHandleStartDrag: () => void
isDragging: boolean
theme?: string
top?: string
}
const ResizeHandle: SFC<Props> = ({
onHandleStartDrag,
isDragging,
theme,
top,
}) => (
<div
className={classnames('resizer--handle', {
dragging: isDragging,
'resizer--malachite': theme === 'kapacitor',
})}
onMouseDown={onHandleStartDrag}
style={{top}}
/>
)
export default ResizeHandle

View File

@ -1,130 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import {Link} from 'react-router'
import classnames from 'classnames'
const {bool, node, string} = PropTypes
const NavListItem = React.createClass({
propTypes: {
link: string.isRequired,
children: node,
location: string,
useAnchor: bool,
isExternal: bool,
},
render() {
const {link, children, location, useAnchor, isExternal} = this.props
const isActive = location.startsWith(link)
return useAnchor ? (
<a
className={classnames('sidebar-menu--item', {active: isActive})}
href={link}
target={isExternal ? '_blank' : '_self'}
>
{children}
</a>
) : (
<Link
className={classnames('sidebar-menu--item', {active: isActive})}
to={link}
>
{children}
</Link>
)
},
})
const NavHeader = React.createClass({
propTypes: {
link: string,
title: string,
useAnchor: bool,
},
render() {
const {link, title, useAnchor} = this.props
// Some nav items, such as Logout, need to hit an external link rather
// than simply route to an internal page. Anchor tags serve that purpose.
return useAnchor ? (
<a className="sidebar-menu--heading" href={link}>
{title}
</a>
) : (
<Link className="sidebar-menu--heading" to={link}>
{title}
</Link>
)
},
})
const NavBlock = React.createClass({
propTypes: {
children: node,
link: string,
icon: string.isRequired,
location: string,
className: string,
},
render() {
const {location, className} = this.props
const isActive = React.Children.toArray(this.props.children).find(child => {
return location.startsWith(child.props.link) // if location is undefined, this will fail silently
})
const children = React.Children.map(this.props.children, child => {
if (child && child.type === NavListItem) {
return React.cloneElement(child, {location})
}
return child
})
return (
<div
className={classnames('sidebar--item', className, {active: isActive})}
>
{this.renderSquare()}
<div className="sidebar-menu">
{children}
<div className="sidebar-menu--triangle" />
</div>
</div>
)
},
renderSquare() {
const {link, icon} = this.props
if (!link) {
return (
<div className="sidebar--square">
<div className={`sidebar--icon icon ${icon}`} />
</div>
)
}
return (
<Link className="sidebar--square" to={link}>
<div className={`sidebar--icon icon ${icon}`} />
</Link>
)
},
})
const NavBar = React.createClass({
propTypes: {
children: node,
},
render() {
const {children} = this.props
return <nav className="sidebar">{children}</nav>
},
})
export {NavBar, NavBlock, NavHeader, NavListItem}

View File

@ -0,0 +1,120 @@
import React, {PureComponent, SFC, ReactNode, ReactElement} from 'react'
import {Link} from 'react-router'
import classnames from 'classnames'
interface NavListItemProps {
link: string
location?: string
useAnchor?: boolean
isExternal?: boolean
children?: ReactNode
}
const NavListItem: SFC<NavListItemProps> = ({
link,
children,
location,
useAnchor,
isExternal,
}) => {
const isActive = location.startsWith(link)
return useAnchor ? (
<a
className={classnames('sidebar-menu--item', {active: isActive})}
href={link}
target={isExternal ? '_blank' : '_self'}
>
{children}
</a>
) : (
<Link
className={classnames('sidebar-menu--item', {active: isActive})}
to={link}
>
{children}
</Link>
)
}
interface NavHeaderProps {
link?: string
title?: string
useAnchor?: string
}
const NavHeader: SFC<NavHeaderProps> = ({link, title, useAnchor}) => {
// Some nav items, such as Logout, need to hit an external link rather
// than simply route to an internal page. Anchor tags serve that purpose.
return useAnchor ? (
<a className="sidebar-menu--heading" href={link}>
{title}
</a>
) : (
<Link className="sidebar-menu--heading" to={link}>
{title}
</Link>
)
}
interface NavBlockProps {
children?: ReactNode
link?: string
icon: string
location?: string
className?: string
matcher?: string
}
class NavBlock extends PureComponent<NavBlockProps> {
public render() {
const {location, className} = this.props
const isActive = React.Children.toArray(this.props.children).find(
(child: ReactElement<any>) => {
return location.startsWith(child.props.link) // if location is undefined, this will fail silently
}
)
const children = React.Children.map(
this.props.children,
(child: ReactElement<any>) => {
if (child && child.type === NavListItem) {
return React.cloneElement(child, {location})
}
return child
}
)
return (
<div
className={classnames('sidebar--item', className, {active: isActive})}
>
{this.renderSquare()}
<div className="sidebar-menu">
{children}
<div className="sidebar-menu--triangle" />
</div>
</div>
)
}
private renderSquare() {
const {link, icon} = this.props
if (!link) {
return (
<div className="sidebar--square">
<div className={`sidebar--icon icon ${icon}`} />
</div>
)
}
return (
<Link className="sidebar--square" to={link}>
<div className={`sidebar--icon icon ${icon}`} />
</Link>
)
}
}
export {NavBlock, NavHeader, NavListItem}

View File

@ -1,66 +1,37 @@
import React from 'react'
import PropTypes from 'prop-types'
import React, {PureComponent} from 'react'
import {withRouter, Link} from 'react-router'
import {connect} from 'react-redux'
import Authorized, {ADMIN_ROLE} from 'src/auth/Authorized'
import UserNavBlock from 'src/side_nav/components/UserNavBlock'
import FeatureFlag from 'src/shared/components/FeatureFlag'
import {
NavBar,
NavBlock,
NavHeader,
NavListItem,
} from 'src/side_nav/components/NavItems'
import {DEFAULT_HOME_PAGE} from 'shared/constants'
import {DEFAULT_HOME_PAGE} from 'src/shared/constants'
import {Params, Location, Links, Me} from 'src/types/sideNav'
const {arrayOf, bool, shape, string} = PropTypes
interface Props {
params: Params
location: Location
isHidden: boolean
isUsingAuth?: boolean
logoutLink?: string
links?: Links
me: Me
}
const SideNav = React.createClass({
propTypes: {
params: shape({
sourceID: string.isRequired,
}).isRequired,
location: shape({
pathname: string.isRequired,
}).isRequired,
isHidden: bool.isRequired,
isUsingAuth: bool,
logoutLink: string,
links: shape({
me: string,
external: shape({
custom: arrayOf(
shape({
name: string.isRequired,
url: string.isRequired,
})
),
}),
}),
me: shape({
currentOrganization: shape({
id: string.isRequired,
name: string.isRequired,
}),
name: string,
organizations: arrayOf(
shape({
id: string.isRequired,
name: string.isRequired,
})
),
roles: arrayOf(
shape({
id: string,
name: string,
})
),
}),
},
class SideNav extends PureComponent<Props> {
constructor(props) {
super(props)
}
render() {
public render() {
const {
params: {sourceID},
location: {pathname: location},
@ -77,7 +48,7 @@ const SideNav = React.createClass({
const isDefaultPage = location.split('/').includes(DEFAULT_HOME_PAGE)
return isHidden ? null : (
<NavBar location={location}>
<nav className="sidebar">
<div
className={isDefaultPage ? 'sidebar--item active' : 'sidebar--item'}
>
@ -103,7 +74,7 @@ const SideNav = React.createClass({
link={`${sourcePrefix}/dashboards`}
location={location}
>
<NavHeader link={`${sourcePrefix}/dashboards`} title={'Dashboards'} />
<NavHeader link={`${sourcePrefix}/dashboards`} title="Dashboards" />
</NavBlock>
<NavBlock
matcher="alerts"
@ -170,10 +141,20 @@ const SideNav = React.createClass({
sourcePrefix={sourcePrefix}
/>
) : null}
</NavBar>
<FeatureFlag name="time-machine">
<NavBlock
icon="cog-thick"
link={`${sourcePrefix}/ifql`}
location={location}
>
<NavHeader link={`${sourcePrefix}/ifql`} title="IFQL Builder" />
</NavBlock>
</FeatureFlag>
</nav>
)
},
})
}
}
const mapStateToProps = ({
auth: {isUsingAuth, logoutLink, me},
app: {ephemeral: {inPresentationMode}},

View File

@ -1,91 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
const {func} = PropTypes
const RenameCluster = React.createClass({
propTypes: {
onRenameCluster: func.isRequired,
},
handleRenameCluster(e) {
e.preventDefault()
this.props.onRenameCluster(this._displayName.value)
this._displayName.value = ''
$('.modal').hide() // eslint-disable-line no-undef
$('.modal-backdrop').hide() // eslint-disable-line no-undef
},
render() {
return (
<div
className="modal fade"
tabIndex="-1"
role="dialog"
id="rename-cluster-modal"
>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<button
type="button"
className="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">×</span>
</button>
<h4 className="modal-title">Rename Cluster</h4>
</div>
<form onSubmit={this.handleRenameCluster}>
<div className="modal-body">
<div className="row">
<div className="col-md-8 col-md-offset-2 text-center">
<p>
A cluster can have an alias that replaces its ID in the
interface.
<br />
This does not affect the cluster ID.
</p>
<div className="form-group">
<label htmlFor="cluster-alias" className="sr-only">
Cluster Alias
</label>
<input
ref={ref => (this._displayName = ref)}
required={true}
type="text"
className="input-lg form-control"
maxLength="22"
id="cluster-alias"
placeholder="Name this cluster..."
/>
</div>
</div>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-default"
data-dismiss="modal"
>
Cancel
</button>
<button
disabled={!this._displayName}
type="submit"
className="btn btn-primary js-rename-cluster"
>
Rename
</button>
</div>
</form>
</div>
</div>
</div>
)
},
})
export default RenameCluster

View File

@ -1,4 +1,6 @@
import AJAX from 'utils/ajax'
import AJAX from 'src/utils/ajax'
const excludeBasepath = true // don't prefix route of external link with basepath/
export const fetchJSONFeed = url =>
AJAX(
@ -9,5 +11,5 @@ export const fetchJSONFeed = url =>
// https://stackoverflow.com/questions/22968406/how-to-skip-the-options-preflight-request-in-angularjs
headers: {'Content-Type': 'text/plain; charset=UTF-8'},
},
{excludeBasepath: true} // don't prefix route of external link with basepath
excludeBasepath // don't prefix route of external link with basepath
)

View File

@ -71,6 +71,7 @@
@import 'components/table-graph';
@import 'components/threshold-controls';
@import 'components/kapacitor-logs-table';
@import 'components/func-node.scss';
// Pages
@import 'pages/config-endpoints';
@ -85,3 +86,6 @@
// TODO
@import 'unsorted';
// IFQL - Time Machine
@import 'components/funcs-button';

View File

@ -0,0 +1,19 @@
.func-node-container {
display: inline-flex;
flex-direction: column;
}
.func-node {
background: #252b35;
border-radius: $radius-small;
padding: 10px;
width: auto;
display: flex;
flex-direction: column;
color: $ix-text-default;
text-transform: uppercase;
margin: $ix-marg-a;
font-family: $ix-text-font;
font-weight: 500;
cursor: pointer;
}

View File

@ -0,0 +1,3 @@
.dropdown-item.func {
text-transform: uppercase;
}

38
ui/src/types/sideNav.ts Normal file
View File

@ -0,0 +1,38 @@
export interface Params {
sourceID: string
}
export interface Location {
pathname: string
}
export interface ExternalLink {
name: string
url: string
}
export interface ExternalLinks {
custom: ExternalLink[]
}
export interface Links {
me?: string
external?: ExternalLinks
}
export interface Organization {
id: string
name: string
}
export interface Role {
id?: string
name?: string
}
export interface Me {
name: string
currentOrganization: Organization
organizations: Organization[]
role: Role[]
}

View File

@ -24,6 +24,7 @@ const generateResponseWithLinks = (response, newLinks) => {
me: meLink,
config,
environment,
ifql,
} = newLinks
return {
@ -37,12 +38,31 @@ const generateResponseWithLinks = (response, newLinks) => {
meLink,
config,
environment,
ifql,
}
}
interface RequestParams {
url: string
resource?: string | null
id?: string | null
method?: string
data?: object
params?: object
headers?: object
}
const AJAX = async (
{url, resource, id, method = 'GET', data = {}, params = {}, headers = {}},
{excludeBasepath} = {}
{
url,
resource = null,
id = null,
method = 'GET',
data = {},
params = {},
headers = {},
}: RequestParams,
excludeBasepath = false
) => {
try {
if (!links) {
@ -81,7 +101,7 @@ export const getAJAX = async url => {
try {
return await axios({
method: 'GET',
url: addBasepath(url),
url: addBasepath(url, false),
})
} catch (error) {
console.error(error)

View File

@ -0,0 +1,20 @@
import {getSuggestions} from 'src/ifql/apis'
import AJAX from 'src/utils/ajax'
jest.mock('src/utils/ajax', () => require('mocks/utils/ajax'))
describe('IFQL.Apis', () => {
afterEach(() => {
jest.clearAllMocks()
})
describe('getSuggestions', () => {
it('is called with the expected body', () => {
const url = '/chronograf/v1/suggestions'
getSuggestions(url)
expect(AJAX).toHaveBeenCalledWith({
url,
})
})
})
})

453
ui/test/ifql/ast/complex.ts Normal file
View File

@ -0,0 +1,453 @@
export default {
type: 'Program',
location: {
start: {
line: 1,
column: 1,
},
end: {
line: 1,
column: 91,
},
source:
'from(db: "telegraf") |> filter(fn: (r) => r["_measurement"] == "cpu") |> range(start: -1m)',
},
body: [
{
type: 'ExpressionStatement',
location: {
start: {
line: 1,
column: 1,
},
end: {
line: 1,
column: 91,
},
source:
'from(db: "telegraf") |> filter(fn: (r) => r["_measurement"] == "cpu") |> range(start: -1m)',
},
expression: {
type: 'PipeExpression',
location: {
start: {
line: 1,
column: 71,
},
end: {
line: 1,
column: 91,
},
source: '|> range(start: -1m)',
},
argument: {
type: 'PipeExpression',
location: {
start: {
line: 1,
column: 22,
},
end: {
line: 1,
column: 70,
},
source: '|> filter(fn: (r) => r["_measurement"] == "cpu")',
},
argument: {
type: 'CallExpression',
location: {
start: {
line: 1,
column: 1,
},
end: {
line: 1,
column: 21,
},
source: 'from(db: "telegraf")',
},
callee: {
type: 'Identifier',
location: {
start: {
line: 1,
column: 1,
},
end: {
line: 1,
column: 5,
},
source: 'from',
},
name: 'from',
},
arguments: [
{
type: 'ObjectExpression',
location: {
start: {
line: 1,
column: 6,
},
end: {
line: 1,
column: 20,
},
source: 'db: "telegraf"',
},
properties: [
{
type: 'Property',
location: {
start: {
line: 1,
column: 6,
},
end: {
line: 1,
column: 20,
},
source: 'db: "telegraf"',
},
key: {
type: 'Identifier',
location: {
start: {
line: 1,
column: 6,
},
end: {
line: 1,
column: 8,
},
source: 'db',
},
name: 'db',
},
value: {
type: 'StringLiteral',
location: {
start: {
line: 1,
column: 10,
},
end: {
line: 1,
column: 20,
},
source: '"telegraf"',
},
value: 'telegraf',
},
},
],
},
],
},
call: {
type: 'CallExpression',
location: {
start: {
line: 1,
column: 25,
},
end: {
line: 1,
column: 70,
},
source: 'filter(fn: (r) => r["_measurement"] == "cpu")',
},
callee: {
type: 'Identifier',
location: {
start: {
line: 1,
column: 25,
},
end: {
line: 1,
column: 31,
},
source: 'filter',
},
name: 'filter',
},
arguments: [
{
type: 'ObjectExpression',
location: {
start: {
line: 1,
column: 32,
},
end: {
line: 1,
column: 69,
},
source: 'fn: (r) => r["_measurement"] == "cpu"',
},
properties: [
{
type: 'Property',
location: {
start: {
line: 1,
column: 32,
},
end: {
line: 1,
column: 69,
},
source: 'fn: (r) => r["_measurement"] == "cpu"',
},
key: {
type: 'Identifier',
location: {
start: {
line: 1,
column: 32,
},
end: {
line: 1,
column: 34,
},
source: 'fn',
},
name: 'fn',
},
value: {
type: 'ArrowFunctionExpression',
location: {
start: {
line: 1,
column: 36,
},
end: {
line: 1,
column: 69,
},
source: '(r) => r["_measurement"] == "cpu"',
},
params: [
{
type: 'Property',
location: {
start: {
line: 1,
column: 37,
},
end: {
line: 1,
column: 38,
},
source: 'r',
},
key: {
type: 'Identifier',
location: {
start: {
line: 1,
column: 37,
},
end: {
line: 1,
column: 38,
},
source: 'r',
},
name: 'r',
},
value: null,
},
],
body: {
type: 'BinaryExpression',
location: {
start: {
line: 1,
column: 43,
},
end: {
line: 1,
column: 69,
},
source: 'r["_measurement"] == "cpu"',
},
operator: '==',
left: {
type: 'MemberExpression',
location: {
start: {
line: 1,
column: 43,
},
end: {
line: 1,
column: 61,
},
source: 'r["_measurement"] ',
},
object: {
type: 'Identifier',
location: {
start: {
line: 1,
column: 43,
},
end: {
line: 1,
column: 44,
},
source: 'r',
},
name: 'r',
},
property: {
type: 'StringLiteral',
location: {
start: {
line: 1,
column: 45,
},
end: {
line: 1,
column: 59,
},
source: '"_measurement"',
},
value: '_measurement',
},
},
right: {
type: 'StringLiteral',
location: {
start: {
line: 1,
column: 64,
},
end: {
line: 1,
column: 69,
},
source: '"cpu"',
},
value: 'cpu',
},
},
},
},
],
},
],
},
},
call: {
type: 'CallExpression',
location: {
start: {
line: 1,
column: 74,
},
end: {
line: 1,
column: 91,
},
source: 'range(start: -1m)',
},
callee: {
type: 'Identifier',
location: {
start: {
line: 1,
column: 74,
},
end: {
line: 1,
column: 79,
},
source: 'range',
},
name: 'range',
},
arguments: [
{
type: 'ObjectExpression',
location: {
start: {
line: 1,
column: 80,
},
end: {
line: 1,
column: 90,
},
source: 'start: -1m',
},
properties: [
{
type: 'Property',
location: {
start: {
line: 1,
column: 80,
},
end: {
line: 1,
column: 90,
},
source: 'start: -1m',
},
key: {
type: 'Identifier',
location: {
start: {
line: 1,
column: 80,
},
end: {
line: 1,
column: 85,
},
source: 'start',
},
name: 'start',
},
value: {
type: 'UnaryExpression',
location: {
start: {
line: 1,
column: 87,
},
end: {
line: 1,
column: 90,
},
source: '-1m',
},
operator: '-',
argument: {
type: 'DurationLiteral',
location: {
start: {
line: 1,
column: 88,
},
end: {
line: 1,
column: 90,
},
source: '1m',
},
value: '1m0s',
},
},
},
],
},
],
},
},
},
],
}

121
ui/test/ifql/ast/from.ts Normal file
View File

@ -0,0 +1,121 @@
export default {
type: 'Program',
location: {
start: {
line: 1,
column: 1,
},
end: {
line: 1,
column: 21,
},
source: 'from(db: "telegraf")',
},
body: [
{
type: 'ExpressionStatement',
location: {
start: {
line: 1,
column: 1,
},
end: {
line: 1,
column: 21,
},
source: 'from(db: "telegraf")',
},
expression: {
type: 'CallExpression',
location: {
start: {
line: 1,
column: 1,
},
end: {
line: 1,
column: 21,
},
source: 'from(db: "telegraf")',
},
callee: {
type: 'Identifier',
location: {
start: {
line: 1,
column: 1,
},
end: {
line: 1,
column: 5,
},
source: 'from',
},
name: 'from',
},
arguments: [
{
type: 'ObjectExpression',
location: {
start: {
line: 1,
column: 6,
},
end: {
line: 1,
column: 20,
},
source: 'db: "telegraf"',
},
properties: [
{
type: 'Property',
location: {
start: {
line: 1,
column: 6,
},
end: {
line: 1,
column: 20,
},
source: 'db: "telegraf"',
},
key: {
type: 'Identifier',
location: {
start: {
line: 1,
column: 6,
},
end: {
line: 1,
column: 8,
},
source: 'db',
},
name: 'db',
},
value: {
type: 'StringLiteral',
location: {
start: {
line: 1,
column: 10,
},
end: {
line: 1,
column: 20,
},
source: '"telegraf"',
},
value: 'telegraf',
},
},
],
},
],
},
},
],
}

View File

@ -0,0 +1,49 @@
import Walker from 'src/ifql/ast/walker'
import From from 'test/ifql/ast/from'
import Complex from 'test/ifql/ast/complex'
describe('IFQL.AST.Walker', () => {
describe('Walker#functions', () => {
describe('simple example', () => {
it('returns a flattened ordered list of from and its arguments', () => {
const walker = new Walker(From)
expect(walker.functions).toEqual([
{
name: 'from',
arguments: [
{
key: 'db',
value: 'telegraf',
},
],
},
])
})
})
describe('complex example', () => {
it('returns a flattened ordered list of all funcs and their arguments', () => {
const walker = new Walker(Complex)
expect(walker.functions).toEqual([
{
name: 'from',
arguments: [{key: 'db', value: 'telegraf'}],
},
{
name: 'filter',
arguments: [
{
key: 'fn',
value: '(r) => r["_measurement"] == "cpu"',
},
],
},
{
name: 'range',
arguments: [{key: 'start', value: '-1m'}],
},
])
})
})
})
})

View File

@ -0,0 +1,137 @@
import React from 'react'
import {shallow} from 'enzyme'
import {FuncSelector} from 'src/ifql/components/FuncSelector'
import DropdownInput from 'src/shared/components/DropdownInput'
import FuncListItem from 'src/ifql/components/FuncListItem'
import FuncList from 'src/ifql/components/FuncList'
const setup = (override = {}) => {
const props = {
funcs: ['f1', 'f2'],
onAddNode: () => {},
...override,
}
const wrapper = shallow(<FuncSelector {...props} />)
return {
wrapper,
}
}
describe('IFQL.Components.FuncsButton', () => {
describe('rendering', () => {
it('renders', () => {
const {wrapper} = setup()
expect(wrapper.exists()).toBe(true)
})
describe('the function list', () => {
it('does not render the list of funcs by default', () => {
const {wrapper} = setup()
const list = wrapper.find({'data-test': 'func-li'})
expect(list.exists()).toBe(false)
})
})
})
describe('user interraction', () => {
describe('clicking the add function button', () => {
it('displays the list of functions', () => {
const {wrapper} = setup()
const dropdownButton = wrapper.find('button')
dropdownButton.simulate('click')
const list = wrapper
.find(FuncList)
.dive()
.find(FuncListItem)
const first = list.first().dive()
const last = list.last().dive()
expect(list.length).toBe(2)
expect(first.text()).toBe('f1')
expect(last.text()).toBe('f2')
})
})
describe('filtering the list', () => {
it('displays the filtered funcs', () => {
const {wrapper} = setup()
const dropdownButton = wrapper.find('button')
dropdownButton.simulate('click')
let list = wrapper
.find(FuncList)
.dive()
.find(FuncListItem)
const first = list.first().dive()
const last = list.last().dive()
expect(list.length).toBe(2)
expect(first.text()).toBe('f1')
expect(last.text()).toBe('f2')
const input = wrapper
.find(FuncList)
.dive()
.find(DropdownInput)
.dive()
.find('input')
input.simulate('change', {target: {value: '2'}})
wrapper.update()
list = wrapper
.find(FuncList)
.dive()
.find(FuncListItem)
const func = list.first().dive()
expect(list.length).toBe(1)
expect(func.text()).toBe('f2')
})
})
describe('exiting the list', () => {
it('closes when ESC is pressed', () => {
const {wrapper} = setup()
const dropdownButton = wrapper.find('button')
dropdownButton.simulate('click')
let list = wrapper
.find(FuncList)
.dive()
.find(FuncListItem)
expect(list.exists()).toBe(true)
const input = wrapper
.find(FuncList)
.dive()
.find(DropdownInput)
.dive()
.find('input')
input.simulate('keyDown', {key: 'Escape'})
wrapper.update()
list = wrapper
.find(FuncList)
.dive()
.find(FuncListItem)
expect(list.exists()).toBe(false)
})
})
})
})

View File

@ -0,0 +1,27 @@
import React from 'react'
import {shallow} from 'enzyme'
import TimeMachine from 'src/ifql/components/TimeMachine'
const setup = () => {
const props = {
funcs: [],
nodes: [],
onAddNode: () => {},
}
const wrapper = shallow(<TimeMachine {...props} />)
return {
wrapper,
}
}
describe('IFQL.Components.TimeMachine', () => {
describe('rendering', () => {
it('renders', () => {
const {wrapper} = setup()
expect(wrapper.exists()).toBe(true)
})
})
})

View File

@ -0,0 +1,45 @@
import React from 'react'
import {shallow} from 'enzyme'
import {IFQLPage} from 'src/ifql/containers/IFQLPage'
import TimeMachine from 'src/ifql/components/TimeMachine'
jest.mock('src/ifql/apis', () => require('mocks/ifql/apis'))
const setup = () => {
const props = {
links: {
self: '',
suggestions: '',
ast: '',
},
}
const wrapper = shallow(<IFQLPage {...props} />)
return {
wrapper,
}
}
describe('IFQL.Containers.IFQLPage', () => {
afterEach(() => {
jest.clearAllMocks()
})
describe('rendering', () => {
it('renders the page', async () => {
const {wrapper} = setup()
expect(wrapper.exists()).toBe(true)
})
it('renders the <TimeMachine/>', () => {
const {wrapper} = setup()
const timeMachine = wrapper.find(TimeMachine)
expect(timeMachine.exists()).toBe(true)
})
})
})

View File

@ -1,6 +1,14 @@
{
"compilerOptions": {
"types": ["node", "chai", "lodash", "enzyme", "react", "prop-types", "jest"],
"types": [
"node",
"chai",
"lodash",
"enzyme",
"react",
"prop-types",
"jest"
],
"target": "es6",
"module": "es2015",
"moduleResolution": "node",
@ -25,7 +33,7 @@
"checkJs": false,
"sourceMap": true,
"baseUrl": "./",
"rootDir": "./src",
"rootDir": "./src"
},
"exclude": ["./assets/*", "./build/*", "./node_modules/*"]
}

View File

@ -38,7 +38,7 @@ module.exports = {
cache: true,
devtool: 'inline-eval-cheap-source-map',
entry: {
app: path.resolve(__dirname, '..', 'src', 'index.js'),
app: path.resolve(__dirname, '..', 'src', 'index.tsx'),
},
output: {
publicPath: '/',

View File

@ -25,7 +25,7 @@ const config = {
bail: true,
devtool: false,
entry: {
app: path.resolve(__dirname, '..', 'src', 'index.js'),
app: path.resolve(__dirname, '..', 'src', 'index.tsx'),
vendor: Object.keys(dependencies),
},
output: {
@ -167,16 +167,10 @@ const config = {
function() {
/* Webpack does not exit with non-zero status if error. */
this.plugin('done', function(stats) {
if (
stats.compilation.errors &&
stats.compilation.errors.length &&
process.argv.indexOf('--watch') == -1
) {
console.log(
stats.compilation.errors.toString({
colors: true,
})
)
const {compilation: {errors}} = stats
if (errors && errors.length) {
errors.forEach(err => console.log(err))
process.exit(1)
}
})