Merge pull request #445 from reduxjs/redux-devtools-app

Move remotedev-app and remotedev-server
This commit is contained in:
Mihail Diordiev 2019-01-05 18:49:09 +02:00 committed by GitHub
commit e67bc5a622
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
100 changed files with 6934 additions and 170 deletions

2
.gitignore vendored
View File

@ -3,5 +3,7 @@ node_modules
.DS_Store
lib
dist
umd
build
coverage
.idea

View File

@ -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)

157
docs/Integrations/Remote.md Normal file
View File

@ -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));
```

View File

@ -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"
]
}

View File

@ -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',

View File

@ -89,7 +89,6 @@
"simple-element-resize-detector": "^1.1.0",
"styled-components": "^2.2.2"
},
"pre-commit": ["lint"],
"jest": {
"setupTestFrameworkScriptFile": "<rootDir>/tests/setup.js"
},

View File

@ -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.

View File

@ -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

View File

@ -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()
}
})

View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Redux DevTools</title>
<style>
html {
min-width: 350px;
min-height: 300px;
}
body {
position: fixed;
overflow: hidden;
height: 100%;
width: 100%;
margin: 0;
padding: 0;
}
#root {
height: 100%;
}
#root > div {
height: 100%;
}
</style>
</head>
<body>
<div id="root"></div>
<script src="/react.production.min.js"></script>
<script src="/react-dom.production.min.js"></script>
<script src="/redux-devtools-core.min.js"></script>
<script src="/port.js"></script>
<script>
ReactDOM.render(
React.createElement(ReduxDevTools, {
socketOptions: {
hostname: location.hostname,
port: reduxDevToolsPort,
autoReconnect: true
}
}),
document.querySelector('#root')
);
</script>
</body>
</html>

View File

@ -0,0 +1,8 @@
{
"private": true,
"name": "redux-devtools-cli",
"version": "0.0.1",
"main": "electron.js",
"description": "Remote Redux DevTools",
"authors": "Mihail Diordiev"
}

View File

@ -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;
};

View File

@ -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;

View File

@ -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);
});
}
});

View File

@ -0,0 +1,13 @@
{
"client": "sqlite3",
"connection": { "filename": ":memory:" },
"pool": {
"min": 1,
"max": 1,
"idleTimeoutMillis": 360000000,
"disposeTimeout": 360000000
},
"useNullAsDefault": true,
"debug": false,
"migrate": true
}

View File

@ -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));
}
});
});
};

View File

@ -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 <zalmoxisus@gmail.com> (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"
}
}

View File

@ -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;

View File

@ -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
}

View File

@ -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;
};

View File

@ -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')
])
};

View File

@ -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'
})
]);
});
};

View File

@ -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' +
'}'
});

View File

@ -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
}
};
});
};

View File

@ -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'
};
}

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,6 @@
var fs = require('fs');
module.exports = function(name, require) {
return fs.readFileSync(require.resolve(name)).toString();
// return GraphQL.buildSchema(schema);
};

View File

@ -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();

View File

@ -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(/<title>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');
});
});
});
});

View File

@ -1,3 +1,4 @@
{
"presets": ["es2015-loose", "stage-0", "react"]
"presets": [ "es2015", "stage-0", "react" ],
"plugins": [ "add-module-exports", "transform-decorators-legacy" ]
}

View File

@ -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"

45
packages/redux-devtools-core/README.md Executable file → Normal file
View File

@ -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

View File

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
<title>Redux DevTools</title>
<style>
body {
position: fixed;
overflow: hidden;
height: 100%;
width: 100%;
margin: 0;
padding: 0;
}
#root {
height: 100%;
}
@media print {
@page {
size: auto;
margin: 0;
}
body {
position: static;
}
#root > div > div:not(:nth-child(2)) {
display: none !important;
}
#root > div > div:nth-child(2) {
overflow: visible !important;
position: absolute !important;
z-index: 2147483647;
page-break-after: avoid;
}
#root > div > div:nth-child(2) * {
overflow: visible !important;
}
}
</style>
</head>
<body>
<div id='root'></div>
</body>
</html>

View File

@ -0,0 +1,25 @@
import React from 'react';
import { render } from 'react-dom';
import App from './src/app';
render(
<App />,
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(
<NextApp />,
document.getElementById('root')
);
});
*/
}

View File

@ -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": "<rootDir>/test/setup.js",
"moduleNameMapper": {
"\\.(css|scss)$": "<rootDir>/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"
}
}

View File

@ -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 };
}

View File

@ -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 (
<Toolbar borderPosition="top">
{features.export &&
<Button
title="Save a report"
tooltipPosition="top-right"
>
<SaveIcon />
</Button>
}
{features.export &&
<ExportButton />
}
{features.import &&
<ImportButton />
}
<PrintButton />
<Divider />
<MonitorSelector />
<Divider />
{features.jump &&
<SliderButton isOpen={this.props.sliderIsOpen} />
}
{features.dispatch &&
<DispatcherButton dispatcherIsOpen={this.props.dispatcherIsOpen} />
}
</Toolbar>
);
}
}

View File

@ -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 (
<Toolbar compact noBorder borderPosition="bottom">
<Tabs
main
collapsible
tabs={tabs}
onClick={this.props.changeSection}
selected={this.props.section || 'Actions'}
/>
<Divider />
<Button
title="Documentation"
tooltipPosition="bottom"
onClick={this.openLink('http://extension.remotedev.io')}
>
<DocsIcon />
</Button>
<Button
title="Feedback"
tooltipPosition="bottom"
onClick={this.openLink('http://extension.remotedev.io/docs/Feedback.html')}
>
<FeedBackIcon />
</Button>
<Button
title="Follow us"
tooltipPosition="bottom"
onClick={this.openLink('https://twitter.com/ReduxDevTools')}
>
<TwitterIcon />
</Button>
<Button
title="Support us"
tooltipPosition="bottom-left"
onClick={this.openLink('https://opencollective.com/redux-devtools-extension')}
>
<SupportIcon />
</Button>
</Toolbar>
);
}
}
function mapDispatchToProps(dispatch) {
return {
changeSection: bindActionCreators(changeSection, dispatch)
};
}
export default connect(null, mapDispatchToProps)(Header);

View File

@ -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 (
<Select
options={this.select}
onChange={this.props.onSelect}
value={this.props.selected || ''}
/>
);
}
}
function mapStateToProps(state) {
return {
selected: state.instances.selected,
instances: state.instances.options
};
}
function mapDispatchToProps(dispatch) {
return {
onSelect: bindActionCreators(selectInstance, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(InstanceSelector);

View File

@ -0,0 +1,45 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { Tabs } from 'devui';
import { monitors } from '../utils/getMonitor';
import { selectMonitor } from '../actions';
class MonitorSelector extends Component {
static propTypes = {
selected: PropTypes.string,
selectMonitor: PropTypes.func.isRequired
};
shouldComponentUpdate(nextProps) {
return nextProps.selected !== this.props.selected;
}
render() {
return (
<Tabs
main
collapsible
position="center"
tabs={monitors}
onClick={this.props.selectMonitor}
selected={this.props.selected || 'InspectorMonitor'}
/>
);
}
}
function mapStateToProps(state) {
return {
selected: state.monitor.selected
};
}
function mapDispatchToProps(dispatch) {
return {
selectMonitor: bindActionCreators(selectMonitor, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(MonitorSelector);

View File

@ -0,0 +1,126 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { Container, Form, Button } from 'devui';
import { saveSocketSettings } from '../../actions';
const defaultSchema = {
type: 'object',
required: [],
properties: {
type: {
title: 'Connection settings (for getting reports and remote debugging)',
type: 'string',
enum: ['disabled', 'remotedev', 'custom'],
enumNames: ['no remote connection', 'connect via remotedev.io', 'use local (custom) server']
},
hostname: {
type: 'string'
},
port: {
type: 'number'
},
secure: {
type: 'boolean'
}
}
};
const uiSchema = {
type: {
'ui:widget': 'radio'
}
};
class Connection extends Component {
static propTypes = {
saveSettings: PropTypes.func.isRequired,
options: PropTypes.object.isRequired,
type: PropTypes.string
};
constructor(props) {
super(props);
this.state = this.setFormData(props.type);
}
shouldComponentUpdate(nextProps, nextState) {
return this.state !== nextState;
}
componentWillReceiveProps(nextProps) {
if (this.props.options !== nextProps.options) {
this.setState({ formData: { ...nextProps.options, type: nextProps.type } });
}
}
handleSave = data => {
this.props.saveSettings(data.formData);
this.setState({ changed: false });
};
setFormData = (type, changed) => {
let schema;
if (type !== 'custom') {
schema = {
type: 'object',
properties: { type: defaultSchema.properties.type }
};
} else {
schema = defaultSchema;
}
return {
formData: {
type,
...this.props.options
},
type,
schema,
changed
};
};
handleChange = data => {
const formData = data.formData;
const type = formData.type;
if (type !== this.state.type) {
this.setState(this.setFormData(type, true));
} else if (!this.state.changed) {
this.setState({ changed: true, formData });
}
};
render() {
const type = this.state.type || 'disabled';
const changed = this.state.changed;
const disabled = type === 'disabled';
return (
<Container>
<Form
primaryButton={changed}
noSubmit={disabled && !changed}
submitText={disabled ? 'Disconnect' : 'Connect'}
formData={this.state.formData}
schema={this.state.schema}
uiSchema={uiSchema}
onChange={this.handleChange}
onSubmit={this.handleSave}
/>
</Container>
);
}
}
function mapStateToProps(state) {
return state.connection;
}
function mapDispatchToProps(dispatch) {
return {
saveSettings: bindActionCreators(saveSocketSettings, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(Connection);

View File

@ -0,0 +1,64 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { Container, Form, Button } from 'devui';
import { listSchemes, listThemes } from 'devui/lib/utils/theme';
import { changeTheme } from '../../actions';
class Themes extends Component {
static propTypes = {
changeTheme: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired
};
render() {
const theme = this.props.theme;
const formData = {
theme: theme.theme,
scheme: theme.scheme,
dark: !theme.light
};
return (
<Container>
<Form
schema={{
type: 'object',
properties: {
theme: {
type: 'string',
enum: listThemes(),
},
scheme: {
title: 'color scheme',
type: 'string',
enum: listSchemes(),
},
dark: {
type: 'boolean'
}
}
}}
formData={formData}
noSubmit
onChange={this.props.changeTheme}
/>
</Container>
);
}
}
function mapStateToProps(state) {
return {
theme: state.theme
};
}
function mapDispatchToProps(dispatch) {
return {
changeTheme: bindActionCreators(changeTheme, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(Themes);

View File

@ -0,0 +1,33 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Tabs } from 'devui';
import Connection from './Connection';
import Themes from './Themes';
class Settings extends Component {
constructor(props) {
super(props);
this.tabs = [
{ name: 'Connection', component: Connection },
{ name: 'Themes', component: Themes }
];
this.state = { selected: 'Connection' };
}
handleSelect = selected => {
this.setState({ selected });
};
render() {
return (
<Tabs
toRight
tabs={this.tabs}
selected={this.state.selected}
onClick={this.handleSelect}
/>
);
}
}
export default Settings;

View File

@ -0,0 +1,102 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { ActionCreators } from 'redux-devtools-instrument';
import { Button, Toolbar, Divider, Spacer } from 'devui';
import RecordButton from './buttons/RecordButton';
import PersistButton from './buttons/PersistButton';
import LockButton from './buttons/LockButton';
import InstanceSelector from './InstanceSelector';
import SyncButton from './buttons/SyncButton';
const { reset, rollback, commit, sweep } = ActionCreators;
export default class TopButtons extends Component {
static propTypes = {
// shouldSync: PropTypes.bool,
liftedState: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
options: PropTypes.object.isRequired
};
shouldComponentUpdate(nextProps) {
return nextProps.options !== this.props.options
|| nextProps.liftedState !== this.props.liftedState;
}
handleRollback = () => {
this.props.dispatch(rollback());
};
handleSweep = () => {
this.props.dispatch(sweep());
};
handleCommit = () => {
this.props.dispatch(commit());
};
handleReset = () => {
this.props.dispatch(reset());
};
render() {
const options = this.props.options;
const features = options.features;
const { computedStates, skippedActionIds, isPaused, isLocked } = this.props.liftedState;
const noStates = computedStates.length < 2;
return (
<Toolbar borderPosition="bottom">
{features.pause &&
<RecordButton paused={isPaused} />
}
{features.persist &&
<PersistButton />
}
{features.lock &&
<LockButton
locked={isLocked}
disabled={options.lib !== 'redux'}
/>
}
<Divider />
<Button
title="Reset to the state you created the store with"
tooltipPosition="bottom"
onClick={this.handleReset}
>
Reset
</Button>
<Button
title="Roll back to the last committed state"
tooltipPosition="bottom"
onClick={this.handleRollback}
disabled={noStates}
>
Revert
</Button>
<Button
title="Remove all currently disabled actions from the log"
tooltipPosition="bottom"
onClick={this.handleSweep}
disabled={skippedActionIds.length === 0}
>
Sweep
</Button>
<Button
title="Remove all actions from the log,\a and make the current state your initial state"
tooltipPosition="bottom"
onClick={this.handleCommit}
disabled={noStates}
>
Commit
</Button>
<Divider />
<InstanceSelector />
{features.sync &&
<SyncButton />
}
</Toolbar>
);
}
}

View File

@ -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 DispatchIcon from 'react-icons/lib/fa/terminal';
import { toggleDispatcher } from '../../actions';
class DispatcherButton extends Component {
static propTypes = {
dispatcherIsOpen: PropTypes.bool,
toggleDispatcher: PropTypes.func.isRequired
};
shouldComponentUpdate(nextProps) {
return nextProps.dispatcherIsOpen !== this.props.dispatcherIsOpen;
}
render() {
return (
<Button
mark={this.props.dispatcherIsOpen && 'base0D'}
title={this.props.dispatcherIsOpen ? 'Hide dispatcher' : 'Show dispatcher'}
onClick={this.props.toggleDispatcher}
tooltipPosition="top-left"
>
<DispatchIcon />
</Button>
);
}
}
function mapDispatchToProps(dispatch) {
return {
toggleDispatcher: bindActionCreators(toggleDispatcher, dispatch)
};
}
export default connect(null, mapDispatchToProps)(DispatcherButton);

View File

@ -0,0 +1,37 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { Button } from 'devui';
import { stringify } from 'jsan';
import DownloadIcon from 'react-icons/lib/ti/download';
import { exportState } from '../../actions';
class ExportButton extends Component {
static propTypes = {
exportState: PropTypes.func.isRequired
};
shouldComponentUpdate() {
return false;
}
render() {
return (
<Button
title="Export to a file"
onClick={this.props.exportState}
>
<DownloadIcon />
</Button>
);
}
}
function mapDispatchToProps(dispatch) {
return {
exportState: bindActionCreators(exportState, dispatch)
};
}
export default connect(null, mapDispatchToProps)(ExportButton);

View File

@ -0,0 +1,65 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { Button } from 'devui';
import UploadIcon from 'react-icons/lib/ti/upload';
import { importState } from '../../actions';
class ImportButton extends Component {
static propTypes = {
importState: PropTypes.func.isRequired
};
constructor() {
super();
this.handleImport = this.handleImport.bind(this);
this.handleImportFile = this.handleImportFile.bind(this);
this.mapRef = this.mapRef.bind(this);
}
shouldComponentUpdate() {
return false;
}
mapRef(node) {
this.fileInput = node;
}
handleImport() {
this.fileInput.click();
}
handleImportFile(e) {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = () => {
this.props.importState(reader.result);
};
reader.readAsText(file);
e.target.value = ''; // eslint-disable-line no-param-reassign
}
render() {
return (
<Button
title="Import from a file"
onClick={this.handleImport}
>
<UploadIcon />
<input
type="file" ref={this.mapRef} style={{ display: 'none' }}
onChange={this.handleImportFile}
/>
</Button>
);
}
}
function mapDispatchToProps(dispatch) {
return {
importState: bindActionCreators(importState, dispatch)
};
}
export default connect(null, mapDispatchToProps)(ImportButton);

View File

@ -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 (
<Button
tooltipPosition="bottom"
disabled={this.props.disabled}
mark={this.props.locked && 'base0D'}
title={this.props.locked ? 'Unlock changes' : 'Lock changes'}
onClick={this.props.lockChanges}
>
<LockIcon />
</Button>
);
}
}
function mapDispatchToProps(dispatch, ownProps) {
return {
lockChanges: () => dispatch(lockChanges(!ownProps.locked))
};
}
export default connect(null, mapDispatchToProps)(LockButton);

View File

@ -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 (
<Button
toolbar
tooltipPosition="bottom"
disabled={this.props.disabled}
mark={this.props.persisted && 'base0D'}
title={this.props.persisted ? 'Persist state history' : 'Disable state persisting'}
onClick={this.props.onClick}
>
<PersistIcon />
</Button>
);
}
}
function mapStateToProps(state) {
return {
persisted: state.instances.persisted
};
}
function mapDispatchToProps(dispatch) {
return {
onClick: bindActionCreators(togglePersist, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(LockButton);

View File

@ -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 (
<Button title="Print" onClick={this.handlePrint}><PrintIcon /></Button>
);
}
}

View File

@ -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 (
<Button
tooltipPosition="bottom-right"
mark={!this.props.paused && 'base08'}
title={this.props.paused ? 'Start recording' : 'Pause recording'}
onClick={this.props.pauseRecording}
>
<RecordIcon />
</Button>
);
}
}
function mapDispatchToProps(dispatch, ownProps) {
return {
pauseRecording: () => dispatch(pauseRecording(!ownProps.paused))
};
}
export default connect(null, mapDispatchToProps)(RecordButton);

View File

@ -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 (
<Button
mark={this.props.isOpen && 'base0D'}
title={this.props.isOpen ? 'Hide slider' : 'Show slider'}
tooltipPosition="top-left"
onClick={this.props.toggleSlider}
>
<HistoryIcon />
</Button>
);
}
}
function mapDispatchToProps(dispatch) {
return {
toggleSlider: bindActionCreators(toggleSlider, dispatch)
};
}
export default connect(null, mapDispatchToProps)(SliderButton);

View File

@ -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 (
<Button
title="Sync actions"
tooltipPosition="bottom-left"
onClick={this.props.onClick}
mark={this.props.sync && 'base0B'}
>
<SyncIcon />
</Button>
);
}
}
function mapStateToProps(state) {
return {
sync: state.instances.sync
};
}
function mapDispatchToProps(dispatch) {
return {
onClick: bindActionCreators(toggleSync, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(SyncButton);

View File

@ -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';

View File

@ -0,0 +1,2 @@
export const DATA_TYPE_KEY = Symbol.for('__serializedType__');
export const DATA_REF_KEY = Symbol.for('__serializedRef__');

View File

@ -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';

View File

@ -0,0 +1,12 @@
const socketOptions = {
hostname: 'remotedev.io',
port: 443,
protocol: 'https',
autoReconnect: true,
secure: true,
autoReconnectOptions: {
randomness: 30000
}
};
export default socketOptions;

View File

@ -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 (
<Container>
<TopButtons
dispatch={liftedDispatch}
liftedState={liftedState}
options={options}
/>
<DevTools
monitor={monitor}
liftedState={liftedState}
monitorState={this.props.monitorState}
dispatch={liftedDispatch}
features={options.features}
/>
{sliderIsOpen && options.connectionId && options.features.jump &&
<SliderMonitor liftedState={liftedState} dispatch={liftedDispatch} />
}
{dispatcherIsOpen && options.connectionId && options.features.dispatch &&
<Dispatcher options={options} />
}
<BottomButtons
dispatcherIsOpen={dispatcherIsOpen}
sliderIsOpen={sliderIsOpen}
options={options}
/>
</Container>
);
}
}
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);

View File

@ -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 = <Settings />; break;
default: body = <Actions />;
}
return (
<Container themeData={theme}>
<Header section={section} />
{body}
{notification &&
<Notification type={notification.type} onClose={this.props.clearNotification}>
{notification.message}
</Notification>
}
</Container>
);
}
}
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);

View File

@ -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 (
<div className={`monitor monitor-${this.props.monitor}`}>
<this.Monitor
{...liftedState}
{...this.monitorProps}
features={this.props.features}
dispatch={this.dispatch}
theme={this.props.theme}
/>
</div>
);
}
}
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);

View File

@ -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 (
<ChartMonitor
defaultIsVisible invertTheme
onClickText={this.onClickText}
{...this.props}
/>
);
}
}
ChartMonitorWrapper.propTypes = {
selectMonitorWithState: PropTypes.func.isRequired
};
function mapDispatchToProps(dispatch) {
return {
selectMonitorWithState: bindActionCreators(selectMonitorWithState, dispatch)
};
}
export default connect(null, mapDispatchToProps)(ChartMonitorWrapper);

View File

@ -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 = (
<CodeContainer>
<Editor
value={this.state.customAction}
onChange={this.handleCustomAction}
/>
</CodeContainer>
);
} else {
actionElement = (
<ActionContainer>
{actionCreators[this.state.selected].args.map((param, i) => (
<div key={`${param}${i}`}>
<div>{param}</div>
<Editor
lineNumbers={false}
value={this.state.args[i]}
onChange={this.handleArg(i)}
/>
</div>
))}
<div>
<div>...rest</div>
<Editor
lineNumbers={false}
value={this.state.rest}
onChange={this.handleRest}
/>
</div>
</ActionContainer>
);
}
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 (
<DispatcherContainer>
{actionElement}
<Toolbar>
<Select
openOuterUp
onChange={this.selectActionCreator}
value={this.state.selected || 'default'}
options={options}
/>
<Button onClick={this.dispatchAction} primary={this.state.changed}>Dispatch</Button>
</Toolbar>
</DispatcherContainer>
);
}
}
function mapDispatchToProps(dispatch) {
return {
dispatch: bindActionCreators(dispatchRemotely, dispatch)
};
}
export default connect(null, mapDispatchToProps)(Dispatcher);

View File

@ -0,0 +1,109 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { withTheme } from 'styled-components';
import { tree } from 'd3-state-visualizer';
import { getPath } from '../ChartMonitorWrapper';
import { updateMonitorState } from '../../../actions';
const style = {
width: '100%',
height: '100%'
};
class ChartTab extends Component {
shouldComponentUpdate() {
return false;
}
componentDidMount() {
this.createChart(this.props);
}
componentWillReceiveProps(nextProps) {
if (
this.props.theme.scheme !== nextProps.theme.scheme ||
nextProps.theme.light !== this.props.theme.light
) {
this.node.innerHTML = '';
this.createChart(nextProps);
} else if (nextProps.data !== this.props.data) {
this.renderChart(nextProps.data);
}
}
getRef = node => {
this.node = node;
};
createChart(props) {
this.renderChart = tree(this.node, this.getChartTheme(props.theme));
this.renderChart(props.data);
}
getChartTheme(theme) {
return {
heightBetweenNodesCoeff: 1,
widthBetweenNodesCoeff: 1.3,
tooltipOptions: {
style: {
color: theme.base06,
'background-color': theme.base01,
opacity: '0.9',
'border-radius': '5px',
padding: '5px'
},
offset: { left: 30, top: 10 },
indentationSize: 2
},
style: {
width: '100%',
height: '100%',
node: {
colors: {
default: theme.base0B,
collapsed: theme.base0B,
parent: theme.base0E
},
radius: 7
},
text: {
colors: {
default: theme.base0D,
hover: theme.base06
}
}
},
onClickText: this.onClickText
};
}
onClickText = (data) => {
const inspectedStatePath = [];
getPath(data, inspectedStatePath);
this.props.updateMonitorState({
inspectedStatePath,
subTabName: data.children ? 'Chart' : 'Tree'
});
};
render() {
return <div style={style} ref={this.getRef} />;
}
}
ChartTab.propTypes = {
data: PropTypes.object,
updateMonitorState: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired
};
function mapDispatchToProps(dispatch) {
return {
updateMonitorState: bindActionCreators(updateMonitorState, dispatch)
};
}
const ConnectedChartTab = connect(null, mapDispatchToProps)(ChartTab);
export default withTheme(ConnectedChartTab);

View File

@ -0,0 +1,29 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Editor } from 'devui';
import stringify from 'javascript-stringify';
export default class RawTab extends Component {
constructor(props) {
super(props);
this.stringifyData(props);
}
shouldComponentUpdate(nextProps) {
return nextProps.data !== this.value;
}
componentWillUpdate(nextProps) {
this.stringifyData(nextProps);
}
stringifyData(props) {
this.value = stringify(props.data, null, 2);
}
render() {
return (
<Editor value={this.value} />
);
}
}

View File

@ -0,0 +1,111 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { Tabs } from 'devui';
import StateTree from 'redux-devtools-inspector/lib/tabs/StateTab';
import ActionTree from 'redux-devtools-inspector/lib/tabs/ActionTab';
import DiffTree from 'redux-devtools-inspector/lib/tabs/DiffTab';
import { selectMonitorTab } from '../../../actions';
import RawTab from './RawTab';
import ChartTab from './ChartTab';
import VisualDiffTab from './VisualDiffTab';
class SubTabs extends Component {
constructor(props) {
super(props);
this.updateTabs(props);
}
componentWillReceiveProps(nextProps) {
if (nextProps.parentTab !== this.props.parentTab) {
this.updateTabs(nextProps);
}
}
selector = () => {
switch (this.props.parentTab) {
case 'Action':
return { data: this.props.action };
case 'Diff':
return { data: this.props.delta };
default:
return { data: this.props.nextState };
}
};
updateTabs(props) {
const parentTab = props.parentTab;
if (parentTab === 'Diff') {
this.tabs = [
{
name: 'Tree',
component: DiffTree,
selector: () => this.props
},
{
name: 'Raw',
component: VisualDiffTab,
selector: this.selector
}
];
return;
}
this.tabs = [
{
name: 'Tree',
component: parentTab === 'Action' ? ActionTree : StateTree,
selector: () => this.props
},
{
name: 'Chart',
component: ChartTab,
selector: this.selector
},
{
name: 'Raw',
component: RawTab,
selector: this.selector
}
];
}
render() {
let selected = this.props.selected;
if (selected === 'Chart' && this.props.parentTab === 'Diff') selected = 'Tree';
return (
<Tabs
tabs={this.tabs}
selected={selected || 'Tree'}
onClick={this.props.selectMonitorTab}
/>
);
}
}
SubTabs.propTypes = {
selected: PropTypes.string,
parentTab: PropTypes.string,
selectMonitorTab: PropTypes.func.isRequired,
action: PropTypes.object,
delta: PropTypes.object,
nextState: PropTypes.object
};
function mapStateToProps(state) {
return {
parentTab: state.monitor.monitorState.tabName,
selected: state.monitor.monitorState.subTabName
};
}
function mapDispatchToProps(dispatch) {
return {
selectMonitorTab: bindActionCreators(selectMonitorTab, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(SubTabs);

View File

@ -0,0 +1,226 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { format } from 'jsondiffpatch/src/formatters/html';
import styled from 'styled-components';
import { effects } from 'devui';
export const StyledContainer = styled.div`
.jsondiffpatch-delta {
line-height: 14px;
font-size: 12px;
padding: 12px;
margin: 0;
display: inline-block;
}
.jsondiffpatch-delta pre {
font-size: 12px;
margin: 0;
padding: 2px 3px;
border-radius: 3px;
position: relative;
color: ${props => props.theme.base07};
display: inline-block;
}
ul.jsondiffpatch-delta {
list-style-type: none;
padding: 0 0 0 20px;
margin: 0;
}
.jsondiffpatch-delta ul {
list-style-type: none;
padding: 0 0 0 20px;
margin: 0;
}
.jsondiffpatch-left-value, .jsondiffpatch-right-value {
vertical-align: top;
}
.jsondiffpatch-modified .jsondiffpatch-right-value:before {
vertical-align: top;
padding: 2px;
color: ${props => props.theme.base0E};
content: ' => ';
}
.jsondiffpatch-added .jsondiffpatch-value pre,
.jsondiffpatch-modified .jsondiffpatch-right-value pre,
.jsondiffpatch-textdiff-added {
background: ${props => effects.color(props.theme.base0B, 'alpha', 0.2)};
}
.jsondiffpatch-deleted pre,
.jsondiffpatch-modified .jsondiffpatch-left-value pre,
.jsondiffpatch-textdiff-deleted {
background: ${props => effects.color(props.theme.base08, 'alpha', 0.2)};
text-decoration: line-through;
}
.jsondiffpatch-unchanged,
.jsondiffpatch-movedestination {
color: gray;
}
.jsondiffpatch-unchanged,
.jsondiffpatch-movedestination > .jsondiffpatch-value {
transition: all 0.5s;
-webkit-transition: all 0.5s;
overflow-y: hidden;
}
.jsondiffpatch-unchanged-showing .jsondiffpatch-unchanged,
.jsondiffpatch-unchanged-showing .jsondiffpatch-movedestination > .jsondiffpatch-value {
max-height: 100px;
}
.jsondiffpatch-unchanged-hidden .jsondiffpatch-unchanged,
.jsondiffpatch-unchanged-hidden .jsondiffpatch-movedestination > .jsondiffpatch-value {
max-height: 0;
}
.jsondiffpatch-unchanged-hiding .jsondiffpatch-movedestination > .jsondiffpatch-value,
.jsondiffpatch-unchanged-hidden .jsondiffpatch-movedestination > .jsondiffpatch-value {
display: block;
}
.jsondiffpatch-unchanged-visible .jsondiffpatch-unchanged,
.jsondiffpatch-unchanged-visible .jsondiffpatch-movedestination > .jsondiffpatch-value {
max-height: 100px;
}
.jsondiffpatch-unchanged-hiding .jsondiffpatch-unchanged,
.jsondiffpatch-unchanged-hiding .jsondiffpatch-movedestination > .jsondiffpatch-value {
max-height: 0;
}
.jsondiffpatch-unchanged-showing .jsondiffpatch-arrow,
.jsondiffpatch-unchanged-hiding .jsondiffpatch-arrow {
display: none;
}
.jsondiffpatch-value {
display: inline-block;
}
.jsondiffpatch-property-name {
display: inline-block;
padding: 2px 0;
padding-right: 5px;
vertical-align: top;
color: ${props => props.theme.base0D};
}
.jsondiffpatch-property-name:after {
content: ': ';
color: ${props => props.theme.base07};
}
.jsondiffpatch-child-node-type-array > .jsondiffpatch-property-name:after {
content: ': [';
}
.jsondiffpatch-child-node-type-array:after {
content: '],';
}
div.jsondiffpatch-child-node-type-array:before {
content: '[';
}
div.jsondiffpatch-child-node-type-array:after {
content: ']';
}
.jsondiffpatch-child-node-type-object > .jsondiffpatch-property-name:after {
content: ': {';
}
.jsondiffpatch-child-node-type-object:after {
content: '},';
}
div.jsondiffpatch-child-node-type-object:before {
content: '{';
}
div.jsondiffpatch-child-node-type-object:after {
content: '}';
}
.jsondiffpatch-value pre:after {
color: ${props => props.theme.base07};
content: ',';
}
li:last-child > .jsondiffpatch-value pre:after,
.jsondiffpatch-modified > .jsondiffpatch-left-value pre:after {
content: '';
}
.jsondiffpatch-modified .jsondiffpatch-value {
display: inline-block;
}
.jsondiffpatch-modified .jsondiffpatch-right-value {
margin-left: 5px;
}
.jsondiffpatch-moved .jsondiffpatch-value {
display: none;
}
.jsondiffpatch-moved .jsondiffpatch-moved-destination {
display: inline-block;
background: ${props => props.theme.base0A};
}
.jsondiffpatch-moved .jsondiffpatch-moved-destination:before {
content: ' => ';
}
ul.jsondiffpatch-textdiff {
padding: 0;
}
.jsondiffpatch-textdiff-location {
display: inline-block;
min-width: 60px;
}
.jsondiffpatch-textdiff-line {
display: inline-block;
}
.jsondiffpatch-textdiff-line-number:after {
content: ',';
}
.jsondiffpatch-error {
background: red;
color: white;
font-weight: bold;
}
`;
export default class VisualDiffTab extends Component {
shouldComponentUpdate(nextProps) {
return this.props.data !== nextProps.data;
}
render() {
let __html;
const data = this.props.data;
if (data) {
__html = format(data);
}
return <StyledContainer dangerouslySetInnerHTML={{ __html }} />;
}
}
VisualDiffTab.propTypes = {
data: PropTypes.object
};

View File

@ -0,0 +1,56 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import InspectorMonitor from 'redux-devtools-inspector';
import StackTraceTab from 'redux-devtools-trace-monitor';
import TestTab from 'redux-devtools-test-generator';
import { DATA_TYPE_KEY } from '../../../constants/dataTypes';
import SubTabs from './SubTabs';
const DEFAULT_TABS = [
{
name: 'Action',
component: SubTabs
}, {
name: 'State',
component: SubTabs
}, {
name: 'Diff',
component: SubTabs
},
{
name: 'Trace',
component: StackTraceTab
}
];
class InspectorWrapper extends Component {
static update = InspectorMonitor.update;
render() {
const { features, ...rest } = this.props;
let tabs;
if (features && features.test) {
tabs = () => [...DEFAULT_TABS, { name: 'Test', component: TestTab }];
} else {
tabs = () => DEFAULT_TABS;
}
return (
<InspectorMonitor
dataTypeKey={DATA_TYPE_KEY}
shouldPersistState={false}
invertTheme={false}
tabs={tabs}
hideActionButtons={!features.skip}
hideMainButtons
{...rest}
/>
);
}
}
InspectorWrapper.propTypes = {
features: PropTypes.object
};
export default InspectorWrapper;

View File

@ -0,0 +1,39 @@
import React, { Component, createElement } from 'react';
import PropTypes from 'prop-types';
import styled, { withTheme } from 'styled-components';
import SliderMonitor from 'redux-slider-monitor';
const SliderWrapper = styled.div`
border-color: ${props => props.theme.base02};
border-style: solid;
border-width: 1px 0;
`;
class Slider extends Component {
shouldComponentUpdate(nextProps) {
return (
nextProps.liftedState !== this.props.liftedState ||
nextProps.theme.scheme !== this.props.theme.scheme
);
}
render() {
return (
<SliderWrapper>
<SliderMonitor
{...this.props.liftedState}
dispatch={this.props.dispatch}
theme={this.props.theme}
hideResetButton
/>
</SliderWrapper>
);
}
}
Slider.propTypes = {
liftedState: PropTypes.object,
dispatch: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired
};
export default withTheme(Slider);

View File

@ -0,0 +1,40 @@
import 'devui/lib/presets';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Provider } from 'react-redux';
import configureStore from './store/configureStore';
import { CONNECT_REQUEST } from './constants/socketActionTypes';
import App from './containers/App';
class Root extends Component {
componentWillMount() {
configureStore((store, preloadedState) => {
this.store = store;
store.dispatch({
type: CONNECT_REQUEST,
options: preloadedState.connection || this.props.socketOptions
});
this.forceUpdate();
});
}
render() {
if (!this.store) return null;
return (
<Provider store={this.store}>
<App {...this.props} />
</Provider>
);
}
}
Root.propTypes = {
socketOptions: PropTypes.shape({
hostname: PropTypes.string,
port: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
autoReconnect: PropTypes.bool,
secure: PropTypes.bool
})
};
export default Root;

View File

@ -0,0 +1,201 @@
import socketCluster from 'socketcluster-client';
import { stringify } from 'jsan';
import socketOptions from '../constants/socketOptions';
import * as actions from '../constants/socketActionTypes';
import { getActiveInstance } from '../reducers/instances';
import {
UPDATE_STATE, REMOVE_INSTANCE, LIFTED_ACTION,
UPDATE_REPORTS, GET_REPORT_REQUEST, GET_REPORT_ERROR, GET_REPORT_SUCCESS
} from '../constants/actionTypes';
import { showNotification, importState } from '../actions';
import { nonReduxDispatch } from '../utils/monitorActions';
let socket;
let store;
function emit({ message: type, id, instanceId, action, state }) {
socket.emit(
id ? 'sc-' + id : 'respond',
{ type, action, state, instanceId }
);
}
function startMonitoring(channel) {
if (channel !== store.getState().socket.baseChannel) return;
store.dispatch({ type: actions.EMIT, message: 'START' });
}
function dispatchRemoteAction({ message, action, state, toAll }) {
const instances = store.getState().instances;
const instanceId = getActiveInstance(instances);
const id = !toAll && instances.options[instanceId].connectionId;
store.dispatch({
type: actions.EMIT,
message,
action,
state: nonReduxDispatch(store, message, instanceId, action, state, instances),
instanceId,
id
});
}
function monitoring(request) {
if (request.type === 'DISCONNECTED') {
store.dispatch({
type: REMOVE_INSTANCE,
id: request.id
});
return;
}
if (request.type === 'START') {
store.dispatch({ type: actions.EMIT, message: 'START', id: request.id });
return;
}
if (request.type === 'ERROR') {
store.dispatch(showNotification(request.payload));
return;
}
store.dispatch({
type: UPDATE_STATE,
request: request.data ? { ...request.data, id: request.id } : request
});
const instances = store.getState().instances;
const instanceId = request.instanceId || request.id;
if (
instances.sync && instanceId === instances.selected &&
(request.type === 'ACTION' || request.type === 'STATE')
) {
socket.emit('respond', {
type: 'SYNC',
state: stringify(instances.states[instanceId]),
id: request.id,
instanceId
});
}
}
function subscribe(channelName, subscription) {
const channel = socket.subscribe(channelName);
if (subscription === UPDATE_STATE) channel.watch(monitoring);
else {
const watcher = request => {
store.dispatch({ type: subscription, request });
};
channel.watch(watcher);
socket.on(channelName, watcher);
}
}
function handleConnection() {
socket.on('connect', status => {
store.dispatch({
type: actions.CONNECT_SUCCESS,
payload: {
id: status.id,
authState: socket.authState,
socketState: socket.state
},
error: status.authError
});
if (socket.authState !== actions.AUTHENTICATED) {
store.dispatch({ type: actions.AUTH_REQUEST });
}
});
socket.on('disconnect', code => {
store.dispatch({ type: actions.DISCONNECTED, code });
});
socket.on('subscribe', channel => {
store.dispatch({ type: actions.SUBSCRIBE_SUCCESS, channel });
});
socket.on('unsubscribe', channel => {
socket.unsubscribe(channel);
socket.unwatch(channel);
socket.off(channel);
store.dispatch({ type: actions.UNSUBSCRIBE, channel });
});
socket.on('subscribeFail', error => {
store.dispatch({ type: actions.SUBSCRIBE_ERROR, error, status: 'subscribeFail' });
});
socket.on('dropOut', error => {
store.dispatch({ type: actions.SUBSCRIBE_ERROR, error, status: 'dropOut' });
});
socket.on('error', error => {
store.dispatch({ type: actions.CONNECT_ERROR, error });
});
}
function connect() {
if (process.env.NODE_ENV === 'test') return;
const connection = store.getState().connection;
try {
socket = socketCluster.connect(
connection.type === 'remotedev' ? socketOptions : connection.options
);
handleConnection(store);
} catch (error) {
store.dispatch({ type: actions.CONNECT_ERROR, error });
store.dispatch(showNotification(error.message || error));
}
}
function disconnect() {
socket.disconnect();
socket.off();
}
function login() {
socket.emit('login', {}, (error, baseChannel) => {
if (error) {
store.dispatch({ type: actions.AUTH_ERROR, error });
return;
}
store.dispatch({ type: actions.AUTH_SUCCESS, baseChannel });
store.dispatch({
type: actions.SUBSCRIBE_REQUEST,
channel: baseChannel,
subscription: UPDATE_STATE
});
store.dispatch({
type: actions.SUBSCRIBE_REQUEST,
channel: 'report',
subscription: UPDATE_REPORTS
});
});
}
function getReport(reportId) {
socket.emit('getReport', reportId, (error, data) => {
if (error) {
store.dispatch({ type: GET_REPORT_ERROR, error });
return;
}
store.dispatch({ type: GET_REPORT_SUCCESS, data });
store.dispatch(importState(data.payload));
});
}
export default function api(inStore) {
store = inStore;
return next => action => {
const result = next(action);
switch (action.type) { // eslint-disable-line default-case
case actions.CONNECT_REQUEST: connect(); break;
case actions.RECONNECT:
disconnect();
if (action.options.type !== 'disabled') connect();
break;
case actions.AUTH_REQUEST: login(); break;
case actions.SUBSCRIBE_REQUEST: subscribe(action.channel, action.subscription); break;
case actions.SUBSCRIBE_SUCCESS: startMonitoring(action.channel); break;
case actions.EMIT: if (socket) emit(action); break;
case LIFTED_ACTION: dispatchRemoteAction(action); break;
case GET_REPORT_REQUEST: getReport(action.report); break;
}
return result;
};
}

View File

@ -0,0 +1,48 @@
import stringifyJSON from '../utils/stringifyJSON';
import { UPDATE_STATE, LIFTED_ACTION, EXPORT } from '../constants/actionTypes';
import { getActiveInstance } from '../reducers/instances';
let toExport;
function download(state) {
const blob = new Blob([state], { type: 'octet/stream' });
const href = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style = 'display: none';
a.download = 'state.json';
a.href = href;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
window.URL.revokeObjectURL(href);
}, 0);
}
const exportState = store => next => action => {
const result = next(action);
if (toExport && action.type === UPDATE_STATE && action.request.type === 'EXPORT') {
const request = action.request;
const id = request.instanceId || request.id;
if (id === toExport) {
toExport = undefined;
download(JSON.stringify({
payload: request.payload, preloadedState: request.committedState
}, null, '\t'));
}
} else if (action.type === EXPORT) {
const instances = store.getState().instances;
const instanceId = getActiveInstance(instances);
const options = instances.options[instanceId];
if (options.features.export === true) {
download(stringifyJSON(instances.states[instanceId], options.serialize));
} else {
toExport = instanceId;
next({ type: LIFTED_ACTION, message: 'EXPORT', toExport: true });
}
}
return result;
};
export default exportState;

View File

@ -0,0 +1,15 @@
import { RECONNECT } from '../constants/socketActionTypes';
export default function connection(
state = {
options: { hostname: 'localhost', port: 8000, secure: false },
type: 'remotedev'
},
action
) {
if (action.type === RECONNECT) {
const { type, ...options } = action.options;
return { ...state, type, options };
}
return state;
}

View File

@ -0,0 +1,22 @@
import { combineReducers } from 'redux';
import section from './section';
import connection from './connection';
import socket from './socket';
import monitor from './monitor';
import notification from './notification';
import instances from './instances';
import reports from './reports';
import theme from './theme';
const rootReducer = combineReducers({
section,
theme,
connection,
socket,
monitor,
instances,
reports,
notification
});
export default rootReducer;

View File

@ -0,0 +1,299 @@
import {
UPDATE_STATE, SET_STATE, LIFTED_ACTION,
SELECT_INSTANCE, REMOVE_INSTANCE, TOGGLE_PERSIST, TOGGLE_SYNC
} from '../constants/actionTypes';
import { DISCONNECTED } from '../constants/socketActionTypes';
import parseJSON from '../utils/parseJSON';
import { recompute } from '../utils/updateState';
export const initialState = {
selected: null,
current: 'default',
sync: false,
connections: {},
options: { default: { features: {} } },
states: {
default: {
actionsById: {},
computedStates: [],
currentStateIndex: -1,
nextActionId: 0,
skippedActionIds: [],
stagedActionIds: []
}
}
};
function updateState(state, request, id, serialize) {
let payload = request.payload;
const actionsById = request.actionsById;
if (actionsById) {
payload = {
...payload,
actionsById: parseJSON(actionsById, serialize),
computedStates: parseJSON(request.computedStates, serialize)
};
if (request.type === 'STATE' && request.committedState) {
payload.committedState = payload.computedStates[0].state;
}
} else {
payload = parseJSON(payload, serialize);
}
let newState;
const liftedState = state[id] || state.default;
const action = request.action && parseJSON(request.action, serialize) || {};
switch (request.type) {
case 'INIT':
newState = recompute(
state.default,
payload,
{ action: { type: '@@INIT' }, timestamp: action.timestamp || Date.now() }
);
break;
case 'ACTION': {
let isExcess = request.isExcess;
const nextActionId = request.nextActionId || (liftedState.nextActionId + 1);
const maxAge = request.maxAge;
if (Array.isArray(action)) {
// Batched actions
newState = liftedState;
for (let i = 0; i < action.length; i++) {
newState = recompute(
newState,
request.batched ? payload : payload[i],
action[i],
newState.nextActionId + 1,
maxAge,
isExcess
);
}
} else {
newState = recompute(
liftedState,
payload,
action,
nextActionId,
maxAge,
isExcess
);
}
break;
}
case 'STATE':
newState = payload;
if (newState.computedStates.length <= newState.currentStateIndex) {
newState.currentStateIndex = newState.computedStates.length - 1;
}
break;
case 'PARTIAL_STATE': {
const maxAge = request.maxAge;
const nextActionId = payload.nextActionId;
const stagedActionIds = payload.stagedActionIds;
let computedStates = payload.computedStates;
let oldActionsById;
let oldComputedStates;
let committedState;
if (nextActionId > maxAge) {
const oldStagedActionIds = liftedState.stagedActionIds;
const excess = oldStagedActionIds.indexOf(stagedActionIds[1]);
let key;
if (excess > 0) {
oldComputedStates = liftedState.computedStates.slice(excess - 1);
oldActionsById = { ...liftedState.actionsById };
for (let i = 1; i < excess; i++) {
key = oldStagedActionIds[i];
if (key) delete oldActionsById[key];
}
committedState = computedStates[0].state;
} else {
oldActionsById = liftedState.actionsById;
oldComputedStates = liftedState.computedStates;
committedState = liftedState.committedState;
}
} else {
oldActionsById = liftedState.actionsById;
oldComputedStates = liftedState.computedStates;
committedState = liftedState.committedState;
}
computedStates = [...oldComputedStates, ...computedStates];
const statesCount = computedStates.length;
let currentStateIndex = payload.currentStateIndex;
if (statesCount <= currentStateIndex) currentStateIndex = statesCount - 1;
newState = {
...liftedState,
actionsById: { ...oldActionsById, ...payload.actionsById },
computedStates,
currentStateIndex,
nextActionId,
stagedActionIds,
committedState
};
break;
}
case 'LIFTED':
newState = liftedState;
break;
default:
return state;
}
if (request.liftedState) newState = { ...newState, ...request.liftedState };
return { ...state, [id]: newState };
}
export function dispatchAction(state, { action }) {
if (action.type === 'JUMP_TO_STATE' || action.type === 'JUMP_TO_ACTION') {
const id = state.selected || state.current;
const liftedState = state.states[id];
let currentStateIndex = action.index;
if (typeof currentStateIndex === 'undefined' && action.actionId) {
currentStateIndex = liftedState.stagedActionIds.indexOf(action.actionId);
}
return {
...state,
states: {
...state.states,
[id]: { ...liftedState, currentStateIndex }
}
};
}
return state;
}
function removeState(state, connectionId) {
const instanceIds = state.connections[connectionId];
if (!instanceIds) return state;
const connections = { ...state.connections };
const options = { ...state.options };
const states = { ...state.states };
let selected = state.selected;
let current = state.current;
let sync = state.sync;
delete connections[connectionId];
instanceIds.forEach(id => {
if (id === selected) {
selected = null;
sync = false;
}
if (id === current) {
const inst = Object.keys(connections)[0];
if (inst) current = connections[inst][0];
else current = 'default';
}
delete options[id];
delete states[id];
});
return {
selected,
current,
sync,
connections,
options,
states
};
}
function init({ type, action, name, libConfig = {} }, connectionId, current) {
let lib;
let actionCreators;
let creators = libConfig.actionCreators || action;
if (typeof creators === 'string') creators = JSON.parse(creators);
if (Array.isArray(creators)) actionCreators = creators;
if (type === 'STATE') lib = 'redux';
return {
name: libConfig.name || name || current,
connectionId,
explicitLib: libConfig.type,
lib,
actionCreators,
features: libConfig.features ? libConfig.features :
{
lock: lib === 'redux',
export: libConfig.type === 'redux' ? 'custom' : true,
import: 'custom',
persist: true,
pause: true,
reorder: true,
jump: true,
skip: true,
dispatch: true,
sync: true,
test: true
},
serialize: libConfig.serialize
};
}
export default function instances(state = initialState, action) {
switch (action.type) {
case UPDATE_STATE: {
const { request } = action;
if (!request) return state;
const connectionId = action.id || request.id;
const current = request.instanceId || connectionId;
let connections = state.connections;
let options = state.options;
if (typeof state.options[current] === 'undefined') {
connections = {
...state.connections,
[connectionId]: [...(connections[connectionId] || []), current]
};
options = { ...options, [current]: init(request, connectionId, current) };
}
return {
...state,
current,
connections,
options,
states: updateState(state.states, request, current, options[current].serialize)
};
}
case SET_STATE:
return {
...state,
states: {
...state.states,
[getActiveInstance(state)]: action.newState
}
};
case TOGGLE_PERSIST:
return { ...state, persisted: !state.persisted };
case TOGGLE_SYNC:
return { ...state, sync: !state.sync };
case SELECT_INSTANCE:
return { ...state, selected: action.selected, sync: false };
case REMOVE_INSTANCE:
return removeState(state, action.id);
case LIFTED_ACTION: {
if (action.message === 'DISPATCH') return dispatchAction(state, action);
if (action.message === 'IMPORT') {
const id = state.selected || state.current;
if (state.options[id].features.import === true) {
return {
...state,
states: {
...state.states,
[id]: parseJSON(action.state)
}
};
}
}
return state;
}
case DISCONNECTED:
return initialState;
default:
return state;
}
}
/* eslint-disable no-shadow */
export const getActiveInstance = instances => instances.selected || instances.current;
/* eslint-enable */

View File

@ -0,0 +1,69 @@
import {
MONITOR_ACTION, SELECT_MONITOR, SELECT_MONITOR_TAB, UPDATE_MONITOR_STATE,
TOGGLE_SLIDER, TOGGLE_DISPATCHER
} from '../constants/actionTypes';
const initialState = {
selected: 'InspectorMonitor',
monitorState: undefined,
sliderIsOpen: true,
dispatcherIsOpen: false
};
export function dispatchMonitorAction(state, action) {
return {
...state,
monitorState: action.action.newMonitorState ||
action.monitorReducer(action.monitorProps, state.monitorState, action.action)
};
}
export default function monitor(state = initialState, action) {
switch (action.type) {
case MONITOR_ACTION:
return dispatchMonitorAction(state, action);
case SELECT_MONITOR: {
let monitorState = state.monitorState;
if (action.monitorState) {
monitorState = {
...action.monitorState,
__overwritten__: action.monitor
};
}
return {
...state,
monitorState,
selected: action.monitor
};
}
case UPDATE_MONITOR_STATE: {
let inspectedStatePath = state.monitorState.inspectedStatePath;
if (action.nextState.inspectedStatePath) {
inspectedStatePath = [
...inspectedStatePath.slice(0, -1),
...action.nextState.inspectedStatePath
];
}
return {
...state,
monitorState: {
...state.monitorState,
...action.nextState,
inspectedStatePath
}
};
}
case TOGGLE_SLIDER:
return {
...state,
sliderIsOpen: !state.sliderIsOpen
};
case TOGGLE_DISPATCHER:
return {
...state,
dispatcherIsOpen: !state.dispatcherIsOpen
};
default:
return state;
}
}

View File

@ -0,0 +1,16 @@
import { SHOW_NOTIFICATION, CLEAR_NOTIFICATION, LIFTED_ACTION, ERROR } from '../constants/actionTypes';
export default function notification(state = null, action) {
switch (action.type) {
case SHOW_NOTIFICATION:
return action.notification;
case ERROR:
return { type: 'error', message: action.payload };
case LIFTED_ACTION:
return null;
case CLEAR_NOTIFICATION:
return null;
default:
return state;
}
}

View File

@ -0,0 +1,37 @@
import { UPDATE_REPORTS, GET_REPORT_SUCCESS } from '../constants/actionTypes';
const initialState = {
data: []
};
export default function reports(state = initialState, action) {
/* if (action.type === GET_REPORT_SUCCESS) {
const id = action.data.id;
return {
...state,
data: state.data.map(d => (d.id === id ? action.data : d))
};
} else */ if (action.type !== UPDATE_REPORTS) return state;
const request = action.request;
const data = request.data;
switch (request.type) {
case 'list':
return {
...state,
data
};
case 'add':
return {
...state,
data: [...state.data, data]
};
case 'remove':
return {
...state,
data: state.data.filter(d => d.id !== request.id)
};
default:
return state;
}
}

View File

@ -0,0 +1,8 @@
import { CHANGE_SECTION } from '../constants/actionTypes';
export default function section(state = 'Actions', action) {
if (action.type === CHANGE_SECTION) {
return action.section;
}
return state;
}

View File

@ -0,0 +1,74 @@
import * as actions from '../constants/socketActionTypes';
const initialState = {
id: null,
channels: [],
socketState: actions.CLOSED,
authState: actions.PENDING,
authToken: null,
error: undefined
};
export default function socket(state = initialState, action) {
switch (action.type) {
case actions.CONNECT_REQUEST: {
return {
...state,
socketState: actions.CONNECTING
};
}
case actions.CONNECT_ERROR:
return {
...state,
error: action.error
};
case actions.CONNECT_SUCCESS:
return {
...state,
id: action.payload.id,
socketState: action.payload.socketState,
authState: action.payload.authState,
error: action.error
};
case actions.AUTH_REQUEST:
return {
...state,
authState: actions.PENDING
};
case actions.AUTH_SUCCESS:
return {
...state,
authState: actions.AUTHENTICATED,
authToken: action.authToken,
baseChannel: action.baseChannel
};
case actions.AUTH_ERROR:
return {
...state,
authState: actions.UNAUTHENTICATED,
error: action.error
};
case actions.DEAUTHENTICATE:
return {
...state,
authState: actions.UNAUTHENTICATED,
authToken: null
};
case actions.SUBSCRIBE_SUCCESS:
return {
...state,
channels: [...state.channels, action.channelName]
};
case actions.UNSUBSCRIBE:
return {
...state,
channels: state.channels.filter(channel =>
channel !== action.channelName
)
};
case actions.DISCONNECTED:
return initialState;
default:
return state;
}
}

View File

@ -0,0 +1,15 @@
import { CHANGE_THEME } from '../constants/actionTypes';
export default function theme(
state = { theme: 'default', scheme: 'default', light: true },
action
) {
if (action.type === CHANGE_THEME) {
return {
theme: action.theme,
scheme: action.scheme,
light: !action.dark
};
}
return state;
}

View File

@ -0,0 +1,39 @@
import { createStore, compose, applyMiddleware } from 'redux';
import localForage from 'localforage';
import { getStoredState, createPersistor } from 'redux-persist';
import api from '../middlewares/api';
import exportState from '../middlewares/exportState';
import rootReducer from '../reducers';
export default function configureStore(callback, key) {
const persistConfig = {
keyPrefix: `redux-devtools${key || ''}:`,
blacklist: ['instances', 'socket'],
storage: localForage,
serialize: data => data,
deserialize: data => data
};
getStoredState(persistConfig, (err, restoredState) => {
let composeEnhancers = compose;
if (process.env.NODE_ENV !== 'production') {
if (window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) {
composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__;
}
if (module.hot) {
// Enable Webpack hot module replacement for reducers
module.hot.accept('../reducers', () => {
const nextReducer = require('../reducers'); // eslint-disable-line global-require
store.replaceReducer(nextReducer);
});
}
}
const store = createStore(rootReducer, restoredState, composeEnhancers(
applyMiddleware(exportState, api)
));
const persistor = createPersistor(store, persistConfig);
callback(store, restoredState);
if (err) persistor.purge();
});
}

View File

@ -0,0 +1,29 @@
// Based on https://github.com/gaearon/redux-devtools/pull/241
/* eslint-disable no-param-reassign */
export default function commitExcessActions(liftedState, n = 1) {
// Auto-commits n-number of excess actions.
let excess = n;
let idsToDelete = liftedState.stagedActionIds.slice(1, excess + 1);
for (let i = 0; i < idsToDelete.length; i++) {
if (liftedState.computedStates[i + 1].error) {
// Stop if error is found. Commit actions up to error.
excess = i;
idsToDelete = liftedState.stagedActionIds.slice(1, excess + 1);
break;
} else {
delete liftedState.actionsById[idsToDelete[i]];
}
}
liftedState.skippedActionIds = liftedState.skippedActionIds.filter(
id => idsToDelete.indexOf(id) === -1
);
liftedState.stagedActionIds = [0, ...liftedState.stagedActionIds.slice(excess + 1)];
liftedState.committedState = liftedState.computedStates[excess].state;
liftedState.computedStates = liftedState.computedStates.slice(excess);
liftedState.currentStateIndex = liftedState.currentStateIndex > excess
? liftedState.currentStateIndex - excess
: 0;
}

View File

@ -0,0 +1,22 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import LogMonitor from 'redux-devtools-log-monitor';
import ChartMonitorWrapper from '../containers/monitors/ChartMonitorWrapper';
import InspectorWrapper from '../containers/monitors/InspectorWrapper';
export const monitors = [
{ value: 'InspectorMonitor', name: 'Inspector' },
{ value: 'LogMonitor', name: 'Log monitor' },
{ value: 'ChartMonitor', name: 'Chart' }
];
export default function getMonitor({ monitor }) { // eslint-disable-line react/prop-types
switch (monitor) {
case 'LogMonitor':
return <LogMonitor preserveScrollTop={false} hideMainButtons markStateDiff />;
case 'ChartMonitor':
return <ChartMonitorWrapper />;
default:
return <InspectorWrapper />;
}
}

View File

@ -0,0 +1,50 @@
import difference from 'lodash/difference';
import omit from 'lodash/omit';
import stringifyJSON from './stringifyJSON';
import { SET_STATE } from '../constants/actionTypes';
export function sweep(state) {
return {
...state,
actionsById: omit(state.actionsById, state.skippedActionIds),
stagedActionIds: difference(state.stagedActionIds, state.skippedActionIds),
skippedActionIds: [],
currentStateIndex: Math.min(state.currentStateIndex, state.stagedActionIds.length - 1)
};
}
export function nonReduxDispatch(store, message, instanceId, action, initialState, preInstances) {
const instances = preInstances || store.getState().instances;
const state = instances.states[instanceId];
const options = instances.options[instanceId];
if (message !== 'DISPATCH') {
if (message === 'IMPORT') {
if (options.features.import === true) {
return stringifyJSON(state.computedStates[state.currentStateIndex].state, true);
}
return initialState;
}
return undefined;
}
if (options.lib === 'redux') return undefined;
switch (action.type) {
case 'TOGGLE_ACTION':
return stringifyJSON(state, true);
case 'JUMP_TO_STATE':
return stringifyJSON(state.computedStates[action.index].state, true);
case 'JUMP_TO_ACTION':
return stringifyJSON(
state.computedStates[state.stagedActionIds.indexOf(action.actionId)].state, true
);
case 'ROLLBACK':
return stringifyJSON(state.computedStates[0].state, true);
case 'SWEEP':
store.dispatch({ type: SET_STATE, newState: sweep(state) });
return undefined;
default:
return undefined;
}
}

View File

@ -0,0 +1,34 @@
import jsan from 'jsan';
import { DATA_TYPE_KEY, DATA_REF_KEY } from '../constants/dataTypes';
export function reviver(key, value) {
if (
typeof value === 'object' && value !== null &&
'__serializedType__' in value && typeof value.data === 'object'
) {
const data = value.data;
data[DATA_TYPE_KEY] = value.__serializedType__;
if ('__serializedRef__' in value) data[DATA_REF_KEY] = value.__serializedRef__;
/*
if (Array.isArray(data)) {
data.__serializedType__ = value.__serializedType__;
} else {
Object.defineProperty(data, '__serializedType__', {
value: value.__serializedType__
});
}
*/
return data;
}
return value;
}
export default function parseJSON(data, serialize) {
if (typeof data !== 'string') return data;
try {
return serialize ? jsan.parse(data, reviver) : jsan.parse(data);
} catch (e) {
if (process.env.NODE_ENV !== 'production') console.error(data + 'is not a valid JSON', e);
return undefined;
}
}

View File

@ -0,0 +1,17 @@
import jsan from 'jsan';
import { DATA_TYPE_KEY, DATA_REF_KEY } from '../constants/dataTypes';
function replacer(key, value) {
if (typeof value === 'object' && value !== null && DATA_TYPE_KEY in value) {
const __serializedType__ = value[DATA_TYPE_KEY];
delete value[DATA_TYPE_KEY]; // eslint-disable-line no-param-reassign
const r = { data: value, __serializedType__ };
if (DATA_REF_KEY in value) r.__serializedRef__ = value[DATA_REF_KEY];
return r;
}
return value;
}
export default function stringifyJSON(data, serialize) {
return serialize ? jsan.stringify(data, replacer, null, true) : jsan.stringify(data);
}

View File

@ -0,0 +1,33 @@
import commitExcessActions from './commitExcessActions';
/* eslint-disable import/prefer-default-export */
export function recompute(previousLiftedState, storeState, action, nextActionId = 1, maxAge, isExcess) {
const actionId = nextActionId - 1;
const liftedState = { ...previousLiftedState };
if (liftedState.currentStateIndex === liftedState.stagedActionIds.length - 1) {
liftedState.currentStateIndex++;
}
liftedState.stagedActionIds = [...liftedState.stagedActionIds, actionId];
liftedState.actionsById = { ...liftedState.actionsById };
if (action.type === 'PERFORM_ACTION') {
liftedState.actionsById[actionId] = action;
} else {
liftedState.actionsById[actionId] = {
action: action.action || action,
timestamp: action.timestamp || Date.now(),
stack: action.stack,
type: 'PERFORM_ACTION'
};
}
liftedState.nextActionId = nextActionId;
liftedState.computedStates = [...liftedState.computedStates, { state: storeState }];
if (isExcess) commitExcessActions(liftedState);
else if (maxAge) {
const excess = liftedState.stagedActionIds.length - maxAge;
if (excess > 0) commitExcessActions(liftedState, excess);
}
return liftedState;
}

View File

@ -1,4 +1,4 @@
const ERROR = '@@remotedev/ERROR';
const ERROR = '@@redux-devtools/ERROR';
export default function catchErrors(sendError) {
if (typeof window === 'object' && typeof window.onerror === 'object') {

View File

@ -35,21 +35,21 @@ export function getLocalFilter(config) {
}
function getDevToolsOptions() {
return typeof window !== 'undefined' && window.devToolsOptions || {};
return (typeof window !== 'undefined' && window.devToolsOptions) || {};
}
export function isFiltered(action, localFilter) {
const { type } = action.action || action;
const opts = getDevToolsOptions();
if (
!localFilter && (opts.filter && opts.filter === FilterState.DO_NOT_FILTER) ||
type && typeof type.match !== 'function'
(!localFilter && (opts.filter && opts.filter === FilterState.DO_NOT_FILTER)) ||
(type && typeof type.match !== 'function')
) return false;
const { whitelist, blacklist } = localFilter || opts;
return (
whitelist && !type.match(whitelist) ||
blacklist && type.match(blacklist)
(whitelist && !type.match(whitelist)) ||
(blacklist && type.match(blacklist))
);
}

View File

@ -0,0 +1 @@
module.exports = {};

View File

@ -0,0 +1,42 @@
import React from 'react';
import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from 'redux';
import { mount } from 'enzyme';
// import { mountToJson } from 'enzyme-to-json';
import App from '../src/app/containers/App';
import api from '../src/app/middlewares/api';
import exportState from '../src/app/middlewares/exportState';
import rootReducer from '../src/app/reducers';
let wrapper;
const store = createStore(rootReducer, applyMiddleware(exportState, api));
describe('App container', () => {
beforeAll(() => {
wrapper = mount(
<Provider store={store}>
<App />
</Provider>
);
});
/*
it('should render the App', () => {
expect(mountToJson(wrapper)).toMatchSnapshot();
});
*/
it('should render inspector monitor\'s wrapper', () => {
expect(wrapper.find('DevtoolsInspector').html()).toBeDefined();
});
it('should contain an empty action list', () => {
expect(
wrapper.find('ActionList').findWhere(n => {
const { className } = n.props();
return className && className.startsWith('actionListRows-');
}).html()
).toMatch(/<div class="actionListRows-\d-\d+"><\/div>/);
});
});

View File

@ -0,0 +1,4 @@
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });

View File

@ -0,0 +1,84 @@
const path = require('path');
const webpack = require('webpack');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = (env = {}) => (
{
mode: 'development',
entry: {
app: './index.js'
},
output: {
path: path.resolve(__dirname, 'build/' + env.platform),
publicPath: '',
filename: 'js/[name].js',
sourceMapFilename: 'js/[name].map'
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.html$/,
loader: 'html-loader'
},
{
test: /\.css$/,
use: [
{ loader: 'style-loader' },
{ loader: 'css-loader' }
]
},
{
test: /\.(png|gif|jpg)$/,
loader: 'url-loader',
options: { limit: '25000', outputPath: 'images/', publicPath: 'images/' }
},
{
test: /\.(ttf|eot|svg|woff|woff2)$/,
loader: 'file-loader',
options: { outputPath: 'fonts/', publicPath: 'fonts/' }
}
]
},
plugins: [
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify(env.development ? 'development' : 'production'),
PLATFORM: JSON.stringify(env.platform)
}
}),
new HtmlWebpackPlugin({
template: 'assets/index.html'
}),
new CopyWebpackPlugin(
env.platform === 'electron' ? [
{ context: './src/electron', from: '*' }
] : []
)
],
optimization: {
minimize: false,
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'common',
chunks: 'all'
}
}
}
},
performance: {
hints: false
},
devServer: {
port: 3000
},
devtool: env.development ? 'eval' : 'source-map'
}
);

View File

@ -0,0 +1,82 @@
const path = require('path');
const webpack = require('webpack');
const TerserPlugin = require('terser-webpack-plugin');
module.exports = (env = {}) => (
{
mode: 'production',
entry: {
app: ['./src/app/index.js']
},
output: {
library: 'ReduxDevTools',
libraryTarget: 'umd',
path: path.resolve(__dirname, 'umd'),
filename: env.minimize ? 'redux-devtools-core.min.js' : 'redux-devtools-core.js'
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.html$/,
loader: 'html-loader'
},
{
test: /\.css$/,
use: [
{ loader: 'style-loader' },
{ loader: 'css-loader' }
]
},
{
test: /\.(png|gif|jpg)$/,
loader: 'url-loader',
options: { limit: '25000' }
},
{
test: /\.(ttf|eot|svg|woff|woff2)$/,
loader: 'url-loader'
}
]
},
plugins: [
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify('production'),
PLATFORM: JSON.stringify('web')
}
})
],
externals: {
react: {
root: 'React',
commonjs2: 'react',
commonjs: 'react',
amd: 'react'
},
'react-dom': {
root: 'ReactDOM',
commonjs2: 'react-dom',
commonjs: 'react-dom',
amd: 'react-dom'
}
},
optimization: {
minimize: !!env.minimize,
minimizer: [
new TerserPlugin({
terserOptions: {
safari10: true
}
})
]
},
performance: {
hints: false
}
}
);

View File

@ -3,6 +3,7 @@
"version": "0.11.0",
"description": "Redux DevTools Diff Monitor",
"scripts": {
"build": "npm run build:lib",
"build:lib": "NODE_ENV=production babel src --out-dir lib",
"build:demo": "NODE_ENV=production webpack -p",
"stats": "webpack --profile --json > stats.json",
@ -44,7 +45,6 @@
"json-loader": "^0.5.4",
"lodash.shuffle": "^4.2.0",
"nyan-progress-webpack-plugin": "^1.1.4",
"pre-commit": "^1.1.3",
"raw-loader": "^0.5.1",
"react": "^16.4.2",
"react-bootstrap": "^0.30.6",
@ -87,8 +87,5 @@
"react-json-tree": "^0.11.1",
"react-pure-render": "^1.0.2",
"redux-devtools-themes": "^1.0.0"
},
"pre-commit": [
"lint"
]
}
}

View File

@ -70,7 +70,7 @@
"redux-devtools": "^3.3.2",
"redux-devtools-dock-monitor": "^1.1.1",
"redux-logger": "^2.8.1",
"remotedev-inspector-monitor": "^0.11.0",
"redux-devtools-inspector": "^0.11.0",
"rimraf": "^2.5.2",
"style-loader": "^0.13.1",
"url-loader": "^0.5.7",

View File

@ -7,7 +7,8 @@
"clean": "rimraf lib",
"build": "babel src --out-dir lib",
"lint": "eslint src examples",
"prepublish": "npm run lint && npm run clean && npm run build"
"prepare": "npm run build",
"prepublishOnly": "npm run lint && npm run clean && npm run build"
},
"repository": {
"url": "https://github.com/reduxjs/redux-devtools"

1905
yarn.lock

File diff suppressed because it is too large Load Diff