diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5b66eb0703..482e308cc8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 ` 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 ` 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. diff --git a/Gopkg.lock b/Gopkg.lock index 390d6fffaf..54b0a0bda3 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -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 diff --git a/Gopkg.toml b/Gopkg.toml index 48bab20af9..bf88922074 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -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" diff --git a/Makefile b/Makefile index 13b501e219..2aae583c55 100644 --- a/Makefile +++ b/Makefile @@ -124,3 +124,6 @@ clean: ctags: ctags -R --languages="Go" --exclude=.git --exclude=ui . + +lint: + cd ui && yarn prettier diff --git a/README.md b/README.md index 9f1d1657e2..a8c657d5c3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/circle.yml b/circle.yml index 59bb2c5671..9cf290a8a0 100644 --- a/circle.yml +++ b/circle.yml @@ -3,7 +3,7 @@ machine: services: - docker environment: - DOCKER_TAG: chronograf-20180207 + DOCKER_TAG: chronograf-20180327 dependencies: override: diff --git a/etc/Dockerfile_build b/etc/Dockerfile_build index 8493143ec0..51cd647211 100644 --- a/etc/Dockerfile_build +++ b/etc/Dockerfile_build @@ -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 ; \ diff --git a/integrations/server_test.go b/integrations/server_test.go index 7620d6a947..b4762d3446 100644 --- a/integrations/server_test.go +++ b/integrations/server_test.go @@ -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" } } `, diff --git a/server/ifql.go b/server/ifql.go new file mode 100644 index 0000000000..46a24713a8 --- /dev/null +++ b/server/ifql.go @@ -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) +} diff --git a/server/links.go b/server/links.go index 3a3b3fd41d..884ef00908 100644 --- a/server/links.go +++ b/server/links.go @@ -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 diff --git a/server/mux.go b/server/mux.go index cf8d1d83ec..11730293f4 100644 --- a/server/mux.go +++ b/server/mux.go @@ -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) diff --git a/server/routes.go b/server/routes.go index a74cd6b9e2..783de7abd2 100644 --- a/server/routes.go +++ b/server/routes.go @@ -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. diff --git a/server/routes_test.go b/server/routes_test.go index 38ff7b8b35..f744d5acad 100644 --- a/server/routes_test.go +++ b/server/routes_test.go @@ -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)) diff --git a/ui/.storybook/config.js b/ui/.storybook/config.js deleted file mode 100644 index 8e314a81f7..0000000000 --- a/ui/.storybook/config.js +++ /dev/null @@ -1,7 +0,0 @@ -import { configure } from '@kadira/storybook'; - -function loadStories() { - require('../stories'); -} - -configure(loadStories, module); diff --git a/ui/.storybook/head.html b/ui/.storybook/head.html deleted file mode 100644 index 0cfde42d2a..0000000000 --- a/ui/.storybook/head.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/ui/.storybook/webpack.config.js b/ui/.storybook/webpack.config.js deleted file mode 100644 index 8050a084f2..0000000000 --- a/ui/.storybook/webpack.config.js +++ /dev/null @@ -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'), -}; diff --git a/ui/karma.conf.js b/ui/karma.conf.js deleted file mode 100644 index 134f85b64b..0000000000 --- a/ui/karma.conf.js +++ /dev/null @@ -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! - }, - }) -} diff --git a/ui/mocks/ifql/apis/index.ts b/ui/mocks/ifql/apis/index.ts new file mode 100644 index 0000000000..2fd7bba2ef --- /dev/null +++ b/ui/mocks/ifql/apis/index.ts @@ -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({})) diff --git a/ui/mocks/utils/ajax.ts b/ui/mocks/utils/ajax.ts index 9798925d1e..9451f91925 100644 --- a/ui/mocks/utils/ajax.ts +++ b/ui/mocks/utils/ajax.ts @@ -1 +1 @@ -export default jest.fn(() => Promise.resolve()) +export default jest.fn(() => Promise.resolve({data: {}})) diff --git a/ui/src/ifql/apis/index.ts b/ui/src/ifql/apis/index.ts new file mode 100644 index 0000000000..4e253af6b8 --- /dev/null +++ b/ui/src/ifql/apis/index.ts @@ -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 + } +} diff --git a/ui/src/ifql/ast/walker.ts b/ui/src/ifql/ast/walker.ts new file mode 100644 index 0000000000..d04755af59 --- /dev/null +++ b/ui/src/ifql/ast/walker.ts @@ -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', {}) + } +} diff --git a/ui/src/ifql/components/FuncList.tsx b/ui/src/ifql/components/FuncList.tsx new file mode 100644 index 0000000000..098c123c94 --- /dev/null +++ b/ui/src/ifql/components/FuncList.tsx @@ -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) => void + onKeyDown: (e: KeyboardEvent) => void + onAddNode: (name: string) => void + funcs: string[] +} + +const FuncList: SFC = ({ + inputText, + isOpen, + onAddNode, + onKeyDown, + onInputChange, + funcs, +}) => { + return ( +
    + + + {isOpen && + funcs.map((func, i) => ( + + ))} + +
+ ) +} + +export default FuncList diff --git a/ui/src/ifql/components/FuncListItem.tsx b/ui/src/ifql/components/FuncListItem.tsx new file mode 100644 index 0000000000..602624797b --- /dev/null +++ b/ui/src/ifql/components/FuncListItem.tsx @@ -0,0 +1,22 @@ +import React, {PureComponent} from 'react' + +interface Props { + name: string + onAddNode: (name: string) => void +} + +export default class FuncListItem extends PureComponent { + public render() { + const {name} = this.props + + return ( +
  • + {name} +
  • + ) + } + + private handleClick = () => { + this.props.onAddNode(this.props.name) + } +} diff --git a/ui/src/ifql/components/FuncSelector.tsx b/ui/src/ifql/components/FuncSelector.tsx new file mode 100644 index 0000000000..67b2e1f4c0 --- /dev/null +++ b/ui/src/ifql/components/FuncSelector.tsx @@ -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 { + constructor(props) { + super(props) + + this.state = { + isOpen: false, + inputText: '', + } + } + + public render() { + const {isOpen, inputText} = this.state + + return ( + +
    + + +
    +
    + ) + } + + 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) => { + this.setState({inputText: e.target.value}) + } + + private handleKeyDown = (e: KeyboardEvent) => { + 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 diff --git a/ui/src/ifql/components/Node.tsx b/ui/src/ifql/components/Node.tsx new file mode 100644 index 0000000000..c4408bff9f --- /dev/null +++ b/ui/src/ifql/components/Node.tsx @@ -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 = ({node}) => { + return ( +
    +
    {node.name}
    +
    + ) +} + +export default Node diff --git a/ui/src/ifql/components/TimeMachine.tsx b/ui/src/ifql/components/TimeMachine.tsx new file mode 100644 index 0000000000..91984eb77b --- /dev/null +++ b/ui/src/ifql/components/TimeMachine.tsx @@ -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 = ({funcs, nodes, onAddNode}) => { + return ( +
    +
    + {nodes.map((n, i) => )} + +
    +
    + ) +} + +export default TimeMachine diff --git a/ui/src/ifql/constants/index.ts b/ui/src/ifql/constants/index.ts new file mode 100644 index 0000000000..e542d8b432 --- /dev/null +++ b/ui/src/ifql/constants/index.ts @@ -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: [], + }, +} diff --git a/ui/src/ifql/containers/IFQLPage.tsx b/ui/src/ifql/containers/IFQLPage.tsx new file mode 100644 index 0000000000..a73841266f --- /dev/null +++ b/ui/src/ifql/containers/IFQLPage.tsx @@ -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 { + 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 ( +
    +
    +
    +
    +

    Time Machine

    +
    +
    +
    +
    +
    + +
    +
    +
    + ) + } + + 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) diff --git a/ui/src/ifql/index.ts b/ui/src/ifql/index.ts new file mode 100644 index 0000000000..b2166a88bc --- /dev/null +++ b/ui/src/ifql/index.ts @@ -0,0 +1,3 @@ +import IFQLPage from 'src/ifql/containers/IFQLPage' + +export {IFQLPage} diff --git a/ui/src/index.js b/ui/src/index.tsx similarity index 81% rename from ui/src/index.js rename to ui/src/index.tsx index 2a012b8e4a..b433750b97 100644 --- a/ui/src/index.js +++ b/ui/src/index.tsx @@ -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 -
    - ) : ( + public render() { + return this.state.ready ? ( @@ -163,14 +144,47 @@ const Root = React.createClass({ + + ) : ( +
    ) - }, -}) + } + + 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(, rootNode) diff --git a/ui/src/shared/apis/metaQuery.js b/ui/src/shared/apis/metaQuery.js index 92f34f8c3e..24645f02e4 100644 --- a/ui/src/shared/apis/metaQuery.js +++ b/ui/src/shared/apis/metaQuery.js @@ -1,4 +1,4 @@ -import AJAX from 'utils/ajax' +import AJAX from 'src/utils/ajax' import _ from 'lodash' import {buildInfluxUrl, proxy} from 'utils/queryUrlGenerator' diff --git a/ui/src/shared/components/ClickOutside.tsx b/ui/src/shared/components/ClickOutside.tsx new file mode 100644 index 0000000000..3105df2e85 --- /dev/null +++ b/ui/src/shared/components/ClickOutside.tsx @@ -0,0 +1,28 @@ +import React, {PureComponent, ReactElement} from 'react' +import ReactDOM from 'react-dom' + +interface Props { + children: ReactElement + onClickOutside: () => void +} + +export class ClickOutside extends PureComponent { + 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() + } + } +} diff --git a/ui/src/shared/components/DropdownInput.js b/ui/src/shared/components/DropdownInput.tsx similarity index 60% rename from ui/src/shared/components/DropdownInput.js rename to ui/src/shared/components/DropdownInput.tsx index 279642c4a3..84f92cfaaa 100644 --- a/ui/src/shared/components/DropdownInput.js +++ b/ui/src/shared/components/DropdownInput.tsx @@ -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) => void +type OnFilterKeyPress = (e: KeyboardEvent) => void + +interface Props { + searchTerm: string + buttonSize: string + buttonColor: string + toggleStyle?: object + disabled?: boolean + onFilterChange: OnFilterChangeHandler + onFilterKeyPress: OnFilterKeyPress +} + +const DropdownInput: SFC = ({ 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, -} diff --git a/ui/src/shared/components/FeatureFlag.tsx b/ui/src/shared/components/FeatureFlag.tsx index ac63488a59..f78e51f17d 100644 --- a/ui/src/shared/components/FeatureFlag.tsx +++ b/ui/src/shared/components/FeatureFlag.tsx @@ -1,6 +1,7 @@ import {SFC} from 'react' interface Props { + name?: string children?: any } diff --git a/ui/src/side_nav/containers/SideNav.tsx b/ui/src/side_nav/containers/SideNav.tsx index ca2d0f7760..a45995cf21 100644 --- a/ui/src/side_nav/containers/SideNav.tsx +++ b/ui/src/side_nav/containers/SideNav.tsx @@ -5,6 +5,8 @@ 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 { NavBlock, NavHeader, @@ -139,6 +141,15 @@ class SideNav extends PureComponent { sourcePrefix={sourcePrefix} /> ) : null} + + + + + ) } diff --git a/ui/src/status/apis/index.js b/ui/src/status/apis/index.ts similarity index 63% rename from ui/src/status/apis/index.js rename to ui/src/status/apis/index.ts index 5a943586b5..7bb8642f16 100644 --- a/ui/src/status/apis/index.js +++ b/ui/src/status/apis/index.ts @@ -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 ) diff --git a/ui/src/style/chronograf.scss b/ui/src/style/chronograf.scss index a82bbefc06..059a35e795 100644 --- a/ui/src/style/chronograf.scss +++ b/ui/src/style/chronograf.scss @@ -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'; diff --git a/ui/src/style/components/func-node.scss b/ui/src/style/components/func-node.scss new file mode 100644 index 0000000000..af14c263ef --- /dev/null +++ b/ui/src/style/components/func-node.scss @@ -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; +} diff --git a/ui/src/style/components/funcs-button.scss b/ui/src/style/components/funcs-button.scss new file mode 100644 index 0000000000..93d243c41a --- /dev/null +++ b/ui/src/style/components/funcs-button.scss @@ -0,0 +1,3 @@ +.dropdown-item.func { + text-transform: uppercase; +} diff --git a/ui/src/utils/ajax.js b/ui/src/utils/ajax.ts similarity index 83% rename from ui/src/utils/ajax.js rename to ui/src/utils/ajax.ts index 947491905e..b28c9e9d82 100644 --- a/ui/src/utils/ajax.js +++ b/ui/src/utils/ajax.ts @@ -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) diff --git a/ui/test/ifql/apis/ifql.test.ts b/ui/test/ifql/apis/ifql.test.ts new file mode 100644 index 0000000000..307d78063b --- /dev/null +++ b/ui/test/ifql/apis/ifql.test.ts @@ -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, + }) + }) + }) +}) diff --git a/ui/test/ifql/ast/complex.ts b/ui/test/ifql/ast/complex.ts new file mode 100644 index 0000000000..18f8f1bbea --- /dev/null +++ b/ui/test/ifql/ast/complex.ts @@ -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', + }, + }, + }, + ], + }, + ], + }, + }, + }, + ], +} diff --git a/ui/test/ifql/ast/from.ts b/ui/test/ifql/ast/from.ts new file mode 100644 index 0000000000..b9cb46ff3f --- /dev/null +++ b/ui/test/ifql/ast/from.ts @@ -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', + }, + }, + ], + }, + ], + }, + }, + ], +} diff --git a/ui/test/ifql/ast/walker.test.ts b/ui/test/ifql/ast/walker.test.ts new file mode 100644 index 0000000000..b614bbe1b6 --- /dev/null +++ b/ui/test/ifql/ast/walker.test.ts @@ -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'}], + }, + ]) + }) + }) + }) +}) diff --git a/ui/test/ifql/components/FuncSelector.test.tsx b/ui/test/ifql/components/FuncSelector.test.tsx new file mode 100644 index 0000000000..ad0a1be368 --- /dev/null +++ b/ui/test/ifql/components/FuncSelector.test.tsx @@ -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() + + 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) + }) + }) + }) +}) diff --git a/ui/test/ifql/components/TimeMachine.test.tsx b/ui/test/ifql/components/TimeMachine.test.tsx new file mode 100644 index 0000000000..0ff8bfdc91 --- /dev/null +++ b/ui/test/ifql/components/TimeMachine.test.tsx @@ -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() + + return { + wrapper, + } +} + +describe('IFQL.Components.TimeMachine', () => { + describe('rendering', () => { + it('renders', () => { + const {wrapper} = setup() + + expect(wrapper.exists()).toBe(true) + }) + }) +}) diff --git a/ui/test/ifql/containers/IFQLPage.test.tsx b/ui/test/ifql/containers/IFQLPage.test.tsx new file mode 100644 index 0000000000..23558838ca --- /dev/null +++ b/ui/test/ifql/containers/IFQLPage.test.tsx @@ -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() + + 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 ', () => { + const {wrapper} = setup() + + const timeMachine = wrapper.find(TimeMachine) + + expect(timeMachine.exists()).toBe(true) + }) + }) +}) diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 23e8473050..f5cb170d85 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -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/*"] } diff --git a/ui/webpack/dev.config.js b/ui/webpack/dev.config.js index 7fc3b64650..f2fc06d6ff 100644 --- a/ui/webpack/dev.config.js +++ b/ui/webpack/dev.config.js @@ -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: '/', diff --git a/ui/webpack/prod.config.js b/ui/webpack/prod.config.js index 411ce8cee4..07c930be09 100644 --- a/ui/webpack/prod.config.js +++ b/ui/webpack/prod.config.js @@ -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) } })