Work in progress
This commit is contained in:
23
app/index.html
Normal file
23
app/index.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Hello React!</title>
|
||||
<style>
|
||||
body, html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="example"></div>
|
||||
|
||||
<!-- Dependencies -->
|
||||
<script src="./node_modules/react/umd/react.development.js"></script>
|
||||
<script src="./node_modules/react-dom/umd/react-dom.development.js"></script>
|
||||
|
||||
<!-- Main -->
|
||||
<script src="./build/bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
6141
app/package-lock.json
generated
Normal file
6141
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
app/package.json
Normal file
37
app/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "mqtt-explorer-app",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"awesome-typescript-loader": "^5.2.1",
|
||||
"source-map-loader": "^0.2.4",
|
||||
"typescript": "^3.2.2",
|
||||
"webpack": "^4.28.2",
|
||||
"webpack-cli": "^3.1.2",
|
||||
"webpack-dev-server": "^3.1.14"
|
||||
},
|
||||
"dependencies": {
|
||||
"@material-ui/core": "^3.7.1",
|
||||
"@types/react": "^16.7.18",
|
||||
"@types/react-dom": "^16.0.11",
|
||||
"@types/socket.io-client": "^1.4.32",
|
||||
"@types/vis": "^4.21.9",
|
||||
"cytoscape": "^3.3.1",
|
||||
"cytoscape-dagre": "^2.2.2",
|
||||
"cytoscape-expand-collapse": "^3.1.2",
|
||||
"jquery": "^3.3.1",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"react": "^16.3.2",
|
||||
"react-cytoscape": "^1.0.6",
|
||||
"react-dom": "^16.3.3",
|
||||
"react-json-view": "^1.19.1",
|
||||
"socket.io-client": "^2.2.0",
|
||||
"vis": "^4.21.0"
|
||||
}
|
||||
}
|
||||
37
app/src/App.tsx
Normal file
37
app/src/App.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as React from "react";
|
||||
import * as q from '../../src/Model'
|
||||
|
||||
import { AppBar, Toolbar, Typography, InputBase } from '@material-ui/core';
|
||||
|
||||
import { Tree } from "./components/Tree";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { withStyles } from '@material-ui/core/styles';
|
||||
|
||||
class State {
|
||||
public selectedNode?: q.TreeNode | undefined
|
||||
}
|
||||
|
||||
export class App extends React.Component<{}, State> {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
this.state = {
|
||||
selectedNode: undefined
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
return <div>
|
||||
<AppBar>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" color="inherit">MQTT-Xplorer</Typography>
|
||||
</Toolbar>
|
||||
<InputBase />
|
||||
</AppBar>
|
||||
<Tree didSelectNode={(node: q.TreeNode) => {
|
||||
this.setState({selectedNode: node})
|
||||
console.log('did select', node)
|
||||
}}/>
|
||||
// <Sidebar node={this.state.selectedNode} />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
74
app/src/components/Sidebar.tsx
Normal file
74
app/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import * as React from "react";
|
||||
import * as q from '../../../src/Model'
|
||||
import Drawer from '@material-ui/core/Drawer';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import { ValueRenderer } from './ValueRenderer'
|
||||
|
||||
interface Props {
|
||||
node?: q.TreeNode | undefined
|
||||
}
|
||||
|
||||
interface State {
|
||||
node?: q.TreeNode | undefined
|
||||
}
|
||||
|
||||
export class Sidebar extends React.Component<Props, State> {
|
||||
private updateNode: (node?: q.TreeNode | undefined) => void
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
this.state = {}
|
||||
this.updateNode = (node) => {
|
||||
if (!node) {
|
||||
this.setState(this.state)
|
||||
} else {
|
||||
this.setState({node: node})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getStyle(): {[s: string]: any} {
|
||||
return {
|
||||
marginTop: '64px'
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillReceiveProps(nextProps: Props) {
|
||||
this.props.node && this.props.node.removeListener('update', this.updateNode)
|
||||
nextProps.node && nextProps.node.on('update', this.updateNode)
|
||||
nextProps.node && this.updateNode(nextProps.node)
|
||||
}
|
||||
|
||||
private open() {
|
||||
return this.props.node !== undefined
|
||||
}
|
||||
|
||||
public render() {
|
||||
return <Drawer open={this.open()} variant="permanent" anchor="right">
|
||||
{this.renderNode()}
|
||||
</Drawer>
|
||||
}
|
||||
|
||||
private renderNode() {
|
||||
let style: React.CSSProperties = {display: 'block', width: '40vw'}
|
||||
let topicStyle: React.CSSProperties = {width: '100%'}
|
||||
|
||||
if (!this.state.node) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div style={style}>
|
||||
<TextField style={topicStyle}
|
||||
label="Topic"
|
||||
value={this.state.node.path()}
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Paper>
|
||||
<ValueRenderer node={this.state.node} />
|
||||
</Paper>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
73
app/src/components/Tree.tsx
Normal file
73
app/src/components/Tree.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import * as React from "react";
|
||||
import * as io from 'socket.io-client';
|
||||
import * as q from '../../../src/Model'
|
||||
import { TreeNode } from './TreeNode'
|
||||
import List from '@material-ui/core/List';
|
||||
|
||||
var throttle = require('lodash.throttle');
|
||||
|
||||
class TreeState {
|
||||
public tree: q.Tree
|
||||
public msg: any
|
||||
constructor(tree: q.Tree, msg: any) {
|
||||
this.tree = tree
|
||||
this.msg = msg
|
||||
}
|
||||
}
|
||||
|
||||
export interface TreeNodeProps {
|
||||
didSelectNode?: (node: q.TreeNode) => void
|
||||
}
|
||||
|
||||
export class Tree extends React.Component<TreeNodeProps, TreeState> {
|
||||
private socket: SocketIOClient.Socket
|
||||
private renderDuration: number = 300
|
||||
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
let tree = new q.Tree()
|
||||
this.state = new TreeState(tree, {})
|
||||
this.socket = io('http://localhost:3000');
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
let updateState = throttle((state: any) => {
|
||||
this.setState(state)
|
||||
updateState.cancel()
|
||||
updateState = throttle(() => {
|
||||
this.setState(state)
|
||||
}, Math.max(this.renderDuration * 5, 300), {trailing: true})
|
||||
}, 1000)
|
||||
|
||||
this.socket.on('message', (msg: any) => {
|
||||
const edges = msg.topic.split('/')
|
||||
const node = q.TreeNodeFactory.fromEdgesAndValue(edges, Buffer.from(msg.payload, "base64").toString())
|
||||
this.state.tree.updateWithNode(node.firstNode())
|
||||
|
||||
updateState({tree: this.state.tree, msg: msg})
|
||||
})
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.socket.removeAllListeners()
|
||||
}
|
||||
|
||||
private getStyle(): {[s: string]: any} {
|
||||
return {
|
||||
marginTop: '64px'
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
return <div {...this.props}>
|
||||
<List style={this.getStyle()}>
|
||||
<TreeNode
|
||||
didSelectNode={this.props.didSelectNode}
|
||||
treeNode={this.state.tree}
|
||||
name="/" collapsed={false}
|
||||
performanceCallback={(ms) => this.renderDuration = ms}
|
||||
/>
|
||||
</List>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
194
app/src/components/TreeNode.tsx
Normal file
194
app/src/components/TreeNode.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import * as React from "react";
|
||||
import * as io from 'socket.io-client';
|
||||
import * as q from '../../../src/Model'
|
||||
import List from '@material-ui/core/List';
|
||||
import ListItem from '@material-ui/core/ListItem';
|
||||
import Collapse from '@material-ui/core/Collapse';
|
||||
|
||||
export interface TreeNodeProps {
|
||||
treeNode: q.TreeNode,
|
||||
name?: string | undefined
|
||||
collapsed?: boolean | undefined
|
||||
performanceCallback?: ((ms: number) => void) | undefined
|
||||
didSelectNode?: (node: q.TreeNode) => void
|
||||
}
|
||||
|
||||
interface TreeNodeState {
|
||||
title: string | undefined,
|
||||
collapsed: boolean,
|
||||
collapsedOverride: boolean | undefined,
|
||||
edgeCount: number
|
||||
}
|
||||
|
||||
let collapseLimit = 0
|
||||
declare var performance: any
|
||||
|
||||
export class TreeNode extends React.Component<TreeNodeProps, TreeNodeState> {
|
||||
private dirty: boolean = true
|
||||
private willUpdateTime: number = performance.now()
|
||||
|
||||
constructor(props: TreeNodeProps, state: TreeNodeState) {
|
||||
super(props, state)
|
||||
|
||||
let edgeCount = Object.keys(props.treeNode.edges).length
|
||||
let collapsed = edgeCount > collapseLimit
|
||||
|
||||
this.props.treeNode.on('update', () => {
|
||||
this.dirty = true
|
||||
})
|
||||
|
||||
this.state = {collapsed, edgeCount: edgeCount, collapsedOverride: props.collapsed, title: props.name}
|
||||
}
|
||||
|
||||
public setState(state: any) {
|
||||
this.dirty = true
|
||||
super.setState(state)
|
||||
}
|
||||
|
||||
public shouldComponentUpdate() {
|
||||
return this.dirty
|
||||
}
|
||||
|
||||
public componentDidUpdate() {
|
||||
this.dirty = false
|
||||
if (this.props.performanceCallback) {
|
||||
let renderTime = performance.now()-this.willUpdateTime
|
||||
this.props.performanceCallback(renderTime)
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUpdate() {
|
||||
if (this.props.performanceCallback) {
|
||||
this.willUpdateTime = performance.now()
|
||||
}
|
||||
}
|
||||
|
||||
private collapsed() {
|
||||
if (this.state.collapsedOverride !== undefined) {
|
||||
return this.state.collapsedOverride
|
||||
}
|
||||
|
||||
return this.state.collapsed
|
||||
}
|
||||
|
||||
private renderNodes() {
|
||||
const edges = Object.values(this.props.treeNode.edges)
|
||||
const listItemStyle = {
|
||||
padding: '3px 8px 3px 8px'
|
||||
}
|
||||
const listStyle = {
|
||||
padding: '3px 8px 3px 16px'
|
||||
}
|
||||
|
||||
if (edges.length > 0) {
|
||||
const listItems = edges
|
||||
.map(edge => edge.node)
|
||||
.map(node => <ListItem style={listItemStyle} button key={node.hash()}>
|
||||
<TreeNode didSelectNode={this.props.didSelectNode} treeNode={node} />
|
||||
</ListItem>)
|
||||
|
||||
return <Collapse in={!this.collapsed()} timeout="auto" unmountOnExit>
|
||||
<List style={listStyle}>{listItems}</List>
|
||||
</Collapse>
|
||||
}
|
||||
}
|
||||
|
||||
private renderSourceEdge() {
|
||||
const style: React.CSSProperties = {
|
||||
fontWeight: "bold",
|
||||
overflow: 'hidden',
|
||||
display: 'inline-block',
|
||||
}
|
||||
let name = this.state.title || (this.props.treeNode.sourceEdge && this.props.treeNode.sourceEdge.name)
|
||||
|
||||
return <span style={style} onClick={() => this.toggle()}>{name}</span>
|
||||
}
|
||||
|
||||
private getStyle(): React.CSSProperties {
|
||||
return {
|
||||
display: 'block',
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillReceiveProps() {
|
||||
let edgeCount = Object.keys(this.props.treeNode.edges).length
|
||||
this.setState({collapsed: edgeCount > collapseLimit, edgeCount: edgeCount})
|
||||
}
|
||||
|
||||
private renderValue() {
|
||||
const style: React.CSSProperties = {
|
||||
width: "15em",
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
padding: '0',
|
||||
paddingLeft: '5px',
|
||||
display: 'inline-block',
|
||||
}
|
||||
return this.props.treeNode.value
|
||||
? <span
|
||||
style={style}
|
||||
onMouseOver={() => this.props.didSelectNode && this.props.didSelectNode(this.props.treeNode)}
|
||||
> = {this.props.treeNode.value.toString()}</span>
|
||||
: null
|
||||
}
|
||||
|
||||
private clear() {
|
||||
return <div style={{clear: 'both'}} />
|
||||
}
|
||||
|
||||
private renderTitleLine() {
|
||||
const style = {
|
||||
lineHeight: '1em'
|
||||
}
|
||||
return <div style={style}>{this.renderExpander()} {this.renderSourceEdge()} {this.renderCollapsedSubnodes()} {this.renderValue()}</div>
|
||||
}
|
||||
|
||||
public render() {
|
||||
const nodeStyle: React.CSSProperties = {
|
||||
//marginLeft: '10px',
|
||||
display: 'block',
|
||||
}
|
||||
|
||||
return <div style={this.getStyle()}>
|
||||
{this.renderTitleLine()}
|
||||
<div style={nodeStyle}>
|
||||
{this.clear()}
|
||||
<div style={this.subnodesStyle()}>
|
||||
{this.collapsed() ? null : this.renderNodes()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
private toggle() {
|
||||
this.setState({collapsedOverride: !this.collapsed()})
|
||||
}
|
||||
|
||||
private renderExpander() {
|
||||
if (this.state.edgeCount === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.collapsed()
|
||||
? <span onClick={() => this.toggle()}>▶</span>
|
||||
: <span onClick={() => this.toggle()}>▼</span>
|
||||
}
|
||||
|
||||
private renderCollapsedSubnodes() {
|
||||
if (this.state.edgeCount === 0 || !this.collapsed()) {
|
||||
return null
|
||||
}
|
||||
|
||||
let style = {
|
||||
color: '#333'
|
||||
}
|
||||
return <span style={style}>({this.props.treeNode.leafes().length} nodes)</span>
|
||||
}
|
||||
|
||||
private subnodesStyle(): React.CSSProperties {
|
||||
return {
|
||||
display: 'block',
|
||||
}
|
||||
}
|
||||
}
|
||||
67
app/src/components/ValueRenderer.tsx
Normal file
67
app/src/components/ValueRenderer.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as React from "react";
|
||||
import * as q from '../../../src/Model'
|
||||
import ReactJson from 'react-json-view'
|
||||
|
||||
interface Props {
|
||||
node?: q.TreeNode | undefined
|
||||
}
|
||||
|
||||
interface State {
|
||||
node?: q.TreeNode | undefined
|
||||
}
|
||||
|
||||
export class ValueRenderer extends React.Component<Props, State> {
|
||||
private updateNode: (node?: q.TreeNode | undefined) => void
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
this.state = {}
|
||||
this.updateNode = (node) => {
|
||||
if (!node) {
|
||||
this.setState(this.state)
|
||||
} else {
|
||||
this.setState({node: node})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillReceiveProps(nextProps: Props) {
|
||||
this.props.node && this.props.node.removeListener('update', this.updateNode)
|
||||
nextProps.node && nextProps.node.on('update', this.updateNode)
|
||||
nextProps.node && this.updateNode(nextProps.node)
|
||||
}
|
||||
|
||||
public render() {
|
||||
let node = this.props.node
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(node.value)
|
||||
} catch(error) {
|
||||
return this.renderRawValue(node.value)
|
||||
}
|
||||
|
||||
if (typeof json === 'string') {
|
||||
return this.renderRawValue(node.value)
|
||||
} else if (typeof json === 'number') {
|
||||
return this.renderRawValue(node.value)
|
||||
} else {
|
||||
return <ReactJson src={json} />
|
||||
}
|
||||
}
|
||||
|
||||
private renderRawValue(value: string) {
|
||||
let style: React.CSSProperties = {
|
||||
wordBreak: 'break-all',
|
||||
width: '100%',
|
||||
overflow: 'scroll',
|
||||
display: 'block',
|
||||
lineHeight: '1.2em',
|
||||
padding: '12px 5px 12px 5px'
|
||||
}
|
||||
|
||||
return <pre><code style={style}>{value}</code></pre>
|
||||
}
|
||||
}
|
||||
10
app/src/index.tsx
Normal file
10
app/src/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import { App } from './App'
|
||||
|
||||
declare var document: any
|
||||
|
||||
ReactDOM.render(
|
||||
<App />,
|
||||
document.getElementById("example")
|
||||
);
|
||||
22
app/tsconfig.json
Normal file
22
app/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compileOnSave": true,
|
||||
"compilerOptions": {
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strict": true,
|
||||
"lib": ["es2017"],
|
||||
"outDir": "./build/",
|
||||
"sourceMap": true,
|
||||
"module": "commonjs",
|
||||
"target": "es5",
|
||||
"jsx": "react"
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*"
|
||||
],
|
||||
"awesomeTypescriptLoaderOptions": {
|
||||
"useCache": true,
|
||||
"transpileModule": true,
|
||||
"errorsAsWarnings": true
|
||||
}
|
||||
}
|
||||
36
app/webpack.config.js
Normal file
36
app/webpack.config.js
Normal file
@@ -0,0 +1,36 @@
|
||||
module.exports = {
|
||||
entry: "./src/index.tsx",
|
||||
output: {
|
||||
filename: "bundle.js",
|
||||
path: __dirname + "/build"
|
||||
},
|
||||
|
||||
mode: 'production',
|
||||
|
||||
// Enable sourcemaps for debugging webpack's output.
|
||||
devtool: "source-map",
|
||||
|
||||
resolve: {
|
||||
// Add '.ts' and '.tsx' as resolvable extensions.
|
||||
extensions: [".ts", ".tsx", ".js", ".json"]
|
||||
},
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
// All files with a '.ts' or '.tsx' extension will be handled by 'awesome-typescript-loader'.
|
||||
{ test: /\.tsx?$/, loader: "awesome-typescript-loader" },
|
||||
|
||||
// All output '.js' files will have any sourcemaps re-processed by 'source-map-loader'.
|
||||
{ enforce: "pre", test: /\.js$/, loader: "source-map-loader" }
|
||||
]
|
||||
},
|
||||
|
||||
// When importing a module whose path matches one of the following, just
|
||||
// assume a corresponding global variable exists and use that instead.
|
||||
// This is important because it allows us to avoid bundling all of our
|
||||
// dependencies, which allows browsers to cache those libraries between builds.
|
||||
externals: {
|
||||
// "react": "React",
|
||||
// "react-dom": "ReactDOM"
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user