diff --git a/.gitignore b/.gitignore index 106d21b5..d8f1a031 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,7 @@ node_modules .DS_Store lib dist +umd +build coverage .idea diff --git a/README.md b/README.md index 155b81c2..9bbc41aa 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ It can be used as a browser extension (for [Chrome](https://chrome.google.com/we - [Manual Integration as a React Component](./docs/Walkthrough.md#manual-integration) - [Extension Options (Arguments)](https://github.com/zalmoxisus/redux-devtools-extension/tree/master/docs/API/Arguments.md) - [Extension Methods (Advanced API)](https://github.com/zalmoxisus/redux-devtools-extension/tree/master/docs/API/Methods.md) +- [Remote monitoring](./docs/Integrations/Remote.md) - [Troubleshooting](https://github.com/zalmoxisus/redux-devtools-extension/tree/master/docs/Troubleshooting.md) - [Recipes](https://github.com/zalmoxisus/redux-devtools-extension/tree/master/docs/Recipes.md) - [FAQ](https://github.com/zalmoxisus/redux-devtools-extension/tree/master/docs/FAQ.md) diff --git a/docs/Integrations/Remote.md b/docs/Integrations/Remote.md new file mode 100644 index 00000000..43ad6598 --- /dev/null +++ b/docs/Integrations/Remote.md @@ -0,0 +1,157 @@ +## Remote monitoring + +By installing [`redux-devtools-cli`](https://github.com/reduxjs/redux-devtools/tree/master/packages/redux-devtools-cli#usage), starting the server server and launching the Redux DevTools app (`redux-devtools --open`), you can connect any remote application, even not javascript. There are some integrations for javascript like [remote-redux-devtools](https://github.com/zalmoxisus/remote-redux-devtools) and [remotedev](https://github.com/zalmoxisus/remotedev), but the plan is to deprecate them and support it out of the box from the extension without a websocket server. It is more useful for non-js apps. + +### WebSocket Clients + +We're using [SocketCluster](http://socketcluster.io/) for realtime communication, which provides a fast and scalable webSocket layer and a minimal pub/sub system. You need to include one of [its clients](https://github.com/SocketCluster/client-drivers) in your app to communicate with RemotedevServer. Currently there are clients for [JavaScript (NodeJS)](https://github.com/SocketCluster/socketcluster-client), [Java](https://github.com/sacOO7/socketcluster-client-java), [Python](https://github.com/sacOO7/socketcluster-client-python), [C](https://github.com/sacOO7/socketcluster-client-C), [Objective-C](https://github.com/abpopov/SocketCluster-ios-client) and [.NET/C#](https://github.com/sacOO7/SocketclusterClientDotNet). + +By default, the websocket server is running on `ws://localhost:8000/socketcluster/`. + +### Messaging lifecycle + +#### 1. Connecting to the WebSocket server + +The client driver provides a way to connect to the server via websockets (see the docs for the selected client). + +##### JavaScript +```js +var socket = socketCluster.connect({ + hostname: 'localhost', + port: 8000 +}); +``` + +##### Python +```py +socket = Socketcluster.socket("ws://localhost:8000/socketcluster/") +socket.connect() +``` + +> Note that JavaScript client composes the url from `hostname` and `port`, adding `/socketcluster/` path automatically. For other clients, you should specify that path. For example, for `ObjectiveC` it would be `self.client.initWithHost("localhost/socketcluster/", onPort: 8000, securely: false)`. + +#### 2. Disconnecting and reconnecting + +SocketCluster client handles reconnecting for you, but you still might want to know when the connection is established, or when it failed to connect. + +##### JavaScript +```js +socket.on('connect', status => { + // Here will come the next step +}); +socket.on('disconnect', code => { + console.warn('Socket disconnected with code', code); +}); +socket.on('error', error => { + console.warn('Socket error', error); +}); +``` + +##### Python +```py +def onconnect(socket): + // Here will call the next step + +def ondisconnect(socket): + logging.info("on disconnect got called") + +def onConnectError(socket, error): + logging.info("On connect error got called") + +socket.setBasicListener(onconnect, ondisconnect, onConnectError) +``` + +#### 3. Authorizing and subscribing to the channel of events + +We're not providing an authorizing mechanism yet. All you have to do is to emit a `login` event, and you'll get a `channelName` you should subscribe for, and watch for messages and events. Make sure to pass the `master` event, otherwise it should be a monitor, not a client app. + +##### JavaScript +```js +socket.emit('login', 'master', (error, channelName) => { + if (error) { console.log(error); return; } + channel = socket.subscribe(channelName); + channel.watch(handleMessages); + socket.on(channelName, handleMessages); +}); + +function handleMessages(message) { + // 5. Listening for monitor events +} +``` + +##### Python +```py +socket.emitack("login", "master", login) + +def login(key, error, channelName): + socket.subscribe(channelName) + socket.onchannel(channelName, handleMessages) + socket.on(channelName, handleMessages) + +def handleMessages(key, message): + // 5. Listening for monitor events +``` + +You could just emit the `login` event, and omit subscribing (and point `5` bellow) if you want only to log data, not to interact with te app. + +#### 4. Sending the action and state to the monitor + +To send your data to the monitor use `log` or `log-noid` channel. The latter will add the socket id to the message from the server side (useful when the message was sent before the connection was established). + +The message object includes the following: +- `type` - usually should be `ACTION`. If you want to indicate that we're starting a new log (clear all actions emitted before and add `@@INIT`), use `INIT`. In case you have a lifted state similar to one provided by [`redux-devtools-instrument`](https://github.com/zalmoxisus/redux-devtools-instrument), use `STATE`. +- `action` - the action object. It is recommended to lift it in another object, and add `timestamp` to show when the action was fired off: `{ timestamp: Date.now(), action: { type: 'SOME_ACTION' } }`. +- `payload` - usually the state or lifted state object. +- `name` - name of the instance to be shown in the instances selector. If not provided, it will be equal to `instanceId`. +- `instanceId` - an id to identify the instance. If not provided, it will be the same as `id`. However, it is useful when having several instances (or stores) in the same connection. Also if the user will specify a constant value, it would allow to persist the state on app reload. +- `id` - socket connection id, which should be either `socket.id` or should not provided and use `log-noid` channel. + +##### JavaScript +```js +const message = { + type: 'ACTION', + action: { action, timestamp: Date.now() }, + payload: state, + id: socket.id, + instanceId: window.btoa(location.href), + name: document.title +}; +socket.emit(socket.id ? 'log' : 'log-noid', message); +``` + +##### Python +```py +class Message: + def __init__(self, action, state): + self.type = "ACTION" + self.action = action + self.payload = state + id: socket.id +socket.emit(socket.id if "log" else "log-noid", Message(action, state)); +``` + +#### 5. Listening for monitor events + +When a monitor action is emitted, you'll get an event on the subscribed function. The argument object includes a `type` key, which can be: +- `DISPATCH` - a monitor action dispatched on Redux DevTools monitor, like `{ type: 'DISPATCH', payload: { type: 'JUMP_TO_STATE', 'index': 2 }`. See [`redux-devtools-instrument`](https://github.com/zalmoxisus/redux-devtools-instrument/blob/master/src/instrument.js) for details. Additionally to that API, you'll get also a stringified `state` object when needed. So, for example, for time travelling (`JUMP_TO_STATE`) you can just parse and set the state (see the example). Usually implementing this type of actions would be enough. +- `ACTION` - the user requested to dispatch an action remotely like `{ type: 'ACTION', action: '{ type: \'INCREMENT_COUNTER\' }' }`. The `action` can be either a stringified javascript object which should be evalled or a function which arguments should be evalled like [here](https://github.com/zalmoxisus/remotedev-utils/blob/master/src/index.js#L62-L70). +- `START` - a monitor was opened. You could handle this event in order not to do extra tasks when the app is not monitored. +- `STOP` - a monitor was closed. You can take this as no need to send data to the monitor. I there are several monitors and one was closed, all others will send `START` event to acknowledge that we still have to send data. + +See [`mobx-remotedev`](https://github.com/zalmoxisus/mobx-remotedev/blob/master/src/monitorActions.js) for an example of implementation without [`redux-devtools-instrument`](https://github.com/zalmoxisus/redux-devtools-instrument/blob/master/src/instrument.js). + +##### JavaScript +```js +function handleMessages(message) { + if (message.type === 'DISPATCH' && message.payload.type === 'JUMP_TO_STATE') { + store.setState(JSON.parse(message.state)); + } +} +``` + +##### Python +```py +def handleMessages(key, message): + if message.type === "DISPATCH" and message.payload.type === "JUMP_TO_STATE": + store.setState(json.loads(message.state)); +``` diff --git a/package.json b/package.json index 289ba1c2..25da808c 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,12 @@ "babel-eslint": "^10.0.0", "eslint-plugin-react": "7.4.0", "eslint-plugin-flowtype": "3.2.0", - "lerna": "3.4.2" + "lerna": "3.4.2", + "pre-commit": "^1.1.3" }, "scripts": { "lerna": "lerna", - "prepare": "lerna run prepare --since master --stream --sort -- --scripts-prepend-node-path", + "build": "lerna run prepare --since master --stream --sort -- --scripts-prepend-node-path", "build:all": "lerna run build", "publish": "lerna publish", "canary": "lerna publish --canary preminor --npm-tag alpha", @@ -20,5 +21,8 @@ }, "workspaces": [ "packages/*" + ], + "pre-commit": [ + "lint" ] } diff --git a/packages/devui/.storybook/themeAddon/constant.js b/packages/devui/.storybook/themeAddon/constant.js index 6a351959..1e557be4 100644 --- a/packages/devui/.storybook/themeAddon/constant.js +++ b/packages/devui/.storybook/themeAddon/constant.js @@ -1,7 +1,7 @@ -export const ADDON_ID = 'remotedev-themes-storybook'; +export const ADDON_ID = 'redux-devtools-themes-storybook'; export const PANEL_ID = `${ADDON_ID}/panel`; export const EVENT_ID_DATA = `${ADDON_ID}/event/data`; -export const CSS_CLASS = 'remotedev-storybook'; +export const CSS_CLASS = 'redux-devtools-storybook'; export const DEFAULT_THEME_STATE = { theme: 'default', scheme: 'default', diff --git a/packages/devui/package.json b/packages/devui/package.json index 3d33d0e3..e2cb7548 100755 --- a/packages/devui/package.json +++ b/packages/devui/package.json @@ -89,7 +89,6 @@ "simple-element-resize-detector": "^1.1.0", "styled-components": "^2.2.2" }, - "pre-commit": ["lint"], "jest": { "setupTestFrameworkScriptFile": "/tests/setup.js" }, diff --git a/packages/redux-devtools-cli/LICENSE.md b/packages/redux-devtools-cli/LICENSE.md new file mode 100644 index 00000000..1a68f555 --- /dev/null +++ b/packages/redux-devtools-cli/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Mihail Diordiev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/redux-devtools-cli/README.md b/packages/redux-devtools-cli/README.md new file mode 100644 index 00000000..b1051759 --- /dev/null +++ b/packages/redux-devtools-cli/README.md @@ -0,0 +1,162 @@ +Redux DevTools Command Line Interface +===================================== + +Bridge for remote debugging via [Redux DevTools extension](https://github.com/zalmoxisus/redux-devtools-extension), [Remote Redux DevTools](https://github.com/zalmoxisus/remote-redux-devtools) or [RemoteDev](https://github.com/zalmoxisus/remotedev). + +### Usage + +#### Install the package globally + +with npm: + +``` +npm install -g redux-devtools-cli +``` + +or with yarn: + +``` +yarn global add redux-devtools-cli +``` + +and start as: + +``` +redux-devtools --hostname=localhost --port=8000 +``` + +> Note the package is called `redux-devtools-cli` not `redux-devtools` (the latter is a React component). + +#### Or add in your project + +with npm: + +``` +npm install --save-dev redux-devtools-cli +``` + +or with yarn: + +``` +yarn add --dev redux-devtools-cli +``` + +and add to `package.json`: + +``` +"scripts": { + "redux-devtools": "redux-devtools --hostname=localhost --port=8000" +} +``` + +So, you can start redux-devtools server by running `npm run redux-devtools`. + +##### Import in your `server.js` script you use for starting a development server: + +```js +var reduxDevTools = require('redux-devtools-cli'); +reduxDevTools({ hostname: 'localhost', port: 8000 }); +``` + +So, you can start redux-devtools server together with your dev server. + +### Open Redux DevTools + +You can add `--open` argument (or set it as `electron`) to open Redux DevTools as a standalone application: + +``` +redux-devtools --open +``` + +Set it as `browser` to open as a web app in the default browser instead: + +``` +redux-devtools --open=browser +``` + +To specify the browser: + +``` +redux-devtools --open=firefox +``` + +### Connection settings + +Set `hostname` and `port` to the values you want. `hostname` by default is `localhost` and `port` is `8000`. + +To use WSS, set `protocol` argument to `https` and provide `key`, `cert` and `passphrase` arguments. + + +#### Available options + +| Console argument | description | default value | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| `--hostname` | hostname | localhost | +| `--port` | port | 8000 | +| `--protocol` | protocol | http | +| `--key` | the key file for [running an https server](https://github.com/SocketCluster/socketcluster#using-over-https) (`--protocol` must be set to 'https') | - | +| `--cert` | the cert file for [running an https server](https://github.com/SocketCluster/socketcluster#using-over-https) (`--protocol` must be set to 'https') | - | +| `--passphrase` | the key passphrase for [running an https server](https://github.com/SocketCluster/socketcluster#using-over-https) (`--protocol` must be set to 'https') | - | +| `--dbOptions` | database configuration, can be whether an object or a path (string) to json configuration file (by default it uses our `./defaultDbOptions.json` file. Set `migrate` key to `true` to use our migrations file. [More details bellow](#save-reports-and-logs). | - | +| `--logLevel` | the socket server log level - 0=none, 1=error, 2=warn, 3=info | 3 | +| `--wsEngine` | the socket server web socket engine - ws or uws (sc-uws) | ws | +| `--open` | open Redux DevTools as a standalone application or as web app. See [Open Redux DevTools](#open-redux-devtools) for details. | false | + +### Inject to React Native local server + +##### Add in your React Native app's `package.json`: + +``` +"scripts": { + "redux-devtools": "redux-devtools --hostname=localhost --port=8000 --injectserver=reactnative" +} +``` + +The `injectserver` value can be `reactnative` or `macos` ([react-native-macos](https://github.com/ptmt/react-native-macos)), it used `reactnative` by default. + +Then, we can start React Native server and Redux DevTools server with one command (`npm start`). + +##### Revert the injection + +Add in your React Native app's `package.json`: + +``` +"scripts": { + "redux-devtools-revert": "redux-devtools --revert=reactnative" +} +``` + +Or just run `$(npm bin)/redux-devtools --revert`. + +### Connect from Android device or emulator + +> Note that if you're using `injectserver` argument explained above, this step is not necessary. + +If you're running an Android 5.0+ device connected via USB or an Android emulator, use [adb command line tool](http://developer.android.com/tools/help/adb.html) to setup port forwarding from the device to your computer: + +``` +adb reverse tcp:8000 tcp:8000 +``` + +If you're still use Android 4.0, you should use `10.0.2.2` (Genymotion: `10.0.3.2`) instead of `localhost` in [remote-redux-devtools](https://github.com/zalmoxisus/remote-redux-devtools#storeconfigurestorejs) or [remotedev](https://github.com/zalmoxisus/remotedev#usage). + +### Save reports and logs + +You can store reports via [`redux-remotedev`](https://github.com/zalmoxisus/redux-remotedev) and get them replicated with [Redux DevTools extension](https://github.com/zalmoxisus/redux-devtools-extension) or [Remote Redux DevTools](https://github.com/zalmoxisus/remote-redux-devtools). You can get action history right in the extension just by clicking the link from a report. Open `http://localhost:8000/graphiql` (assuming you're using `localhost` as host and `8000`) to explore in GraphQL. Reports are posted to `http://localhost:8000/`. See examples in [tests](https://github.com/zalmoxisus/remotedev-server/blob/937cfa1f0ac9dc12ebf7068eeaa8b03022ec33bc/test/integration.spec.js#L110-L165). + +Redux DevTools server is database agnostic using `knex` schema. By default everything is stored in the memory using sqlite database. See [`defaultDbOptions.json`](https://github.com/reduxjs/redux-devtools/tree/master/packages/redux-devtools-cli/defaultDbOptions.json) for example of sqlite. You can replace `"connection": { "filename": ":memory:" },` with your file name (instead of `:memory:`) to persist teh database. Here's an example for PostgreSQL: +``` +{ + "client": "pg", + "connection": { "user": "myuser", "password": "mypassword", "database": "mydb" }, + "debug": false, + "migrate": true +} +``` + +### Advanced +- [Writing your integration for a native application](https://github.com/reduxjs/redux-devtools/blob/master/docs/Integrations/Remote.md) + +### License + +MIT diff --git a/packages/redux-devtools-cli/app/electron.js b/packages/redux-devtools-cli/app/electron.js new file mode 100644 index 00000000..056873a2 --- /dev/null +++ b/packages/redux-devtools-cli/app/electron.js @@ -0,0 +1,50 @@ +// Based on https://github.com/electron/electron-quick-start + +const { app, BrowserWindow } = require('electron') +const argv = require('minimist')(process.argv.slice(2)); + +let mainWindow; + +function createWindow () { + mainWindow = new BrowserWindow({ + width: 800, + height: 600, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + webSecurity: true + } + }) + + // mainWindow.loadFile('index.html') + mainWindow.loadURL('http://localhost:'+ (argv.port? argv.port: 8000) ); + + // Open the DevTools. + // mainWindow.webContents.openDevTools() + + mainWindow.on('closed', function () { + // Dereference the window object, usually you would store windows + // in an array if your app supports multi windows, this is the time + // when you should delete the corresponding element. + mainWindow = null + }) +} + +app.on('ready', createWindow) + +app.on('window-all-closed', function () { + // On macOS it is common for applications and their menu bar + // to stay active until the user quits explicitly with Cmd + Q + if (process.platform !== 'darwin') { + app.quit() + } +}) + +app.on('activate', function () { + // On macOS it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + if (mainWindow === null) { + createWindow() + } +}) diff --git a/packages/redux-devtools-cli/app/index.html b/packages/redux-devtools-cli/app/index.html new file mode 100644 index 00000000..6fd164fe --- /dev/null +++ b/packages/redux-devtools-cli/app/index.html @@ -0,0 +1,46 @@ + + + + + Redux DevTools + + + +
+ + + + + + + diff --git a/packages/redux-devtools-cli/app/package.json b/packages/redux-devtools-cli/app/package.json new file mode 100644 index 00000000..7b98eaba --- /dev/null +++ b/packages/redux-devtools-cli/app/package.json @@ -0,0 +1,8 @@ +{ + "private": true, + "name": "redux-devtools-cli", + "version": "0.0.1", + "main": "electron.js", + "description": "Remote Redux DevTools", + "authors": "Mihail Diordiev" +} diff --git a/packages/redux-devtools-cli/bin/injectServer.js b/packages/redux-devtools-cli/bin/injectServer.js new file mode 100644 index 00000000..43dc31ae --- /dev/null +++ b/packages/redux-devtools-cli/bin/injectServer.js @@ -0,0 +1,98 @@ +var fs = require('fs'); +var path = require('path'); +var semver = require('semver'); + +var name = 'redux-devtools-cli'; +var startFlag = '/* ' + name + ' start */'; +var endFlag = '/* ' + name + ' end */'; +var serverFlags = { + 'react-native': { + '0.0.1': ' _server(argv, config, resolve, reject);', + '0.31.0': " runServer(args, config, () => console.log('\\nReact packager ready.\\n'));", + '0.44.0-rc.0': ' runServer(args, config, startedCallback, readyCallback);', + '0.46.0-rc.0': ' runServer(runServerArgs, configT, startedCallback, readyCallback);', + '0.57.0': ' runServer(args, configT);' + }, + 'react-native-desktop': { + '0.0.1': ' _server(argv, config, resolve, reject);' + } +}; + +function getModuleVersion(modulePath) { + return JSON.parse( + fs.readFileSync( + path.join(modulePath, 'package.json'), + 'utf-8' + ) + ).version; +} + +function getServerFlag(moduleName, version) { + var flags = serverFlags[moduleName || 'react-native']; + var versions = Object.keys(flags); + var flag; + for (var i = 0; i < versions.length; i++) { + if (semver.gte(version, versions[i])) { + flag = flags[versions[i]]; + } + } + return flag; +} + +exports.dir = 'local-cli/server'; +exports.file = 'server.js'; +exports.fullPath = path.join(exports.dir, exports.file); + +exports.inject = function(modulePath, options, moduleName) { + var filePath = path.join(modulePath, exports.fullPath); + if (!fs.existsSync(filePath)) return false; + + var serverFlag = getServerFlag( + moduleName, + getModuleVersion(modulePath) + ); + var code = [ + startFlag, + ' require("' + name + '")(' + JSON.stringify(options) + ')', + ' .then(_remotedev =>', + ' _remotedev.on("ready", () => {', + ' if (!_remotedev.portAlreadyUsed) console.log("-".repeat(80));', + ' ' + serverFlag, + ' })', + ' );', + endFlag, + ].join('\n'); + + var serverCode = fs.readFileSync(filePath, 'utf-8'); + var start = serverCode.indexOf(startFlag); // already injected ? + var end = serverCode.indexOf(endFlag) + endFlag.length; + if (start === -1) { + start = serverCode.indexOf(serverFlag); + end = start + serverFlag.length; + } + fs.writeFileSync( + filePath, + serverCode.substr(0, start) + code + serverCode.substr(end, serverCode.length) + ); + return true; +}; + +exports.revert = function(modulePath, moduleName) { + var filePath = path.join(modulePath, exports.fullPath); + if (!fs.existsSync(filePath)) return false; + + var serverFlag = getServerFlag( + moduleName, + getModuleVersion(modulePath) + ); + var serverCode = fs.readFileSync(filePath, 'utf-8'); + var start = serverCode.indexOf(startFlag); // already injected ? + var end = serverCode.indexOf(endFlag) + endFlag.length; + if (start !== -1) { + fs.writeFileSync( + filePath, + serverCode.substr(0, start) + serverFlag + serverCode.substr(end, serverCode.length) + ); + } + return true; +}; diff --git a/packages/redux-devtools-cli/bin/open.js b/packages/redux-devtools-cli/bin/open.js new file mode 100644 index 00000000..c1ec762a --- /dev/null +++ b/packages/redux-devtools-cli/bin/open.js @@ -0,0 +1,30 @@ +var opn = require('opn'); +var path = require('path'); +var spawn = require('cross-spawn'); + +function open(app, options) { + if (app === true || app === 'electron') { + try { + spawn.sync( + require('electron'), + [path.join(__dirname, '..', 'app')] + ); + } catch (error) { + if (error.message === 'Cannot find module \'electron\'') { + // TODO: Move electron to dev-dependences to make our package installation faster when not needed. + console.log(' \x1b[1;31m[Warn]\x1b[0m Electron module not installed.\n'); + /* + We will use "npm" to install Electron via "npm install -D". + Do you want to install 'electron' (yes/no): yes + Installing 'electron' (running 'npm install -D webpack-cli')... + */ + } else { + console.log(error); + } + } + return; + } + opn('http://localhost:' + options.port + '/', app !== 'browser' ? { app: app } : undefined); +} + +module.exports = open; diff --git a/packages/redux-devtools-cli/bin/redux-devtools.js b/packages/redux-devtools-cli/bin/redux-devtools.js new file mode 100755 index 00000000..45a1fa8e --- /dev/null +++ b/packages/redux-devtools-cli/bin/redux-devtools.js @@ -0,0 +1,82 @@ +#! /usr/bin/env node +var fs = require('fs'); +var path = require('path'); +var argv = require('minimist')(process.argv.slice(2)); +var chalk = require('chalk'); +var injectServer = require('./injectServer'); +var getOptions = require('./../src/options'); +var server = require('../index'); +var open = require('./open'); + +var options = getOptions(argv); + +function readFile(filePath) { + return fs.readFileSync(path.resolve(process.cwd(), filePath), 'utf-8'); +} + +if (argv.protocol === 'https') { + argv.key = argv.key ? readFile(argv.key) : null; + argv.cert = argv.cert ? readFile(argv.cert) : null; +} + +function log(pass, msg) { + var prefix = pass ? chalk.green.bgBlack('PASS') : chalk.red.bgBlack('FAIL'); + var color = pass ? chalk.blue : chalk.red; + console.log(prefix, color(msg)); +} + +function getModuleName(type) { + switch (type) { + case 'macos': + return 'react-native-macos'; + // react-native-macos is renamed from react-native-desktop + case 'desktop': + return 'react-native-desktop'; + case 'reactnative': + default: + return 'react-native'; + } +} + +function getModulePath(moduleName) { + return path.join(process.cwd(), 'node_modules', moduleName); +} + +function getModule(type) { + var moduleName = getModuleName(type); + var modulePath = getModulePath(moduleName); + if (type === 'desktop' && !fs.existsSync(modulePath)) { + moduleName = getModuleName('macos'); + modulePath = getModulePath(moduleName); + } + return { + name: moduleName, + path: modulePath + }; +} + +if (argv.revert) { + var module = getModule(argv.revert); + var pass = injectServer.revert(module.path, module.name); + var msg = 'Revert injection of ReduxDevTools server from React Native local server'; + log(pass, msg + (!pass ? ', the file `' + path.join(module.name, injectServer.fullPath) + '` not found.' : '.')); + + process.exit(pass ? 0 : 1); +} + +if (argv.injectserver) { + var module = getModule(argv.injectserver); + var pass = injectServer.inject(module.path, options, module.name); + var msg = 'Inject ReduxDevTools server into React Native local server'; + log(pass, msg + (pass ? '.' : ', the file `' + path.join(module.name, injectServer.fullPath) + '` not found.')); + + process.exit(pass ? 0 : 1); +} + +server(argv).then(function (r) { + if (argv.open && argv.open !== 'false') { + r.on('ready', function () { + open(argv.open, options); + }); + } +}); diff --git a/packages/redux-devtools-cli/defaultDbOptions.json b/packages/redux-devtools-cli/defaultDbOptions.json new file mode 100644 index 00000000..4ea45a9b --- /dev/null +++ b/packages/redux-devtools-cli/defaultDbOptions.json @@ -0,0 +1,13 @@ +{ + "client": "sqlite3", + "connection": { "filename": ":memory:" }, + "pool": { + "min": 1, + "max": 1, + "idleTimeoutMillis": 360000000, + "disposeTimeout": 360000000 + }, + "useNullAsDefault": true, + "debug": false, + "migrate": true +} diff --git a/packages/redux-devtools-cli/index.js b/packages/redux-devtools-cli/index.js new file mode 100644 index 00000000..dad76046 --- /dev/null +++ b/packages/redux-devtools-cli/index.js @@ -0,0 +1,40 @@ +var getPort = require('getport'); +var SocketCluster = require('socketcluster'); +var getOptions = require('./src/options'); + +var LOG_LEVEL_NONE = 0; +var LOG_LEVEL_ERROR = 1; +var LOG_LEVEL_WARN = 2; +var LOG_LEVEL_INFO = 3; + +module.exports = function(argv) { + var options = Object.assign(getOptions(argv), { + workerController: __dirname + '/src/worker.js', + allowClientPublish: false + }); + var port = options.port; + var logLevel = options.logLevel === undefined ? LOG_LEVEL_INFO : options.logLevel; + return new Promise(function(resolve) { + // Check port already used + getPort(port, function(err, p) { + if (err) { + if (logLevel >= LOG_LEVEL_ERROR) { + console.error(err); + } + return; + } + if (port !== p) { + if (logLevel >= LOG_LEVEL_WARN) { + console.log('[ReduxDevTools] Server port ' + port + ' is already used.'); + } + resolve({ portAlreadyUsed: true, on: function(status, cb) { cb(); } }); + } else { + if (logLevel >= LOG_LEVEL_INFO) { + console.log('[ReduxDevTools] Start server...'); + console.log('-'.repeat(80) + '\n'); + } + resolve(new SocketCluster(options)); + } + }); + }); +}; diff --git a/packages/redux-devtools-cli/package.json b/packages/redux-devtools-cli/package.json new file mode 100644 index 00000000..067bc6c4 --- /dev/null +++ b/packages/redux-devtools-cli/package.json @@ -0,0 +1,66 @@ +{ + "name": "redux-devtools-cli", + "version": "1.0.0-1", + "description": "CLI for remote debugging with Redux DevTools.", + "main": "index.js", + "bin": { + "redux-devtools": "bin/redux-devtools.js" + }, + "files": [ + "bin", + "src", + "app", + "index.js", + "defaultDbOptions.json" + ], + "scripts": { + "start": "node ./bin/redux-devtools.js", + "start:electron": "node ./bin/redux-devtools.js --open", + "test": "NODE_ENV=test mocha --recursive", + "test:watch": "NODE_ENV=test mocha --recursive --watch", + "prepublishOnly": "npm run test" + }, + "repository": { + "type": "git", + "url": "https://github.com/reduxjs/redux-devtools.git" + }, + "engines": { + "node": ">=6.0.0" + }, + "author": "Mihail Diordiev (https://github.com/zalmoxisus)", + "license": "MIT", + "bugs": { + "url": "https://github.com/reduxjs/redux-devtools/issues" + }, + "homepage": "https://github.com/reduxjs/redux-devtools", + "dependencies": { + "body-parser": "^1.15.0", + "chalk": "^1.1.3", + "cors": "^2.7.1", + "cross-spawn": "^6.0.5", + "electron": "^4.0.1", + "express": "^4.13.3", + "getport": "^0.1.0", + "graphql": "^0.13.0", + "graphql-server-express": "^1.4.0", + "graphql-tools": "^4.0.3", + "knex": "^0.15.2", + "lodash": "^4.15.0", + "minimist": "^1.2.0", + "morgan": "^1.7.0", + "opn": "^5.4.0", + "react": "^16.0.0", + "react-dom": "^16.0.0", + "redux-devtools-core": "^1.0.0-1", + "semver": "^5.3.0", + "socketcluster": "^14.3.3", + "sqlite3": "^4.0.4", + "uuid": "^3.0.1" + }, + "devDependencies": { + "expect": "^1.20.2", + "mocha": "^3.2.0", + "socketcluster-client": "^14.0.0", + "supertest": "^3.0.0" + } +} diff --git a/packages/redux-devtools-cli/src/api/schema.js b/packages/redux-devtools-cli/src/api/schema.js new file mode 100644 index 00000000..9ba9c53b --- /dev/null +++ b/packages/redux-devtools-cli/src/api/schema.js @@ -0,0 +1,21 @@ +var makeExecutableSchema = require('graphql-tools').makeExecutableSchema; +var requireSchema = require('../utils/requireSchema'); +var schema = requireSchema('./schema_def.graphql', require); + +var resolvers = { + Query: { + reports: function report(source, args, context, ast) { + return context.store.listAll(); + }, + report: function report(source, args, context, ast) { + return context.store.get(args.id); + } + } +}; + +var executableSchema = makeExecutableSchema({ + typeDefs: schema, + resolvers: resolvers +}); + +module.exports = executableSchema; diff --git a/packages/redux-devtools-cli/src/api/schema_def.graphql b/packages/redux-devtools-cli/src/api/schema_def.graphql new file mode 100644 index 00000000..c8e0b918 --- /dev/null +++ b/packages/redux-devtools-cli/src/api/schema_def.graphql @@ -0,0 +1,60 @@ +# A list of options for the type of the report +enum ReportType { + STATE + ACTION + STATES + ACTIONS +} + +type Report { + # Report ID + id: ID! + # Type of the report, can be: STATE, ACTION, STATES, ACTIONS + type: ReportType, + # Briefly what happened + title: String, + # Details supplied by the user + description: String, + # The last dispatched action before the report was sent + action: String, + # Stringified actions or the state or both, which should be loaded the application to reproduce the exact behavior + payload: String, + # Stringified preloaded state object. Could be the initial state of the app or committed state (after dispatching COMMIT action or reaching maxAge) + preloadedState: String, + # Screenshot url or blob as a string + screenshot: String, + # User Agent String + userAgent: String, + # Application version to group the reports and versioning + version: String, + # Used to identify the user who sent the report + userId: String, + # More detailed data about the user, usually it's a stringified object + user: String, + # Everything else you want to send + meta: String, + # Error message which invoked sending the report + exception: String, + # Id to identify the store in case there are multiple stores + instanceId: String, + # Timestamp when the report was added + added: String + # Id to identify the application (from apps table) + appId: ID +} + +# Explore GraphQL query schema +type Query { + # List all reports + reports: [Report] + # Get a report by ID + report( + # Report ID + id: ID! + ): Report +} + +schema { + query: Query + #mutation: Mutation +} diff --git a/packages/redux-devtools-cli/src/db/connector.js b/packages/redux-devtools-cli/src/db/connector.js new file mode 100644 index 00000000..b2d6836d --- /dev/null +++ b/packages/redux-devtools-cli/src/db/connector.js @@ -0,0 +1,27 @@ +var path = require('path'); +var knexModule = require('knex'); + +module.exports = function connector(options) { + var dbOptions = options.dbOptions; + dbOptions.useNullAsDefault = true; + if (!dbOptions.migrate) { + return knexModule(dbOptions); + } + + dbOptions.migrations = { directory: path.resolve(__dirname, 'migrations') }; + dbOptions.seeds = { directory: path.resolve(__dirname, 'seeds') }; + var knex = knexModule(dbOptions); + + knex.migrate.latest() + .then(function() { + return knex.seed.run(); + }) + .then(function() { + console.log(' \x1b[0;32m[Done]\x1b[0m Migrations are finished\n'); + }) + .catch(function(error) { + console.error(error); + }); + + return knex; +}; diff --git a/packages/redux-devtools-cli/src/db/migrations/index.js b/packages/redux-devtools-cli/src/db/migrations/index.js new file mode 100644 index 00000000..e7e39fbe --- /dev/null +++ b/packages/redux-devtools-cli/src/db/migrations/index.js @@ -0,0 +1,71 @@ +exports.up = function(knex, Promise) { + return Promise.all([ + knex.schema.createTable('remotedev_reports', function(table) { + table.uuid('id').primary(); + table.string('type'); + table.string('title'); + table.string('description'); + table.string('action'); + table.text('payload', 'longtext'); + table.text('preloadedState', 'longtext'); + table.text('screenshot', 'longtext'); + table.string('userAgent'); + table.string('version'); + table.string('user'); + table.string('userId'); + table.string('instanceId'); + table.string('meta'); + table.string('exception'); + table.timestamp('added').defaultTo(knex.fn.now()); + table.uuid('appId') + .references('id') + .inTable('remotedev_apps').onDelete('CASCADE').onUpdate('CASCADE') + .defaultTo('78626c31-e16b-4528-b8e5-f81301b627f4'); + }), + knex.schema.createTable('remotedev_payloads', function(table){ + table.uuid('id').primary(); + table.text('state'); + table.text('action'); + table.timestamp('added').defaultTo(knex.fn.now()); + table.uuid('reportId') + .references('id') + .inTable('remotedev_reports').onDelete('CASCADE').onUpdate('CASCADE'); + }), + knex.schema.createTable('remotedev_apps', function(table){ + table.uuid('id').primary(); + table.string('title'); + table.string('description'); + table.string('url'); + table.timestamps(false, true); + }), + knex.schema.createTable('remotedev_users', function(table){ + table.uuid('id').primary(); + table.string('name'); + table.string('login'); + table.string('email'); + table.string('avatarUrl'); + table.string('profileUrl'); + table.string('oauthId'); + table.string('oauthType'); + table.string('token'); + table.timestamps(false, true); + }), + knex.schema.createTable('remotedev_users_apps', function(table){ + table.boolean('readOnly').defaultTo(false); + table.uuid('userId'); + table.uuid('appId'); + table.primary(['userId', 'appId']); + table.foreign('userId') + .references('id').inTable('remotedev_users').onDelete('CASCADE').onUpdate('CASCADE'); + table.foreign('appId') + .references('id').inTable('remotedev_apps').onDelete('CASCADE').onUpdate('CASCADE'); + }) + ]) +}; + +exports.down = function(knex, Promise) { + return Promise.all([ + knex.schema.dropTable('remotedev_reports'), + knex.schema.dropTable('remotedev_apps') + ]) +}; diff --git a/packages/redux-devtools-cli/src/db/seeds/index.js b/packages/redux-devtools-cli/src/db/seeds/index.js new file mode 100644 index 00000000..d4371538 --- /dev/null +++ b/packages/redux-devtools-cli/src/db/seeds/index.js @@ -0,0 +1,12 @@ +exports.seed = function(knex, Promise) { + return Promise.all([ + knex('remotedev_apps').del() + ]).then(function() { + return Promise.all([ + knex('remotedev_apps').insert({ + id: '78626c31-e16b-4528-b8e5-f81301b627f4', + title: 'Default' + }) + ]); + }); +}; diff --git a/packages/redux-devtools-cli/src/middleware/graphiql.js b/packages/redux-devtools-cli/src/middleware/graphiql.js new file mode 100644 index 00000000..30fb4efb --- /dev/null +++ b/packages/redux-devtools-cli/src/middleware/graphiql.js @@ -0,0 +1,13 @@ +var graphiqlExpress = require('graphql-server-express').graphiqlExpress; + +module.exports = graphiqlExpress({ + endpointURL: '/graphql', + query: + '{\n' + + ' reports {\n' + + ' id,\n' + + ' type,\n' + + ' title\n' + + ' }\n' + + '}' +}); diff --git a/packages/redux-devtools-cli/src/middleware/graphql.js b/packages/redux-devtools-cli/src/middleware/graphql.js new file mode 100644 index 00000000..8f3ee082 --- /dev/null +++ b/packages/redux-devtools-cli/src/middleware/graphql.js @@ -0,0 +1,13 @@ +var graphqlExpress = require('graphql-server-express').graphqlExpress; +var schema = require('../api/schema'); + +module.exports = function (store) { + return graphqlExpress(function() { + return { + schema: schema, + context: { + store: store + } + }; + }); +}; diff --git a/packages/redux-devtools-cli/src/options.js b/packages/redux-devtools-cli/src/options.js new file mode 100644 index 00000000..b7404d25 --- /dev/null +++ b/packages/redux-devtools-cli/src/options.js @@ -0,0 +1,26 @@ +var path = require('path'); + +module.exports = function getOptions(argv) { + var dbOptions = argv.dbOptions; + if (typeof dbOptions === 'string') { + dbOptions = require(path.resolve(process.cwd(), argv.dbOptions)); + } else if (typeof dbOptions === 'undefined') { + dbOptions = require('../defaultDbOptions.json'); + } + + return { + host: argv.hostname || process.env.npm_package_remotedev_hostname || null, + port: Number(argv.port || process.env.npm_package_remotedev_port) || 8000, + protocol: argv.protocol || process.env.npm_package_remotedev_protocol || 'http', + protocolOptions: !(argv.protocol === 'https') ? null : { + key: argv.key || process.env.npm_package_remotedev_key || null, + cert: argv.cert || process.env.npm_package_remotedev_cert || null, + passphrase: argv.passphrase || process.env.npm_package_remotedev_passphrase || null + }, + dbOptions: dbOptions, + maxRequestBody: argv.passphrase || '16mb', + logHTTPRequests: argv.logHTTPRequests, + logLevel: argv.logLevel || 3, + wsEngine: argv.wsEngine || process.env.npm_package_remotedev_wsengine || 'ws' + }; +} diff --git a/packages/redux-devtools-cli/src/routes.js b/packages/redux-devtools-cli/src/routes.js new file mode 100644 index 00000000..79724fc6 --- /dev/null +++ b/packages/redux-devtools-cli/src/routes.js @@ -0,0 +1,77 @@ +var path = require('path'); +var express = require('express'); +var morgan = require('morgan'); +var bodyParser = require('body-parser'); +var cors = require('cors'); +var graphiqlMiddleware = require('./middleware/graphiql'); +var graphqlMiddleware = require('./middleware/graphql'); + +var app = express.Router(); + +function serveUmdModule(name) { + app.use(express.static(require.resolve(name).match(/.*\/(node_modules|packages)\/[^/]+\//)[0] + 'umd')); +} + +function routes(options, store) { + var limit = options.maxRequestBody; + var logHTTPRequests = options.logHTTPRequests; + + if (logHTTPRequests) { + if (typeof logHTTPRequests === 'object') app.use(morgan('combined', logHTTPRequests)); + else app.use(morgan('combined')); + } + + app.use('/graphiql', graphiqlMiddleware); + + serveUmdModule('react'); + serveUmdModule('react-dom'); + serveUmdModule('redux-devtools-core'); + + app.get('/port.js', function (req, res) { + res.send('reduxDevToolsPort = ' + options.port); + }); + app.get('*', function (req, res) { + res.sendFile(path.join(__dirname, '../app/index.html')); + }); + + app.use(cors({methods: 'POST'})); + app.use(bodyParser.json({limit: limit})); + app.use(bodyParser.urlencoded({limit: limit, extended: false})); + + app.use('/graphql', graphqlMiddleware(store)); + + app.post('/', function (req, res) { + if (!req.body) return res.status(404).end(); + switch (req.body.op) { + case 'get': + store.get(req.body.id).then(function (r) { + res.send(r || {}); + }).catch(function (error) { + console.error(error); + res.sendStatus(500) + }); + break; + case 'list': + store.list(req.body.query, req.body.fields).then(function (r) { + res.send(r); + }).catch(function (error) { + console.error(error); + res.sendStatus(500) + }); + break; + default: + store.add(req.body).then(function (r) { + res.send({id: r.id, error: r.error}); + scServer.exchange.publish('report', { + type: 'add', data: r + }); + }).catch(function (error) { + console.error(error); + res.status(500).send({}) + }); + } + }); + return app; +} + +module.exports = routes; diff --git a/packages/redux-devtools-cli/src/store.js b/packages/redux-devtools-cli/src/store.js new file mode 100644 index 00000000..49d057b4 --- /dev/null +++ b/packages/redux-devtools-cli/src/store.js @@ -0,0 +1,103 @@ +var uuidV4 = require('uuid/v4'); +var pick = require('lodash/pick'); +var connector = require('./db/connector'); + +var reports = 'remotedev_reports'; +// var payloads = 'remotedev_payloads'; +var knex; + +var baseFields = ['id', 'title', 'added']; + +function error(msg) { + return new Promise(function(resolve, reject) { + return resolve({ error: msg }); + }); +} + +function list(query, fields) { + var r = knex.select(fields || baseFields).from(reports); + if (query) return r.where(query); + return r; +} + +function listAll(query) { + var r = knex.select().from(reports); + if (query) return r.where(query); + return r; +} + +function get(id) { + if (!id) return error('No id specified.'); + + return knex(reports).where('id', id).first(); +} + +function add(data) { + if (!data.type || !data.payload) { + return error('Required parameters aren\'t specified.'); + } + if (data.type !== 'ACTIONS' && data.type !== 'STATE') { + return error('Type ' + data.type + ' is not supported yet.'); + } + + var reportId = uuidV4(); + var report = { + id: reportId, + type: data.type, + title: data.title || data.exception && data.exception.message || data.action, + description: data.description, + action: data.action, + payload: data.payload, + preloadedState: data.preloadedState, + screenshot: data.screenshot, + version: data.version, + userAgent: data.userAgent, + user: data.user, + userId: typeof data.user === 'object' ? data.user.id : data.user, + instanceId: data.instanceId, + meta: data.meta, + exception: composeException(data.exception), + added: new Date().toISOString(), + }; + if (data.appId) report.appId = data.appId; // TODO check if the id exists and we have access to link it + /* + var payload = { + id: uuid.v4(), + reportId: reportId, + state: data.payload + }; + */ + + return knex.insert(report).into(reports) + .then(function (){ return byBaseFields(report); }) +} + +function byBaseFields(data) { + return pick(data, baseFields); +} + +function createStore(options) { + knex = connector(options); + + return { + list: list, + listAll: listAll, + get: get, + add: add + }; +} + +function composeException(exception) { + var message = ''; + + if (exception) { + message = 'Exception thrown: '; + if (exception.message) + message += exception.message; + if (exception.stack) + message += '\n' + exception.stack; + } + return message; +} + +module.exports = createStore; diff --git a/packages/redux-devtools-cli/src/utils/requireSchema.js b/packages/redux-devtools-cli/src/utils/requireSchema.js new file mode 100644 index 00000000..d5b73cab --- /dev/null +++ b/packages/redux-devtools-cli/src/utils/requireSchema.js @@ -0,0 +1,6 @@ +var fs = require('fs'); + +module.exports = function(name, require) { + return fs.readFileSync(require.resolve(name)).toString(); + // return GraphQL.buildSchema(schema); +}; diff --git a/packages/redux-devtools-cli/src/worker.js b/packages/redux-devtools-cli/src/worker.js new file mode 100644 index 00000000..791e36df --- /dev/null +++ b/packages/redux-devtools-cli/src/worker.js @@ -0,0 +1,75 @@ +var SCWorker = require("socketcluster/scworker"); +var express = require('express'); +var app = express(); +var routes = require('./routes'); +var createStore = require('./store'); + +class Worker extends SCWorker { + run() { + var httpServer = this.httpServer; + var scServer = this.scServer; + var options = this.options; + var store = createStore(options); + + httpServer.on('request', app); + + app.use(routes(options, store)); + + scServer.addMiddleware(scServer.MIDDLEWARE_EMIT, function (req, next) { + var channel = req.event; + var data = req.data; + if (channel.substr(0, 3) === 'sc-' || channel === 'respond' || channel === 'log') { + scServer.exchange.publish(channel, data); + } else if (channel === 'log-noid') { + scServer.exchange.publish('log', {id: req.socket.id, data: data}); + } + next(); + }); + + scServer.addMiddleware(scServer.MIDDLEWARE_SUBSCRIBE, function (req, next) { + next(); + if (req.channel === 'report') { + store.list().then(function (data) { + req.socket.emit(req.channel, {type: 'list', data: data}); + }).catch(function (error) { + console.error(error); + }); + } + }); + + scServer.on('connection', function (socket) { + var channelToWatch, channelToEmit; + socket.on('login', function (credentials, respond) { + if (credentials === 'master') { + channelToWatch = 'respond'; + channelToEmit = 'log'; + } else { + channelToWatch = 'log'; + channelToEmit = 'respond'; + } + this.exchange.subscribe('sc-' + socket.id).watch(function (msg) { + socket.emit(channelToWatch, msg); + }); + respond(null, channelToWatch); + }); + socket.on('getReport', function (id, respond) { + store.get(id).then(function (data) { + respond(null, data); + }).catch(function (error) { + console.error(error); + }); + }); + socket.on('disconnect', function () { + var channel = this.exchange.channel('sc-' + socket.id); + channel.unsubscribe(); + channel.destroy(); + scServer.exchange.publish( + channelToEmit, + {id: socket.id, type: 'DISCONNECTED'} + ); + }); + }); + }; +} + +new Worker(); diff --git a/packages/redux-devtools-cli/test/integration.spec.js b/packages/redux-devtools-cli/test/integration.spec.js new file mode 100644 index 00000000..b5bf18ba --- /dev/null +++ b/packages/redux-devtools-cli/test/integration.spec.js @@ -0,0 +1,185 @@ +var childProcess = require('child_process'); +var request = require('supertest'); +var expect = require('expect'); +var scClient = require('socketcluster-client'); + +describe('Server', function() { + var scServer; + this.timeout(5000); + before(function(done) { + scServer = childProcess.fork(__dirname + '/../bin/redux-devtools.js'); + setTimeout(done, 2000); + }); + + after(function() { + if (scServer) { + scServer.kill(); + } + }); + + describe('Express backend', function() { + it('loads main page', function() { + request('http://localhost:8000') + .get('/') + .expect('Content-Type', /text\/html/) + .expect(200) + .then(function(res) { + expect(res.text).toMatch(/Redux DevTools<\/title>/); + }) + }); + + it('resolves an inexistent url', function(done) { + request('http://localhost:8000/jreerfr/123') + .get('/') + .expect('Content-Type', /text\/html/) + .expect(200, done); + }); + }); + + describe('Realtime monitoring', function() { + var socket, socket2, channel; + before(function() { + socket = scClient.connect({ hostname: 'localhost', port: 8000 }); + socket.connect(); + socket.on('error', function(error) { + console.error('Socket1 error', error); + }); + socket2 = scClient.connect({ hostname: 'localhost', port: 8000 }); + socket2.connect(); + socket.on('error', function(error) { + console.error('Socket2 error', error); + }); + }); + + after(function() { + socket.disconnect(); + socket2.disconnect(); + }); + + it('should connect', function(done) { + socket.on('connect', function(status) { + expect(status.id).toExist(); + done(); + }); + }); + + it('should login', function() { + socket.emit('login', 'master', function(error, channelName) { + if (error) { console.log(error); return; } + expect(channelName).toBe('respond'); + channel = socket.subscribe(channelName); + expect(channel.SUBSCRIBED).toBe('subscribed'); + }); + }); + + it('should send message', function(done) { + var data = { + "type": "ACTION", + "payload": { + "todos": "do some" + }, + "action": { + "timestamp": 1483349708506, + "action": { + "type": "ADD_TODO", + "text": "hggg" + } + }, + "instanceId": "tAmA7H5fclyWhvizAAAi", + "name": "LoggerInstance", + "id": "tAmA7H5fclyWhvizAAAi" + }; + + socket2.emit('login', '', function(error, channelName) { + if (error) { console.log(error); return; } + expect(channelName).toBe('log'); + var channel2 = socket2.subscribe(channelName); + expect(channel2.SUBSCRIBED).toBe('subscribed'); + channel2.on('subscribe', function() { + channel2.watch(function(message) { + expect(message).toEqual(data); + done(); + }); + socket.emit(channelName, data); + }) + }); + }); + }); + + describe('REST backend', function() { + var id; + var report = { + type: 'ACTIONS', + title: 'Test report', + description: 'Test body report', + action: 'SOME_FINAL_ACTION', + payload: '[{"type":"ADD_TODO","text":"hi"},{"type":"SOME_FINAL_ACTION"}]', + preloadedState: '{"todos":[{"text":"Use Redux","completed":false,"id":0}]}', + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 Safari/537.36' + }; + it('should add a report', function() { + request('http://localhost:8000') + .post('/') + .send(report) + .set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200) + .then(function(res) { + id = res.body.id; + expect(id).toExist(); + }); + }); + + it('should get the report', function() { + request('http://localhost:8000') + .post('/') + .send({ + op: 'get', + id: id + }) + .set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200) + .then(function(res) { + expect(res.body).toInclude(report); + }); + }); + + it('should list reports', function() { + request('http://localhost:8000') + .post('/') + .send({ + op: 'list' + }) + .set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200) + .then(function(res) { + expect(res.body.length).toBe(1); + expect(res.body[0].id).toBe(id); + expect(res.body[0].title).toBe('Test report'); + expect(res.body[0].added).toExist(); + }); + }); + }); + + describe('GraphQL backend', function() { + it('should get the report', function() { + request('http://localhost:8000') + .post('/graphql') + .send({ + query: '{ reports { id, type, title } }' + }) + .set('Accept', 'application/json') + .expect('Content-Type', /application\/json/) + .expect(200) + .then(function(res) { + var reports = res.body.data.reports; + expect(reports.length).toBe(1); + expect(reports[0].id).toExist(); + expect(reports[0].title).toBe('Test report'); + expect(reports[0].type).toBe('ACTIONS'); + }); + }); + }); +}); diff --git a/packages/redux-devtools-core/.babelrc b/packages/redux-devtools-core/.babelrc index 65836a67..120f832f 100644 --- a/packages/redux-devtools-core/.babelrc +++ b/packages/redux-devtools-core/.babelrc @@ -1,3 +1,4 @@ { - "presets": ["es2015-loose", "stage-0", "react"] + "presets": [ "es2015", "stage-0", "react" ], + "plugins": [ "add-module-exports", "transform-decorators-legacy" ] } diff --git a/packages/redux-devtools-core/.eslintrc b/packages/redux-devtools-core/.eslintrc index 90959cf7..8c52ca40 100644 --- a/packages/redux-devtools-core/.eslintrc +++ b/packages/redux-devtools-core/.eslintrc @@ -1,16 +1,26 @@ { "extends": "eslint-config-airbnb", + "globals": { + "chrome": true + }, "env": { - "mocha": true, + "jest": true, "browser": true, "node": true }, "parser": "babel-eslint", "rules": { + "react/prefer-stateless-function": 0, + "react/no-array-index-key": 0, + "react/forbid-prop-types": 0, + "react/require-default-props": 0, + "react/jsx-filename-extension": 0, "react/jsx-uses-react": 2, "react/jsx-uses-vars": 2, "react/react-in-jsx-scope": 2, + "react/sort-comp": 0, "react/jsx-quotes": 0, + "import/no-extraneous-dependencies": 0, "block-scoped-var": 0, "padded-blocks": 0, "quotes": [ 1, "single" ], @@ -20,16 +30,22 @@ "no-console": 0, "func-names": 0, "prefer-const": 0, - "prefer-arrow-callback": 0, "comma-dangle": 0, "id-length": 0, + "no-use-before-define": 0, "indent": [2, 2, {"SwitchCase": 1}], - "default-case": 0, - "prefer-template": 0, - "prefer-rest-params": 0, - "no-proto": 0, + "new-cap": [2, { "capIsNewExceptions": ["Test"] }], "no-underscore-dangle": 0, - "max-len": ["error", { "code": 120 }] + "no-plusplus": 0, + "no-proto": 0, + "arrow-parens": 0, + "prefer-arrow-callback": 0, + "prefer-rest-params": 0, + "prefer-template": 0, + "class-methods-use-this": 0, + "max-len": ["error", { "code": 120 }], + "no-mixed-operators": 0, + "no-undef": 0 }, "plugins": [ "react" diff --git a/packages/redux-devtools-core/LICENSE.txt b/packages/redux-devtools-core/LICENSE.md similarity index 100% rename from packages/redux-devtools-core/LICENSE.txt rename to packages/redux-devtools-core/LICENSE.md diff --git a/packages/redux-devtools-core/README.md b/packages/redux-devtools-core/README.md old mode 100755 new mode 100644 index c799607a..e9366edd --- a/packages/redux-devtools-core/README.md +++ b/packages/redux-devtools-core/README.md @@ -1,5 +1,44 @@ -Common functions for [Redux DevTools Extension](https://github.com/zalmoxisus/redux-devtools-extension) and [Remote Redux DevTools](https://github.com/zalmoxisus/remote-redux-devtools). +Redux DevTools monitor app +================================== -## LICENSE +![Demo](https://raw.githubusercontent.com/zalmoxisus/remote-redux-devtools/master/demo.gif) -[MIT](LICENSE) +Web, Electron and Chrome app for monitoring [remote-redux-devtools](https://github.com/zalmoxisus/remote-redux-devtools). Can be accessed on [`remotedev.io`](http://remotedev.io/local). + +Also it's a react component you can use to build amazing monitor applications like: + +* [redux-devtools-extension](https://github.com/zalmoxisus/redux-devtools-extension). +* [react-native-debugger](https://github.com/jhen0409/react-native-debugger) - Electron app, which already includes `remotedev-server`, `redux-devtools-core` and even React DevTools. +* [remote-redux-devtools-on-debugger](https://github.com/jhen0409/remote-redux-devtools-on-debugger) - Used in React Native debugger as a dock monitor. +* [atom-redux-devtools](https://github.com/zalmoxisus/atom-redux-devtools) - Used in Atom editor. +* [vscode-redux-devtools](https://github.com/jkzing/vscode-redux-devtools) - Used in Visual Studio Code. + +### Usage + +```js +import React from 'react'; +import ReactDom from 'react-dom'; +import DevToolsApp from 'redux-devtools-core/lib/app'; + +ReactDom.render( + <App />, + document.getElementById('root') +); + +``` + +### Parameters + +* `socketOptions` - *object* used to specify predefined options for the connection: + * `hostname` - *string* + * `port` - *number or string* + * `autoReconnect` - *boolean* + * `secure` - *boolean*. +* `monitorOptions` - *object* used to specify predefined monitor options: + * `selected` - *string* - which monitor is selected by default. One of the following values: `LogMonitor`, `InspectorMonitor`, `ChartMonitor`. +* `testTemplates` - *array* of strings representing predefined test templates. +* `noSettings` - *boolean* set to `true` in order to hide settings button and dialog. + +### License + +MIT diff --git a/packages/redux-devtools-core/assets/index.html b/packages/redux-devtools-core/assets/index.html new file mode 100644 index 00000000..5b1f1aa0 --- /dev/null +++ b/packages/redux-devtools-core/assets/index.html @@ -0,0 +1,45 @@ +<!DOCTYPE html> +<html> +<head> + <meta http-equiv="Content-type" content="text/html; charset=utf-8"/> + <title>Redux DevTools + + + +
+ + \ No newline at end of file diff --git a/packages/redux-devtools-core/index.js b/packages/redux-devtools-core/index.js new file mode 100644 index 00000000..9063fc12 --- /dev/null +++ b/packages/redux-devtools-core/index.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { render } from 'react-dom'; +import App from './src/app'; + +render( + , + document.getElementById('root') +); + +if (module.hot) { + // https://github.com/webpack/webpack/issues/418#issuecomment-53398056 + module.hot.accept(err => { + if (err) console.error(err.message); + }); + + /* + module.hot.accept('./app', () => { + const NextApp = require('./app').default; + render( + , + document.getElementById('root') + ); + }); + */ +} diff --git a/packages/redux-devtools-core/package.json b/packages/redux-devtools-core/package.json index d00b8a08..21c83777 100644 --- a/packages/redux-devtools-core/package.json +++ b/packages/redux-devtools-core/package.json @@ -1,20 +1,31 @@ { "name": "redux-devtools-core", - "version": "0.2.1", + "version": "1.0.0-1", "description": "Reusable functions of Redux DevTools", + "scripts": { + "start": "webpack-dev-server --hot --inline --env.development --env.platform=web --progress", + "build:web": "rimraf ./build/web && webpack -p --env.platform=web --progress", + "build:umd": "rimraf ./umd && webpack --progress --config webpack.config.umd.js", + "build:umd:min": "webpack --env.minimize --progress --config webpack.config.umd.js", + "build": "rimraf ./lib && babel ./src/app --out-dir lib", + "clean": "rimraf lib", + "lint": "eslint src test", + "lint:fix": "eslint src --fix", + "test": "NODE_ENV=test jest --no-cache", + "prepare": "npm run build && npm run build:umd && npm run build:umd:min", + "prepublishOnly": "eslint ./src/app && npm run test && npm run build && npm run build:umd && npm run build:umd:min" + }, "main": "lib/index.js", "files": [ "src", - "lib" + "lib", + "umd" ], - "scripts": { - "clean": "rimraf lib", - "build:lib": "babel src --out-dir lib", - "build": "npm run build:lib", - "lint": "eslint src test", - "lintfix": "eslint src --fix", - "prepare": "npm run build", - "prepublishOnly": "npm run lint && npm run clean && npm run build" + "jest": { + "setupTestFrameworkScriptFile": "/test/setup.js", + "moduleNameMapper": { + "\\.(css|scss)$": "/test/__mocks__/styleMock.js" + } }, "repository": { "type": "git", @@ -29,28 +40,71 @@ "devDependencies": { "babel-cli": "^6.26.0", "babel-core": "^6.26.3", - "babel-eslint": "^6.1.2", - "babel-loader": "^6.2.4", + "babel-eslint": "^7.1.1", + "babel-loader": "^7.1.5", + "babel-plugin-add-module-exports": "^0.2.1", + "babel-plugin-react-transform": "^2.0.2", "babel-plugin-transform-decorators-legacy": "^1.3.4", + "babel-preset-env": "^1.1.8", "babel-preset-es2015": "^6.24.1", - "babel-preset-es2015-loose": "^7.0.0", - "babel-preset-react": "^6.5.0", - "babel-preset-stage-0": "^6.5.0", - "eslint": "^3.2.0", - "eslint-config-airbnb": "^9.0.1", - "eslint-plugin-import": "^1.12.0", - "eslint-plugin-jsx-a11y": "^2.0.1", - "eslint-plugin-react": "^5.2.2", - "rimraf": "^2.5.2" + "babel-preset-react": "^6.22.0", + "babel-preset-stage-0": "^6.22.0", + "babel-register": "^6.22.0", + "copy-webpack-plugin": "^4.0.1", + "css-loader": "^2.1.0", + "enzyme": "^3.1.0", + "enzyme-adapter-react-16": "^1.0.2", + "enzyme-to-json": "^3.1.4", + "eslint": "^3.15.0", + "eslint-config-airbnb": "^14.1.0", + "eslint-plugin-import": "^2.2.0", + "eslint-plugin-jsx-a11y": "^4.0.0", + "eslint-plugin-react": "^6.9.0", + "file-loader": "^3.0.0", + "html-loader": "^0.4.4", + "html-webpack-plugin": "^3.2.0", + "jest": "^21.2.1", + "raw-loader": "^1.0.0", + "react": "^16.0.0", + "react-dom": "^16.0.0", + "redux-logger": "^2.2.1", + "rimraf": "^2.5.4", + "style-loader": "^0.13.0", + "terser-webpack-plugin": "^1.2.1", + "url-loader": "^1.1.0", + "webpack": "^4.27.1", + "webpack-cli": "^3.2.0", + "webpack-dev-server": "^3.1.14", + "webpack-hot-middleware": "^2.16.1" }, "dependencies": { + "d3-state-visualizer": "^1.3.1", + "devui": "^1.0.0-2", "get-params": "^0.1.2", + "javascript-stringify": "^1.5.0", "jsan": "^3.1.13", - "lodash": "^4.17.11", + "jsondiffpatch": "^0.2.4", + "localforage": "^1.5.0", + "lodash": "^4.0.0", "nanoid": "^2.0.0", - "remotedev-serialize": "^0.1.8" + "prop-types": "^15.5.10", + "react-icons": "^2.2.5", + "react-redux": "^5.0.5", + "redux": "^3.0.5", + "redux-devtools": "^3.4.0", + "redux-devtools-chart-monitor": "^1.6.1", + "redux-devtools-inspector": "^0.11.0", + "redux-devtools-instrument": "^1.8.0", + "redux-devtools-log-monitor": "^1.3.0", + "redux-devtools-test-generator": "^0.5.1", + "redux-devtools-trace-monitor": "^0.1.0", + "redux-persist": "^4.8.0", + "redux-slider-monitor": "^2.0.0-0", + "remotedev-serialize": "^0.1.8", + "socketcluster-client": "^5.5.0", + "styled-components": "^2.0.0" }, - "pre-commit": [ - "lint" - ] + "peerDependencies": { + "react": "^16.0.0" + } } diff --git a/packages/redux-devtools-core/src/app/actions/index.js b/packages/redux-devtools-core/src/app/actions/index.js new file mode 100644 index 00000000..1cdfb2cd --- /dev/null +++ b/packages/redux-devtools-core/src/app/actions/index.js @@ -0,0 +1,110 @@ +import { + CHANGE_SECTION, CHANGE_THEME, SELECT_INSTANCE, SELECT_MONITOR, UPDATE_MONITOR_STATE, + LIFTED_ACTION, MONITOR_ACTION, EXPORT, TOGGLE_SYNC, TOGGLE_SLIDER, TOGGLE_DISPATCHER, + TOGGLE_PERSIST, GET_REPORT_REQUEST, SHOW_NOTIFICATION, CLEAR_NOTIFICATION +} from '../constants/actionTypes'; +import { RECONNECT } from '../constants/socketActionTypes'; + +let monitorReducer; +let monitorProps = {}; + +export function changeSection(section) { + return { type: CHANGE_SECTION, section }; +} + +export function changeTheme(data) { + return { type: CHANGE_THEME, ...data.formData }; +} + +export function liftedDispatch(action) { + if (action.type[0] === '@') { + if (action.type === '@@INIT_MONITOR') { + monitorReducer = action.update; + monitorProps = action.monitorProps; + } + return { type: MONITOR_ACTION, action, monitorReducer, monitorProps }; + } + return { type: LIFTED_ACTION, message: 'DISPATCH', action }; +} + +export function selectInstance(selected) { + return { type: SELECT_INSTANCE, selected }; +} + +export function selectMonitor(monitor) { + return { type: SELECT_MONITOR, monitor }; +} + +export function selectMonitorWithState(value, monitorState) { + return { type: SELECT_MONITOR, monitor: value, monitorState }; +} + +export function selectMonitorTab(subTabName) { + return { type: UPDATE_MONITOR_STATE, nextState: { subTabName } }; +} + +export function updateMonitorState(nextState) { + return { type: UPDATE_MONITOR_STATE, nextState }; +} + +export function importState(state, preloadedState) { + return { type: LIFTED_ACTION, message: 'IMPORT', state, preloadedState }; +} + +export function exportState() { + return { type: EXPORT }; +} + +export function lockChanges(status) { + return { + type: LIFTED_ACTION, + message: 'DISPATCH', + action: { type: 'LOCK_CHANGES', status }, + toAll: true + }; +} + +export function pauseRecording(status) { + return { + type: LIFTED_ACTION, + message: 'DISPATCH', + action: { type: 'PAUSE_RECORDING', status }, + toAll: true + }; +} + +export function dispatchRemotely(action) { + return { type: LIFTED_ACTION, message: 'ACTION', action }; +} + +export function togglePersist() { + return { type: TOGGLE_PERSIST }; +} + +export function toggleSync() { + return { type: TOGGLE_SYNC }; +} + +export function toggleSlider() { + return { type: TOGGLE_SLIDER }; +} + +export function toggleDispatcher() { + return { type: TOGGLE_DISPATCHER }; +} + +export function saveSocketSettings(options) { + return { type: RECONNECT, options }; +} + +export function showNotification(message) { + return { type: SHOW_NOTIFICATION, notification: { type: 'error', message } }; +} + +export function clearNotification() { + return { type: CLEAR_NOTIFICATION }; +} + +export function getReport(report) { + return { type: GET_REPORT_REQUEST, report }; +} diff --git a/packages/redux-devtools-core/src/app/components/BottomButtons.js b/packages/redux-devtools-core/src/app/components/BottomButtons.js new file mode 100644 index 00000000..e9de2292 --- /dev/null +++ b/packages/redux-devtools-core/src/app/components/BottomButtons.js @@ -0,0 +1,56 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Button, Toolbar, Divider, Spacer } from 'devui'; +import SaveIcon from 'react-icons/lib/md/save'; +import ExportButton from './buttons/ExportButton'; +import ImportButton from './buttons/ImportButton'; +import PrintButton from './buttons/PrintButton'; +import DispatcherButton from './buttons/DispatcherButton'; +import SliderButton from './buttons/SliderButton'; +import MonitorSelector from './MonitorSelector'; + +export default class BottomButtons extends Component { + static propTypes = { + dispatcherIsOpen: PropTypes.bool, + sliderIsOpen: PropTypes.bool, + options: PropTypes.object.isRequired + }; + + shouldComponentUpdate(nextProps, nextState) { + return nextProps.dispatcherIsOpen !== this.props.dispatcherIsOpen + || nextProps.sliderIsOpen !== this.props.sliderIsOpen + || nextProps.options !== this.props.options; + } + + render() { + const features = this.props.options.features; + return ( + + {features.export && + + } + {features.export && + + } + {features.import && + + } + + + + + {features.jump && + + } + {features.dispatch && + + } + + ); + } +} diff --git a/packages/redux-devtools-core/src/app/components/Header.js b/packages/redux-devtools-core/src/app/components/Header.js new file mode 100644 index 00000000..e92d578f --- /dev/null +++ b/packages/redux-devtools-core/src/app/components/Header.js @@ -0,0 +1,78 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Tabs, Toolbar, Button, Divider, Spacer } from 'devui'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import DocsIcon from 'react-icons/lib/go/book'; +import FeedBackIcon from 'react-icons/lib/io/android-textsms'; +import TwitterIcon from 'react-icons/lib/ti/social-twitter'; +import SupportIcon from 'react-icons/lib/ti/heart-full-outline'; +import { changeSection } from '../actions'; + +const tabs = [ + { name: 'Actions' }, + { name: 'Reports' }, + { name: 'Settings' } +]; + +class Header extends Component { + static propTypes = { + section: PropTypes.string.isRequired, + changeSection: PropTypes.func.isRequired + }; + + openLink = url => () => { + window.open(url); + }; + + render() { + return ( + + + + + + + + + ); + } +} + +function mapDispatchToProps(dispatch) { + return { + changeSection: bindActionCreators(changeSection, dispatch) + }; +} + +export default connect(null, mapDispatchToProps)(Header); diff --git a/packages/redux-devtools-core/src/app/components/InstanceSelector.js b/packages/redux-devtools-core/src/app/components/InstanceSelector.js new file mode 100644 index 00000000..2bdf43c3 --- /dev/null +++ b/packages/redux-devtools-core/src/app/components/InstanceSelector.js @@ -0,0 +1,47 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import { Select } from 'devui'; +import { selectInstance } from '../actions'; + +class InstanceSelector extends Component { + static propTypes = { + selected: PropTypes.string, + instances: PropTypes.object.isRequired, + onSelect: PropTypes.func.isRequired + }; + + render() { + this.select = [{ value: '', label: 'Autoselect instances' }]; + const instances = this.props.instances; + let name; + Object.keys(instances).forEach(key => { + name = instances[key].name; + if (name !== undefined) this.select.push({ value: key, label: instances[key].name }); + }); + + return ( + + + ); + } +} + +function mapDispatchToProps(dispatch) { + return { + importState: bindActionCreators(importState, dispatch) + }; +} + +export default connect(null, mapDispatchToProps)(ImportButton); diff --git a/packages/redux-devtools-core/src/app/components/buttons/LockButton.js b/packages/redux-devtools-core/src/app/components/buttons/LockButton.js new file mode 100644 index 00000000..673eb7ff --- /dev/null +++ b/packages/redux-devtools-core/src/app/components/buttons/LockButton.js @@ -0,0 +1,40 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { Button } from 'devui'; +import LockIcon from 'react-icons/lib/io/ios-locked'; +import { lockChanges } from '../../actions'; + +class LockButton extends Component { + static propTypes = { + locked: PropTypes.bool, + disabled: PropTypes.bool, + lockChanges: PropTypes.func.isRequired + }; + + shouldComponentUpdate(nextProps) { + return nextProps.locked !== this.props.locked; + } + + render() { + return ( + + ); + } +} + +function mapDispatchToProps(dispatch, ownProps) { + return { + lockChanges: () => dispatch(lockChanges(!ownProps.locked)) + }; +} + +export default connect(null, mapDispatchToProps)(LockButton); diff --git a/packages/redux-devtools-core/src/app/components/buttons/PersistButton.js b/packages/redux-devtools-core/src/app/components/buttons/PersistButton.js new file mode 100644 index 00000000..127b4cc8 --- /dev/null +++ b/packages/redux-devtools-core/src/app/components/buttons/PersistButton.js @@ -0,0 +1,48 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { Button } from 'devui'; +import PersistIcon from 'react-icons/lib/fa/thumb-tack'; +import { togglePersist } from '../../actions'; + +class LockButton extends Component { + static propTypes = { + persisted: PropTypes.bool, + disabled: PropTypes.bool, + onClick: PropTypes.func.isRequired + }; + + shouldComponentUpdate(nextProps) { + return nextProps.persisted !== this.props.persisted; + } + + render() { + return ( + + ); + } +} + +function mapStateToProps(state) { + return { + persisted: state.instances.persisted + }; +} + +function mapDispatchToProps(dispatch) { + return { + onClick: bindActionCreators(togglePersist, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(LockButton); diff --git a/packages/redux-devtools-core/src/app/components/buttons/PrintButton.js b/packages/redux-devtools-core/src/app/components/buttons/PrintButton.js new file mode 100644 index 00000000..662d7821 --- /dev/null +++ b/packages/redux-devtools-core/src/app/components/buttons/PrintButton.js @@ -0,0 +1,40 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Button } from 'devui'; +import PrintIcon from 'react-icons/lib/md/print'; + +export default class PrintButton extends Component { + shouldComponentUpdate() { + return false; + } + + handlePrint() { + const d3svg = document.getElementById('d3svg'); + if (!d3svg) { + window.print(); + return; + } + + const initHeight = d3svg.style.height; + const initWidth = d3svg.style.width; + const box = d3svg.getBBox(); + d3svg.style.height = box.height; + d3svg.style.width = box.width; + + const g = d3svg.firstChild; + const initTransform = g.getAttribute('transform'); + g.setAttribute('transform', initTransform.replace(/.+scale\(/, 'translate(57, 10) scale(')); + + window.print(); + + d3svg.style.height = initHeight; + d3svg.style.width = initWidth; + g.setAttribute('transform', initTransform); + } + + render() { + return ( + + ); + } +} diff --git a/packages/redux-devtools-core/src/app/components/buttons/RecordButton.js b/packages/redux-devtools-core/src/app/components/buttons/RecordButton.js new file mode 100644 index 00000000..a6da9128 --- /dev/null +++ b/packages/redux-devtools-core/src/app/components/buttons/RecordButton.js @@ -0,0 +1,38 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { Button } from 'devui'; +import RecordIcon from 'react-icons/lib/md/fiber-manual-record'; +import { pauseRecording } from '../../actions'; + +class RecordButton extends Component { + static propTypes = { + paused: PropTypes.bool, + pauseRecording: PropTypes.func.isRequired + }; + + shouldComponentUpdate(nextProps) { + return nextProps.paused !== this.props.paused; + } + + render() { + return ( + + ); + } +} + +function mapDispatchToProps(dispatch, ownProps) { + return { + pauseRecording: () => dispatch(pauseRecording(!ownProps.paused)) + }; +} + +export default connect(null, mapDispatchToProps)(RecordButton); diff --git a/packages/redux-devtools-core/src/app/components/buttons/SliderButton.js b/packages/redux-devtools-core/src/app/components/buttons/SliderButton.js new file mode 100644 index 00000000..b031f43d --- /dev/null +++ b/packages/redux-devtools-core/src/app/components/buttons/SliderButton.js @@ -0,0 +1,39 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import { Button } from 'devui'; +import HistoryIcon from 'react-icons/lib/md/av-timer'; +import { toggleSlider } from '../../actions'; + +class SliderButton extends Component { + static propTypes = { + isOpen: PropTypes.bool, + toggleSlider: PropTypes.func.isRequired + }; + + shouldComponentUpdate(nextProps) { + return nextProps.isOpen !== this.props.isOpen; + } + + render() { + return ( + + ); + } +} + +function mapDispatchToProps(dispatch) { + return { + toggleSlider: bindActionCreators(toggleSlider, dispatch) + }; +} + +export default connect(null, mapDispatchToProps)(SliderButton); diff --git a/packages/redux-devtools-core/src/app/components/buttons/SyncButton.js b/packages/redux-devtools-core/src/app/components/buttons/SyncButton.js new file mode 100644 index 00000000..3d8c1062 --- /dev/null +++ b/packages/redux-devtools-core/src/app/components/buttons/SyncButton.js @@ -0,0 +1,45 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import { Button } from 'devui'; +import SyncIcon from 'react-icons/lib/ti/arrow-sync'; +import { toggleSync } from '../../actions'; + +class SyncButton extends Component { + static propTypes = { + sync: PropTypes.bool, + onClick: PropTypes.func.isRequired + }; + + shouldComponentUpdate(nextProps) { + return nextProps.sync !== this.props.sync; + } + + render() { + return ( + + ); + } +} + +function mapStateToProps(state) { + return { + sync: state.instances.sync + }; +} + +function mapDispatchToProps(dispatch) { + return { + onClick: bindActionCreators(toggleSync, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(SyncButton); diff --git a/packages/redux-devtools-core/src/app/constants/actionTypes.js b/packages/redux-devtools-core/src/app/constants/actionTypes.js new file mode 100644 index 00000000..13898f66 --- /dev/null +++ b/packages/redux-devtools-core/src/app/constants/actionTypes.js @@ -0,0 +1,24 @@ +export const CHANGE_SECTION = 'main/CHANGE_SECTION'; +export const CHANGE_THEME = 'main/CHANGE_THEME'; + +export const UPDATE_STATE = 'devTools/UPDATE_STATE'; +export const SET_STATE = 'devTools/SET_STATE'; +export const SELECT_INSTANCE = 'devTools/SELECT_INSTANCE'; +export const REMOVE_INSTANCE = 'devTools/REMOVE_INSTANCE'; +export const LIFTED_ACTION = 'devTools/LIFTED_ACTION'; +export const MONITOR_ACTION = 'devTools/MONITOR_ACTION'; +export const TOGGLE_SYNC = 'devTools/TOGGLE_SYNC'; +export const TOGGLE_PERSIST = 'devTools/TOGGLE_PERSIST'; +export const SELECT_MONITOR = 'devTools/SELECT_MONITOR'; +export const UPDATE_MONITOR_STATE = 'devTools/UPDATE_MONITOR_STATE'; +export const TOGGLE_SLIDER = 'devTools/TOGGLE_SLIDER'; +export const TOGGLE_DISPATCHER = 'devTools/TOGGLE_DISPATCHER'; +export const EXPORT = 'devTools/EXPORT'; +export const SHOW_NOTIFICATION = 'devTools/SHOW_NOTIFICATION'; +export const CLEAR_NOTIFICATION = 'devTools/CLEAR_NOTIFICATION'; + +export const UPDATE_REPORTS = 'reports/UPDATE'; +export const GET_REPORT_REQUEST = 'reports/GET_REPORT_REQUEST'; +export const GET_REPORT_ERROR = 'reports/GET_REPORT_ERROR'; +export const GET_REPORT_SUCCESS = 'reports/GET_REPORT_SUCCESS'; +export const ERROR = 'ERROR'; diff --git a/packages/redux-devtools-core/src/app/constants/dataTypes.js b/packages/redux-devtools-core/src/app/constants/dataTypes.js new file mode 100644 index 00000000..a7c5b562 --- /dev/null +++ b/packages/redux-devtools-core/src/app/constants/dataTypes.js @@ -0,0 +1,2 @@ +export const DATA_TYPE_KEY = Symbol.for('__serializedType__'); +export const DATA_REF_KEY = Symbol.for('__serializedRef__'); diff --git a/packages/redux-devtools-core/src/app/constants/socketActionTypes.js b/packages/redux-devtools-core/src/app/constants/socketActionTypes.js new file mode 100644 index 00000000..1df28612 --- /dev/null +++ b/packages/redux-devtools-core/src/app/constants/socketActionTypes.js @@ -0,0 +1,19 @@ +import socketCluster from 'socketcluster-client'; + +export const { + CLOSED, CONNECTING, OPEN, AUTHENTICATED, PENDING, UNAUTHENTICATED + } = socketCluster.SCSocket; +export const CONNECT_REQUEST = 'socket/CONNECT_REQUEST'; +export const CONNECT_SUCCESS = 'socket/CONNECT_SUCCESS'; +export const CONNECT_ERROR = 'socket/CONNECT_ERROR'; +export const RECONNECT = 'socket/RECONNECT'; +export const AUTH_REQUEST = 'socket/AUTH_REQUEST'; +export const AUTH_SUCCESS = 'socket/AUTH_SUCCESS'; +export const AUTH_ERROR = 'socket/AUTH_ERROR'; +export const DISCONNECTED = 'socket/DISCONNECTED'; +export const DEAUTHENTICATE = 'socket/DEAUTHENTICATE'; +export const SUBSCRIBE_REQUEST = 'socket/SUBSCRIBE_REQUEST'; +export const SUBSCRIBE_SUCCESS = 'socket/SUBSCRIBE_SUCCESS'; +export const SUBSCRIBE_ERROR = 'socket/SUBSCRIBE_ERROR'; +export const UNSUBSCRIBE = 'socket/UNSUBSCRIBE'; +export const EMIT = 'socket/EMIT'; diff --git a/packages/redux-devtools-core/src/app/constants/socketOptions.js b/packages/redux-devtools-core/src/app/constants/socketOptions.js new file mode 100644 index 00000000..500d9a6a --- /dev/null +++ b/packages/redux-devtools-core/src/app/constants/socketOptions.js @@ -0,0 +1,12 @@ +const socketOptions = { + hostname: 'remotedev.io', + port: 443, + protocol: 'https', + autoReconnect: true, + secure: true, + autoReconnectOptions: { + randomness: 30000 + } +}; + +export default socketOptions; diff --git a/packages/redux-devtools-core/src/app/containers/Actions.js b/packages/redux-devtools-core/src/app/containers/Actions.js new file mode 100644 index 00000000..4f464b5c --- /dev/null +++ b/packages/redux-devtools-core/src/app/containers/Actions.js @@ -0,0 +1,80 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import { Container } from 'devui'; +import SliderMonitor from './monitors/Slider'; +import { liftedDispatch as liftedDispatchAction, getReport } from '../actions'; +import { getActiveInstance } from '../reducers/instances'; +import DevTools from '../containers/DevTools'; +import Dispatcher from './monitors/Dispatcher'; +import TopButtons from '../components/TopButtons'; +import BottomButtons from '../components/BottomButtons'; + +class Actions extends Component { + render() { + const { + monitor, dispatcherIsOpen, sliderIsOpen, options, liftedState, liftedDispatch + } = this.props; + return ( + + + + {sliderIsOpen && options.connectionId && options.features.jump && + + } + {dispatcherIsOpen && options.connectionId && options.features.dispatch && + + } + + + ); + } +} + +Actions.propTypes = { + liftedDispatch: PropTypes.func.isRequired, + liftedState: PropTypes.object.isRequired, + monitorState: PropTypes.object, + options: PropTypes.object.isRequired, + monitor: PropTypes.string, + dispatcherIsOpen: PropTypes.bool, + sliderIsOpen: PropTypes.bool +}; + +function mapStateToProps(state) { + const instances = state.instances; + const id = getActiveInstance(instances); + return { + liftedState: instances.states[id], + monitorState: state.monitor.monitorState, + options: instances.options[id], + monitor: state.monitor.selected, + dispatcherIsOpen: state.monitor.dispatcherIsOpen, + sliderIsOpen: state.monitor.sliderIsOpen, + reports: state.reports.data + }; +} + +function mapDispatchToProps(dispatch) { + return { + liftedDispatch: bindActionCreators(liftedDispatchAction, dispatch), + getReport: bindActionCreators(getReport, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(Actions); diff --git a/packages/redux-devtools-core/src/app/containers/App.js b/packages/redux-devtools-core/src/app/containers/App.js new file mode 100644 index 00000000..80187e48 --- /dev/null +++ b/packages/redux-devtools-core/src/app/containers/App.js @@ -0,0 +1,58 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { Container, Notification } from 'devui'; +import { clearNotification } from '../actions'; +import Header from '../components/Header'; +import Actions from '../containers/Actions'; +import Settings from '../components/Settings'; + +class App extends Component { + render() { + const { section, theme, notification } = this.props; + let body; + switch (section) { + case 'Settings': body = ; break; + default: body = ; + } + + return ( + +
+ {body} + {notification && + + {notification.message} + + } + + ); + } +} + +App.propTypes = { + section: PropTypes.string.isRequired, + theme: PropTypes.object.isRequired, + notification: PropTypes.shape({ + message: PropTypes.string, + type: PropTypes.string + }), + clearNotification: PropTypes.func +}; + +function mapStateToProps(state) { + return { + section: state.section, + theme: state.theme, + notification: state.notification + }; +} + +function mapDispatchToProps(dispatch) { + return { + clearNotification: bindActionCreators(clearNotification, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(App); diff --git a/packages/redux-devtools-core/src/app/containers/DevTools.js b/packages/redux-devtools-core/src/app/containers/DevTools.js new file mode 100644 index 00000000..f0102264 --- /dev/null +++ b/packages/redux-devtools-core/src/app/containers/DevTools.js @@ -0,0 +1,89 @@ +import React, { Component, createElement } from 'react'; +import PropTypes from 'prop-types'; +import { withTheme } from 'styled-components'; +import getMonitor from '../utils/getMonitor'; + +class DevTools extends Component { + constructor(props) { + super(props); + this.getMonitor(props, props.monitorState); + } + + getMonitor(props, skipUpdate) { + const monitorElement = getMonitor(props); + this.monitorProps = monitorElement.props; + this.Monitor = monitorElement.type; + + const update = this.Monitor.update; + if (update) { + let newMonitorState; + const monitorState = props.monitorState; + if (skipUpdate || monitorState && monitorState.__overwritten__ === props.monitor) { + newMonitorState = monitorState; + } else { + newMonitorState = update(this.monitorProps, undefined, {}); + if (newMonitorState !== monitorState) { + this.preventRender = true; + } + } + this.dispatch({ + type: '@@INIT_MONITOR', + newMonitorState, + update, + monitorProps: this.monitorProps + }); + } + } + + componentWillUpdate(nextProps) { + if (nextProps.monitor !== this.props.monitor) this.getMonitor(nextProps); + } + + shouldComponentUpdate(nextProps) { + return ( + nextProps.monitor !== this.props.monitor || + nextProps.liftedState !== this.props.liftedState || + nextProps.monitorState !== this.props.liftedState || + nextProps.features !== this.props.features || + nextProps.theme.scheme !== this.props.theme.scheme + ); + } + + dispatch = action => { + this.props.dispatch(action); + }; + + render() { + if (this.preventRender) { + this.preventRender = false; + return null; + } + + const liftedState = { + ...this.props.liftedState, + monitorState: this.props.monitorState + }; + return ( +
+ +
+ ); + } +} + +DevTools.propTypes = { + liftedState: PropTypes.object, + monitorState: PropTypes.object, + dispatch: PropTypes.func.isRequired, + monitor: PropTypes.string, + features: PropTypes.object.isRequired, + theme: PropTypes.object.isRequired +}; + +export default withTheme(DevTools); diff --git a/packages/redux-devtools-core/src/app/containers/monitors/ChartMonitorWrapper.js b/packages/redux-devtools-core/src/app/containers/monitors/ChartMonitorWrapper.js new file mode 100644 index 00000000..658fba66 --- /dev/null +++ b/packages/redux-devtools-core/src/app/containers/monitors/ChartMonitorWrapper.js @@ -0,0 +1,55 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import ChartMonitor from 'redux-devtools-chart-monitor'; +import { selectMonitorWithState } from '../../actions'; + +export function getPath(obj, inspectedStatePath) { + const parent = obj.parent; + if (!parent) return; + getPath(parent, inspectedStatePath); + let name = obj.name; + const item = name.match(/.+\[(\d+)]/); + if (item) name = item[1]; + inspectedStatePath.push(name); +} + +class ChartMonitorWrapper extends Component { + static update = ChartMonitor.update; + + onClickText = (data) => { + const inspectedStatePath = []; + getPath(data, inspectedStatePath); + this.props.selectMonitorWithState('InspectorMonitor', { + inspectedStatePath, + tabName: 'State', + subTabName: data.children ? 'Chart' : 'Tree', + selectedActionId: null, + startActionId: null, + inspectedActionPath: [] + }); + }; + + render() { + return ( + + ); + } +} + +ChartMonitorWrapper.propTypes = { + selectMonitorWithState: PropTypes.func.isRequired +}; + +function mapDispatchToProps(dispatch) { + return { + selectMonitorWithState: bindActionCreators(selectMonitorWithState, dispatch) + }; +} + +export default connect(null, mapDispatchToProps)(ChartMonitorWrapper); diff --git a/packages/redux-devtools-core/src/app/containers/monitors/Dispatcher.js b/packages/redux-devtools-core/src/app/containers/monitors/Dispatcher.js new file mode 100644 index 00000000..f725b803 --- /dev/null +++ b/packages/redux-devtools-core/src/app/containers/monitors/Dispatcher.js @@ -0,0 +1,201 @@ +// Based on https://github.com/YoruNoHikage/redux-devtools-dispatch + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import styled from 'styled-components'; +import { Button, Select, Editor, Toolbar } from 'devui'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import { dispatchRemotely } from '../../actions'; + +export const DispatcherContainer = styled.div` + display: flex; + flex-direction: column; + flex-shrink: 0; + padding-top: 2px; + background: ${props => props.theme.base01}; +`; + +export const CodeContainer = styled.div` + height: 75px; + padding-right: 6px; + overflow: auto; +`; + +export const ActionContainer = styled.div` + display: table; + width: 100%; + color: ${props => props.theme.base06}; + + > div { + display: table-row; + + > div:first-child { + width: 1px; + padding-left: 8px; + display: table-cell; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + > div:nth-child(2) { + display: table-cell; + width: 100%; + padding: 6px; + } + } +`; + +class Dispatcher extends Component { + static propTypes = { + options: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired + }; + + state = { + selected: 'default', + customAction: this.props.options.lib === 'redux' ? '{\n type: \'\'\n}' : 'this.', + args: [], + rest: '[]', + changed: false + }; + + componentWillReceiveProps(nextProps) { + if (this.state.selected !== 'default' && !nextProps.options.actionCreators) { + this.setState({ + selected: 'default', + args: [] + }); + } + } + + shouldComponentUpdate(nextProps, nextState) { + return nextState !== this.state || + nextProps.options.actionCreators !== this.props.options.actionCreators; + } + + selectActionCreator = selected => { + if (selected === 'actions-help') { + window.open('https://github.com/zalmoxisus/redux-devtools-extension/blob/master/docs/' + + 'basics/Dispatcher.md'); + return; + } + + const args = []; + if (selected !== 'default') { + args.length = this.props.options.actionCreators[selected].args.length; + } + this.setState({ selected, args, rest: '[]', changed: false }); + }; + + handleArg = argIndex => value => { + const args = [ + ...this.state.args.slice(0, argIndex), + value || undefined, + ...this.state.args.slice(argIndex + 1), + ]; + this.setState({ args, changed: true }); + }; + + handleRest = rest => { + this.setState({ rest, changed: true }); + }; + + handleCustomAction = customAction => { + this.setState({ customAction, changed: true }); + }; + + dispatchAction = () => { + const { selected, customAction, args, rest } = this.state; + + if (this.state.selected !== 'default') { + // remove trailing `undefined` arguments + let i = args.length - 1; + while (i >= 0 && typeof args[i] === 'undefined') { + args.pop(i); i--; + } + this.props.dispatch({ + name: this.props.options.actionCreators[selected].name, + selected, + args, + rest + }); + } else { + this.props.dispatch(customAction); + } + this.setState({ changed: false }); + }; + + render() { + const actionCreators = this.props.options.actionCreators; + let actionElement; + + if (this.state.selected === 'default' || !actionCreators) { + actionElement = ( + + + + ); + } else { + actionElement = ( + + {actionCreators[this.state.selected].args.map((param, i) => ( +
+
{param}
+ +
+ ))} +
+
...rest
+ +
+
+ ); + } + + let options = [{ value: 'default', label: 'Custom action' }]; + if (actionCreators && actionCreators.length > 0) { + options = options.concat(actionCreators.map(({ name, func, args }, i) => ({ + value: i, + label: `${name}(${args.join(', ')})` + }))); + } else { + options.push({ value: 'actions-help', label: 'Add your app built-in actions…' }); + } + + return ( + + {actionElement} + +