diff --git a/web/vtadmin/README.md b/web/vtadmin/README.md index 7c7f57778c6..0f024212d1f 100644 --- a/web/vtadmin/README.md +++ b/web/vtadmin/README.md @@ -1,12 +1,64 @@ # VTAdmin -## Getting started +## Running vtadmin-web locally -1. Install node v.14.15.3. (Using a Node version manager like [nvm](https://github.com/nvm-sh/nvm) is highly recommended.) -1. From the `vitess/web/vtadmin/` directory, install dependencies with `npm install`. -1. Run `npm start` to start vtadmin-web on [http://localhost:3000](http://localhost:3000) +In this section, we'll get vtadmin-web, vtadmin-api, and Vitess all running locally. This process is still somewhat... cumbersome, apologies. 😰 -Have fun! 🎉 +1. Run Vitess locally [with Docker](https://vitess.io/docs/get-started/local-docker/) (or another way, if you prefer): + + ```bash + make docker_local + ./docker/local/run.sh + ``` + +1. Create an empty vtgate credentials file to avoid the gRPC dialer bug mentioned in https://github.com/vitessio/vitess/pull/7187. Location and filename don't matter since you'll be passing this in as a flag; I put mine at ` /Users/sarabee/id1-grpc_vtgate_credentials.json`: + + ```json + { + "Username": "", + "Password": "" + } + ``` + +1. Create a cluster configuration file for the local Vitess you started up in step 1. Again, filename and location don't matter since we'll be passing in the path as a flag; I put mine at `/Users/sarabee/vtadmin-cluster1.json`. Here it is with default values for the [local Vitess/Docker example](https://vitess.io/docs/get-started/local-docker/) we're following: + + ```json + { + "vtgates": [ + { + "host": { + "hostname": "127.0.0.1:15991" + }, + "tags": ["pool:pool1", "cell:zone1", "extra:tag"] + }, + { + "host": { + "hostname": "127.0.0.1:15992" + }, + "tags": ["dead-dove-do-not-eat"] + } + ] + } + ``` + +1. Start up vtadmin-api but **make sure to update the filepaths** for the vtgate creds file and static service discovery file you created above! + + ```bash + make build + + ./bin/vtadmin \ + --addr ":15999" \ + --cluster-defaults "vtsql-credentials-path-tmpl=/Users/sarabee/id1-grpc_vtgate_credentials.json" \ + --cluster "name=cluster1,id=id1,discovery=staticFile,discovery-staticFile-path=/Users/sarabee/vtadmin-cluster1.json,vtsql-discovery-tags=cell:zone1" + ``` + +1. Finally! Start up vtadmin-web on [http://localhost:3000](http://localhost:3000), pointed at the vtadmin-api server you started in the last step. + + ```bash + cd web/vtadmin + npm install + REACT_APP_VTADMIN_API_ADDRESS="http://127.0.0.1:15999" npm start + ``` # Developer guide @@ -31,6 +83,12 @@ Scripts for common and not-so-common tasks. These are always run from the `vites - [TypeScript](http://typescriptlang.org/) - [protobufjs](https://github.com/protobufjs) +## Environment Variables + +Under the hood, we use create-react-app's environment variable set-up which is very well documented: https://create-react-app.dev/docs/adding-custom-environment-variables. + +All of our environment variables are enumerated and commented in [react-app-env.d.ts](./src/react-app-env.d.ts). This also gives us type hinting on `process.env`, for editors that support it. + ## Configuring your editor ### VS Code diff --git a/web/vtadmin/package-lock.json b/web/vtadmin/package-lock.json index d61392a8ecd..cb25ffa0647 100644 --- a/web/vtadmin/package-lock.json +++ b/web/vtadmin/package-lock.json @@ -1798,6 +1798,12 @@ } } }, + "@open-draft/until": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-1.0.3.tgz", + "integrity": "sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q==", + "dev": true + }, "@pmmmwh/react-refresh-webpack-plugin": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.4.2.tgz", @@ -2281,6 +2287,12 @@ "@babel/types": "^7.3.0" } }, + "@types/cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-y7mImlc/rNkvCRmg8gC3/lj87S7pTUIJ6QGjwHR9WQJcFs+ZMTOaoPrkdFA/YdbuqVEmEbb5RdhVxMkAcgOnpg==", + "dev": true + }, "@types/eslint": { "version": "7.2.6", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.2.6.tgz", @@ -3698,8 +3710,7 @@ "binary-extensions": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.1.0.tgz", - "integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==", - "optional": true + "integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==" }, "bindings": { "version": "1.5.0", @@ -4146,7 +4157,6 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.4.3.tgz", "integrity": "sha512-DtM3g7juCXQxFVSNPNByEC2+NImtBuxQQvWlHunpJIS5Ocr0lG306cC7FCi7cEA0fzmybPUIl4txBIobk1gGOQ==", - "optional": true, "requires": { "anymatch": "~3.1.1", "braces": "~3.0.2", @@ -7293,6 +7303,12 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==" }, + "graphql": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.4.0.tgz", + "integrity": "sha512-EB3zgGchcabbsU9cFe1j+yxdzKQKAbGUWRb13DsrsMN1yyfmmIq+2+L5MqVWcDCE4V89R5AyUOi7sMOGxdsYtA==", + "dev": true + }, "growly": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", @@ -7454,6 +7470,12 @@ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==" }, + "headers-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/headers-utils/-/headers-utils-1.2.0.tgz", + "integrity": "sha512-4/BMXcWrJErw7JpM87gF8MNEXcIMLzepYZjNRv/P9ctgupl2Ywa3u1PgHtNhSRq84bHH9Ndlkdy7bSi+bZ9I9A==", + "dev": true + }, "hex-color-regex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz", @@ -8049,7 +8071,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "optional": true, "requires": { "binary-extensions": "^2.0.0" } @@ -10346,6 +10367,15 @@ "object-visit": "^1.0.0" } }, + "match-sorter": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.1.0.tgz", + "integrity": "sha512-sKPMf4kbF7Dm5Crx0bbfLpokK68PUJ/0STUIOPa1ZmTZEA3lCaPK3gapQR573oLmvdkTfGojzySkIwuq6Z6xRQ==", + "requires": { + "@babel/runtime": "^7.12.5", + "remove-accents": "0.4.2" + } + }, "mathml-tag-names": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", @@ -10845,6 +10875,138 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "msw": { + "version": "0.24.4", + "resolved": "https://registry.npmjs.org/msw/-/msw-0.24.4.tgz", + "integrity": "sha512-RKPAyfOrEVwritMn6RL5QROYhZMeRVApGuj4JdIQh/uyV0ASx349wsARJVI4VAXGEKu4B1TBvJZf0P0iVMsheg==", + "dev": true, + "requires": { + "@open-draft/until": "^1.0.3", + "@types/cookie": "^0.4.0", + "chalk": "^4.1.0", + "chokidar": "^3.4.2", + "cookie": "^0.4.1", + "graphql": "^15.4.0", + "headers-utils": "^1.2.0", + "node-fetch": "^2.6.1", + "node-match-path": "^0.6.0", + "node-request-interceptor": "^0.5.3", + "statuses": "^2.0.0", + "yargs": "^16.2.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "y18n": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.5.tgz", + "integrity": "sha512-hsRUr4FFrvhhRH12wOdfs38Gy7k2FFzB9qgN9v3aLykRq0dRcdcpz5C9FxdS2NuhOrI/628b/KSTJ3rwHysYSg==", + "dev": true + }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + }, + "yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", + "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "dev": true + } + } + }, "multicast-dns": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-6.2.3.tgz", @@ -10936,6 +11098,12 @@ } } }, + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==", + "dev": true + }, "node-forge": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", @@ -11051,6 +11219,12 @@ } } }, + "node-match-path": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/node-match-path/-/node-match-path-0.6.0.tgz", + "integrity": "sha512-mld1LbiLaufULAYFPAWgNEG4P0ccL49otlL/nbF5VBQLATuzfS1BGYV1rjRMsxbc0vcnasikFqGHoKDFMQylMw==", + "dev": true + }, "node-modules-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz", @@ -11086,6 +11260,17 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.67.tgz", "integrity": "sha512-V5QF9noGFl3EymEwUYzO+3NTDpGfQB4ve6Qfnzf3UNydMhjQRVPR1DZTuvWiLzaFJYw2fmDwAfnRNEVb64hSIg==" }, + "node-request-interceptor": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/node-request-interceptor/-/node-request-interceptor-0.5.9.tgz", + "integrity": "sha512-M1a3aulCW/kqajDn/w+qBX86G4So7utJGlrODAjQ1piz/kR8ZaDfd/wrJnsuPtUM12F0YxsnXG8qRKFkIEIxsw==", + "dev": true, + "requires": { + "@open-draft/until": "^1.0.3", + "debug": "^4.3.0", + "headers-utils": "^1.2.0" + } + }, "node-sass": { "version": "4.14.1", "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.14.1.tgz", @@ -13387,6 +13572,15 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "react-query": { + "version": "3.5.9", + "resolved": "https://registry.npmjs.org/react-query/-/react-query-3.5.9.tgz", + "integrity": "sha512-thlxrnl7cDg6qmk+N2ADjDVDJkoU3c7ZFJivYph0XoBDgkRIpb3A+tpqH7o6gu7JXZum9lfX1o294UfYfTiwvg==", + "requires": { + "@babel/runtime": "^7.5.5", + "match-sorter": "^6.0.2" + } + }, "react-refresh": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.8.3.tgz", @@ -13551,7 +13745,6 @@ "version": "3.5.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==", - "optional": true, "requires": { "picomatch": "^2.2.1" } @@ -13714,6 +13907,11 @@ "mdast-util-to-markdown": "^0.6.0" } }, + "remove-accents": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", + "integrity": "sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U=" + }, "remove-trailing-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", diff --git a/web/vtadmin/package.json b/web/vtadmin/package.json index adc2c3f2cf6..7766bf30968 100644 --- a/web/vtadmin/package.json +++ b/web/vtadmin/package.json @@ -17,6 +17,7 @@ "node-sass": "^4.14.1", "react": "^17.0.1", "react-dom": "^17.0.1", + "react-query": "^3.5.9", "react-scripts": "4.0.1", "typescript": "^4.1.3", "web-vitals": "^0.2.4" @@ -54,6 +55,7 @@ ] }, "devDependencies": { + "msw": "^0.24.4", "prettier": "^2.2.1", "protobufjs": "^6.10.2", "stylelint": "^13.8.0", diff --git a/web/vtadmin/src/api/http.test.ts b/web/vtadmin/src/api/http.test.ts new file mode 100644 index 00000000000..cfd319156a6 --- /dev/null +++ b/web/vtadmin/src/api/http.test.ts @@ -0,0 +1,178 @@ +/** + * Copyright 2021 The Vitess Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; + +import * as api from './http'; +import { vtadmin as pb } from '../proto/vtadmin'; +import { HTTP_RESPONSE_NOT_OK_ERROR, MALFORMED_HTTP_RESPONSE_ERROR } from './http'; + +// This test suite uses Mock Service Workers (https://github.com/mswjs/msw) +// to mock HTTP responses from vtadmin-api. +// +// MSW lets us intercept requests at the network level. This decouples the tests from +// whatever particular HTTP fetcher interface we are using, and obviates the need +// to mock `fetch` directly (by using a library like jest-fetch-mock, for example). +// +// MSW gives us full control over the response, including edge cases like errors, +// malformed payloads, and timeouts. +// +// The big downside to mocking or "faking" APIs like vtadmin is that +// we end up re-implementing some (or all) of vtadmin-api in our test environment. +// It is, unfortunately, impossible to completely avoid this kind of duplication +// unless we solely use e2e tests (which have their own trade-offs). +// +// That said, our use of protobufjs to validate and strongly type HTTP responses +// means our fake is more robust than it would be otherwise. Since we are using +// the exact same protos in our fake as in our real vtadmin-api server, we're guaranteed +// to have type parity. +process.env.REACT_APP_VTADMIN_API_ADDRESS = ''; +const server = setupServer(); + +const mockServerJson = (endpoint: string, json: object) => { + server.use(rest.get(endpoint, (req, res, ctx) => res(ctx.json(json)))); +}; + +// Enable API mocking before tests. +beforeAll(() => server.listen()); + +// Reset any runtime request handlers we may add during the tests. +afterEach(() => server.resetHandlers()); + +// Disable API mocking after the tests are done. +afterAll(() => server.close()); + +describe('api/http', () => { + describe('vtfetch', () => { + it('parses and returns JSON, given an HttpOkResponse response', async () => { + const endpoint = `/api/tablets`; + const response = { ok: true, result: null }; + mockServerJson(endpoint, response); + + const result = await api.vtfetch(endpoint); + expect(result).toEqual(response); + }); + + it('parses and returns JSON, given an HttpErrorResponse response', async () => { + const endpoint = `/api/tablets`; + const response = { ok: false }; + mockServerJson(endpoint, response); + + const result = await api.vtfetch(endpoint); + expect(result).toEqual(response); + }); + + it('throws an error on malformed JSON', async () => { + const endpoint = `/api/tablets`; + server.use(rest.get(endpoint, (req, res, ctx) => res(ctx.body('this is fine')))); + + expect.assertions(2); + + try { + await api.vtfetch(endpoint); + } catch (e) { + /* eslint-disable jest/no-conditional-expect */ + expect(e.name).toEqual('SyntaxError'); + expect(e.message.startsWith('Unexpected token')).toBeTruthy(); + /* eslint-enable jest/no-conditional-expect */ + } + }); + + it('throws an error on malformed response envelopes', async () => { + const endpoint = `/api/tablets`; + mockServerJson(endpoint, { foo: 'bar' }); + + expect.assertions(1); + + try { + await api.vtfetch(endpoint); + } catch (e) { + /* eslint-disable jest/no-conditional-expect */ + expect(e.name).toEqual(MALFORMED_HTTP_RESPONSE_ERROR); + /* eslint-enable jest/no-conditional-expect */ + } + }); + }); + + describe('fetchTablets', () => { + it('returns a list of Tablets, given a successful response', async () => { + const t0 = pb.Tablet.create({ tablet: { hostname: 't0' } }); + const t1 = pb.Tablet.create({ tablet: { hostname: 't1' } }); + const t2 = pb.Tablet.create({ tablet: { hostname: 't2' } }); + const tablets = [t0, t1, t2]; + + mockServerJson(`/api/tablets`, { + ok: true, + result: { + tablets: tablets.map((t) => t.toJSON()), + }, + }); + + const result = await api.fetchTablets(); + expect(result).toEqual(tablets); + }); + + it('throws an error if response.ok is false', async () => { + const response = { ok: false }; + mockServerJson('/api/tablets', response); + + expect.assertions(3); + + try { + await api.fetchTablets(); + } catch (e) { + /* eslint-disable jest/no-conditional-expect */ + expect(e.name).toEqual(HTTP_RESPONSE_NOT_OK_ERROR); + expect(e.message).toEqual('/api/tablets'); + expect(e.response).toEqual(response); + /* eslint-enable jest/no-conditional-expect */ + } + }); + + it('throws an error if result.tablets is not an array', async () => { + mockServerJson('/api/tablets', { ok: true, result: { tablets: null } }); + + expect.assertions(1); + + try { + await api.fetchTablets(); + } catch (e) { + /* eslint-disable jest/no-conditional-expect */ + expect(e.message).toMatch('expected tablets to be an array'); + /* eslint-enable jest/no-conditional-expect */ + } + }); + + it('throws an error if JSON cannot be unmarshalled into Tablet objects', async () => { + mockServerJson(`/api/tablets`, { + ok: true, + result: { + tablets: [{ cluster: 'this should be an object, not a string' }], + }, + }); + + expect.assertions(1); + + try { + await api.fetchTablets(); + } catch (e) { + /* eslint-disable jest/no-conditional-expect */ + expect(e.message).toEqual('cluster.object expected'); + /* eslint-enable jest/no-conditional-expect */ + } + }); + }); +}); diff --git a/web/vtadmin/src/api/http.ts b/web/vtadmin/src/api/http.ts new file mode 100644 index 00000000000..dceb9ae3e98 --- /dev/null +++ b/web/vtadmin/src/api/http.ts @@ -0,0 +1,87 @@ +/** + * Copyright 2021 The Vitess Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { vtadmin as pb } from '../proto/vtadmin'; + +interface HttpOkResponse { + ok: true; + result: any; +} + +interface HttpErrorResponse { + ok: false; +} + +type HttpResponse = HttpOkResponse | HttpErrorResponse; + +export const MALFORMED_HTTP_RESPONSE_ERROR = 'MalformedHttpResponseError'; +class MalformedHttpResponseError extends Error { + responseJson: object; + + constructor(message: string, responseJson: object) { + super(message); + this.name = MALFORMED_HTTP_RESPONSE_ERROR; + this.responseJson = responseJson; + } +} + +export const HTTP_RESPONSE_NOT_OK_ERROR = 'HttpResponseNotOkError'; +class HttpResponseNotOkError extends Error { + response: HttpErrorResponse | null; + + constructor(endpoint: string, response: HttpErrorResponse) { + super(endpoint); + this.name = HTTP_RESPONSE_NOT_OK_ERROR; + this.response = response; + } +} + +// vtfetch makes HTTP requests against the given vtadmin-api endpoint +// and returns the parsed response. +// +// HttpResponse envelope types are not defined in vtadmin.proto (nor should they be) +// thus we have to validate the shape of the API response with more care. +// +// Note that this only validates the HttpResponse envelope; it does not +// do any type checking or validation on the result. +export const vtfetch = async (endpoint: string): Promise => { + const url = `${process.env.REACT_APP_VTADMIN_API_ADDRESS}${endpoint}`; + const response = await fetch(url); + + const json = await response.json(); + if (!('ok' in json)) throw new MalformedHttpResponseError('invalid http envelope', json); + + return json as HttpResponse; +}; + +export const fetchTablets = async () => { + const endpoint = '/api/tablets'; + const res = await vtfetch(endpoint); + + // Throw "not ok" responses so that react-query correctly interprets them as errors. + // See https://react-query.tanstack.com/guides/query-functions#handling-and-throwing-errors + if (!res.ok) throw new HttpResponseNotOkError(endpoint, res); + + const tablets = res.result?.tablets; + if (!Array.isArray(tablets)) throw Error(`expected tablets to be an array, got ${tablets}`); + + return tablets.map((t: any) => { + const err = pb.Tablet.verify(t); + if (err) throw Error(err); + + return pb.Tablet.create(t); + }); +}; diff --git a/web/vtadmin/src/components/App.tsx b/web/vtadmin/src/components/App.tsx index 8c74240c390..e6438e67daa 100644 --- a/web/vtadmin/src/components/App.tsx +++ b/web/vtadmin/src/components/App.tsx @@ -18,84 +18,29 @@ import * as React from 'react'; import style from './App.module.scss'; import logo from '../img/vitess-icon-color.svg'; import { TabletList } from './TabletList'; -import { vtadmin as pb, topodata } from '../proto/vtadmin'; +import { useTablets } from '../hooks/api'; export const App = () => { + const { data, error, isError, isSuccess } = useTablets(); + + // Placeholder UI :D + let content =
Loading...
; + if (isError) { + content = ( +
+ {error?.name}: {error?.message} +
+ ); + } else if (isSuccess) { + content = ; + } + return (
logo

VTAdmin

- + + {content}
); }; - -// Add some fake data solely for this super duper simple hello world example. -// When we do this for reals, we'll be fetching cluster JSON over HTTP -// from vtadmin-api and casting/validating the responses to Cluster objects. -// This requires merging https://github.com/vitessio/vitess/pull/7187 first. -// -// Long-term, we can use grpc in the browser with a library like https://github.com/grpc/grpc-web, -// which will obviate the casting/validation steps, too. -const fakeTablets = [ - { - cluster: { id: 'prod', name: 'prod' }, - tablet: { - hostname: 'tablet-prod-commerce-00-00-abcd', - keyspace: 'commerce', - shard: '-', - type: topodata.TabletType.MASTER, - }, - state: pb.Tablet.ServingState.SERVING, - }, - { - cluster: { id: 'prod', name: 'prod' }, - tablet: { - hostname: 'tablet-prod-commerce-00-00-efgh', - keyspace: 'commerce', - shard: '-', - type: topodata.TabletType.REPLICA, - }, - state: pb.Tablet.ServingState.SERVING, - }, - { - cluster: { id: 'prod', name: 'prod' }, - tablet: { - hostname: 'tablet-prod-commerce-00-00-ijkl', - keyspace: 'commerce', - shard: '-', - type: topodata.TabletType.REPLICA, - }, - state: pb.Tablet.ServingState.SERVING, - }, - { - cluster: { id: 'dev', name: 'dev' }, - tablet: { - hostname: 'tablet-dev-commerce-00-00-mnop', - keyspace: 'commerce', - shard: '-', - type: topodata.TabletType.MASTER, - }, - state: pb.Tablet.ServingState.SERVING, - }, - { - cluster: { id: 'dev', name: 'dev' }, - tablet: { - hostname: 'tablet-dev-commerce-00-00-qrst', - keyspace: 'commerce', - shard: '-', - type: topodata.TabletType.REPLICA, - }, - state: pb.Tablet.ServingState.SERVING, - }, - { - cluster: { id: 'dev', name: 'dev' }, - tablet: { - hostname: 'tablet-dev-commerce-00-00-uvwx', - keyspace: 'commerce', - shard: '-', - type: topodata.TabletType.REPLICA, - }, - state: pb.Tablet.ServingState.SERVING, - }, -].map((opts) => pb.Tablet.create(opts)); diff --git a/web/vtadmin/src/hooks/api.ts b/web/vtadmin/src/hooks/api.ts new file mode 100644 index 00000000000..c8c783ff439 --- /dev/null +++ b/web/vtadmin/src/hooks/api.ts @@ -0,0 +1,9 @@ +import { useQuery } from 'react-query'; +import { fetchTablets } from '../api/http'; +import { vtadmin as pb } from '../proto/vtadmin'; + +export const useTablets = () => { + return useQuery(['tablets'], async () => { + return await fetchTablets(); + }); +}; diff --git a/web/vtadmin/src/index.tsx b/web/vtadmin/src/index.tsx index e6ba2e48a86..fdfa229a4a5 100644 --- a/web/vtadmin/src/index.tsx +++ b/web/vtadmin/src/index.tsx @@ -15,12 +15,18 @@ */ import React from 'react'; import ReactDOM from 'react-dom'; +import { QueryClient, QueryClientProvider } from 'react-query'; + import './index.css'; import { App } from './components/App'; +const queryClient = new QueryClient(); + ReactDOM.render( - + + + , document.getElementById('root') ); diff --git a/web/vtadmin/src/react-app-env.d.ts b/web/vtadmin/src/react-app-env.d.ts index ae651b93b89..56282a3b44f 100644 --- a/web/vtadmin/src/react-app-env.d.ts +++ b/web/vtadmin/src/react-app-env.d.ts @@ -3,6 +3,10 @@ declare namespace NodeJS { interface ProcessEnv { NODE_ENV: 'development' | 'production' | 'test'; PUBLIC_URL: string; + + // Required. The full address of vtadmin-api's HTTP interface. + // Example: "http://127.0.0.1:12345" + REACT_APP_VTADMIN_API_ADDRESS: string; } }