Compare commits

..
114 Commits
Author SHA1 Message Date
Thomas Nordquist efc6d2fb73 Prepare release 2019-01-25 17:32:11 +01:00
Thomas Nordquist 6b6c066e12 Fix animations update dependencies 2019-01-25 17:30:24 +01:00
Thomas Nordquist 72a3c5953f Make topics selectable 2019-01-25 13:06:01 +01:00
Thomas Nordquist 370dbdb483 By by nucleus 2019-01-23 21:40:40 +01:00
Thomas Nordquist 672d5e8109 Speed up start time 2019-01-23 21:35:52 +01:00
Thomas Nordquist 8091323623 Improve application size and loading type
by by nucleus
2019-01-23 13:55:16 +01:00
Thomas Nordquist b2e4c7db36 Fix value renderer width 2019-01-23 12:03:34 +01:00
Thomas Nordquist 6b0f2085e5 Add pane resizing
Refactor styles
Update autoExpand with filtered result count
Add redux batch reducer
2019-01-23 11:54:12 +01:00
Thomas Nordquist 37694d38b0 Add broker statistics 2019-01-22 19:54:36 +01:00
Thomas Nordquist a86871b161 Fix setting alignment 2019-01-22 18:08:57 +01:00
Thomas Nordquist be8e05dbfa Add clear button to topic search 2019-01-22 17:10:48 +01:00
Thomas Nordquist 28cc72a868 Rename leafes/nodes to topic 2019-01-22 15:57:11 +01:00
Thomas Nordquist 796233da18 Modify animation 2019-01-22 15:12:49 +01:00
Thomas Nordquist bcd5a32483 Increase tree statistic performance
Fixes #44
2019-01-22 14:04:49 +01:00
Thomas Nordquist c2345a17ba Clean up 2019-01-22 12:30:00 +01:00
Thomas Nordquist d4ce58a8ec Fix emitter leaks, style, tree swaps 2019-01-22 12:17:59 +01:00
Thomas Nordquist 221abd8c48 Add test for EventDispatcher 2019-01-21 16:05:59 +01:00
Thomas Nordquist 4c438bd00b Add topic filter 2019-01-21 15:07:53 +01:00
Thomas Nordquist 4d21c63da9 Extract settings reducer 2019-01-21 09:40:04 +01:00
Thomas Nordquist 61737fa97b Update readme 2019-01-20 23:08:20 +01:00
Thomas Nordquist ba4efbe30d Refactor connection reducer & tree 2019-01-20 22:34:15 +01:00
Thomas Nordquist ecd3a23311 Update readme template 2019-01-20 22:33:20 +01:00
Thomas Nordquist 4e51d7af79 Prepare release 2019-01-20 19:07:54 +01:00
Thomas Nordquist 9bf3973160 Fix plot 2019-01-20 19:05:33 +01:00
Thomas Nordquist 1719876734 Fix state mixup 2019-01-20 18:48:18 +01:00
Thomas Nordquist 82ef5bc4e7 Remove arm64 build
Due to people confusing the arm64 with amd64 release
2019-01-20 18:36:50 +01:00
Thomas Nordquist ce60d1b20a Update Readme 2019-01-20 18:35:15 +01:00
Thomas Nordquist 2316dac809 Update Readme 2019-01-20 16:37:33 +01:00
Thomas Nordquist 3b049d2008 Add topic selection to topic drawer 2019-01-20 15:38:55 +01:00
Thomas Nordquist 9f48301c4b Fix dependencies 2019-01-20 15:15:27 +01:00
Thomas Nordquist 009fb08b33 Bump version 2019-01-20 14:58:15 +01:00
Thomas Nordquist fa24fa9abb Add tooltip to retain checkbox 2019-01-20 14:57:10 +01:00
Thomas Nordquist 1ac1dede98 Add "delete retained topic" button 2019-01-20 14:56:52 +01:00
Thomas Nordquist b0d25b438d Bump version 2019-01-20 13:47:54 +01:00
Thomas Nordquist 4b42c23121 Update dependecies 2019-01-20 13:38:54 +01:00
Thomas Nordquist 0f7ea7c491 Add formatter to plot tooltip 2019-01-20 13:37:13 +01:00
Thomas Nordquist 71c4d1e04b Improve plot 2019-01-20 13:27:29 +01:00
Thomas Nordquist 435fb1f9b9 Supposably fix travis builds 2019-01-20 12:56:35 +01:00
Thomas Nordquist ab83c682ac Remove "empty payload" toggle switch
This function is redundat, simply send an empty message wil suffice
2019-01-20 12:54:54 +01:00
Thomas Nordquist 4d794205d5 Fix publish topic update 2019-01-20 12:47:46 +01:00
Thomas Nordquist fa9def4dd7 Fix mqttMessage merge 2019-01-20 12:29:00 +01:00
Thomas Nordquist f893d5ce60 Refactor communication
Add QoS andd retain flag
Refactor reducer
2019-01-20 05:30:21 +01:00
Thomas Nordquist 1839b551c0 Add webpack bundle analyzer 2019-01-19 19:21:26 +01:00
Thomas Nordquist 6f48f0bd8e Fix dependencies 2019-01-19 19:03:24 +01:00
Thomas Nordquist 07bfde6244 Add numeric history plot
Resolves #31
2019-01-19 18:59:28 +01:00
Thomas Nordquist b23ffd334d Refactor & add error reporing 2019-01-19 16:48:36 +01:00
Thomas Nordquist f3a686b23f Refactor connection 2019-01-19 15:00:26 +01:00
Thomas Nordquist bfcee49e74 Also subscribe to $SYS 2019-01-18 23:54:08 +01:00
Thomas Nordquist 2c4c7bb72e Beautify error boundary 2019-01-18 19:16:37 +01:00
Thomas Nordquist 353081175b Add error boundary to nake frontend more resiliant
Allows users to recover from errors in userStorage

Is related to #23
2019-01-18 13:13:36 +01:00
Thomas Nordquist f462e7a881 Show developer toolbar in menu 2019-01-18 11:18:48 +01:00
Thomas Nordquist 14401fbc7b Reverse publish history order 2019-01-18 10:26:19 +01:00
Thomas Nordquist fb7c27c53f Fix message history order
The latest message should be the first, oldest last.
Fixes #32
2019-01-18 10:23:42 +01:00
Thomas Nordquist 96c7c56f64 Add clear topic button
Resolves #29
2019-01-18 10:21:20 +01:00
Thomas Nordquist 8a72a0224d Always show mqtt client id 2019-01-18 10:04:20 +01:00
Thomas Nordquist 2f2f2842b4 Support custom client ids
Resolves #25
2019-01-18 00:21:07 +01:00
Thomas Nordquist ef9bdad984 Fix peer dependecy 2019-01-17 23:49:35 +01:00
Thomas Nordquist 0250d61dbd Add menus items
Fixes copy paste issue in release
Fixes #27
2019-01-17 23:46:43 +01:00
Thomas Nordquist a06ff100a8 Restructure 2019-01-17 23:31:36 +01:00
Thomas Nordquist ed0942983b Remove dev code 2019-01-17 23:28:17 +01:00
Thomas Nordquist 3777963f8c Switch electron.js to typescript 2019-01-17 23:27:45 +01:00
Thomas Nordquist d3a684d89e Update Readme 2019-01-17 22:30:31 +01:00
Thomas Nordquist 73e83beeb0 Update Readme 2019-01-16 02:39:09 +01:00
Thomas Nordquist 27c23f7432 Fix certificate validation switch 2019-01-16 02:23:20 +01:00
Thomas Nordquist d6335e68de Refactor history badge 2019-01-16 02:19:09 +01:00
Thomas Nordquist 60382e3c80 Update electron 2019-01-16 01:33:53 +01:00
Thomas Nordquist 4192e91cce Revert "Update readme"
This reverts commit 64358e772d.
2019-01-16 01:16:17 +01:00
Thomas Nordquist 64358e772d Update readme 2019-01-16 01:14:07 +01:00
Thomas Nordquist 3ba31de468 Add last message time to sidebar 2019-01-16 00:53:18 +01:00
Thomas Nordquist ab6938c7da Fix debugging 2019-01-16 00:33:03 +01:00
Thomas Nordquist 1eeaee0fe0 Fix update url 2019-01-16 00:29:55 +01:00
Thomas Nordquist 0e7ec90c65 Add electron-debug 2019-01-16 00:08:46 +01:00
Thomas Nordquist 5eb6f41189 Add debug option --debug 2019-01-15 23:54:03 +01:00
Thomas Nordquist 830dd31f06 Update readme 2019-01-15 22:26:16 +01:00
Thomas Nordquist 0de3ce2c41 Update build targets 2019-01-15 22:01:54 +01:00
Thomas Nordquist 9c5140c419 Lazy load js 2019-01-15 22:00:47 +01:00
Thomas Nordquist 61b64a5ac2 Fix authentication 2019-01-15 22:00:35 +01:00
Thomas Nordquist f905ba8e89 Clarify mouse interaction 2019-01-15 19:00:37 +01:00
Thomas Nordquist c6a0a15d95 Print update info 2019-01-15 15:10:29 +01:00
Thomas Nordquist 75230ccb40 Add snap package target 2019-01-15 13:55:06 +01:00
Thomas Nordquist 97a1cf72c5 Fix linux icon 2019-01-15 12:51:21 +01:00
Thomas Nordquist 71c06b3f74 Try fix linux app icon 2019-01-14 18:20:29 +01:00
Thomas Nordquist b0fa4a9f7a Update readme 2019-01-14 17:38:00 +01:00
Thomas Nordquist d8c8799406 Don't send events after window is gone 2019-01-14 17:12:46 +01:00
Thomas Nordquist 7c72344174 Update readme 2019-01-14 17:00:14 +01:00
Thomas Nordquist 7d42ed12b1 Add readme template 2019-01-14 16:48:01 +01:00
Thomas Nordquist 0df5ff13c1 Update publish settings 2019-01-14 15:28:24 +01:00
Thomas Nordquist ac2387ed9b Update CI deploy configuration 2019-01-14 14:20:33 +01:00
Thomas Nordquist d4bb00262e Prepare release 2019-01-14 12:14:04 +01:00
Thomas Nordquist d41a2c2269 Remove root node name from json view 2019-01-14 12:14:04 +01:00
Thomas Nordquist 4d11302b24 Update screenshot 2019-01-14 12:08:00 +01:00
Thomas Nordquist fd386bf1df Fix types in tests 2019-01-14 11:49:57 +01:00
Thomas Nordquist ad78ca03d8 Close connections when window closed
Fixes #8
2019-01-14 11:23:46 +01:00
Thomas Nordquist 611fde13e2 Improve scroll behavior 2019-01-14 11:16:39 +01:00
Thomas Nordquist 4f02e19f65 Only package on tag builds 2019-01-14 11:02:50 +01:00
Thomas Nordquist 2268175a38 Fix performance issue
When the root node was collapsed, it also was selected due to the click event.
The sidebar was updated every time a new message was receivend,
effectivly inhibiting the "render the tree if you got nothing else to do" optimization to render anything at all.

Fixes #7
2019-01-14 10:45:15 +01:00
Thomas Nordquist bd3b81273d Clean up 2019-01-14 00:15:53 +01:00
Thomas Nordquist 322c8e67aa Fix product name 2019-01-13 23:58:06 +01:00
Thomas Nordquist 906eadf08b Add splash screen 2019-01-13 21:55:55 +01:00
Thomas Nordquist 225f536cd1 Tweak update notification settings 2019-01-13 21:09:20 +01:00
Thomas Nordquist 87f0f06a07 Remove debug code 2019-01-13 20:51:41 +01:00
Thomas Nordquist e294d9700f Add manual auto-update fallback 2019-01-13 20:49:36 +01:00
Thomas Nordquist fdece7ae91 Notify user if auto update failed 2019-01-13 17:37:13 +01:00
Thomas Nordquist d2bb098772 Track redux actions 2019-01-13 17:13:34 +01:00
Thomas Nordquist 490678d7b4 Fix sort topic function
Fixes #4
2019-01-13 15:26:32 +01:00
Thomas Nordquist 2d649b3f51 Refactor 2019-01-13 15:26:19 +01:00
Thomas Nordquist 0de67a48a6 Fix auto-expand selection
Fixes #5
2019-01-13 15:19:34 +01:00
Thomas Nordquist 1267bb27f7 Clean up 2019-01-13 15:19:09 +01:00
Thomas Nordquist 31b3077743 Remove unnecessary json editor options 2019-01-13 15:12:57 +01:00
Thomas Nordquist 7015c31a91 Remove unused code 2019-01-13 15:11:11 +01:00
Thomas Nordquist f486f5888c Change default server to iot.eclipse.org 2019-01-13 15:10:30 +01:00
Thomas Nordquist 33bd2b7340 Update Readme.md 2019-01-13 15:05:07 +01:00
Thomas Nordquist 241a99207d Add readme 2019-01-13 14:40:47 +01:00
Thomas Nordquist fa9f321d88 Add crash reporter 2019-01-13 14:38:31 +01:00
82 changed files with 6262 additions and 1651 deletions
+1
View File
@@ -2,6 +2,7 @@ node_modules
backend/coverage
backend/.nyc_output
build
/dist
.DS_Store
test.dot
test.png
+8 -5
View File
@@ -1,5 +1,8 @@
language: node_js
services:
- docker
cache:
directories:
- node_modules
@@ -16,11 +19,11 @@ os:
install:
- npm install
- if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then sudo apt-get install snapd && sudo snap install snapcraft --classic; fi
script:
- npm run build
- npm run prepare-release
- if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then npm run package -- linux; fi
- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then npm run package -- mac; fi
- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then npm run package -- win; fi
- npm run test
- if [[ "$TRAVIS_TAG" != "" ]]; then npm run prepare-release; fi
- if [[ "$TRAVIS_OS_NAME" == "linux" ]] && [[ "$TRAVIS_TAG" != "" ]]; then npm run package -- linux; fi
- if [[ "$TRAVIS_OS_NAME" == "osx" ]] && [[ "$TRAVIS_TAG" != "" ]]; then npm run package -- mac; fi
- if [[ "$TRAVIS_OS_NAME" == "osx" ]] && [[ "$TRAVIS_TAG" != "" ]]; then npm run package -- win; fi
+41
View File
@@ -0,0 +1,41 @@
# MQTT-Explorer
[![Downloads](https://github-basic-badges.herokuapp.com/release/thomasnordquist/MQTT-Explorer.svg)](https://travis-ci.org/thomasnordquist/MQTT-Explorer/releases)
[![Downloads](https://img.shields.io/github/downloads/thomasnordquist/mqtt-explorer/total.svg)](https://travis-ci.org/thomasnordquist/MQTT-Explorer/releases)
[![Build Status](https://travis-ci.org/thomasnordquist/MQTT-Explorer.svg?branch=master)](https://travis-ci.org/thomasnordquist/MQTT-Explorer)
### Version 0.1.0
See the whole picture of your message queue.
The perfect tool to integrate new services, IoT devices in your network.
This application subscribes to all topics on your MQTT-Server and displays your message queue hierarchy, allowing you to drill-down to the topics that are of interest.
## Download
The app is prebuilt for Windows ([portable](https://github.com/thomasnordquist/MQTT-Explorer/releases/download/v0.1.0/MQTT-Explorer-0.1.0.exe), [installer](https://github.com/thomasnordquist/MQTT-Explorer/releases/download/v0.1.0/MQTT-Explorer-Setup-0.1.0.exe)), Linux ([AppImage](https://github.com/thomasnordquist/MQTT-Explorer/releases/download/v0.1.0/MQTT-Explorer-0.1.0-x86_64.AppImage)) and Mac ([dmg](https://github.com/thomasnordquist/MQTT-Explorer/releases/download/v0.1.0/MQTT-Explorer-0.1.0.dmg)).
<center>
| | | |
|:----------|:-------------:|:------:|
| ![windows](https://user-images.githubusercontent.com/7721625/51445407-b4172080-1d04-11e9-8c70-d8413d1d6d8b.png) | Windows | [portable](https:&#x2F;&#x2F;github.com&#x2F;thomasnordquist&#x2F;MQTT-Explorer&#x2F;releases&#x2F;download&#x2F;v0.1.0&#x2F;MQTT-Explorer-0.1.0.exe), [installer](https:&#x2F;&#x2F;github.com&#x2F;thomasnordquist&#x2F;MQTT-Explorer&#x2F;releases&#x2F;download&#x2F;v0.1.0&#x2F;MQTT-Explorer-Setup-0.1.0.exe) |
| ![linux](https://user-images.githubusercontent.com/7721625/51445392-947ff800-1d04-11e9-8c7f-a30efb755651.png) | Linux | [AppImage](https:&#x2F;&#x2F;github.com&#x2F;thomasnordquist&#x2F;MQTT-Explorer&#x2F;releases&#x2F;download&#x2F;v0.1.0&#x2F;MQTT-Explorer-0.1.0-x86_64.AppImage)<br>*Run AppImage:<br>Make it executable and double-click it.* |
| ![mac](https://user-images.githubusercontent.com/7721625/51445390-921d9e00-1d04-11e9-8339-351469ef20ae.png) | Mac | [dmg](https:&#x2F;&#x2F;github.com&#x2F;thomasnordquist&#x2F;MQTT-Explorer&#x2F;releases&#x2F;download&#x2F;v0.1.0&#x2F;MQTT-Explorer-0.1.0.dmg) |
</center>
More architectures and package types: [Downloads](https://github.com/thomasnordquist/MQTT-Explorer/releases)
![screen1](https://user-images.githubusercontent.com/7721625/51441563-41418180-1cd3-11e9-893b-c22f6a6c3695.png)
![screen2](https://user-images.githubusercontent.com/7721625/51441470-59fd6780-1cd2-11e9-925f-bc491027300c.png)
## Develop
PRs and issues are welcome
Install with `npm run install`, build with `npm run build`
Start with `npm run start`
The `app` directory contains all the rendering logic, the `backend` directory currently contains the models, tests, connection management.
## License
Not yet decided which license exactly, but the basic idea is: "You may do whatever you want with this tool, except sell it."
+41
View File
@@ -0,0 +1,41 @@
# MQTT-Explorer
[![Downloads](https://github-basic-badges.herokuapp.com/release/thomasnordquist/MQTT-Explorer.svg)](https://travis-ci.org/thomasnordquist/MQTT-Explorer/releases)
[![Downloads](https://img.shields.io/github/downloads/thomasnordquist/mqtt-explorer/total.svg)](https://travis-ci.org/thomasnordquist/MQTT-Explorer/releases)
[![Build Status](https://travis-ci.org/thomasnordquist/MQTT-Explorer.svg?branch=master)](https://travis-ci.org/thomasnordquist/MQTT-Explorer)
### Version {{ version }}
See the whole picture of your message queue.
The perfect tool to integrate new services, IoT devices in your network.
This application subscribes to all topics on your MQTT-Server and displays your message queue hierarchy, allowing you to drill-down to the topics that are of interest.
## Download
The app is prebuilt for Windows ({{windowsTargets}}), Linux ({{linuxTargets}}) and Mac ({{macTargets}}).
<center>
| | | |
|:----------|:-------------:|:------:|
| ![windows](https://user-images.githubusercontent.com/7721625/51445407-b4172080-1d04-11e9-8c70-d8413d1d6d8b.png) | Windows | {{windowsTargets}} |
| ![linux](https://user-images.githubusercontent.com/7721625/51445392-947ff800-1d04-11e9-8c7f-a30efb755651.png) | Linux | {{linuxTargets}}<br>*Run AppImage:<br>Make it executable and double-click it.* |
| ![mac](https://user-images.githubusercontent.com/7721625/51445390-921d9e00-1d04-11e9-8339-351469ef20ae.png) | Mac | {{macTargets}} |
</center>
More architectures and package types: [Downloads](https://github.com/thomasnordquist/MQTT-Explorer/releases)
![screen1](https://user-images.githubusercontent.com/7721625/51441563-41418180-1cd3-11e9-893b-c22f6a6c3695.png)
![screen2](https://user-images.githubusercontent.com/7721625/51441470-59fd6780-1cd2-11e9-925f-bc491027300c.png)
## Develop
PRs and issues are welcome
Install with `npm run install`, build with `npm run build`
Start with `npm run start`
The `app` directory contains all the rendering logic, the `backend` directory currently contains the models, tests, connection management.
## License
Not yet decided which license exactly, but the basic idea is: "You may do whatever you want with this tool, except sell it."
+97 -4
View File
@@ -3,20 +3,24 @@
<head>
<meta charset="UTF-8" />
<title>MQTT-Explorer</title>
<script src="./build/bugtracking.bundle.js"></script>
<style>
body, html {
margin: 0;
padding: 0;
}
@keyframes example {
0% {background-color: inherit;}
0% {background-color: none;}
25% {background-color: #3f51b5;}
50% {background-color: #3f51b5;}
100% {background-color: inherit;}
100% {background-color: none;}
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-corner {
@@ -29,13 +33,102 @@
::-webkit-scrollbar-thumb {
background-color: rgba(140,140,140,0.8);
#-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.8);
}
</style>
<style>
#splash {
z-index: 1000000;
background-color: #303030;
display: block;
width: 100vw;
height: 100vh;
position: fixed;
opacity: 1;
}
#splash1 {
margin: 37vh auto 0 auto;
height: 25vh;
width: 25vh;
/* background-image:url('../rings.svg'); */
background-size: cover;
}
#splash2 {
margin: 0 auto;
}
@keyframes unsplash {
0% {opacity: 1;}
100% {opacity: 0;}
}
.Resizer {
background: #eee;
opacity: .2;
z-index: 1;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
-moz-background-clip: padding;
-webkit-background-clip: padding;
background-clip: padding-box;
}
.Resizer:hover {
-webkit-transition: all 0.3s ease-out;
transition: all 0.3s ease-out;
}
.Resizer.horizontal {
height: 11px;
margin: -5px 0;
border-top: 5px solid rgba(255, 255, 255, 0);
border-bottom: 5px solid rgba(255, 255, 255, 0);
cursor: row-resize;
width: 100%;
}
.Resizer.horizontal:hover {
border-top: 5px solid rgba(0, 0, 0, 0.5);
border-bottom: 5px solid rgba(0, 0, 0, 0.5);
}
.Resizer.vertical {
width: 11px;
margin: 0 -5px;
border-left: 5px solid rgba(255, 255, 255, 0);
border-right: 5px solid rgba(255, 255, 255, 0);
cursor: col-resize;
}
.Resizer.vertical:hover {
border-left: 5px solid rgba(255, 255, 255, 0.5);
border-right: 5px solid rgba(255, 255, 255, 0.5);
}
.Resizer.disabled {
cursor: not-allowed;
}
.Resizer.disabled:hover {
border-color: transparent;
}
</style>
<script src="http://localhost:35729/livereload.js"></script>
</head>
<body>
<div id="splash"><div id="splash1"></div></div>
<div id="app" style="font:-webkit-control"></div>
<script src="./build/bundle.js"></script>
<script>
function loadScript(path) {
var script = document.createElement("script");
script.src = path
document.head.appendChild(script);
}
document.addEventListener('DOMContentLoaded', onLoad(), false);
function onLoad() {
// <% _.forEach(htmlWebpackPlugin.files.js, function(file) { %>loadScript("<%- file %>");<% }); %>
// loadScript("<%= JSON.stringify(htmlWebpackPlugin) %>")
}
</script>
<% _.forEach(htmlWebpackPlugin.files.js, function(file) { %><script src="<%- file %>"></script><% }); %>
</body>
</html>
+2604 -26
View File
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -10,19 +10,29 @@
"license": "ISC",
"devDependencies": {
"@material-ui/icons": "^3.0.1",
"@material-ui/lab": "^3.0.0-alpha.27",
"@types/react": "^16.7.18",
"@types/react-dom": "^16.0.11",
"@types/react-redux": "^6.0.12",
"react": "^16.7.0",
"css-loader": "^2.1.0",
"electron-nucleus": "^1.11.0",
"electron-telemetry": "git+https://github.com/thomasnordquist/electron-telemetry.git",
"html-webpack-plugin": "^4.0.0-beta.5",
"number-abbreviate": "^2.0.0",
"react": "^16.8.0-alpha.1",
"react-dom": "^16.7.0",
"react-redux": "^6.0.0",
"react-vis": "^1.11.6",
"redux": "^4.0.1",
"style-loader": "^0.23.1",
"webpack-bundle-analyzer": "^3.0.3",
"webpack-livereload-plugin": "^2.2.0"
},
"dependencies": {
"@material-ui/core": "^3.7.1",
"@material-ui/core": "^3.9.0",
"@material-ui/styles": "^3.0.0-alpha.8",
"@types/node": "^10.12.18",
"@types/react-resize-detector": "^3.1.0",
"@types/react-split-pane": "^0.1.67",
"@types/sha1": "^1.1.1",
"@types/socket.io-client": "^1.4.32",
"@types/vis": "^4.21.9",
@@ -33,6 +43,9 @@
"moving-average": "^1.0.0",
"react-ace": "^6.3.2",
"react-json-view": "^1.19.1",
"react-resize-detector": "^3.4.0",
"react-split-pane": "^0.1.85",
"redux-batched-actions": "^0.4.1",
"sha1": "^1.1.1",
"socket.io-client": "^2.2.0",
"source-map-loader": "^0.2.4",
+42
View File
@@ -0,0 +1,42 @@
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
<svg width="45" height="45" viewBox="0 0 45 45" xmlns="http://www.w3.org/2000/svg" stroke="#fff">
<g fill="none" fill-rule="evenodd" transform="translate(1 1)" stroke-width="2">
<circle cx="22" cy="22" r="6" stroke-opacity="0">
<animate attributeName="r"
begin="1.5s" dur="3s"
values="6;22"
calcMode="linear"
repeatCount="indefinite" />
<animate attributeName="stroke-opacity"
begin="1.5s" dur="3s"
values="1;0" calcMode="linear"
repeatCount="indefinite" />
<animate attributeName="stroke-width"
begin="1.5s" dur="3s"
values="2;0" calcMode="linear"
repeatCount="indefinite" />
</circle>
<circle cx="22" cy="22" r="6" stroke-opacity="0">
<animate attributeName="r"
begin="3s" dur="3s"
values="6;22"
calcMode="linear"
repeatCount="indefinite" />
<animate attributeName="stroke-opacity"
begin="3s" dur="3s"
values="1;0" calcMode="linear"
repeatCount="indefinite" />
<animate attributeName="stroke-width"
begin="3s" dur="3s"
values="2;0" calcMode="linear"
repeatCount="indefinite" />
</circle>
<circle cx="22" cy="22" r="8">
<animate attributeName="r"
begin="0s" dur="1.5s"
values="6;1;2;3;4;5;6"
calcMode="linear"
repeatCount="indefinite" />
</circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+96 -82
View File
@@ -1,109 +1,123 @@
import * as React from 'react'
import { connect } from 'react-redux'
import * as q from '../../backend/src/Model'
import CssBaseline from '@material-ui/core/CssBaseline'
import { withStyles, Theme } from '@material-ui/core/styles'
import Tree from './components/Tree/Tree'
import TitleBar from './components/TitleBar'
import Sidebar from './components/Sidebar/Sidebar'
import Connection from './components/ConnectionSetup/Connection'
import Settings from './components/Settings'
import { AppState } from './reducers'
interface State {
selectedNode?: q.TreeNode,
connectionId?: string
}
import { Theme, withStyles } from '@material-ui/core/styles'
import { AppState } from './reducers'
import Connection from './components/ConnectionSetup/Connection'
import CssBaseline from '@material-ui/core/CssBaseline'
const Settings = React.lazy(() => import('./components/Settings'))
import Sidebar from './components/Sidebar/Sidebar'
import TitleBar from './components/TitleBar'
import Tree from './components/Tree/Tree'
import UpdateNotifier from './UpdateNotifier'
import { connect } from 'react-redux'
import ErrorBoundary from './ErrorBoundary'
import { default as SplitPane } from 'react-split-pane'
interface Props {
name: string
theme: Theme
connectionId: string
classes: any
settingsVisible: boolean
}
class App extends React.Component<Props, State> {
class App extends React.PureComponent<Props, {}> {
constructor(props: any) {
super(props)
this.state = {
selectedNode: undefined,
}
}
private getStyles(): {[s: string]: React.CSSProperties} {
const { theme } = this.props
const drawerWidth = 300
return {
left: {
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
height: 'calc(100vh - 64px)',
float: 'left',
overflowY: 'scroll',
overflowX: 'hidden',
display: 'block',
width: '60vw',
},
right: {
height: 'calc(100vh - 64px)',
color: theme.palette.text.primary,
float: 'left',
width: '40vw',
overflowY: 'scroll',
overflowX: 'hidden',
display: 'block',
},
centerContent: {
width: '100vw',
overflow: 'hidden',
},
content: {
backgroundColor: theme.palette.background.default,
transition: theme.transitions.create('margin', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
marginLeft: 0,
},
contentShift: {
padding: 0,
backgroundColor: theme.palette.background.default,
transition: theme.transitions.create('margin', {
easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen,
}),
marginLeft: drawerWidth,
},
}
this.state = { }
}
public render() {
const { settingsVisible } = this.props
const { content, contentShift, centerContent } = this.getStyles()
return <div style={centerContent}>
<CssBaseline />
<Settings />
<div style={settingsVisible ? contentShift : content}>
<TitleBar />
<div style={centerContent}>
<div style={this.getStyles().left}>
<Tree connectionId={this.state.connectionId} didSelectNode={(node: q.TreeNode) => {
this.setState({ selectedNode: node })
}} />
const { content, contentShift, centerContent, paneDefaults, heightProperty } = this.props.classes
return (
<div className={centerContent}>
<CssBaseline />
<ErrorBoundary>
<React.Suspense fallback={<div>Loading...</div>}>
<Settings />
</React.Suspense>
<div className={centerContent}>
<div className={`${settingsVisible ? contentShift : content}`}>
<TitleBar />
</div>
<div style={this.getStyles().right}>
<Sidebar connectionId={this.state.connectionId} />
<div>
<SplitPane
step={48}
primary="second"
className={`${settingsVisible ? contentShift : content} ${heightProperty}`}
split="vertical"
minSize={250}
defaultSize={500}
allowResize={true}
pane1Style={{ overflow: 'hidden' }}
>
<div className={paneDefaults}>
<Tree />
</div>
<div className={paneDefaults}>
<Sidebar connectionId={this.props.connectionId} />
</div>
</SplitPane>
</div>
</div>
</div>
<Connection onConnection={(connectionId: string) => this.setState({ connectionId })}/>
</div >
<UpdateNotifier />
<Connection />
</ErrorBoundary>
</div >
)
}
}
const mapStateToProps = (state: AppState) => {
return {
settingsVisible: state.settings.visible,
connectionId: state.connection.connectionId,
}
}
export default withStyles({}, { withTheme: true })(connect(mapStateToProps)(App))
const styles = (theme: Theme) => {
const drawerWidth = 300
return {
heightProperty: {
height: 'calc(100vh - 64px) !important',
},
paneDefaults: {
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
overflowY: 'scroll' as 'scroll',
overflowX: 'hidden' as 'hidden',
display: 'block' as 'block',
height: 'calc(100vh - 64px)',
},
centerContent: {
width: '100vw',
overflow: 'hidden' as 'hidden',
},
content: {
width: '100vw',
overflowX: 'hidden',
backgroundColor: theme.palette.background.default,
transition: theme.transitions.create('transform', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
transform: 'translateX(0px)',
},
contentShift: {
overflowX: 'hidden',
width: '100vw',
padding: 0,
backgroundColor: theme.palette.background.default,
transition: theme.transitions.create('transform', {
easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen,
}),
transform: `translateX(${drawerWidth}px)`,
},
}
}
export default withStyles(styles)(connect(mapStateToProps)(App))
+112
View File
@@ -0,0 +1,112 @@
import * as React from 'react'
import { electronRendererTelementry } from 'electron-telemetry'
import {
Button,
Modal,
Paper,
Toolbar,
Typography,
} from '@material-ui/core'
import Warning from '@material-ui/icons/Warning'
import SentimentDissatisfied from '@material-ui/icons/SentimentDissatisfied'
import { Theme, withStyles } from '@material-ui/core/styles'
interface State {
error?: Error
}
interface Props {
classes: any
}
class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = {}
}
public componentDidCatch(error: Error, errorInfo: any) {
electronRendererTelementry.trackError(error)
console.log('did catch', error)
}
public static getDerivedStateFromError(error: Error) {
return { error }
}
private restart = () => {
window.location = window.location
}
private clearStorage = () => {
localStorage.clear()
window.location = window.location
}
public render() {
if (!this.state.error) {
return this.props.children
}
const { classes } = this.props
return (
<Modal open={true} disableAutoFocus={true}>
<Paper className={classes.root}>
<Toolbar style={{ padding: '0' }}>
<Typography className={classes.title} variant="h6" color="inherit"><Warning /> Oooooops!</Typography>
</Toolbar>
<Typography className={classes.centered}>I hoped that you would never see this window, but MQTT-Explorer had an unexpected error.</Typography>
<Typography className={classes.centered}><SentimentDissatisfied /></Typography>
<pre className={classes.textColor} style={{ maxHeight: '35vh', overflow: 'scroll' }}>
<code className={classes.textColor}>
{this.state.error.stack}
</code>
</pre>
<Typography>
Please report this issue with a short description of what happened to
<span> <a className={classes.textColor} href="https://github.com/thomasnordquist/MQTT-Explorer/issues">https://github.com/thomasnordquist/MQTT-Explorer/issues</a></span>
</Typography>
<div>
<div className={classes.buttonPositioning}>
<Button className={classes.button} variant="contained" color="secondary" onClick={this.clearStorage}>Start Fresh</Button>
<Button className={classes.button} variant="contained" color="primary" onClick={this.restart}>Restart</Button>
</div>
</div>
</Paper>
</Modal>
)
}
}
const styles = (theme: Theme) => ({
button: {
margin: '0 8px 0 8px',
},
root: {
minWidth: 550,
maxWidth: 650,
backgroundColor: theme.palette.background.default,
margin: '10vh auto auto auto',
padding: `${2 * theme.spacing.unit}px`,
outline: 'none',
},
title: {
color: theme.palette.text.primary,
margin: '0',
textAlign: 'center' as 'center',
},
textColor: {
color: theme.palette.text.primary,
},
centered: {
textAlign: 'center' as 'center',
},
buttonPositioning: {
textAlign: 'center' as 'center',
marginTop: `${theme.spacing.unit * 2}px`,
},
})
export default withStyles(styles)(ErrorBoundary)
+19
View File
@@ -0,0 +1,19 @@
import { EventDispatcher } from '../../events'
export class TopicViewModel {
private selected: boolean
public change = new EventDispatcher<void, TopicViewModel>(this)
public constructor() {
this.selected = false
}
public isSelected() {
return this.selected
}
public setSelected(selected: boolean) {
this.selected = selected
this.change.dispatch()
}
}
+226
View File
@@ -0,0 +1,226 @@
import * as React from 'react'
import {
Button,
IconButton,
Modal,
Paper,
Snackbar,
SnackbarContent,
Typography,
} from '@material-ui/core'
import { Theme, withStyles } from '@material-ui/core/styles'
import { UpdateInfo, checkForUpdates, rendererEvents, updateAvailable } from '../../events'
import { green, red } from '@material-ui/core/colors'
import { AppState } from './reducers'
import Close from '@material-ui/icons/Close'
import CloudDownload from '@material-ui/icons/CloudDownload'
import { UpdateFileInfo } from 'builder-util-runtime'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { updateNotifierActions } from './actions'
interface Props {
showUpdateNotification: boolean
showUpdateDetails: boolean
classes: any
actions: any
}
class UpdateNotifier extends React.Component<Props, {}> {
private updateInfo?: UpdateInfo
constructor(props: any) {
super(props)
this.state = {
selectedNode: undefined,
}
}
public componentDidMount() {
rendererEvents.emit(checkForUpdates, undefined)
rendererEvents.subscribe(updateAvailable, this.handleUpdate)
}
public componentWillUnmount() {
rendererEvents.unsubscribeAll(updateAvailable)
}
private fixUrl(url: string, version: string) {
if (!/^http/.test(url)) {
return `https://github.com/thomasnordquist/MQTT-Explorer/releases/download/v${version}/${url}`
}
return url
}
private handleUpdate = (updateInfo: UpdateInfo) => {
this.updateInfo = updateInfo
this.props.actions.showUpdateNotification(true)
}
private onCloseNotification = (event: React.SyntheticEvent<any>, reason: string) => {
if (reason === 'clickaway') {
return
}
this.props.actions.showUpdateNotification(false)
}
private closeNotification = () => {
this.props.actions.showUpdateNotification(false)
}
private showDetails = () => {
this.props.actions.showUpdateNotification(false)
this.props.actions.showUpdateDetails(true)
}
private hideDetails = () => {
this.props.actions.showUpdateDetails(false)
}
public render() {
return (
<div>
{this.renderUpdateNotification()}
{this.renderUpdateDetails()}
</div>
)
}
private renderUpdateNotification() {
const snackbarAnchor: any = {
vertical: 'top',
horizontal: 'right',
}
return (
<Snackbar
anchorOrigin={snackbarAnchor}
open={this.props.showUpdateNotification}
autoHideDuration={7000}
onClose={this.onCloseNotification}
>
<SnackbarContent
className={this.props.classes.success}
message="Update available"
action={this.notificationActions()}
/>
</Snackbar>
)
}
private notificationActions() {
return [(
<Button key="undo" size="small" onClick={this.showDetails}>
Download
</Button>
), (
<IconButton
key="close"
aria-label="Close"
color="inherit"
className={this.props.classes.close}
onClick={this.closeNotification}
>
<Close />
</IconButton>
),
]
}
private renderUpdateDetails() {
if (!this.updateInfo) {
return null
}
const releaseNotes = (this.updateInfo.releaseNotes as string) || ''
return (
<Modal
open={this.props.showUpdateDetails}
disableAutoFocus={true}
onClose={this.hideDetails}
>
<Paper className={this.props.classes.root}>
<Typography variant="h6" className={this.props.classes.title}>Version {this.updateInfo.version}</Typography>
<Typography className={this.props.classes.title}>Changelog</Typography>
<div className={this.props.classes.releaseNotes} dangerouslySetInnerHTML={{ __html: releaseNotes }} />
{this.renderDownloads(this.updateInfo)}
<Button className={this.props.classes.closeButton} color="secondary" onClick={this.hideDetails}>Close</Button>
</Paper>
</Modal>
)
}
private urlToFilename(url: string) {
const parts = url.split('/')
return parts[parts.length - 1]
}
private renderDownloads(updateInfo: UpdateInfo) {
return updateInfo.files
.map((file: UpdateFileInfo, index: number) => (
<div key={index}>
<Button
className={this.props.classes.download}
href={this.fixUrl(file.url, updateInfo.version)}
>
<IconButton><CloudDownload /></IconButton>{this.urlToFilename(file.url)}
</Button>
</div>
))
}
}
const styles = (theme: Theme) => ({
success: {
backgroundColor: green[600],
color: theme.typography.button.color,
},
close: {
padding: theme.spacing.unit / 2,
},
root: {
minWidth: '350px',
maxWidth: '500px',
backgroundColor: theme.palette.background.default,
margin: '20vh auto auto auto',
padding: `${2 * theme.spacing.unit}px`,
outline: 'none',
},
title: {
color: theme.palette.text.primary,
},
releaseNotes: {
overflow: 'auto scroll',
color: theme.palette.text.secondary,
backgroundColor: 'rgba(60, 60, 60, 0.6)',
maxHeight: '28vh',
},
paper: {
padding: `${theme.spacing.unit * 2}px`,
color: theme.palette.text.secondary,
},
download: {
width: '100%',
},
closeButton: {
display: 'block',
margin: '0 0 0 auto',
},
})
const mapStateToProps = (state: AppState) => {
return {
showUpdateNotification: state.tooBigReducer.showUpdateNotification,
showUpdateDetails: state.tooBigReducer.showUpdateDetails,
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(updateNotifierActions, dispatch),
}
}
export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(UpdateNotifier))
+55
View File
@@ -0,0 +1,55 @@
import { ActionTypes, Action, ConnectionState } from '../reducers/Connection'
import { MqttOptions } from '../../../backend/src/DataSource'
import { Dispatch } from 'redux'
import { rendererEvents, addMqttConnectionEvent, makeConnectionStateEvent, removeConnection } from '../../../events'
import { AppState } from '../reducers'
import * as q from '../../../backend/src/Model'
import { showTree } from './Tree'
import * as url from 'url'
import { TopicViewModel } from '../TopicViewModel'
export const connect = (options: MqttOptions, connectionId: string) => (dispatch: Dispatch<any>, getState: () => AppState) => {
dispatch(connecting(connectionId))
rendererEvents.emit(addMqttConnectionEvent, { options, id: connectionId })
const event = makeConnectionStateEvent(connectionId)
const host = url.parse(options.url).hostname
rendererEvents.subscribe(event, (dataSourceState) => {
if (dataSourceState.connected) {
const tree = new q.Tree<TopicViewModel>()
tree.updateWithConnection(rendererEvents, connectionId)
dispatch(connected(tree, host!))
dispatch(showTree(tree))
} else if (dataSourceState.error) {
dispatch(showError(dataSourceState.error))
dispatch(disconnect())
}
})
}
export const connected: (tree: q.Tree<TopicViewModel>, host: string) => Action = (tree: q.Tree<TopicViewModel>, host: string) => ({
tree,
host,
type: ActionTypes.CONNECTION_SET_CONNECTED,
})
export const connecting: (connectionId: string) => Action = (connectionId: string) => ({
connectionId,
type: ActionTypes.CONNECTION_SET_CONNECTING,
})
export const showError = (error?: string) => ({
error,
type: ActionTypes.CONNECTION_SET_SHOW_ERROR,
})
export const disconnect = () => (dispatch: Dispatch<any>, getState: () => AppState) => {
const { connectionId, tree } = getState().connection
rendererEvents.emit(removeConnection, connectionId)
tree && tree.stopUpdating()
dispatch(showTree(undefined))
dispatch({
type: ActionTypes.CONNECTION_SET_DISCONNECTED,
})
}
+56
View File
@@ -0,0 +1,56 @@
import { ActionTypes, Action } from '../reducers/Publish'
import { AppState } from '../reducers'
import { Dispatch } from 'redux'
import { rendererEvents, makePublishEvent } from '../../../events'
export const setTopic = (topic?: string): Action => {
return {
topic,
type: ActionTypes.PUBLISH_SET_TOPIC,
}
}
export const setPayload = (payload?: string): Action => {
return {
payload,
type: ActionTypes.PUBLISH_SET_PAYLOAD,
}
}
export const setQoS = (qos: 0 | 1 | 2): Action => {
return {
qos,
type: ActionTypes.PUBLISH_SET_QOS,
}
}
export const setEditorMode = (editorMode: string): Action => {
return {
editorMode,
type: ActionTypes.PUBLISH_SET_EDITOR_MODE,
}
}
export const publish = (connectionId: string) => (dispatch: Dispatch<Action>, getState: () => AppState) => {
const state = getState()
const topic = state.publish.topic
if (!topic) {
return
}
const publishEvent = makePublishEvent(connectionId)
const mqttMessage = {
topic,
payload: state.publish.payload,
retain: state.publish.retain,
qos: state.publish.qos,
}
rendererEvents.emit(publishEvent, mqttMessage)
}
export const toggleRetain = (): Action => {
return {
type: ActionTypes.PUBLISH_TOGGLE_RETAIN,
}
}
+76 -8
View File
@@ -1,21 +1,89 @@
import { ActionTypes, NodeOrder, CustomAction } from '../reducers'
import { Action, ActionTypes, TopicOrder } from '../reducers/Settings'
import { ActionTypes as TreeActionTypes } from '../reducers/Tree'
import { Dispatch } from 'redux'
import { showTree } from './Tree'
import { AppState } from '../reducers'
import * as q from '../../../backend/src/Model'
import { batchActions } from 'redux-batched-actions'
import { autoExpandLimitSet } from '../components/Settings'
import { TopicViewModel } from '../TopicViewModel'
export const setAutoExpandLimit = (autoExpandLimit: number = 0): CustomAction => {
export const setAutoExpandLimit = (autoExpandLimit: number = 0): Action => {
return {
autoExpandLimit,
type: ActionTypes.setAutoExpandLimit,
type: ActionTypes.SETTINGS_SET_AUTO_EXPAND_LIMIT,
}
}
export const toggleSettingsVisibility = (): CustomAction => {
export const toggleSettingsVisibility = (): Action => {
return {
type: ActionTypes.toggleSettingsVisibility,
type: ActionTypes.SETTINGS_TOGGLE_VISIBILITY,
}
}
export const setNodeOrder = (nodeOrder: NodeOrder = NodeOrder.none): CustomAction => {
export const setTopicOrder = (topicOrder: TopicOrder = TopicOrder.none): Action => {
return {
nodeOrder,
type: ActionTypes.setNodeOrder,
topicOrder,
type: ActionTypes.SETTINGS_SET_TOPIC_ORDER,
}
}
export const filterTopics = (filterStr: string) => (dispatch: Dispatch<any>, getState: () => AppState) => {
const { tree } = getState().connection
dispatch({
topicFilter: filterStr,
type: ActionTypes.SETTINGS_FILTER_TOPICS,
})
if (!filterStr || !tree) {
dispatch(batchActions([setAutoExpandLimit(0), (showTree(tree) as any)]))
return
}
const topicFilter = filterStr.toLowerCase()
const nodeFilter = (node: q.TreeNode<TopicViewModel>): boolean => {
const topicMatches = node.path().toLowerCase().indexOf(topicFilter) !== -1
if (topicMatches) {
return true
}
const messageMatches = (node.message && typeof node.message.value === 'string' && node.message.value.toLowerCase().indexOf(filterStr) !== -1)
return Boolean(messageMatches)
}
const resultTree = tree.childTopics()
.filter(nodeFilter)
.map((node: q.TreeNode<TopicViewModel>) => {
const clone = node.unconnectedClone()
q.TreeNodeFactory.insertNodeAtPosition(node.path().split('/'), clone)
return clone.firstNode()
})
.reduce((a: q.TreeNode<TopicViewModel>, b: q.TreeNode<TopicViewModel>) => {
a.updateWithNode(b)
return a
}, new q.Tree<TopicViewModel>())
const nextTree: q.Tree<TopicViewModel> = resultTree as q.Tree<TopicViewModel>
if (tree.updateSource && tree.connectionId) {
nextTree.updateWithConnection(tree.updateSource, tree.connectionId, nodeFilter)
}
dispatch(batchActions([setAutoExpandLimit(autoExpandLimitForTree(nextTree)), (showTree(nextTree) as any)]))
}
function autoExpandLimitForTree(tree: q.Tree<TopicViewModel>) {
if (!tree) {
return 0
}
function closestExistingLimit(i: number): number {
const sorted = autoExpandLimitSet.sort((a, b) => Math.abs(a.limit - i) - Math.abs(b.limit - i))
return sorted[0]!.limit
}
const count = tree.childTopicCount()
const calculatedLimit = Math.max(7 - Math.log(count), 0) * 2
return closestExistingLimit(calculatedLimit)
}
+17 -11
View File
@@ -1,15 +1,21 @@
import { ActionTypes, CustomAction } from '../reducers'
import { Dispatch, Action } from 'redux'
import { AppState } from '../reducers'
import { makePublishEvent, rendererEvents } from '../../../events'
export const setPublishTopic = (topic: string): CustomAction => {
return {
publishTopic: topic,
type: ActionTypes.setPublishTopic,
}
}
export const clearRetainedTopic = () => (dispatch: Dispatch<Action>, getState: () => AppState) => {
const { selectedTopic } = getState().tree
const { connectionId } = getState().connection
export const setPublishPayload = (payload: string): CustomAction => {
return {
publishPayload: payload,
type: ActionTypes.setPublishPayload,
if (!selectedTopic || !connectionId) {
return
}
const publishEvent = makePublishEvent(connectionId)
const mqttMessage = {
topic: selectedTopic.path(),
payload: null,
retain: true,
qos: 0 as 0,
}
rendererEvents.emit(publishEvent, mqttMessage)
}
+49 -4
View File
@@ -1,9 +1,54 @@
import { ActionTypes, CustomAction } from '../reducers'
import { AppState } from '../reducers'
import { ActionTypes } from '../reducers/Tree'
import * as q from '../../../backend/src/Model'
import { Dispatch, AnyAction } from 'redux'
import { setTopic } from './Publish'
import { TopicViewModel } from '../TopicViewModel'
import { batchActions } from 'redux-batched-actions'
export const selectTopic = (topic: q.TreeNode): CustomAction => {
return {
export const selectTopic = (topic: q.TreeNode<TopicViewModel>) => (dispatch: Dispatch<any>, getState: () => AppState) => {
const { selectedTopic } = getState().tree
if (selectedTopic === topic) {
return
}
// Update publish topic
let setTopicDispatch: any | undefined
if (selectedTopic && (selectedTopic.path() === getState().publish.topic || !getState().publish.topic)) {
setTopicDispatch = setTopic(topic.path())
}
if (selectedTopic && selectedTopic.viewModel) {
selectedTopic.viewModel.setSelected(false)
}
if (topic.viewModel) {
topic.viewModel.setSelected(true)
}
const selectTreeTopicDispatch = {
selectedTopic: topic,
type: ActionTypes.selectTopic,
type: ActionTypes.TREE_SELECT_TOPIC,
}
if (setTopicDispatch) {
dispatch(batchActions([selectTreeTopicDispatch, setTopicDispatch]))
} else {
dispatch(selectTreeTopicDispatch)
}
}
export const showTree = (tree?: q.Tree<TopicViewModel>) => (dispatch: Dispatch<any>, getState: () => AppState): AnyAction => {
const visibleTree = getState().tree.tree
const connectionTree = getState().connection.tree
// Stop updates of old tree
if (visibleTree !== connectionTree && visibleTree) {
visibleTree.stopUpdating()
}
return dispatch({
tree,
type: ActionTypes.TREE_SHOW_TREE,
})
}
+15
View File
@@ -0,0 +1,15 @@
import { ActionTypes, CustomAction } from '../reducers'
export const showUpdateNotification = (show: boolean): CustomAction => {
return {
type: ActionTypes.showUpdateNotification,
showUpdateNotification: show,
}
}
export const showUpdateDetails = (show: boolean): CustomAction => {
return {
type: ActionTypes.showUpdateDetails,
showUpdateDetails: show,
}
}
+5 -2
View File
@@ -1,5 +1,8 @@
import * as settingsActions from './Settings'
import * as publishActions from './Publish'
import * as treeActions from './Tree'
import * as sidebarActions from './Sidebar'
import * as updateNotifierActions from './UpdateNotifier'
import * as connectionActions from './Connection'
import * as sidebarActons from './Sidebar'
export { settingsActions, treeActions, sidebarActions }
export { settingsActions, treeActions, publishActions, updateNotifierActions, connectionActions, sidebarActons }
+4
View File
@@ -0,0 +1,4 @@
import { electronRendererTelementry } from 'electron-telemetry'
const spareMeFromGc = electronRendererTelementry
electronRendererTelementry.registerErrorHandler()
+132
View File
@@ -0,0 +1,132 @@
import * as React from 'react'
import * as q from '../../../backend/src/Model'
import { AppState } from '../reducers'
import { Typography } from '@material-ui/core'
import { StyleRulesCallback, withStyles } from '@material-ui/core/styles'
import { connect } from 'react-redux'
import { TopicViewModel } from '../TopicViewModel'
const abbreviate = require('number-abbreviate')
interface Stats {
topic: string
title: string
}
const styles: StyleRulesCallback = theme => ({
flex: {
display: 'flex',
width: '100%',
},
container: {
width: '100%',
height: '224px',
backgroundColor: 'rebeccapurple',
marginBottom: 0,
marginTop: 'auto',
padding: '8px',
},
})
interface Props {
classes: any
tree?: q.Tree<TopicViewModel>
}
class BrokerStatistics extends React.Component<Props, {}> {
constructor(props: any) {
super(props)
this.state = {}
}
public render() {
const { tree, classes } = this.props
if (!tree) {
return null
}
const stats: any = {
broker: {
topic: '$SYS/broker/version',
title: 'Broker',
},
clients: {
topic: '$SYS/broker/clients/total',
title: 'Clients',
},
subscriptions: {
topic: '$SYS/broker/subscriptions/count',
title: 'Subscriptions',
},
received: {
topic: '$SYS/broker/messages/received',
title: 'Received',
},
sent: {
topic: '$SYS/broker/messages/sent',
title: 'Sent',
},
received5m: {
topic: '$SYS/broker/load/messages/received/5min',
title: 'Received last 5min',
},
sent5m: {
topic: '$SYS/broker/load/messages/sent/5min',
title: 'Sent 5m',
},
heap: {
topic: '$SYS/broker/heap/current',
title: 'Memory',
},
heapMax: {
topic: '$SYS/broker/heap/maximum',
title: 'Memory (max)',
},
}
return (
<div className={classes.container}>
{this.renderStat(tree, stats.broker)}
{this.renderPair(tree, stats.sent, stats.received)}
{this.renderPair(tree, stats.clients, stats.subscriptions)}
{this.renderPair(tree, stats.sent5m, stats.received5m)}
{this.renderPair(tree, stats.heap, stats.heapMax)}
</div>
)
}
private renderPair(tree: q.Tree<TopicViewModel>, a: Stats, b: Stats) {
return (
<div className={this.props.classes.flex}>
<div style={{ flex: 1 }}>{this.renderStat(tree, a)}</div>
<div style={{ flex: 1 }}>{this.renderStat(tree, b)}</div>
</div>
)
}
public renderStat(tree: q.Tree<TopicViewModel>, stat: Stats) {
const node = tree.findNode(stat.topic)
if (!node) {
return null
}
let value = node.message && node.message.value
value = !isNaN(value) ? abbreviate(value) : value
return (
<div key={stat.title}>
<Typography><b>{stat.title}</b></Typography>
<Typography style={{ paddingLeft: '8px' }}><i>{value}</i></Typography>
</div>
)
}
}
const mapStateToProps = (state: AppState) => {
return {
tree: state.connection.tree,
}
}
export default withStyles(styles)(connect(mapStateToProps)(BrokerStatistics))
+295 -184
View File
@@ -1,21 +1,43 @@
import * as React from 'react'
import {
Typography, Toolbar, Modal,
MenuItem, Button, Grid, Paper,
TextField, Switch, FormControlLabel,
Button,
CircularProgress,
FormControl,
FormControlLabel,
Grid,
IconButton,
Input,
InputAdornment,
InputLabel,
MenuItem,
Modal,
Paper,
Switch,
TextField,
Toolbar,
Typography,
} from '@material-ui/core'
import { withStyles, Theme, StyleRulesCallback } from '@material-ui/core/styles'
import { connect } from 'react-redux'
import { MqttOptions } from '../../../../backend/src/DataSource'
import { StyleRulesCallback, Theme, withStyles } from '@material-ui/core/styles'
import Notification from './Notification'
import { MqttOptions, DataSourceState } from '../../../../backend/src/DataSource'
import { addMqttConnectionEvent, makeConnectionStateEvent, removeConnection, rendererEvents } from '../../../../events'
import sha1 = require('sha1')
import Visibility from '@material-ui/icons/Visibility'
import VisibilityOff from '@material-ui/icons/VisibilityOff'
const sha1 = require('sha1')
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connectionActions } from '../../actions'
interface Props {
classes: {[s: string]: string}
theme: Theme
onAbort: () => void,
onConnection: (connectionId: string) => void
actions: typeof connectionActions,
visible: boolean
connected: boolean
connecting: boolean
error?: string
}
const protocols = [
@@ -24,16 +46,18 @@ const protocols = [
]
interface State {
connecting: boolean
connectionId?: string
error?: Error
visible: boolean
showPassword: boolean
connectionSettings: ConnectionSettings
}
interface ConnectionSettings {
host: string
protocol: string
port: number
tls: boolean
certValidation: boolean
clientId: string
connectionId?: string
username: string
password: string
}
@@ -41,80 +65,65 @@ interface State {
declare var window: any
class Connection extends React.Component<Props, State> {
private randomClientId: string
private defaultConnectionSettings: ConnectionSettings = {
host: 'iot.eclipse.org',
protocol: protocols[0],
port: 1883,
tls: false,
certValidation: true,
clientId: '',
username: '',
password: '',
connectionId: undefined,
}
constructor(props: any) {
super(props)
const clientIdSha = sha1(`${Math.random()}`).slice(0, 8)
this.randomClientId = `mqtt-explorer-${clientIdSha}`
this.state = {
connectionSettings: this.loadConnectionSettings(),
showPassword: false,
}
}
private loadConnectionSettings(): ConnectionSettings {
let storedSettings: ConnectionSettings | undefined
const storedSettingsString = window.localStorage.getItem('connectionSettings')
let storedSettings
try {
storedSettings = storedSettingsString ? JSON.parse(storedSettingsString) : undefined
} catch {
window.localStorage.setItem('connectionSettings', undefined)
}
const defaultState = {
visible: true,
host: 'nodered',
protocol: protocols[0],
port: 1883,
tls: false,
certValidation: true,
clientId: '',
username: '',
password: '',
connecting: false,
connectionId: undefined,
}
this.state = Object.assign({}, defaultState, storedSettings)
return storedSettings || this.defaultConnectionSettings
}
private saveConnectionSettings() {
window.localStorage.setItem('connectionSettings', JSON.stringify(this.state))
window.localStorage.setItem('connectionSettings', JSON.stringify(this.state.connectionSettings))
}
private handleClickShowPassword = () => {
this.setState({ showPassword: !this.state.showPassword })
}
private optionsFromState(): MqttOptions {
const protocol = this.state.protocol === 'tcp://' ? 'mqtt://' : this.state.protocol
const url = `${protocol}${this.state.host}:${this.state.port}`
const protocol = this.state.connectionSettings.protocol === 'tcp://' ? 'mqtt://' : this.state.connectionSettings.protocol
const url = `${protocol}${this.state.connectionSettings.host}:${this.state.connectionSettings.port}`
return {
url,
username: this.state.username || undefined,
password: this.state.username || undefined,
tls: this.state.tls,
certValidation: this.state.certValidation,
username: this.state.connectionSettings.username || undefined,
password: this.state.connectionSettings.password || undefined,
clientId: this.state.connectionSettings.clientId || this.randomClientId,
tls: this.state.connectionSettings.tls,
certValidation: this.state.connectionSettings.certValidation,
}
}
private connect() {
this.setState({
connecting: true,
})
const options = this.optionsFromState()
const connectionId = (sha1(Math.random() + JSON.stringify(options)).slice(0, 8)) as string
this.setState({ connectionId })
rendererEvents.emit(addMqttConnectionEvent, { options, id: connectionId })
const event = makeConnectionStateEvent(connectionId)
rendererEvents.subscribe(event, (state: DataSourceState) => {
console.log(state)
if (state.connected) {
this.props.onConnection(connectionId)
this.setState({ visible: false })
} else if (state.error) {
console.log('error', state.error)
this.setState({ error: state.error })
}
})
}
private disconnect() {
this.setState({
connecting: false,
})
rendererEvents.emit(removeConnection, this.state.connectionId)
}
public static styles: StyleRulesCallback<string> = (theme: Theme) => {
return {
root: {
@@ -142,143 +151,245 @@ class Connection extends React.Component<Props, State> {
button: {
margin: theme.spacing.unit,
},
inputFormControl: {
marginTop: '16px',
},
}
}
private handleChange = (name: string) => (event: any) => {
const state: any = {
[name]: event.target.value,
}
this.setState(state)
this.setState({
connectionSettings: {
...this.state.connectionSettings,
[name]: event.target.value,
},
})
}
public render() {
const { classes } = this.props
return <Modal open={this.state.visible} disableAutoFocus={true} onClose={() => { console.log('close') }}>
<Paper className={classes.root}>
<Toolbar>
<Typography className={classes.title} variant="h6" color="inherit">MQTT Connection</Typography>
</Toolbar>
<form className={classes.container} noValidate autoComplete="off">
<Grid container spacing={24}>
<Grid item xs={2}>
<TextField
select
label="Protocol"
className={classes.textField}
value={this.state.protocol}
onChange={this.handleChange('protocol')}
margin="normal"
>
{protocols.map((value: string) => (
<MenuItem key={value} value={value}>
{value}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={7}>
<TextField
label="Host"
className={classes.textField}
value={this.state.host}
onChange={this.handleChange('host')}
margin="normal"
/>
</Grid>
<Grid item xs={3}>
<TextField
label="Port"
className={classes.textField}
value={this.state.port}
onChange={this.handleChange('port')}
margin="normal"
/>
</Grid>
<Grid item xs={5}>
<TextField
label="Username"
className={classes.textField}
value={this.state.username}
onChange={this.handleChange('username')}
margin="normal"
/>
</Grid>
<Grid item xs={5}>
<TextField
label="Password"
type="type"
className={classes.textField}
value={this.state.password}
onChange={this.handleChange('password')}
margin="normal"
/>
</Grid>
<Grid item xs={4}>
<div className={classes.switch}>
<FormControlLabel
control={(
<Switch
checked={this.state.certValidation}
onChange={() => this.setState({ certValidation: !this.state.certValidation })}
color="primary"
const passwordVisibilityButton = (
<InputAdornment position="end">
<IconButton
aria-label="Toggle password visibility"
onClick={this.handleClickShowPassword}
>
{this.state.showPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
</InputAdornment>
)
let renderError = null
if (this.props.error) {
renderError = (
<Notification
message={this.props.error}
onClose={() => { this.props.actions.showError(undefined) }}
/>
)
}
return (
<div>
{renderError}
<Modal open={this.props.visible} disableAutoFocus={true}>
<Paper className={classes.root}>
<Toolbar>
<Typography className={classes.title} variant="h6" color="inherit">MQTT Connection</Typography>
</Toolbar>
<form className={classes.container} noValidate={true} autoComplete="off">
<Grid container={true} spacing={24}>
<Grid item={true} xs={2}>
{this.renderProtocols()}
</Grid>
<Grid item={true} xs={7}>
<TextField
label="Host"
className={classes.textField}
value={this.state.connectionSettings.host}
onChange={this.handleChange('host')}
margin="normal"
/>
</Grid>
<Grid item={true} xs={3}>
<TextField
label="Port"
className={classes.textField}
value={this.state.connectionSettings.port}
onChange={this.handleChange('port')}
margin="normal"
/>
</Grid>
<Grid item={true} xs={5}>
<TextField
label="Username"
className={classes.textField}
value={this.state.connectionSettings.username}
onChange={this.handleChange('username')}
margin="normal"
/>
</Grid>
<Grid item={true} xs={5}>
<FormControl className={`${classes.textField} ${classes.inputFormControl}`}>
<InputLabel htmlFor="adornment-password">Password</InputLabel>
<Input
id="adornment-password"
type={this.state.showPassword ? 'text' : 'password'}
value={this.state.connectionSettings.password}
onChange={this.handleChange('password')}
endAdornment={passwordVisibilityButton}
/>
)}
label="Validate certificate"
labelPlacement="bottom"
/>
</div>
</Grid>
<Grid item xs={4}>
<div className={classes.switch}>
<FormControlLabel
control={(
<Switch
checked={this.state.tls}
onChange={() => this.setState({ tls: !this.state.tls })}
color="primary"
</FormControl>
</Grid>
<Grid item={true} xs={5}>
<FormControl className={`${classes.textField} ${classes.inputFormControl}`}>
<InputLabel htmlFor="client-id">Client ID</InputLabel>
<Input
placeholder={this.randomClientId}
className={classes.textField}
value={this.state.connectionSettings.clientId || ''}
onChange={this.handleChange('clientId')}
startAdornment={<span />}
/>
)}
label="Encryption (tls)"
labelPlacement="bottom"
/>
</FormControl>
</Grid>
<Grid item={true} xs={4}>
{this.renderCertValidationSwitch()}
</Grid>
<Grid item={true} xs={3}>
{this.renderTlsSwitch()}
</Grid>
</Grid>
<br />
<div style={{ textAlign: 'right' }}>
<Button variant="contained" color="secondary" className={classes.button} onClick={() => this.saveConnectionSettings()}>
Save
</Button>
{this.renderConnectButton()}
</div>
</Grid>
<Grid item xs={4}></Grid>
</Grid>
<br />
<div style={{ textAlign: 'right' }}>
<Button variant="contained" color="secondary" className={classes.button} onClick={() => this.saveConnectionSettings()}>
Save
</Button>
{ this.renderConnectButton() }
</div>
</form>
</Paper>
</Modal>
</form>
</Paper>
</Modal>
</div>
)
}
private renderConnectButton() {
private renderProtocols() {
const { classes } = this.props
const protocolItems = protocols.map((value: string) => (
<MenuItem key={value} value={value}>
{value}
</MenuItem>
))
if (this.state.connecting) {
return <Button variant="contained" color="primary" className={classes.button} onClick={this.onClickAbort}>
<CircularProgress size={22} style={{ marginRight: '10px' }} color="secondary" /> Abort
</Button>
return (
<TextField
select={true}
label="Protocol"
className={classes.textField}
value={this.state.connectionSettings.protocol}
onChange={this.handleChange('protocol')}
margin="normal"
>
{protocolItems}
</TextField>
)
}
private renderCertValidationSwitch() {
const { classes } = this.props
const certSwitch = (
<Switch
checked={this.state.connectionSettings.certValidation}
onChange={this.toggleCertValidation}
color="primary"
/>
)
return (
<div className={classes.switch}>
<FormControlLabel
control={certSwitch}
label="Validate certificate"
labelPlacement="bottom"
/>
</div>
)
}
private toggleCertValidation = () => this.setState({
connectionSettings: {
...this.state.connectionSettings,
certValidation: !this.state.connectionSettings.certValidation,
},
})
private renderTlsSwitch() {
const { classes } = this.props
const tlsSwitch = (
<Switch
checked={this.state.connectionSettings.tls}
onChange={this.toggleTls}
color="primary"
/>
)
return (
<div className={classes.switch}>
<FormControlLabel
control={tlsSwitch}
label="Encryption (tls)"
labelPlacement="bottom"
/>
</div>
)
}
private toggleTls = () => this.setState({
connectionSettings: {
...this.state.connectionSettings,
tls: !this.state.connectionSettings.tls,
},
})
private renderConnectButton() {
const { classes, actions } = this.props
if (this.props.connecting) {
return (
<Button variant="contained" color="primary" className={classes.button} onClick={actions.disconnect}>
<CircularProgress size={22} style={{ marginRight: '10px' }} color="secondary" /> Abort
</Button>
)
}
return <Button variant="contained" color="primary" className={classes.button} onClick={this.onClickConnect}>
Connect
</Button>
return (
<Button variant="contained" color="primary" className={classes.button} onClick={this.onClickConnect}>
Connect
</Button>
)
}
private onClickConnect = () => {
this.connect()
}
private onClickAbort = () => {
this.disconnect()
const connectionId = String(sha1(String(Math.random())).slice(0, 8))
const options = this.optionsFromState()
this.props.actions.connect(options, connectionId)
}
}
export default withStyles(Connection.styles, { withTheme: true })(Connection)
const mapStateToProps = (state: AppState) => {
return {
visible: !state.connection.connected,
connected: state.connection.connected,
connecting: state.connection.connecting,
error: state.connection.error,
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(connectionActions, dispatch),
}
}
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(Connection.styles)(Connection))
@@ -1,28 +1,18 @@
import * as React from 'react'
import { Snackbar, SnackbarContent } from '@material-ui/core'
import { red, green } from '@material-ui/core/colors'
import { withStyles, Theme } from '@material-ui/core/styles'
enum MessageType {
success = 'success',
error = 'error',
}
import { Snackbar, SnackbarContent } from '@material-ui/core'
import { Theme, withStyles } from '@material-ui/core/styles'
import { green, red } from '@material-ui/core/colors'
interface Props {
message: string
type: MessageType
message?: string
onClose: () => void
classes: any
}
interface State {
snackBarOpen: boolean
}
class Notification extends React.Component<Props, State> {
class Notification extends React.Component<Props, {}> {
constructor(props: Props) {
super(props)
this.state = { snackBarOpen: false }
}
public static styles = (theme: Theme) => ({
@@ -37,20 +27,24 @@ class Notification extends React.Component<Props, State> {
})
public render() {
return <Snackbar
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
open={this.state.snackBarOpen}
autoHideDuration={6000}
onClose={() => { this.setState({ snackBarOpen: false }) }}
>
<SnackbarContent
className={this.props.classes[this.props.type]}
message={this.props.message}
/>
</Snackbar>
const snackbarAnchor = {
vertical: 'bottom' as 'bottom',
horizontal: 'left' as 'left',
}
return (
<Snackbar
anchorOrigin={snackbarAnchor}
open={Boolean(this.props.message)}
autoHideDuration={10000}
onClose={this.props.onClose}
>
<SnackbarContent
className={this.props.classes.error}
message={this.props.message}
/>
</Snackbar>
)
}
}
+56 -39
View File
@@ -1,23 +1,41 @@
import * as React from 'react'
import * as q from '../../../backend/src/Model'
import Drawer from '@material-ui/core/Drawer'
import IconButton from '@material-ui/core/IconButton'
import Paper from '@material-ui/core/Paper'
import Divider from '@material-ui/core/Divider'
import MenuItem from '@material-ui/core/MenuItem'
import Select from '@material-ui/core/Select'
import { AppState } from '../reducers'
import {
Divider,
Drawer,
IconButton,
Input,
InputLabel,
MenuItem,
Select,
Typography,
} from '@material-ui/core'
import { StyleRulesCallback, withStyles } from '@material-ui/core/styles'
import ChevronRight from '@material-ui/icons/ChevronRight'
import { Typography, InputBase, Input, InputLabel } from '@material-ui/core'
import { withStyles, StyleRulesCallback } from '@material-ui/core/styles'
import { settingsActions } from '../actions'
import { AppState, NodeOrder } from '../reducers'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { settingsActions, treeActions } from '../actions'
import { TopicOrder } from '../reducers/Settings'
import BrokerStatistics from './BrokerStatistics'
export const autoExpandLimitSet = [{
limit: 0,
name: 'Collapsed',
}, {
limit: 2,
name: 'Few',
}, {
limit: 3,
name: 'Some',
}, {
limit: 10,
name: 'Most',
}, {
limit: 1E6,
name: 'All',
}]
const styles: StyleRulesCallback = theme => ({
drawer: {
@@ -39,12 +57,12 @@ const styles: StyleRulesCallback = theme => ({
})
interface Props {
actions?: any
autoExpandLimit: number
visible: boolean
store?: any
topicOrder: TopicOrder
classes: any
nodeOrder: NodeOrder
actions: typeof settingsActions
}
class Settings extends React.Component<Props, {}> {
@@ -79,6 +97,7 @@ class Settings extends React.Component<Props, {}> {
{this.renderAutoExpand()}
{this.renderNodeOrder()}
</div>
<BrokerStatistics />
</Drawer>
)
}
@@ -86,62 +105,60 @@ class Settings extends React.Component<Props, {}> {
private renderAutoExpand() {
const { classes, autoExpandLimit } = this.props
const limits = autoExpandLimitSet.map(limit => <MenuItem key={limit.limit} value={limit.limit}>{limit.name}</MenuItem>)
return (
<div style={{ padding: '8px' }}>
<InputLabel htmlFor="auto-expand">Auto Expand</InputLabel>
<div style={{ padding: '8px', display: 'flex' }}>
<InputLabel htmlFor="auto-expand" style={{ flex: '1', marginTop: '8px' }}>Auto Expand</InputLabel>
<Select
value={autoExpandLimit}
onChange={this.onChangeAutoExpand}
input={<Input name="auto-expand" id="auto-expand-label-placeholder" />}
displayEmpty={true}
name="auto-expand"
className={classes.input}
style={{ flex: '1' }}
>
<MenuItem value={0}><em>Disabled</em></MenuItem>
<MenuItem value={2}>Few</MenuItem>
<MenuItem value={3}>Some</MenuItem>
<MenuItem value={10}>Most</MenuItem>
<MenuItem value={1E6}>All</MenuItem>
{limits}
</Select>
</div>
)
}
private onChangeAutoExpand = (e: React.ChangeEvent<HTMLSelectElement>) => {
this.props.actions.setAutoExpandLimit(e.target.value)
this.props.actions.setAutoExpandLimit(parseInt(e.target.value, 10))
}
private renderNodeOrder() {
const { classes, nodeOrder } = this.props
const { classes, topicOrder } = this.props
return (
<div style={{ padding: '8px' }}>
<InputLabel htmlFor="auto-expand">Topic order</InputLabel>
<Select
value={nodeOrder}
<div style={{ padding: '8px', display: 'flex' }}>
<InputLabel htmlFor="auto-expand" style={{ flex: '1', marginTop: '8px' }}>Topic order</InputLabel>
<Select
value={topicOrder}
onChange={this.onChangeSorting}
input={<Input name="node-order" id="node-order-label-placeholder" />}
displayEmpty={true}
name="node-order"
className={classes.input}
>
<MenuItem value={NodeOrder.none}><em>default</em></MenuItem>
<MenuItem value={NodeOrder.abc}>a-z</MenuItem>
<MenuItem value={NodeOrder.messages}>{NodeOrder.messages}</MenuItem>
<MenuItem value={NodeOrder.topics}>{NodeOrder.topics}</MenuItem>
</Select>
style={{ flex: '1' }}
>
<MenuItem value={TopicOrder.none}><em>default</em></MenuItem>
<MenuItem value={TopicOrder.abc}>a-z</MenuItem>
<MenuItem value={TopicOrder.messages}>{TopicOrder.messages}</MenuItem>
<MenuItem value={TopicOrder.topics}>{TopicOrder.topics}</MenuItem>
</Select>
</div>)
}
private onChangeSorting = (e: React.ChangeEvent<HTMLSelectElement>) => {
this.props.actions.setNodeOrder(e.target.value)
this.props.actions.setTopicOrder(e.target.value as TopicOrder)
}
}
const mapStateToProps = (state: AppState) => {
return {
autoExpandLimit: state.settings.autoExpandLimit,
nodeOrder: state.settings.nodeOrder,
topicOrder: state.settings.topicOrder,
visible: state.settings.visible,
}
}
+25 -16
View File
@@ -1,5 +1,4 @@
import * as React from 'react'
import * as q from '../../../../backend/src/Model'
import { Badge, Typography } from '@material-ui/core'
import { Theme, withStyles } from '@material-ui/core/styles'
@@ -13,6 +12,7 @@ interface Props {
items: HistoryItem[]
onClick?: (index: number, element: EventTarget) => void
classes: any
contentTypeIndicator?: String
}
interface State {
@@ -28,16 +28,18 @@ class MessageHistory extends React.Component<Props, State> {
}
public renderHistory() {
const style = {
backgroundColor: 'rgba(80, 80, 80, 0.6)',
margin: '8px',
padding: '8px 8px 0 8px',
cursor: this.props.onClick ? 'pointer' : 'inherit',
}
const messageStyle: React.CSSProperties = { textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden' }
const elements = this.props.items.map((element, index) => (
<div
key={index}
style={{
backgroundColor: 'rgba(80, 80, 80, 0.6)',
margin: '8px',
padding: '8px 8px 0 8px',
cursor: this.props.onClick ? 'pointer' : 'inherit',
}}
style={style}
onClick={(event: React.MouseEvent) => this.props.onClick && this.props.onClick(index, event.target)}
>
<div><i>{element.title}</i></div>
@@ -47,15 +49,27 @@ class MessageHistory extends React.Component<Props, State> {
</div>
))
const visible = this.props.items.length > 0 && this.state.collapsed
return (
<div style={{ backgroundColor: 'rgba(60, 60, 60, 0.6)', marginTop: '16px' }}>
<Typography
component={'span'}
onClick={this.toggle}
style={{ cursor: 'pointer' }}
>
{this.state.collapsed ? '▶' : '▼'} History
<Badge
classes={{ badge: this.props.classes.badge }}
invisible={!visible}
badgeContent={this.props.items.length}
color="primary"
>
{this.state.collapsed ? '▶' : '▼'} History
</Badge>
<div style={{ float: 'right' }}>{this.state.collapsed ? this.props.contentTypeIndicator : null}</div>
</Typography>
<div style={{ maxHeight: '230px', overflowY: 'scroll' }}>
{this.state.collapsed ? null : this.props.children}
{this.state.collapsed ? null : elements}
</div>
</div>
@@ -63,17 +77,12 @@ class MessageHistory extends React.Component<Props, State> {
}
public render() {
const visible = this.props.items.length > 0 && this.state.collapsed
return (
<Badge
<div
style={{ display: 'block', width: '100%' }}
invisible={!visible}
badgeContent={this.props.items.length}
color="primary"
classes={{ badge: this.props.classes.badge }}
>
{this.renderHistory()}
</Badge>
</div>
)
}
@@ -83,7 +92,7 @@ class MessageHistory extends React.Component<Props, State> {
}
const styles = (theme: Theme) => ({
badge: { top: '-8px', left:'64px' },
badge: { right:'-25px' },
})
export default withStyles(styles)(MessageHistory)
+28 -15
View File
@@ -1,17 +1,16 @@
import * as React from 'react'
import * as q from '../../../../backend/src/Model'
import { Theme, withTheme } from '@material-ui/core/styles'
import Fade from '@material-ui/core/Fade'
import BarChart from '@material-ui/icons/BarChart'
import DateFormatter from '../helper/DateFormatter'
import History from './History'
import Paper from '@material-ui/core/Paper'
import Popper from '@material-ui/core/Popper'
import ValueRenderer from './ValueRenderer'
import { TopicViewModel } from '../../TopicViewModel'
const PlotHistory = React.lazy(() => import('./PlotHistory'))
const throttle = require('lodash.throttle')
interface Props {
node?: q.TreeNode
theme: Theme
node?: q.TreeNode<TopicViewModel>
onSelect: (message: q.Message) => void
}
@@ -26,16 +25,16 @@ class MessageHistory extends React.Component<Props, State> {
this.state = { }
}
private updateNode = () => {
private updateNode = throttle(() => {
this.setState(this.state)
}
}, 300)
public componentWillReceiveProps(nextProps: Props) {
this.props.node && this.props.node.onMessage.unsubscribe(this.updateNode)
nextProps.node && nextProps.node.onMessage.subscribe(this.updateNode)
}
public componentWillMount() {
public componentDidMount() {
this.props.node && this.props.node.onMessage.subscribe(this.updateNode)
}
@@ -50,25 +49,39 @@ class MessageHistory extends React.Component<Props, State> {
}
const history = node.messageHistory.toArray()
const historyElements = history.map(message => ({
title: message.received.toGMTString(),
const historyElements = history.reverse().map(message => ({
title: <DateFormatter date={message.received} />,
value: message.value,
}))
const numericMessages = history.filter(message => !isNaN(parseFloat(message.value)))
const showPlot = numericMessages.length >= 2
return (
<div>
<History
items={historyElements}
contentTypeIndicator={showPlot ? <BarChart /> : null}
onClick={this.displayMessage}
/>
>
{showPlot ? this.renderPlot(numericMessages) : null}
</History>
</div>
)
}
public renderPlot(numericMessages: q.Message[]) {
return (
<React.Suspense fallback={<div>Loading...</div>}>
<PlotHistory messages={numericMessages} />
</React.Suspense>
)
}
private displayMessage = (index: number, eventTarget: EventTarget) => {
const message = this.props.node && this.props.node.messageHistory.toArray()[index]
this.props.onSelect(message)
}
}
export default withTheme()(MessageHistory)
export default MessageHistory
+4 -2
View File
@@ -1,9 +1,11 @@
import * as React from 'react'
import * as q from '../../../../backend/src/Model'
import { Typography } from '@material-ui/core'
import { TopicViewModel } from '../../TopicViewModel'
interface Props {
node: q.TreeNode
node: q.TreeNode<TopicViewModel>
}
class NodeStats extends React.Component<Props, {}> {
@@ -17,7 +19,7 @@ class NodeStats extends React.Component<Props, {}> {
return (
<div>
<Typography>Messages: #{node.messages}</Typography>
<Typography>Subtopics: {node.leafCount()}</Typography>
<Typography>Subtopics: {node.childTopicCount()}</Typography>
<Typography>Messages Subtopics: #{node.leafMessageCount()}</Typography>
</div>
)
@@ -0,0 +1,73 @@
const { XYPlot, XAxis, LineMarkSeries, Hint, YAxis, HorizontalGridLines, LineSeries } = require('react-vis')
import { default as ReactResizeDetector } from 'react-resize-detector'
import DateFormatter from '../helper/DateFormatter'
import * as React from 'react'
import * as q from '../../../../backend/src/Model'
import 'react-vis/dist/style.css'
interface Props {
messages: q.Message[]
}
interface Stats {
width: number
value?: any
}
class PlotHistory extends React.Component<Props, Stats> {
constructor(props: Props) {
super(props)
this.state = { width: 300 }
}
private resize = (width: number, height: number) => {
this.setState({ width: width - 12 })
}
public render() {
const data = this.props.messages.map((message) => {
return {
x: message.received.getTime(),
y: parseFloat(message.value),
}
})
return (
<div>
<XYPlot width={this.state.width} height={150}>
<HorizontalGridLines />
<YAxis />
<LineMarkSeries
onValueMouseOver={this._rememberValue}
onValueMouseOut={this._forgetValue}
size={3}
data={data}
curve="curveCardinal"
/>
{this.state.value ? <Hint format={this.hintFormatter} value={this.state.value} /> : null}
</XYPlot>
<ReactResizeDetector handleWidth={true} onResize={this.resize} />
</div>
)
}
private hintFormatter = (point: any) => {
return [
{ title: <b>Time</b>, value: <DateFormatter date={new Date(point.x)} /> },
{ title: <b>Value</b>, value: point.y },
]
}
private _forgetValue = () => {
this.setState({
value: undefined,
})
}
private _rememberValue = (value: any) => {
this.setState({ value })
}
}
export default PlotHistory
+164 -87
View File
@@ -1,4 +1,3 @@
// tslint:disable-next-line
import 'react-ace'
import 'brace/mode/json'
import 'brace/mode/text'
@@ -6,69 +5,79 @@ import 'brace/mode/xml'
import 'brace/theme/monokai'
import * as React from 'react'
import * as brace from 'brace'
import * as q from '../../../../../backend/src/Model'
import {
Button,
Fab,
FormControlLabel,
InputAdornment,
Radio,
RadioGroup,
TextField,
Typography,
FormControl,
InputLabel,
Input,
Checkbox,
MenuItem,
Tooltip,
} from '@material-ui/core'
import { makePublishEvent, rendererEvents } from '../../../../../events'
// tslint:disable-next-line
import { default as AceEditor } from 'react-ace'
import { AppState } from '../../../reducers'
import History from '../History'
import Message from './Model/Message'
import Navigation from '@material-ui/icons/Navigation'
import Clear from '@material-ui/icons/Clear'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { sidebarActions } from '../../../actions'
import { publishActions } from '../../../actions'
import ClearAdornment from '../../helper/ClearAdornment'
import { TopicViewModel } from '../../../TopicViewModel'
interface Props {
node?: q.TreeNode
node?: q.TreeNode<TopicViewModel>
connectionId?: string
topic?: string
payload?: string
actions: any
actions: typeof publishActions
retain: boolean
editorMode: string
qos: 0 | 1 | 2
}
interface State {
mode: string
history: Message[]
}
class Publish extends React.Component<Props, State> {
constructor(props: any) {
super(props)
this.state = { mode: 'json', history: [] }
this.state = { history: [] }
}
private updatePayload = (value: string, event?: any) => {
this.props.actions.setPublishPayload(value)
private updatePayload = (payload: string, event?: any) => {
this.props.actions.setPayload(payload)
}
private updateTopic = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value)
this.props.actions.setPublishTopic(e.target.value)
this.props.actions.setTopic(e.target.value)
}
private updateMode = (e: React.ChangeEvent<{}>, value: string) => {
this.setState({ mode: value })
this.props.actions.setEditorMode(value)
}
private publish = (e: React.MouseEvent) => {
e.stopPropagation()
const topic = this.currentTopic() || ''
const payload = this.props.payload
if (!this.props.connectionId) {
return
}
this.props.actions.publish(this.props.connectionId)
const topic = this.props.topic || ''
const payload = this.props.payload
if (this.props.connectionId && topic) {
rendererEvents.emit(makePublishEvent(this.props.connectionId), { topic, payload })
this.addMessageToHistory(topic, payload)
}
}
@@ -91,42 +100,39 @@ class Publish extends React.Component<Props, State> {
)
}
private currentTopic(): string | undefined {
const { node, topic } = this.props
const selectedNodePath = (node ? node.path() : undefined)
return (topic !== undefined) ? topic : selectedNodePath
private clearTopic = () => {
this.props.actions.setTopic('')
}
private topic() {
const topicStr = this.currentTopic() || ''
const topicStr = this.props.topic || ''
return (
<div>
<Typography>Topic</Typography>
<TextField
placeholder="example/topic"
style={{ width: '100%' }}
margin="normal"
value={topicStr}
multiline={true}
onChange={this.updateTopic}
InputProps={{ onBlur: this.onTopicBlur }}
/>
<FormControl style={{ width: '100%' }}>
<InputLabel htmlFor="publish-topic">Topic</InputLabel>
<Input
id="publish-topic"
value={topicStr}
startAdornment={<span/>}
endAdornment={<ClearAdornment action={this.clearTopic} value={topicStr} />}
onBlur={this.onTopicBlur}
onChange={this.updateTopic}
multiline={true}
placeholder="example/topic"
/>
</FormControl>
</div>
)
}
private onTopicBlur = (e: React.FocusEvent<HTMLInputElement>) => {
if (!e.target.value) {
this.props.actions.setPublishTopic(undefined)
this.props.actions.setTopic(undefined)
}
}
private editorOptions = {
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
enableSnippets: true,
showLineNumbers: false,
tabSize: 2,
}
@@ -145,38 +151,10 @@ class Publish extends React.Component<Props, State> {
}
private editorMode() {
const labelStyle = { margin: '0 8px 0 8px' }
return (
<div style={{ marginTop: '16px' }}>
<div style={{ width: '100%', lineHeight: '64px' }}>
<RadioGroup
style={{ display: 'inline-block', float: 'left' }}
value={this.state.mode}
onChange={this.updateMode}
row={true}
>
<FormControlLabel
value="text"
style={labelStyle}
control={<Radio color="primary" />}
label="raw"
labelPlacement="top"
/>
<FormControlLabel
value="xml"
style={labelStyle}
control={<Radio color="primary" />}
label="xml"
labelPlacement="top"
/>
<FormControlLabel
value="json"
style={labelStyle}
control={<Radio color="primary" />}
label="json"
labelPlacement="top"
/>
</RadioGroup>
{this.renderEditorModeSelection()}
<div style={{ float: 'right', marginRight: '16px' }}>
{this.publishButton()}
</div>
@@ -185,8 +163,97 @@ class Publish extends React.Component<Props, State> {
)
}
private renderEditorModeSelection() {
const labelStyle = { margin: '0 8px 0 8px' }
return (
<RadioGroup
style={{ display: 'inline-block', float: 'left' }}
value={this.props.editorMode}
onChange={this.updateMode}
row={true}
>
<FormControlLabel
value="text"
style={labelStyle}
control={<Radio color="primary" />}
label="raw"
labelPlacement="top"
/>
<FormControlLabel
value="xml"
style={labelStyle}
control={<Radio color="primary" />}
label="xml"
labelPlacement="top"
/>
<FormControlLabel
value="json"
style={labelStyle}
control={<Radio color="primary" />}
label="json"
labelPlacement="top"
/>
</RadioGroup>
)
}
private onChangeQoS = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value, 10)
if (value !== 0 && value !== 1 && value !== 2) {
return
}
this.props.actions.setQoS(value)
}
private publishMode() {
const labelStyle = { margin: '0 8px 0 8px' }
const itemStyle = { padding: '0' }
const tooltipStyle = { textAlign: 'center' as 'center', width: '100%' }
const qosSelect = (
<TextField
select={true}
value={this.props.qos}
margin="normal"
style={{ margin: '8px 0 8px 8px' }}
onChange={this.onChangeQoS}
>
<MenuItem key={0} value={0} style={itemStyle}>
<Tooltip title="At most once"><div style={tooltipStyle}>0</div></Tooltip>
</MenuItem>
<MenuItem key={1} value={1} style={itemStyle}>
<Tooltip title="At least once"><div style={tooltipStyle}>1</div></Tooltip>
</MenuItem>
<MenuItem key={2} value={2} style={itemStyle}>
<Tooltip title="Exactly once"><div style={tooltipStyle}>2</div></Tooltip>
</MenuItem>
</TextField>
)
return (
<div style={{ marginTop: '8px', clear: 'both' }}>
<div style={{ width: '100%', textAlign: 'right' }}>
<FormControlLabel
style={labelStyle}
control={qosSelect}
label="QoS"
labelPlacement="start"
/>
<Tooltip title="Retained messages only appear to be retained, when client subscribes after the initial publish." placement="top">
<FormControlLabel
value="retain"
style={labelStyle}
control={<Checkbox color="primary" checked={this.props.retain} onChange={this.props.actions.toggleRetain} />}
label="retain"
labelPlacement="end"
/>
</Tooltip>
</div>
</div>
)
}
private history() {
const items = this.state.history.map(message => ({
const items = this.state.history.reverse().map(message => ({
title: message.topic,
value: message.payload,
}))
@@ -195,42 +262,52 @@ class Publish extends React.Component<Props, State> {
}
private didSelectHistoryEntry = (index: number) => {
let message = this.state.history[index]
this.props.actions.setPublishTopic(message.topic)
this.props.actions.setPublishPayload(message.payload)
const message = this.state.history[index]
this.props.actions.setTopic(message.topic)
this.props.actions.setPayload(message.payload)
}
private editor() {
return (
<div style={{ width: '100%', display: 'block' }}>
{this.editorMode()}
<AceEditor
mode={this.state.mode}
theme="monokai"
name="UNIQUE_ID_OF_DIV"
width="100%"
height="200px"
showGutter={true}
value={this.props.payload}
onChange={this.updatePayload}
setOptions={this.editorOptions}
editorProps={{ $blockScrolling: true }}
/>
{this.renderEditor()}
{this.publishMode()}
</div>
)
}
private renderEditor() {
return (
<AceEditor
mode={this.props.editorMode}
theme="monokai"
name="UNIQUE_ID_OF_DIV"
width="100%"
height="200px"
showGutter={true}
value={this.props.payload}
onChange={this.updatePayload}
setOptions={this.editorOptions}
editorProps={{ $blockScrolling: true }}
/>
)
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(sidebarActions, dispatch),
actions: bindActionCreators(publishActions, dispatch),
}
}
const mapStateToProps = (state: AppState) => {
return {
topic: state.sidebar.publishTopic,
payload: state.sidebar.publishPayload,
topic: state.publish.topic,
payload: state.publish.payload,
editorMode: state.publish.editorMode,
retain: state.publish.retain,
qos: state.publish.qos,
}
}
+96 -36
View File
@@ -2,6 +2,7 @@ import * as React from 'react'
import * as q from '../../../../backend/src/Model'
import {
Button,
ExpansionPanel,
ExpansionPanelDetails,
ExpansionPanelSummary,
@@ -9,36 +10,45 @@ import {
Paper,
Popper,
Typography,
Tooltip,
} from '@material-ui/core'
import { StyleRulesCallback, Theme, withStyles } from '@material-ui/core/styles'
import { sidebarActons } from '../../actions'
import { AppState } from '../../reducers'
import Copy from '../Copy'
import DateFormatter from '../helper/DateFormatter'
import ExpandMore from '@material-ui/icons/ExpandMore'
import Clear from '@material-ui/icons/Clear'
import MessageHistory from './MessageHistory'
import NodeStats from './NodeStats'
import Publish from './Publish/Publish'
const Publish = React.lazy(() => import('./Publish/Publish'))
import Topic from './Topic'
import ValueRenderer from './ValueRenderer'
const ValueRenderer = React.lazy(() => import('./ValueRenderer'))
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import { TopicViewModel } from '../../TopicViewModel'
const throttle = require('lodash.throttle')
interface Props {
node?: q.TreeNode,
node?: q.TreeNode<TopicViewModel>,
actions: typeof sidebarActons,
classes: any,
theme: Theme,
connectionId?: string,
}
interface State {
node: q.TreeNode,
node: q.TreeNode<TopicViewModel>
compareMessage?: q.Message
}
class Sidebar extends React.Component<Props, State> {
private valueRef: any = React.createRef<HTMLDivElement>()
private updateNode = () => {
private updateNode = throttle(() => {
this.setState(this.state)
}
}, 300)
constructor(props: any) {
super(props)
@@ -46,22 +56,6 @@ class Sidebar extends React.Component<Props, State> {
this.state = { node: new q.Tree() }
}
public static styles: StyleRulesCallback<string> = (theme: Theme) => {
return {
drawer: {
display: 'block',
height: '100%',
},
valuePaper: {
margin: `${theme.spacing.unit}px ${theme.spacing.unit}px ${theme.spacing.unit}px ${theme.spacing.unit}px`,
},
heading: {
fontSize: theme.typography.pxToRem(15),
fontWeight: theme.typography.fontWeightRegular,
},
}
}
public componentWillReceiveProps(nextProps: Props) {
this.props.node && this.removeUpdateListener(this.props.node)
nextProps.node && this.registerUpdateListener(nextProps.node)
@@ -72,12 +66,16 @@ class Sidebar extends React.Component<Props, State> {
}
}
private registerUpdateListener(node: q.TreeNode) {
public componentWillUnmount() {
this.props.node && this.removeUpdateListener(this.props.node)
}
private registerUpdateListener(node: q.TreeNode<TopicViewModel>) {
node.onMerge.subscribe(this.updateNode)
node.onMessage.subscribe(this.updateNode)
}
private removeUpdateListener(node: q.TreeNode) {
private removeUpdateListener(node: q.TreeNode<TopicViewModel>) {
node.onMerge.unsubscribe(this.updateNode)
node.onMessage.unsubscribe(this.updateNode)
}
@@ -113,16 +111,15 @@ class Sidebar extends React.Component<Props, State> {
<Typography className={classes.heading}>Value {copyValue}</Typography>
</ExpansionPanelSummary>
<ExpansionPanelDetails style={this.detailsStyle}>
<div ref={this.valueRef}><ValueRenderer message={this.props.node && this.props.node.message} /></div>
{this.messageMetaInfo()}
<div ref={this.valueRef}>
<React.Suspense fallback={<div>Loading...</div>}>
<ValueRenderer message={this.props.node && this.props.node.message} />
</React.Suspense>
</div>
<div><MessageHistory onSelect={this.handleMessageHistorySelect} node={this.props.node} /></div>
<Popper open={Boolean(this.state.compareMessage)} anchorEl={this.valueRef.current} placement="left" transition={true}>
{({ TransitionProps }) => (
<Fade {...TransitionProps} timeout={350}>
<Paper>
<ValueRenderer message={this.state.compareMessage} />
</Paper>
</Fade>
)}
{this.showValueComparison}
</Popper>
</ExpansionPanelDetails>
</ExpansionPanel>
@@ -131,7 +128,9 @@ class Sidebar extends React.Component<Props, State> {
<Typography className={classes.heading}>Publish</Typography>
</ExpansionPanelSummary>
<ExpansionPanelDetails style={this.detailsStyle}>
<Publish node={this.props.node} connectionId={this.props.connectionId} />
<React.Suspense fallback={<div>Loading...</div>}>
<Publish node={this.props.node} connectionId={this.props.connectionId} />
</React.Suspense>
</ExpansionPanelDetails>
</ExpansionPanel>
<ExpansionPanel defaultExpanded={Boolean(this.props.node)}>
@@ -144,6 +143,45 @@ class Sidebar extends React.Component<Props, State> {
)
}
private showValueComparison = (a: any) => (
<Fade {...a.TransitionProps} timeout={350}>
<Paper>
<ValueRenderer message={this.state.compareMessage} />
</Paper>
</Fade>
)
private messageMetaInfo() {
if (!this.props.node || !this.props.node.message || !this.props.node.mqttMessage) {
return null
}
const retainedButton = (
<Tooltip title="Delete retained topic" placement="top">
<Button
size="small"
color="secondary"
variant="contained"
mini={true}
style={{ marginTop: '-3px', padding: '0px 4px', minHeight: '24px' }}
onClick={this.props.actions.clearRetainedTopic}
>
retained <Clear style={{ fontSize: '16px', marginLeft: '2px' }} />
</Button>
</Tooltip>
)
return (
<div style={{ width: '100%', display: 'flex' }}>
<div style={{ flex: 6 }}><Typography>QoS: {this.props.node.mqttMessage.qos}</Typography></div>
<div style={{ flex: 8, textAlign: 'center' }}>
{this.props.node.mqttMessage.retain ? retainedButton : null}
</div>
<div style={{ flex: 8, textAlign: 'right' }}><Typography><i><DateFormatter date={this.props.node.message.received} /></i></Typography></div>
</div>
)
}
private handleMessageHistorySelect = (message: q.Message) => {
if (message !== this.state.compareMessage) {
this.setState({ compareMessage: message })
@@ -167,8 +205,30 @@ class Sidebar extends React.Component<Props, State> {
const mapStateToProps = (state: AppState) => {
return {
node: state.selectedTopic,
node: state.tree.selectedTopic,
}
}
export default withStyles(Sidebar.styles, { withTheme: true })(connect(mapStateToProps)(Sidebar))
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(sidebarActons, dispatch),
}
}
const styles: StyleRulesCallback<string> = (theme: Theme) => {
return {
drawer: {
display: 'block',
height: '100%',
},
valuePaper: {
margin: `${theme.spacing.unit}px ${theme.spacing.unit}px ${theme.spacing.unit}px ${theme.spacing.unit}px`,
},
heading: {
fontSize: theme.typography.pxToRem(15),
fontWeight: theme.typography.fontWeightRegular,
},
}
}
export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(Sidebar))
+16 -5
View File
@@ -2,13 +2,18 @@ import * as React from 'react'
import * as q from '../../../../backend/src/Model'
import Button from '@material-ui/core/Button'
import { withStyles, Theme, StyleRulesCallback } from '@material-ui/core/styles'
import { treeActions } from '../../actions'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { TopicViewModel } from '../../TopicViewModel'
interface Props {
classes: any
theme: Theme
node?: q.TreeNode
selected?: q.TreeNode
didSelectNode: (node: q.TreeNode) => void
node?: q.TreeNode<TopicViewModel>
selected?: q.TreeNode<TopicViewModel>
actions: typeof treeActions
didSelectNode: (node: q.TreeNode<TopicViewModel>) => void
}
const styles: StyleRulesCallback<string> = (theme: Theme) => ({
@@ -33,7 +38,7 @@ class Topic extends React.Component<Props, {}> {
.map(edge =>
[(
<Button
onClick={() => this.setState({ node: edge!.target })}
onClick={() => this.props.actions.selectTopic(edge!.target)}
size="small"
color="secondary"
className={this.props.classes.button}
@@ -56,4 +61,10 @@ class Topic extends React.Component<Props, {}> {
}
}
export default withStyles(styles, { withTheme: true })(Topic)
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(treeActions, dispatch),
}
}
export default connect(null, mapDispatchToProps)(withStyles(styles, { withTheme: true })(Topic))
+2 -3
View File
@@ -40,6 +40,7 @@ class ValueRenderer extends React.Component<Props, {}> {
<ReactJson
style={{ width: '100%' }}
src={json}
name={null}
theme={theme}
/>
)
@@ -51,12 +52,10 @@ class ValueRenderer extends React.Component<Props, {}> {
backgroundColor: 'rgba(80, 80, 80, 0.6)',
wordBreak: 'break-all',
width: '100%',
overflowX: 'scroll',
overflowY: 'clip',
overflow: 'auto hidden',
display: 'block',
lineHeight: '1.2em',
padding: '12px 5px 12px 5px',
maxWidth: '40vw',
color: this.props.theme.palette.text.primary,
}
+61 -40
View File
@@ -1,15 +1,17 @@
import * as React from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import * as q from '../../../backend/src/Model'
import { AppBar, IconButton, InputBase, Toolbar, Typography } from '@material-ui/core'
import Search from '@material-ui/icons/Search'
import { AppBar, Button, IconButton, InputBase, Toolbar, Typography } from '@material-ui/core'
import { StyleRulesCallback, withStyles } from '@material-ui/core/styles'
import CloudOff from '@material-ui/icons/CloudOff'
import Menu from '@material-ui/icons/Menu'
import { withStyles, StyleRulesCallback } from '@material-ui/core/styles'
import Search from '@material-ui/icons/Search'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { fade } from '@material-ui/core/styles/colorManipulator'
import { settingsActions } from '../actions'
import { settingsActions, connectionActions } from '../actions'
import { AppState } from '../reducers'
import ClearAdornment from './helper/ClearAdornment'
const styles: StyleRulesCallback = theme => ({
title: {
@@ -34,7 +36,7 @@ const styles: StyleRulesCallback = theme => ({
},
},
searchIcon: {
width: theme.spacing.unit * 9,
width: theme.spacing.unit * 8,
height: '100%',
position: 'absolute',
pointerEvents: 'none',
@@ -65,60 +67,79 @@ const styles: StyleRulesCallback = theme => ({
interface Props {
classes: any
actions: any
actions: {
settings: typeof settingsActions,
connection: typeof connectionActions,
}
topicFilter?: string
}
interface State {
selectedNode?: q.TreeNode
settingsVisible: boolean
autoExpandLimit: number
}
class TitleBar extends React.Component<Props, State> {
class TitleBar extends React.Component<Props, {}> {
constructor(props: any) {
super(props)
this.state = {
selectedNode: undefined,
settingsVisible: false,
autoExpandLimit: 0,
}
this.state = { }
}
public render() {
const { classes } = this.props
const { actions, classes } = this.props
return <AppBar position="static">
return (
<AppBar position="static">
<Toolbar>
<IconButton className={classes.menuButton} color="inherit" aria-label="Menu" onClick={this.props.actions.toggleSettingsVisibility}>
<IconButton className={classes.menuButton} color="inherit" aria-label="Menu" onClick={actions.settings.toggleSettingsVisibility}>
<Menu />
</IconButton>
<Typography className={classes.title} variant="h6" color="inherit">MQTT-Xplorer</Typography>
<Typography className={classes.title} variant="h6" color="inherit">MQTT-Explorer</Typography>
{this.renderSearch()}
<Button style={{ margin: 'auto 8px auto auto' }} onClick={actions.connection.disconnect}>
Disconnect <CloudOff style={{ marginRight: '8px', paddingLeft: '8px' }}/>
</Button>
</Toolbar>
</AppBar>
)
}
private renderSearch() {
const { classes } = this.props
const { classes, topicFilter } = this.props
return <div className={classes.search}>
<div className={classes.searchIcon}>
<Search />
return (
<div className={classes.search}>
<div className={classes.searchIcon}>
<Search />
</div>
<InputBase
value={topicFilter}
onChange={this.onFilterChange}
placeholder="Search…"
endAdornment={<div style={{ width: '24px', paddingRight: '8px' }}><ClearAdornment action={this.clearFilter} value={topicFilter} /></div>}
classes={{ root: classes.inputRoot, input: classes.inputInput }}
/>
</div>
<InputBase
placeholder="Search…"
classes={{
root: classes.inputRoot,
input: classes.inputInput,
}}
/>
</div>
)
}
private clearFilter = () => {
this.props.actions.settings.filterTopics('')
}
private onFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
this.props.actions.settings.filterTopics(event.target.value)
}
}
const mapStateToProps = (state: AppState) => {
return {
topicFilter: state.settings.topicFilter,
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(settingsActions, dispatch),
actions: {
settings: bindActionCreators(settingsActions, dispatch),
connection: bindActionCreators(connectionActions, dispatch),
},
}
}
export default withStyles(styles)(connect(null, mapDispatchToProps)(TitleBar))
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(TitleBar))
+75 -69
View File
@@ -1,39 +1,44 @@
import * as React from 'react'
import { connect } from 'react-redux'
import * as q from '../../../../backend/src/Model'
import { Typography } from '@material-ui/core'
import TreeNode from './TreeNode'
import { makeConnectionMessageEvent, rendererEvents } from '../../../../events'
import { AppState } from '../../reducers'
import TreeNode from './TreeNode'
import { connect } from 'react-redux'
import { TopicOrder } from '../../reducers/Settings'
import { TopicViewModel } from '../../TopicViewModel'
import { treeActions } from '../../actions'
import { bindActionCreators } from 'redux'
const MovingAverage = require('moving-average')
declare const performance: any
declare const window: any
const averagingTimeInterval = 10 * 1000
const average = MovingAverage(averagingTimeInterval)
const timeInterval = 10 * 1000
const average = MovingAverage(timeInterval)
declare var window: any
interface Props {
autoExpandLimit: number
didSelectNode?: (node: q.TreeNode) => void
actions: typeof treeActions
connectionId?: string
tree?: q.Tree<TopicViewModel>
filter: string
host?: string
topicOrder: TopicOrder
autoExpandLimit: number
}
interface TreeState {
tree: q.Tree
msg: any
interface State {
lastUpdate: number
}
class Tree extends React.Component<Props, TreeState> {
class Tree extends React.PureComponent<Props, State> {
private updateTimer?: any
private lastUpdate: number = 0
private perf: number = 0
private renderTime = 0
constructor(props: any) {
super(props)
const tree = new q.Tree()
this.state = { tree, msg: {} }
this.state = { lastUpdate: 0 }
}
public time(): number {
@@ -43,92 +48,93 @@ class Tree extends React.Component<Props, TreeState> {
return time
}
public throttledStateUpdate(state: any) {
public componentWillReceiveProps(nextProps: Props) {
if (this.props.tree !== nextProps.tree) {
if (this.props.tree) {
this.props.tree.didReceive.unsubscribe(this.throttledTreeUpdate)
}
if (nextProps.tree) {
nextProps.tree.didReceive.subscribe(this.throttledTreeUpdate)
}
this.setState(this.state)
}
}
public componentWillUnmount() {
this.props.tree && this.props.tree.didReceive.unsubscribe(this.throttledTreeUpdate)
}
public throttledTreeUpdate = () => {
if (this.updateTimer) {
return
}
const expectedRenderTime = average.forecast()
const updateInterval = Math.max(expectedRenderTime * 20, 300)
const timeUntilNextUpdate = updateInterval - (performance.now() - this.lastUpdate)
const updateInterval = Math.max(expectedRenderTime * 7, 300)
const timeUntilNextUpdate = updateInterval - (performance.now() - this.renderTime)
this.updateTimer = setTimeout(() => {
window.requestAnimationFrame(() => {
this.lastUpdate = performance.now()
window.requestIdleCallback(() => {
this.updateTimer && clearTimeout(this.updateTimer)
this.updateTimer = undefined
this.setState(state)
})
this.renderTime = performance.now()
this.props.tree && this.props.tree.applyUnmergedChanges()
window.requestIdleCallback(() => {
this.setState({ lastUpdate: this.renderTime })
}, { timeout: 100 })
}, { timeout: 500 })
}, Math.max(0, timeUntilNextUpdate))
}
public componentWillReceiveProps(nextProps: Props) {
if (this.props.connectionId) {
const event = makeConnectionMessageEvent(this.props.connectionId)
rendererEvents.unsubscribeAll(event)
}
if (nextProps.connectionId) {
const event = makeConnectionMessageEvent(nextProps.connectionId)
rendererEvents.subscribe(event, this.handleNewData)
}
}
public componentDidMount() {
if (this.props.connectionId) {
const event = makeConnectionMessageEvent(this.props.connectionId)
rendererEvents.subscribe(event, this.handleNewData)
}
}
public componentWillUnmount() {
if (this.props.connectionId) {
const event = makeConnectionMessageEvent(this.props.connectionId)
rendererEvents.unsubscribeAll(event)
}
}
private handleNewData = (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())
this.throttledStateUpdate({ msg, tree: this.state.tree })
}
public render() {
console.log('render called')
const { tree, filter } = this.props
if (!tree) {
return null
}
const style: React.CSSProperties = {
lineHeight: '1.1',
cursor: 'default',
}
const performanceCallback = (ms: number) => {
average.push(Date.now(), ms)
}
return (
<div style={style}>
<TreeNode
key={tree.hash()}
animateChages={true}
autoExpandLimit={this.props.autoExpandLimit}
isRoot={true}
didSelectNode={this.props.didSelectNode}
treeNode={this.state.tree}
name="/"
treeNode={tree}
name={this.props.host}
collapsed={false}
key="rootNode"
lastUpdate={this.state.tree.lastUpdate}
performanceCallback={this.performanceCallback}
autoExpandLimit={this.props.autoExpandLimit}
topicOrder={this.props.topicOrder}
lastUpdate={tree.lastUpdate}
didSelectTopic={this.props.actions.selectTopic}
/>
</div>
)
}
private performanceCallback = (ms: number) => {
average.push(Date.now(), ms)
}
}
const mapStateToProps = (state: AppState) => {
return {
tree: state.tree.tree,
filter: state.tree.filter,
host: state.connection.host,
autoExpandLimit: state.settings.autoExpandLimit,
topicOrder: state.settings.topicOrder,
}
}
export default connect(mapStateToProps)(Tree)
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(treeActions, dispatch),
}
}
export default connect(mapStateToProps, mapDispatchToProps)(Tree)
+88 -54
View File
@@ -1,13 +1,13 @@
import * as React from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import { treeActions } from '../../actions'
import * as q from '../../../../backend/src/Model'
import { withStyles, Theme } from '@material-ui/core/styles'
import { isElementInViewport } from '../helper/isElementInViewport'
import TreeNodeTitle from './TreeNodeTitle'
import { Theme, withStyles } from '@material-ui/core/styles'
import TreeNodeSubnodes from './TreeNodeSubnodes'
import TreeNodeTitle from './TreeNodeTitle'
import { TopicOrder } from '../../reducers/Settings'
import { TopicViewModel } from '../../TopicViewModel'
const debounce = require('lodash.debounce')
declare var performance: any
@@ -23,31 +23,40 @@ const styles = (theme: Theme) => {
display: 'block',
marginLeft: '10px',
},
topicSelect: {
float: 'right' as 'right',
opacity: 0,
cursor: 'pointer',
marginTop: '-1px',
},
selected: {
backgroundColor: 'rgba(120, 120, 120, 0.55)',
},
hover: {
'&:hover': {
backgroundColor: 'rgba(80, 80, 80, 0.35)',
},
backgroundColor: 'rgba(80, 80, 80, 0.55)',
},
}
}
interface Props {
actions: any
lastUpdate: number
animateChages: boolean
isRoot?: boolean
treeNode: q.TreeNode
treeNode: q.TreeNode<TopicViewModel>
name?: string | undefined
collapsed?: boolean | undefined
performanceCallback?: ((ms: number) => void) | undefined
didSelectNode?: (node: q.TreeNode) => void
classes: any
className?: string
topicOrder: TopicOrder
autoExpandLimit: number
style?: React.CSSProperties
lastUpdate: number
didSelectTopic: any
}
interface State {
collapsedOverride: boolean | undefined
mouseOver: boolean
selected: boolean
}
class TreeNode extends React.Component<Props, State> {
@@ -55,11 +64,13 @@ class TreeNode extends React.Component<Props, State> {
private dirtyEdges: boolean = true
private dirtyMessage: boolean = true
private animationDirty: boolean = false
private lastRenderTime = 0
private cssAnimationWasSetAt?: number
private willUpdateTime: number = performance.now()
private titleRef = React.createRef<HTMLDivElement>()
private titleRef?: React.RefObject<HTMLDivElement> = React.createRef<HTMLDivElement>()
private nodeRef?: React.RefObject<HTMLDivElement> = React.createRef<HTMLDivElement>()
private subnodesDidchange = () => {
this.dirtySubnodes = true
@@ -78,25 +89,54 @@ class TreeNode extends React.Component<Props, State> {
this.state = {
collapsedOverride: props.collapsed,
mouseOver: false,
selected: false,
}
}
public componentDidMount() {
const { treeNode } = this.props
this.addSubscriber(treeNode)
}
private addSubscriber(treeNode: q.TreeNode<TopicViewModel>) {
treeNode.viewModel = new TopicViewModel()
treeNode.viewModel.change.subscribe(this.viewStateHasChanged)
treeNode.onMerge.subscribe(this.subnodesDidchange)
treeNode.onEdgesChange.subscribe(this.edgesDidChange)
treeNode.onMessage.subscribe(this.messageDidChange)
}
public componentWillUnmount() {
const { treeNode } = this.props
private viewStateHasChanged = (msg: void, viewModel: TopicViewModel) => {
this.setState({ selected: viewModel.isSelected() })
}
private removeSubscriber(treeNode: q.TreeNode<TopicViewModel>) {
treeNode.viewModel && treeNode.viewModel.change.unsubscribe(this.viewStateHasChanged)
treeNode.viewModel = undefined
treeNode.onMerge.unsubscribe(this.subnodesDidchange)
treeNode.onEdgesChange.unsubscribe(this.edgesDidChange)
treeNode.onMessage.unsubscribe(this.messageDidChange)
}
public componentWillReceiveProps(nextProps: Props) {
if (nextProps.treeNode !== this.props.treeNode) {
this.removeSubscriber(this.props.treeNode)
this.addSubscriber(nextProps.treeNode)
}
}
public componentWillUnmount() {
const { treeNode } = this.props
this.removeSubscriber(treeNode)
this.titleRef = undefined
this.nodeRef = undefined
}
private stateHasChanged(newState: State) {
return this.state.collapsedOverride !== newState.collapsedOverride
|| this.state.mouseOver !== newState.mouseOver
|| this.state.selected !== newState.selected
}
private propsHasChanged(newProps: Props) {
@@ -107,9 +147,7 @@ class TreeNode extends React.Component<Props, State> {
const shouldRenderToRemoveCssAnimation = this.cssAnimationWasSetAt !== undefined
return this.stateHasChanged(nextState)
|| this.propsHasChanged(nextProps)
|| this.dirtyEdges
|| this.dirtyMessage
|| this.dirtySubnodes
|| (this.dirtyEdges || this.dirtyMessage || this.dirtySubnodes)
|| this.animationDirty
|| shouldRenderToRemoveCssAnimation
}
@@ -119,12 +157,6 @@ class TreeNode extends React.Component<Props, State> {
const renderTime = performance.now() - this.willUpdateTime
this.props.performanceCallback(renderTime)
}
// setTimeout(() => {
// this.setState(this.state)
// }, 500)
// this.addCssAnimation()
// setTimeout(this.removeCssAnimation, 500)
}
public componentWillUpdate() {
@@ -145,42 +177,31 @@ class TreeNode extends React.Component<Props, State> {
return this.props.treeNode.edgeCount() > this.props.autoExpandLimit
}
// private addCssAnimation = () => {
// const element = this.titleRef.current
// if ((this.dirtyEdges || this.dirtyMessage || this.dirtySubnodes) && element && isElementInViewport(element)) {
// element.style.animation = 'example 0.5s'
// }
// }
//
// private removeCssAnimation = () => {
// const element = this.titleRef.current
// if (element && element.style.animation) {
// element.style.animation = ''
// }
// }
public render() {
const { classes } = this.props
const isDirty = this.dirtyEdges || this.dirtyMessage || this.dirtySubnodes
this.dirtyEdges = this.dirtyMessage = this.dirtySubnodes = false
const shouldStartAnimation = (isDirty && !this.animationDirty) && !this.props.isRoot
const animation = shouldStartAnimation ? { animation: 'example 0.5s' } : {}
const animation = shouldStartAnimation ? { willChange: 'auto', translateZ: 0, animation: 'example 0.5s' } : {}
this.animationDirty = shouldStartAnimation
const highlightClass = this.state.selected ? this.props.classes.selected : (this.state.mouseOver ? this.props.classes.hover : '')
return (
<div
key={this.props.treeNode.hash()}
className={`${classes.node} ${!this.props.isRoot ? classes.hover : ''}`}
className={`${classes.node} ${this.props.className} ${highlightClass}`}
onClick={this.didClickNode}
style={this.props.style}
onMouseOver={this.mouseOver}
onMouseOut={this.mouseOut}
ref={this.nodeRef}
>
<span ref={this.titleRef} style={animation}>
<TreeNodeTitle
collapsed={this.collapsed()}
treeNode={this.props.treeNode}
name={this.props.name}
lastUpdate={this.props.treeNode.lastUpdate}
didSelectNode={this.didSelectTopic}
/>
</span>
{this.renderNodes()}
@@ -188,10 +209,28 @@ class TreeNode extends React.Component<Props, State> {
)
}
private didSelectTopic = () => {
this.props.didSelectTopic(this.props.treeNode)
}
private mouseOver = (event: React.MouseEvent) => {
event.stopPropagation()
this.setHover(true)
}
private mouseOut = (event: React.MouseEvent) => {
event.stopPropagation()
this.setHover(false)
}
private setHover = debounce((hover: boolean) => {
this.setState({ mouseOver: hover })
}, 5)
private didClickNode = (event: React.MouseEvent) => {
event.stopPropagation()
this.toggle()
this.props.actions.selectTopic(this.props.treeNode)
this.didSelectTopic()
}
private renderNodes() {
@@ -199,19 +238,14 @@ class TreeNode extends React.Component<Props, State> {
<TreeNodeSubnodes
animateChanges={this.props.animateChages}
collapsed={this.collapsed()}
autoExpandLimit={this.props.autoExpandLimit}
didSelectNode={this.props.didSelectNode}
treeNode={this.props.treeNode}
autoExpandLimit={this.props.autoExpandLimit}
topicOrder={this.props.topicOrder}
lastUpdate={this.props.treeNode.lastUpdate}
didSelectTopic={this.props.didSelectTopic}
/>
)
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(treeActions, dispatch),
}
}
export default withStyles(styles)(connect(null, mapDispatchToProps)(TreeNode))
export default withStyles(styles)(TreeNode)
+75 -41
View File
@@ -1,78 +1,112 @@
import * as React from 'react'
import { connect } from 'react-redux'
import * as q from '../../../../backend/src/Model'
import { withTheme, Theme } from '@material-ui/core/styles'
import { AppState, NodeOrder } from '../../reducers'
import { AppState } from '../../reducers'
import TreeNode from './TreeNode'
import { connect } from 'react-redux'
import { TopicOrder } from '../../reducers/Settings'
import { Theme, withStyles } from '@material-ui/core'
import { TopicViewModel } from '../../TopicViewModel'
export interface Props {
lastUpdate: number
nodeOrder?: NodeOrder
animateChanges: boolean
treeNode: q.TreeNode
autoExpandLimit: number
treeNode: q.TreeNode<TopicViewModel>
filter?: string
collapsed?: boolean | undefined
didSelectNode?: (node: q.TreeNode) => void
theme: Theme
classes: any
lastUpdate: number
topicOrder: TopicOrder
selectedTopic?: q.TreeNode<TopicViewModel>
autoExpandLimit: number
didSelectTopic: any
}
class TreeNodeSubnodes extends React.Component<Props, {}> {
private sortedNodes(): q.TreeNode[] {
const { nodeOrder, treeNode } = this.props
interface State {
alreadyAdded: number
}
let edges = Object.values(treeNode.edges)
if (nodeOrder === NodeOrder.abc) {
class TreeNodeSubnodes extends React.Component<Props, State> {
private renderMoreAnimationFrame?: any
constructor(props: Props) {
super(props)
this.state = { alreadyAdded: 10 }
}
private sortedNodes(): q.TreeNode<TopicViewModel>[] {
const { topicOrder, treeNode } = this.props
let edges = treeNode.edgeArray
if (topicOrder === TopicOrder.abc) {
edges = edges.sort((a, b) => a.name.localeCompare(b.name))
}
let nodes = edges.map(edge => edge.target)
if (nodeOrder === NodeOrder.messages) {
if (topicOrder === TopicOrder.messages) {
nodes = nodes.sort((a, b) => b.leafMessageCount() - a.leafMessageCount())
}
if (nodeOrder === NodeOrder.topics) {
nodes = nodes.sort((a, b) => b.leafCount() - a.leafCount())
if (topicOrder === TopicOrder.topics) {
nodes = nodes.sort((a, b) => b.childTopicCount() - a.childTopicCount())
}
return nodes
}
private renderMore() {
this.renderMoreAnimationFrame = window.requestAnimationFrame(() => {
this.setState({ ...this.state, alreadyAdded: this.state.alreadyAdded * 1.5 })
})
}
public componentWillUnmount() {
window.cancelAnimationFrame(this.renderMoreAnimationFrame)
}
public render() {
const edges = Object.values(this.props.treeNode.edges)
const edges = this.props.treeNode.edgeArray
if (edges.length === 0 || this.props.collapsed) {
return null
}
const listItemStyle = {
padding: '3px 0px 0px 8px',
if (this.state.alreadyAdded < edges.length) {
const delta = Math.min(this.state.alreadyAdded, edges.length - this.state.alreadyAdded)
this.renderMore()
}
const nodes = this.sortedNodes()
const listItems = nodes.map(node => (
<div key={node.hash()}>
<TreeNode
animateChages={this.props.animateChanges}
treeNode={node}
didSelectNode={this.props.didSelectNode}
autoExpandLimit={this.props.autoExpandLimit}
lastUpdate={node.lastUpdate}
style={listItemStyle}
/>
</div>
))
const nodes = this.sortedNodes().slice(0, this.state.alreadyAdded)
const listItems = nodes.map((node) => {
return (
<TreeNode
key={`${node.hash()}-${this.props.filter}`}
animateChages={this.props.animateChanges}
treeNode={node}
className={this.props.classes.listItem}
topicOrder={this.props.topicOrder}
autoExpandLimit={this.props.autoExpandLimit}
lastUpdate={node.lastUpdate}
didSelectTopic={this.props.didSelectTopic}
/>
)
})
return (
<span style={{ display: 'block', clear: 'both' }} >
{this.props.collapsed ? null : listItems}
<span className={this.props.classes.list}>
{listItems}
</span>
)
}
}
const mapStateToProps = (state: AppState) => {
return {
nodeOrder: state.settings.nodeOrder,
}
}
const styles = (theme: Theme) => ({
list: {
display: 'block' as 'block',
clear: 'both' as 'both',
},
listItem: {
padding: '3px 0px 0px 8px',
},
})
export default withTheme()(connect(mapStateToProps)(TreeNodeSubnodes))
export default withStyles(styles)(TreeNodeSubnodes)
+40 -50
View File
@@ -1,74 +1,47 @@
import * as React from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import { treeActions } from '../../actions'
import * as q from '../../../../backend/src/Model'
import { withTheme, Theme } from '@material-ui/core/styles'
import { withStyles, Theme } from '@material-ui/core'
import { TopicViewModel } from '../../TopicViewModel'
const debounce = require('lodash.debounce')
export interface TreeNodeProps extends React.HTMLAttributes<HTMLElement> {
treeNode: q.TreeNode
actions: any
treeNode: q.TreeNode<TopicViewModel>
name?: string | undefined
collapsed?: boolean | undefined
theme: Theme
lastUpdate: number
classes: any
didSelectNode: any
}
class TreeNodeTitle extends React.Component<TreeNodeProps, {}> {
private getStyles() {
const { theme } = this.props
return {
collapsedSubnodes: {
color: theme.palette.text.secondary,
},
container: {
display: 'block',
},
}
private mouseOver = (event: React.MouseEvent) => {
event.preventDefault()
this.selectTopic()
}
private didSelectNode = (event: React.MouseEvent) => {
event.stopPropagation()
private selectTopic = debounce(() => {
if (this.props.treeNode.message) {
this.props.actions.selectTopic(this.props.treeNode)
this.props.didSelectNode(this.props.treeNode)
}
}
}, 5)
public render() {
const style: React.CSSProperties = {
lineHeight: '1em',
whiteSpace: 'nowrap',
}
return (
<span style={style} onMouseOver={this.didSelectNode}>
<span className={this.props.classes.title} onMouseOver={this.props.treeNode.message ? this.mouseOver : undefined}>
{this.renderExpander()} {this.renderSourceEdge()} {this.renderCollapsedSubnodes()} {this.renderValue()}
</span>
)
}
private renderSourceEdge() {
const style: React.CSSProperties = {
fontWeight: 'bold',
overflow: 'hidden',
display: 'inline-block',
}
const name = this.props.name || (this.props.treeNode.sourceEdge && this.props.treeNode.sourceEdge.name)
return <span style={style}>{name}</span>
return <span className={this.props.classes.sourceEdge}>{name}</span>
}
private renderValue() {
const style: React.CSSProperties = {
maxWidth: '15em',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
padding: '0',
marginLeft: '5px',
display: 'inline-block',
}
return this.props.treeNode.message
? <span style={style}> = {this.props.treeNode.message.value.toString()}</span>
return this.props.treeNode.message && this.props.treeNode.message.length > 0
? <span className={this.props.classes.value}> = {this.props.treeNode.message.value.toString().slice(0, 120)}</span>
: null
}
@@ -86,14 +59,31 @@ class TreeNodeTitle extends React.Component<TreeNodeProps, {}> {
}
const messages = this.props.treeNode.leafMessageCount()
return <span style={this.getStyles().collapsedSubnodes}>({this.props.treeNode.leafCount()} nodes, {messages} messages)</span>
return <span className={this.props.classes.collapsedSubnodes}>({this.props.treeNode.childTopicCount()} topics, {messages} messages)</span>
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(treeActions, dispatch),
}
}
const styles = (theme: Theme) => ({
value: {
whiteSpace: 'nowrap' as 'nowrap',
overflow: 'hidden' as 'hidden',
textOverflow: 'ellipsis' as 'ellipsis',
padding: '0',
marginLeft: '5px',
display: 'inline-block' as 'inline-block',
},
sourceEdge: {
fontWeight: 'bold' as 'bold',
overflow: 'hidden' as 'hidden',
display: 'inline-block' as 'inline-block',
},
title: {
lineHeight: '1em',
whiteSpace: 'nowrap' as 'nowrap',
},
collapsedSubnodes: {
color: theme.palette.text.secondary,
},
})
export default withTheme()(connect(null, mapDispatchToProps)(TreeNodeTitle))
export default withStyles(styles)(TreeNodeTitle)
@@ -0,0 +1,25 @@
import * as React from 'react'
import { IconButton } from '@material-ui/core'
import Clear from '@material-ui/icons/Clear'
interface Props {
value?: string
action: any
style?: React.CSSProperties
}
class ClearAdornment extends React.Component<Props, {}> {
public render() {
if (this.props.value) {
return (
<IconButton style={{ ...this.props.style, padding: '1px' }} onClick={this.props.action}>
<Clear style={{ fontSize: '16px' }} />
</IconButton>
)
}
return null
}
}
export default ClearAdornment
@@ -0,0 +1,14 @@
import * as React from 'react'
interface Props {
date: Date
}
class DateFormatter extends React.Component<Props, {}> {
public render() {
return <span>{this.props.date.toLocaleDateString()} {this.props.date.toLocaleTimeString()}</span>
}
}
export default DateFormatter
+29 -15
View File
@@ -1,21 +1,27 @@
import './tracking'
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles'
import reducers, { NodeOrder, AppState } from './reducers'
import App from './App'
import reduxThunk from 'redux-thunk'
import { batchDispatchMiddleware } from 'redux-batched-actions'
const initialAppState: AppState = {
settings: {
autoExpandLimit: 0,
nodeOrder: NodeOrder.none,
visible: false,
},
sidebar: {},
selectedTopic: undefined,
}
const store = createStore(reducers, initialAppState)
import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles'
import reducers from './reducers'
import App from './App'
import { Provider } from 'react-redux'
import { createStore, applyMiddleware, compose } from 'redux'
const composeEnhancers = /*(window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || */ compose
const store = createStore(
reducers,
composeEnhancers(
applyMiddleware(
reduxThunk,
batchDispatchMiddleware,
),
),
)
const theme = createMuiTheme({
palette: {
@@ -24,6 +30,14 @@ const theme = createMuiTheme({
typography: { useNextVariants: true },
})
setTimeout(() => {
const splash = document.getElementById('splash')
if (splash) {
splash.style.animation = 'unsplash 0.5s ease-in 0s 1 normal forwards'
setTimeout(() => splash.remove(), 600)
}
}, 300)
ReactDOM.render(
<MuiThemeProvider theme={theme}>
<Provider store={store}>
+93
View File
@@ -0,0 +1,93 @@
import { Action } from 'redux'
import { createReducer } from './lib'
import * as q from '../../../backend/src/Model'
import { MqttOptions } from '../../../backend/src/DataSource'
import { TopicViewModel } from '../TopicViewModel'
export interface ConnectionState {
host?: string
tree?: q.Tree<TopicViewModel>
connectionOptions?: MqttOptions
connectionId?: string
error?: string
connected: boolean
connecting: boolean
}
export type Action = SetConnecting | SetConnected | SetDisconnected | ShowError
export enum ActionTypes {
CONNECTION_SET_CONNECTING = 'CONNECTION_SET_CONNECTING',
CONNECTION_SET_CONNECTED = 'CONNECTION_SET_CONNECTED',
CONNECTION_SET_DISCONNECTED = 'CONNECTION_SET_DISCONNECTED',
CONNECTION_SET_SHOW_ERROR = 'CONNECTION_SET_SHOW_ERROR',
}
export interface SetConnecting {
type: ActionTypes.CONNECTION_SET_CONNECTING,
connectionId: string
}
export interface SetConnected {
type: ActionTypes.CONNECTION_SET_CONNECTED
host: string
tree: q.Tree<TopicViewModel>
}
export interface SetDisconnected {
type: ActionTypes.CONNECTION_SET_DISCONNECTED
}
export interface ShowError {
type: ActionTypes.CONNECTION_SET_SHOW_ERROR
error?: Error
}
const initialState: ConnectionState = {
connected: false,
connecting: false,
}
export const connectionReducer = createReducer(initialState, {
CONNECTION_SET_CONNECTING: setConnecting,
CONNECTION_SET_CONNECTED: setConnected,
CONNECTION_SET_DISCONNECTED: setDisconnected,
CONNECTION_SET_SHOW_ERROR: showError,
})
function setConnecting(state: ConnectionState, action: SetConnecting): ConnectionState {
return {
...state,
connecting: true,
connected: false,
connectionId: action.connectionId,
}
}
function setConnected(state: ConnectionState, action: SetConnected): ConnectionState {
return {
...state,
host: action.host,
connecting: false,
connected: true,
tree: action.tree,
}
}
function setDisconnected(state: ConnectionState, action: SetDisconnected): ConnectionState {
return {
...state,
host: undefined,
connecting: false,
connected: false,
connectionId: undefined,
tree: undefined,
}
}
function showError(state: ConnectionState, action: ShowError) {
return {
...state,
error: action.error,
}
}
+93
View File
@@ -0,0 +1,93 @@
import { Action } from 'redux'
import { createReducer } from './lib'
export interface PublishState {
topic?: string
payload?: string
retain: boolean
editorMode: string
qos: 0 | 1 | 2
}
export type Action = SetPayload | SetTopic | ToggleRetain | SetEditorMode | SetQoS
export enum ActionTypes {
PUBLISH_SET_TOPIC = 'PUBLISH_SET_TOPIC',
PUBLISH_SET_PAYLOAD = 'PUBLISH_SET_PAYLOAD',
PUBLISH_TOGGLE_RETAIN = 'PUBLISH_TOGGLE_RETAIN',
PUBLISH_SET_EDITOR_MODE = 'PUBLISH_SET_EDITOR_MODE',
PUBLISH_SET_QOS = 'PUBLISH_SET_QOS',
}
export interface SetPayload {
type: ActionTypes.PUBLISH_SET_PAYLOAD
payload?: string
}
export interface SetTopic {
type: ActionTypes.PUBLISH_SET_TOPIC
topic?: string
}
export interface SetQoS {
type: ActionTypes.PUBLISH_SET_QOS
qos: 0 | 1 | 2
}
export interface SetEditorMode {
type: ActionTypes.PUBLISH_SET_EDITOR_MODE
editorMode: string
}
export interface ToggleRetain {
type: ActionTypes.PUBLISH_TOGGLE_RETAIN
}
const initialState: PublishState = {
editorMode: 'text',
retain: false,
qos: 0,
}
export const publishReducer = createReducer(initialState, {
PUBLISH_SET_TOPIC: setTopic,
PUBLISH_SET_PAYLOAD: setPayload,
PUBLISH_TOGGLE_RETAIN: toggleRetain,
PUBLISH_SET_EDITOR_MODE: setEditorMode,
PUBLISH_SET_QOS: setQoS,
})
function setTopic(state: PublishState, action: SetTopic) {
return {
...state,
topic: action.topic,
}
}
function setPayload(state: PublishState, action: SetPayload) {
return {
...state,
payload: action.payload,
}
}
function setQoS(state: PublishState, action: SetQoS) {
return {
...state,
qos: action.qos,
}
}
function setEditorMode(state: PublishState, action: SetEditorMode) {
return {
...state,
editorMode: action.editorMode,
}
}
function toggleRetain(state: PublishState) {
return {
...state,
retain: !state.retain,
}
}
+84
View File
@@ -0,0 +1,84 @@
import { Action } from 'redux'
import { createReducer } from './lib'
export enum TopicOrder {
none = 'none',
messages = '#messages',
abc = 'abc',
topics = '#topics',
}
export interface SettingsState {
autoExpandLimit: number
visible: boolean
topicOrder: TopicOrder
topicFilter?: string
}
export type Action = SetAutoExpandLimit | ToggleVisibility | SetTopicOrder | FilterTopics
export enum ActionTypes {
SETTINGS_SET_AUTO_EXPAND_LIMIT = 'SETTINGS_SET_AUTO_EXPAND_LIMIT',
SETTINGS_TOGGLE_VISIBILITY = 'SETTINGS_TOGGLE_VISIBILITY',
SETTINGS_SET_TOPIC_ORDER = 'SETTINGS_SET_TOPIC_ORDER',
SETTINGS_FILTER_TOPICS = 'SETTINGS_FILTER_TOPICS',
}
const initialState: SettingsState = {
autoExpandLimit: 0,
topicOrder: TopicOrder.none,
visible: false,
}
export const settingsReducer = createReducer(initialState, {
SETTINGS_SET_AUTO_EXPAND_LIMIT: setAutoExpandLimit,
SETTINGS_TOGGLE_VISIBILITY: toggleVisibility,
SETTINGS_SET_TOPIC_ORDER: setTopicOrder,
SETTINGS_FILTER_TOPICS: filterTopics,
})
function setAutoExpandLimit(state: SettingsState, action: SetAutoExpandLimit) {
return {
...state,
autoExpandLimit: action.autoExpandLimit,
}
}
export interface SetAutoExpandLimit {
type: ActionTypes.SETTINGS_SET_AUTO_EXPAND_LIMIT
autoExpandLimit: number
}
function toggleVisibility(state: SettingsState, action: ToggleVisibility) {
return {
...state,
visible: !state.visible,
}
}
export interface ToggleVisibility {
type: ActionTypes.SETTINGS_TOGGLE_VISIBILITY
}
function setTopicOrder(state: SettingsState, action: SetTopicOrder) {
return {
...state,
topicOrder: action.topicOrder,
}
}
export interface SetTopicOrder {
type: ActionTypes.SETTINGS_SET_TOPIC_ORDER
topicOrder: string
}
function filterTopics(state: SettingsState, action: FilterTopics) {
return {
...state,
topicFilter: action.topicFilter,
}
}
export interface FilterTopics {
type: ActionTypes.SETTINGS_FILTER_TOPICS
topicFilter: string
}
+11
View File
@@ -0,0 +1,11 @@
import { createReducer } from './lib'
import { createMuiTheme, Theme } from '@material-ui/core'
const initialState: Theme = createMuiTheme({
palette: {
type: 'dark',
},
typography: { useNextVariants: true },
})
export const theme = createReducer(initialState, {})
+50
View File
@@ -0,0 +1,50 @@
import * as q from '../../../backend/src/Model'
import { Action } from 'redux'
import { createReducer } from './lib'
import { TopicViewModel } from '../TopicViewModel'
export interface TreeState {
tree?: q.Tree<TopicViewModel>
selectedTopic?: q.TreeNode<TopicViewModel>
filter?: string
}
export type Action = ShowTree | SelectTopic
export enum ActionTypes {
TREE_SHOW_TREE = 'TREE_SHOW_TREE',
TREE_SELECT_TOPIC = 'TREE_SELECT_TOPIC',
}
export interface ShowTree {
type: ActionTypes.TREE_SHOW_TREE
tree?: q.Tree<TopicViewModel>
filter?: string
}
export interface SelectTopic {
type: ActionTypes.TREE_SELECT_TOPIC
selectedTopic?: q.TreeNode<TopicViewModel>
}
const initialState: TreeState = { }
export const treeReducer = createReducer(initialState, {
TREE_SHOW_TREE: showTree,
TREE_SELECT_TOPIC: selectTopic,
})
function showTree(state: TreeState, action: ShowTree) {
return {
...state,
tree: action.tree,
filter: action.filter,
}
}
function selectTopic(state: TreeState, action: SelectTopic) {
return {
...state,
selectedTopic: action.selectedTopic,
}
}
+42 -72
View File
@@ -1,104 +1,74 @@
import { Reducer, Action } from 'redux'
import * as q from '../../../backend/src/Model'
import { Action, Reducer, combineReducers } from 'redux'
import { trackEvent } from '../tracking'
import { PublishState, publishReducer } from './Publish'
import { ConnectionState, connectionReducer } from './Connection'
import { SettingsState, settingsReducer } from './Settings'
import { TreeState, treeReducer } from './Tree'
import { theme } from './theme'
export enum ActionTypes {
setAutoExpandLimit = 'SET_AUTO_EXPAND_LIMIT',
toggleSettingsVisibility = 'TOGGLE_SETTINGS_VISIBILITY',
setNodeOrder = 'SET_NODE_ORDER',
selectTopic = 'SELECT_TOPIC',
setPublishTopic = 'SET_PUBLISH_TOPIC',
setPublishPayload = 'SET_PUBLISH_PAYLOAD',
showUpdateNotification = 'SHOW_UPDATE_NOTIFICATION',
showUpdateDetails = 'SHOW_UPDATE_DETAILS',
}
export interface CustomAction extends Action {
type: ActionTypes,
autoExpandLimit?: number
nodeOrder?: NodeOrder
selectedTopic?: q.TreeNode
publishTopic?: string
publishPayload?: string
}
export interface SidebarState {
publishTopic?: string
publishPayload?: string
showUpdateNotification?: boolean
showUpdateDetails?: boolean
}
export interface AppState {
tooBigReducer: TooBigOfState
tree: TreeState
settings: SettingsState,
selectedTopic?: q.TreeNode
sidebar: SidebarState
publish: PublishState
connection: ConnectionState
}
export interface SettingsState {
autoExpandLimit: number
visible: boolean
nodeOrder: NodeOrder
export interface TooBigOfState {
showUpdateNotification?: boolean
showUpdateDetails: boolean
}
export enum NodeOrder {
none = 'none',
messages = '#messages',
abc = 'abc',
topics = '#topics',
const initialBigState: TooBigOfState = {
showUpdateDetails: false,
}
const reducer: Reducer<AppState | undefined, CustomAction> = (state, action) => {
const tooBigReducer: Reducer<TooBigOfState | undefined, CustomAction> = (state = initialBigState, action) => {
if (!state) {
throw Error('No initial state')
}
console.log(action)
trackEvent(action.type)
switch (action.type) {
case ActionTypes.setAutoExpandLimit:
if (!action.autoExpandLimit) {
case ActionTypes.showUpdateNotification:
return {
...state,
showUpdateNotification: action.showUpdateNotification,
}
case ActionTypes.showUpdateDetails:
if (action.showUpdateDetails === undefined) {
return state
}
return {
...state,
settings: {
...state.settings,
autoExpandLimit: action.autoExpandLimit,
},
}
case ActionTypes.setPublishTopic:
console.log(state)
return {
...state,
sidebar: { ...state.sidebar, publishTopic: action.publishTopic },
}
case ActionTypes.setPublishPayload:
return {
...state,
sidebar: { ...state.sidebar, publishPayload: action.publishPayload },
}
case ActionTypes.toggleSettingsVisibility:
return {
...state,
settings: {
...state.settings,
visible: !state.settings.visible,
},
}
case ActionTypes.selectTopic:
if (!action.selectedTopic) {
return state
}
return {
...state,
selectedTopic: action.selectedTopic,
}
case ActionTypes.setNodeOrder:
if (!action.nodeOrder) {
return state
}
return {
...state,
settings: { ...state.settings, autoExpandLimit: state.settings.autoExpandLimit },
showUpdateDetails: action.showUpdateDetails,
}
default:
return state
}
}
const reducer = combineReducers({
theme,
tooBigReducer,
publish: publishReducer,
connection: connectionReducer,
settings: settingsReducer,
tree: treeReducer,
})
export default reducer
+9
View File
@@ -0,0 +1,9 @@
export const createReducer = (initialState: any, handlers: any) => {
return (state = initialState, action: any) => {
if (handlers.hasOwnProperty(action.type)) {
return handlers[action.type](state, action)
} else {
return state
}
}
}
+24
View File
@@ -0,0 +1,24 @@
let userId = window.localStorage.getItem('userId')
const sha1 = require('sha1')
import { electronRendererTelementry } from 'electron-telemetry'
if (!userId) {
userId = sha1(sha1(Math.random()) + sha1(performance.now()) + sha1(Date.now())).slice(0, 8) as string
window.localStorage.setItem('userId', userId)
}
setInterval(() => {
try {
electronRendererTelementry.trackCustomEvent({ name: 'heapStatistics', payload: process.getHeapStatistics() })
electronRendererTelementry.trackCustomEvent({ name: 'cpuUsage', payload: process.getCPUUsage() })
} catch (error) {
console.error(error)
}
}, 30 * 1000)
export function trackEvent(name: string) {
if (name.match(/^@@redux/)) {
return
}
electronRendererTelementry.trackEvent(name)
}
+7 -6
View File
@@ -5,10 +5,11 @@
"strictNullChecks": true,
"strict": true,
"lib": ["es2017", "dom"],
"moduleResolution": "node",
"outDir": "./build/",
"sourceMap": true,
"module": "commonjs",
"target": "es5",
"module": "esnext",
"target": "es2017",
"jsx": "react"
},
"include": [
@@ -18,8 +19,8 @@
"**/*.d.ts"
],
"awesomeTypescriptLoaderOptions": {
"useCache": true,
"transpileModule": true,
"errorsAsWarnings": true
}
"useCache": true,
"transpileModule": true,
"errorsAsWarnings": true
}
}
+39 -8
View File
@@ -1,15 +1,39 @@
var LiveReloadPlugin = require('webpack-livereload-plugin');
const LiveReloadPlugin = require('webpack-livereload-plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
entry: "./src/index.tsx",
entry: {
app: "./src/index.tsx",
bugtracking: "./src/bugtracking.ts",
},
output: {
filename: "bundle.js",
chunkFilename: '[name].bundle.js',
filename: "[name].bundle.js",
path: __dirname + "/build"
},
optimization: {
removeAvailableModules: false,
removeEmptyChunks: false,
splitChunks: false,
splitChunks: {
chunks: 'async',
minSize: 30000,
maxSize: 0,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 3,
automaticNameDelimiter: '~',
name: true,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true
}
}
}
},
target: 'electron-renderer',
@@ -30,12 +54,19 @@ module.exports = {
{ 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" }
{ enforce: "pre", test: /\.js$/, loader: "source-map-loader" },
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
]
},
plugins: [
new LiveReloadPlugin({})
new LiveReloadPlugin({}),
new HtmlWebpackPlugin({ template: './index.html', file: './build/index.html', inject: false }),
new BundleAnalyzerPlugin(),
],
// When importing a module whose path matches one of the following, just
+2 -1
View File
@@ -1,4 +1,5 @@
import { DataSourceStateMachine } from './'
import { MqttMessage } from '../../../events'
type MessageCallback = (topic: string, payload: Buffer) => void
@@ -7,7 +8,7 @@ interface DataSource<DataSourceOptions> {
connect(options: DataSourceOptions): DataSourceStateMachine
disconnect(): void
onMessage(messageCallback: MessageCallback): void
publish(topic: string, payload: any): void
publish(msg: MqttMessage): void
topicSeparator: string
stateMachine: DataSourceStateMachine
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { EventDispatcher } from '../../../events'
export interface DataSourceState {
connecting: boolean
connected: boolean
error?: Error
error?: string
}
export class DataSourceStateMachine {
@@ -25,7 +25,7 @@ export class DataSourceStateMachine {
public setError(error: Error) {
this.state = {
error,
error: error.message,
connected: false,
connecting: false,
}
+19 -8
View File
@@ -1,6 +1,8 @@
import * as Url from 'url'
import { Client, connect as mqttConnect } from 'mqtt'
import { DataSource, DataSourceStateMachine } from './'
import * as Url from 'url'
import { MqttMessage } from '../../../events'
export interface MqttOptions {
url: string
@@ -8,16 +10,17 @@ export interface MqttOptions {
password?: string
tls: boolean
certValidation: boolean
clientId?: string
}
export class MqttSource implements DataSource<MqttOptions> {
public stateMachine: DataSourceStateMachine = new DataSourceStateMachine()
private client: Client | undefined
private messageCallback?: (topic: string, message: Buffer) => void
private messageCallback?: (topic: string, message: Buffer, packet: any) => void
private rootSubscription = '#'
public topicSeparator = '/'
public onMessage(messageCallback: (topic: string, message: Buffer) => void) {
public onMessage(messageCallback: (topic: string, message: Buffer, packet: any) => void) {
this.messageCallback = messageCallback
}
@@ -35,7 +38,10 @@ export class MqttSource implements DataSource<MqttOptions> {
const client = mqttConnect(url, {
resubscribe: false,
rejectUnauthorized: !options.certValidation,
rejectUnauthorized: options.certValidation,
username: options.username,
password: options.password,
clientId: options.clientId,
})
this.client = client
@@ -60,17 +66,22 @@ export class MqttSource implements DataSource<MqttOptions> {
throw new Error('mqtt subscription failed')
}
})
client.subscribe('$SYS/#', (err: Error) => {
if (err) {
throw new Error('mqtt subscription failed')
}
})
})
client.on('message', (topic, message) => {
this.messageCallback && this.messageCallback(topic, message)
client.on('message', (topic, message, packet) => {
this.messageCallback && this.messageCallback(topic, message, packet)
})
return this.stateMachine
}
public publish(topic: string, payload: any) {
this.client && this.client.publish(topic, payload)
public publish(msg: MqttMessage) {
this.client && this.client.publish(msg.topic, msg.payload, { qos: msg.qos, retain: msg.retain })
}
public disconnect() {
+13 -6
View File
@@ -1,11 +1,11 @@
import { Hashable, TreeNode } from './'
const sha1 = require('sha1')
export class Edge implements Hashable {
export class Edge<ViewModel> implements Hashable {
public name: string
public target!: TreeNode
public source?: TreeNode | undefined
public target!: TreeNode<ViewModel>
public source?: TreeNode<ViewModel> | undefined
private cachedHash?: string
constructor(name: string) {
@@ -13,19 +13,26 @@ export class Edge implements Hashable {
}
public edges() {
return this.target ? Object.values(this.target.edges) : []
return this.target ? this.target.edgeArray : []
}
public hash(): string {
if (!this.cachedHash) {
const previousHash = (this.source && this.source.sourceEdge) ? this.source.sourceEdge.hash() : ''
let previousHash
if (this.source && this.source.sourceEdge) {
previousHash = this.source.sourceEdge.hash()
} else {
// Use the tree hash to distinguish between different trees
previousHash = this.source && this.source.isTree ? (this.source as any).hash() : ''
}
this.cachedHash = `H${sha1(previousHash + this.name)}`
}
return this.cachedHash
}
public firstEdge(): Edge {
public firstEdge(): Edge<ViewModel> {
if (this.source && this.source.sourceEdge) {
return this.source.sourceEdge.firstEdge()
}
+11 -1
View File
@@ -10,9 +10,19 @@ export class RingBuffer<T extends Lengthwise> {
private start: number = 0
private end: number = 0
constructor(capacity: number, maxItems = Infinity) {
constructor(capacity: number, maxItems = Infinity, ringBuffer?: RingBuffer<T>) {
this.capacity = capacity
this.maxItems = maxItems
if (ringBuffer) {
this.items = ringBuffer.toArray()
this.end = this.items.length
this.usage = this.items.length
}
}
public clone(): RingBuffer<T> {
return new RingBuffer(this.capacity, this.maxItems, this)
}
public toArray() {
+50 -2
View File
@@ -1,7 +1,55 @@
import { Edge, TreeNode } from './'
import { TreeNode } from './'
import { EventBusInterface, makeConnectionMessageEvent, MqttMessage, EventDispatcher } from '../../../events'
import { TreeNodeFactory } from './TreeNodeFactory'
export class Tree<ViewModel> extends TreeNode<ViewModel> {
public connectionId?: string
public updateSource?: EventBusInterface
public nodeFilter?: (node: TreeNode<ViewModel>) => boolean
private subscriptionEvent?: any
public isTree = true
private cachedHash = `${Math.random()}`
private unmergedMessages: MqttMessage[] = []
public didReceive = new EventDispatcher<void, Tree<ViewModel>>(this)
export class Tree extends TreeNode {
constructor() {
super(undefined, undefined)
}
public updateWithConnection(emitter: EventBusInterface, connectionId: string, nodeFilter?:(node: TreeNode<ViewModel>) => boolean) {
this.updateSource = emitter
this.connectionId = connectionId
this.nodeFilter = nodeFilter
this.subscriptionEvent = makeConnectionMessageEvent(connectionId)
this.updateSource.subscribe(this.subscriptionEvent, this.handleNewData)
}
public hash() {
return this.cachedHash
}
public applyUnmergedChanges() {
this.unmergedMessages.forEach((msg) => {
const edges = msg.topic.split('/')
const node = TreeNodeFactory.fromEdgesAndValue<ViewModel, any>(edges, msg.payload)
node.mqttMessage = msg
if (!this.nodeFilter || this.nodeFilter(node)) {
this.updateWithNode(node.firstNode())
}
})
this.unmergedMessages = []
}
private handleNewData = (msg: MqttMessage) => {
this.unmergedMessages.push(msg)
this.didReceive.dispatch()
}
public stopUpdating() {
if (this.subscriptionEvent && this.updateSource) {
this.updateSource.unsubscribe(this.subscriptionEvent, this.handleNewData)
}
}
}
+84 -37
View File
@@ -1,22 +1,39 @@
import { Edge, Message, RingBuffer } from './'
import { EventDispatcher } from '../../../events'
import { EventDispatcher, MqttMessage } from '../../../events'
export class TreeNode {
public sourceEdge?: Edge
export class TreeNode<ViewModel> {
public sourceEdge?: Edge<ViewModel>
public message?: Message
public mqttMessage?: MqttMessage
public messageHistory: RingBuffer<Message> = new RingBuffer<Message>(3000, 100)
public edges: {[s: string]: Edge} = {}
public viewModel?: ViewModel
public edges: {[s: string]: Edge<ViewModel>} = {}
public edgeArray: Edge<ViewModel>[] = []
public collapsed = false
public messages: number = 0
public lastUpdate: number = Date.now()
public onMerge = new EventDispatcher<void, TreeNode<ViewModel>>(this)
public onEdgesChange = new EventDispatcher<void, TreeNode<ViewModel>>(this)
public onMessage = new EventDispatcher<Message, TreeNode<ViewModel>>(this)
public isTree = false
public onMerge = new EventDispatcher<void, TreeNode>(this)
public onEdgesChange = new EventDispatcher<void, TreeNode>(this)
public onMessage = new EventDispatcher<Message, TreeNode>(this)
private cachedLeafes?: TreeNode[]
private cachedPath?: string
private cachedChildTopics?: TreeNode<ViewModel>[]
private cachedLeafMessageCount?: number
private cachedChildTopicCount?: number
constructor(sourceEdge?: Edge, message?: Message) {
public unconnectedClone() {
const node = new TreeNode<ViewModel>()
node.message = this.message
node.mqttMessage = this.mqttMessage
node.messageHistory = this.messageHistory.clone()
node.messages = this.messages
node.lastUpdate = this.lastUpdate
return node
}
constructor(sourceEdge?: Edge<ViewModel>, message?: Message) {
if (sourceEdge) {
this.sourceEdge = sourceEdge
sourceEdge.target = this
@@ -24,7 +41,8 @@ export class TreeNode {
message && this.setMessage(message)
this.onMerge.subscribe(() => {
this.cachedLeafes = undefined
this.cachedChildTopics = undefined
this.cachedChildTopicCount = undefined
this.cachedLeafMessageCount = undefined
this.lastUpdate = Date.now()
})
@@ -46,23 +64,28 @@ export class TreeNode {
return `N${(this.sourceEdge ? this.sourceEdge.hash() : '')}`
}
public firstNode(): TreeNode {
public firstNode(): TreeNode<ViewModel> {
return this.sourceEdge && this.sourceEdge.source ? this.sourceEdge.source.firstNode() : this
}
public path(): string {
return this.branch()
.map(node => (node.sourceEdge && node.sourceEdge.name))
.filter(name => name !== undefined)
.join('/')
if (!this.cachedPath) {
return this.branch()
.map(node => (node.sourceEdge && node.sourceEdge.name))
.filter(name => name !== undefined)
.join('/')
}
return this.cachedPath
}
private previous(): TreeNode | undefined {
private previous(): TreeNode<ViewModel> | undefined {
return this.sourceEdge ? this.sourceEdge.source || undefined : undefined
}
public addEdge(edge: Edge, emitUpdate: boolean = false) {
public addEdge(edge: Edge<ViewModel>, emitUpdate: boolean = false) {
this.edges[edge.name] = edge
this.edgeArray.push(edge)
edge.source = this
if (emitUpdate) {
@@ -70,7 +93,7 @@ export class TreeNode {
}
}
public branch(): TreeNode[] {
public branch(): TreeNode<ViewModel>[] {
const previous = this.previous()
if (!previous) {
return [this]
@@ -79,49 +102,73 @@ export class TreeNode {
return previous.branch().concat([this])
}
public updateWithNode(node: TreeNode) {
public updateWithNode(node: TreeNode<ViewModel>) {
if (node.message) {
this.setMessage(node.message)
this.onMessage.dispatch(node.message)
this.mqttMessage = node.mqttMessage
}
this.mergeEdges(node)
this.onMerge.dispatch()
}
public leafMessageCount() {
public leafMessageCount(): number {
if (this.cachedLeafMessageCount === undefined) {
this.cachedLeafMessageCount = this.leafes()
.map(leaf => leaf.messages)
.reduce((a, b) => a + b)
this.cachedLeafMessageCount = this.edgeArray
.map(edge => edge.target.leafMessageCount())
.reduce((a, b) => a + b, 0) + this.messages
}
return this.cachedLeafMessageCount
return this.cachedLeafMessageCount as number
}
public leafCount(): number {
return this.leafes().length
public childTopicCount(): number {
if (this.cachedChildTopicCount === undefined) {
this.cachedChildTopicCount = this.edgeArray
.map(e => e.target.childTopicCount())
.reduce((a, b) => a + b, this.edgeArray.length === 0 ? 1 : 0)
}
return this.cachedChildTopicCount as number
}
public edgeCount(): number {
return Object.values(this.edges).length
return this.edgeArray.length
}
public leafes(): TreeNode[] {
if (this.cachedLeafes === undefined) {
if (Object.values(this.edges).length === 0) {
return [this]
}
public childTopics(): TreeNode<ViewModel>[] {
if (this.cachedChildTopics === undefined) {
const initialValue = this.message && this.message.value ? [this] : []
this.cachedLeafes = Object.values(this.edges)
.map(e => e.target.leafes())
.reduce((a, b) => a.concat(b), [])
this.cachedChildTopics = this.edgeArray
.map(e => e.target.childTopics())
.reduce((a, b) => a.concat(b), initialValue)
}
return this.cachedLeafes
return this.cachedChildTopics as TreeNode<ViewModel>[]
}
private mergeEdges(node: TreeNode) {
public findNode (path: String): TreeNode<ViewModel> | undefined {
const topics = path.split('/')
return this.findChild(topics)
}
private findChild(edges: string[]): TreeNode<ViewModel> | undefined {
if (edges.length === 0) {
return this
}
const nextEdge = this.edges[edges[0]]
if (!nextEdge) {
return undefined
}
return nextEdge.target.findChild(edges.slice(1))
}
private mergeEdges(node: TreeNode<ViewModel>) {
const edgeKeys = Object.keys(node.edges)
let edgesDidUpdate = false
+17 -9
View File
@@ -5,21 +5,29 @@ interface HasLength {
}
export abstract class TreeNodeFactory {
public static fromEdgesAndValue<T extends HasLength>(edgeNames: string[], value: T): TreeNode {
let currentNode: TreeNode = new Tree()
public static insertNodeAtPosition<ViewModel>(edgeNames: string[], node: TreeNode<ViewModel>) {
let currentNode: TreeNode<ViewModel> = new Tree()
let edge
for (const edgeName of edgeNames) {
const edge = new Edge(edgeName)
const newNode = new TreeNode(edge)
edge.target = newNode
edge = new Edge<ViewModel>(edgeName)
currentNode.addEdge(edge)
currentNode = newNode
currentNode = new TreeNode(edge)
edge.target = currentNode
}
node.sourceEdge = edge
node.sourceEdge!.target = node
}
currentNode.setMessage({
public static fromEdgesAndValue<ViewModel, T extends HasLength>(edgeNames: string[], value?: T): TreeNode<ViewModel> {
const node = new TreeNode<ViewModel>()
node.setMessage({
value,
length: value.length,
length: value ? value.length : 0,
received: new Date(),
})
return currentNode
this.insertNodeAtPosition<ViewModel>(edgeNames, node)
return node
}
}
+7 -5
View File
@@ -1,7 +1,9 @@
import { Edge, TreeNode, TreeNodeFactory } from '../'
import { expect } from 'chai'
import 'mocha'
import { Edge, TreeNode, TreeNodeFactory } from '../'
import { expect } from 'chai'
describe('Edge', () => {
it('should contain a name', () => {
const e = new Edge('foo')
@@ -10,7 +12,7 @@ describe('Edge', () => {
it('firstEdge should retireve the first edge', () => {
const topics = 'foo/bar/baz'.split('/')
const leaf = TreeNodeFactory.fromEdgesAndValue(topics, 5)
const leaf = TreeNodeFactory.fromEdgesAndValue(topics, undefined)
const bazEdge = leaf.sourceEdge
if (!bazEdge) {
@@ -35,10 +37,10 @@ describe('Edge', () => {
it('hash should include change if parents are different', () => {
const topics1 = 'foo/bar/baz'.split('/')
const bazEdge1 = TreeNodeFactory.fromEdgesAndValue(topics1, 5).sourceEdge
const bazEdge1 = TreeNodeFactory.fromEdgesAndValue(topics1, undefined).sourceEdge
const topics2 = 'foo/foo/baz'.split('/')
const bazEdge2 = TreeNodeFactory.fromEdgesAndValue(topics2, 5).sourceEdge
const bazEdge2 = TreeNodeFactory.fromEdgesAndValue(topics2, undefined).sourceEdge
if (!bazEdge1 || !bazEdge2) {
throw Error('should not happen')
@@ -0,0 +1,42 @@
import 'mocha'
import { EventDispatcher } from '../../../../events'
import { expect } from 'chai'
describe('EventDispatcher', async () => {
it('should dispatch', async function () {
const dispatcher = new EventDispatcher<string, string>('me')
this.timeout(300)
setTimeout(() => dispatcher.dispatch('hello'), 5)
const response = await new Promise((resolve) => {
dispatcher.subscribe((msg) => {
resolve(msg)
})
})
expect(response).to.eq('hello')
})
it('should unsubscribe', async function () {
const dispatcher = new EventDispatcher<string, string>('me')
this.timeout(300)
let incrementee = 0
const callback = (msg: any) => {
incrementee += 1
}
dispatcher.subscribe(callback)
dispatcher.subscribe(callback)
dispatcher.unsubscribe(callback)
// Should increment to 1
dispatcher.dispatch('hello')
dispatcher.unsubscribe(callback)
// should not cause any increment
dispatcher.dispatch('hello')
expect(incrementee).to.eq(1)
})
})
+4 -4
View File
@@ -1,15 +1,15 @@
import { Tree, TreeNodeFactory } from '../'
import { expect } from 'chai'
import 'mocha'
import './TreeNode.findNode'
import { Tree, TreeNodeFactory } from '../'
import { expect } from 'chai'
describe('Tree', () => {
it('node can be merged into a tree', () => {
const tree = new Tree()
const topics = 'foo/bar'.split('/')
const leaf = TreeNodeFactory.fromEdgesAndValue(topics, 3)
const leaf = TreeNodeFactory.fromEdgesAndValue(topics, undefined)
tree.updateWithNode(leaf.firstNode())
const expectedNode = tree.findNode('foo/bar')
@@ -1,13 +1,12 @@
import { TreeNodeFactory } from '../'
import { expect } from 'chai'
import 'mocha'
import './TreeNode.findNode'
import { TreeNodeFactory } from '../'
import { expect } from 'chai'
describe('TreeNode.findNode', () => {
it('findNode should retrieve node', () => {
const topics = 'foo/bar/baz'.split('/')
const leaf = TreeNodeFactory.fromEdgesAndValue(topics, 5)
const leaf = TreeNodeFactory.fromEdgesAndValue(topics, undefined)
const root = leaf.firstNode()
expect(root.sourceEdge).to.eq(undefined)
@@ -1,19 +0,0 @@
import { TreeNode } from '../'
declare module '../' {
interface TreeNode {
findNode(path: String): TreeNode | undefined
}
}
TreeNode.prototype.findNode = function (path: String): TreeNode | undefined {
const topics = path.split('/')
const edge = this.edges[topics[0]]
const remainingTopics = topics.slice(1, topics.length)
if (edge && remainingTopics.length === 0) {
return edge.target
} else if (edge) {
return edge.target.findNode(remainingTopics.join('/'))
}
return undefined
}
+15 -15
View File
@@ -1,52 +1,52 @@
import { TreeNodeFactory } from '../'
import './TreeNode.findNode'
import { expect } from 'chai'
import 'mocha'
import { TreeNodeFactory } from '../'
import { expect } from 'chai'
describe('TreeNode', () => {
it('firstNode should retrieve first node', () => {
const topics = 'foo/bar'.split('/')
const leaf = TreeNodeFactory.fromEdgesAndValue(topics, 3)
const leaf = TreeNodeFactory.fromEdgesAndValue(topics, undefined)
expect(leaf.firstNode().edges['foo']).to.not.eq(undefined)
})
it('updateWithNode should update value', () => {
const topics = 'foo/bar'.split('/')
const leaf = TreeNodeFactory.fromEdgesAndValue(topics, 3)
expect(leaf.message && leaf.message.value).to.eq(3)
const updateLeave = TreeNodeFactory.fromEdgesAndValue(topics, 5)
const leaf = TreeNodeFactory.fromEdgesAndValue(topics, '3')
expect(leaf.message && leaf.message.value).to.eq('3')
const updateLeave = TreeNodeFactory.fromEdgesAndValue(topics, '5')
const root = leaf.firstNode()
root.updateWithNode(updateLeave.firstNode())
expect(root.sourceEdge).to.eq(undefined)
expect(leaf.message && leaf.message.value).to.eq(5)
expect(leaf.message && leaf.message.value).to.eq('5')
})
it('updateWithNode should update intermediate nodes', () => {
const topics1 = 'foo/bar/baz'.split('/')
const leaf = TreeNodeFactory.fromEdgesAndValue(topics1, 3)
expect(leaf.message && leaf.message.value).to.eq(3)
const leaf = TreeNodeFactory.fromEdgesAndValue(topics1, '3')
expect(leaf.message && leaf.message.value).to.eq('3')
const topics2 = 'foo/bar'.split('/')
const updateLeave = TreeNodeFactory.fromEdgesAndValue(topics2, 5)
const updateLeave = TreeNodeFactory.fromEdgesAndValue(topics2, '5')
leaf.firstNode().updateWithNode(updateLeave.firstNode())
const barNode = leaf.firstNode().findNode('foo/bar')
expect(barNode && barNode.sourceEdge && barNode.sourceEdge.name).to.eq('bar')
expect(barNode && barNode.message && barNode.message.value).to.eq(5)
expect(barNode && barNode.message && barNode.message.value).to.eq('5')
expect(leaf.sourceEdge && leaf.sourceEdge.name).to.eq('baz')
expect(leaf.message && leaf.message.value).to.eq(3)
expect(leaf.message && leaf.message.value).to.eq('3')
})
it('updateWithNode should add nodes to the tree', () => {
const topics1 = 'foo/bar'.split('/')
const leaf1 = TreeNodeFactory.fromEdgesAndValue(topics1, 3)
const leaf1 = TreeNodeFactory.fromEdgesAndValue(topics1, undefined)
const topics2 = 'foo/bar/baz'.split('/')
const leaf2 = TreeNodeFactory.fromEdgesAndValue(topics2, 5)
const leaf2 = TreeNodeFactory.fromEdgesAndValue(topics2, undefined)
leaf1.firstNode().updateWithNode(leaf2.firstNode())
@@ -1,13 +1,13 @@
import 'mocha'
import { TreeNodeFactory } from '../'
import { expect } from 'chai'
import 'mocha'
import './TreeNode.findNode'
describe('TreeNodeFactory', () => {
it('root node must not have a sourceEdge', () => {
const topic = 'foo/bar'
const edges = topic.split('/')
const leaf = TreeNodeFactory.fromEdgesAndValue(edges, 5)
const leaf = TreeNodeFactory.fromEdgesAndValue(edges, undefined)
expect(leaf.firstNode().sourceEdge).to.eq(undefined)
})
@@ -15,7 +15,7 @@ describe('TreeNodeFactory', () => {
it('should create node', () => {
const topic = 'foo/bar'
const edges = topic.split('/')
const node = TreeNodeFactory.fromEdgesAndValue(edges, 5)
const node = TreeNodeFactory.fromEdgesAndValue(edges, '5')
if (!node.sourceEdge || !node.sourceEdge.source || !node.message) {
expect.fail('should not happen')
@@ -24,7 +24,7 @@ describe('TreeNodeFactory', () => {
expect(node).to.not.eq(undefined)
expect(node.sourceEdge.name).to.eq('bar')
expect(node.message.value).to.eq(5)
expect(node.message.value).to.eq('5')
const foo = node.firstNode().findNode('foo')
expect(foo && foo.sourceEdge && foo.sourceEdge.name).to.eq('foo')
@@ -33,14 +33,14 @@ describe('TreeNodeFactory', () => {
it('node should contain edges in order', () => {
const topic = 'foo/bar/baz'
const edges = topic.split('/')
const node = TreeNodeFactory.fromEdgesAndValue(edges, 5)
const node = TreeNodeFactory.fromEdgesAndValue(edges, '5')
if (!node.sourceEdge || !node.sourceEdge.source || !node.message) {
expect.fail('should not happen')
return
}
expect(node.message.value).to.eq(5)
expect(node.message.value).to.eq('5')
expect(node.sourceEdge.name).to.eq('baz')
const barNode = node.sourceEdge.source
+38 -11
View File
@@ -1,11 +1,21 @@
import {
addMqttConnectionEvent, backendEvents,
makeConnectionStateEvent, removeConnection,
makeConnectionMessageEvent, makePublishEvent, AddMqttConnection, Message,
AddMqttConnection,
EventDispatcher,
MqttMessage,
addMqttConnectionEvent,
backendEvents,
checkForUpdates,
makeConnectionMessageEvent,
makeConnectionStateEvent,
makePublishEvent,
removeConnection,
updateAvailable,
} from '../../events'
import { MqttSource, DataSource } from './DataSource'
import { DataSource, MqttSource } from './DataSource'
class ConnectionManager {
import { UpdateInfo } from 'builder-util-runtime'
export class ConnectionManager {
private connections: {[s: string]: DataSource<any>} = {}
public manageConnections() {
@@ -26,19 +36,20 @@ class ConnectionManager {
connection.connect(options)
this.handleNewMessagesForConnection(connectionId, connection)
backendEvents.subscribe(makePublishEvent(connectionId), (msg: Message) => {
this.connections[connectionId].publish(msg.topic, msg.payload)
backendEvents.subscribe(makePublishEvent(connectionId), (msg: MqttMessage) => {
this.connections[connectionId].publish(msg)
})
}
private handleNewMessagesForConnection(connectionId: string, connection: MqttSource) {
const messageEvent = makeConnectionMessageEvent(connectionId)
connection.onMessage((topic: string, payload: Buffer) => {
connection.onMessage((topic: string, payload: Buffer, packet: any) => {
let buffer = payload
if (buffer.length > 10000) {
buffer = buffer.slice(0, 10000)
}
backendEvents.emit(messageEvent, { topic, payload: buffer.toString('base64') })
backendEvents.emit(messageEvent, { topic, payload: buffer.toString(), qos: packet.qos, retain: packet.retain })
})
}
@@ -47,7 +58,23 @@ class ConnectionManager {
connection.disconnect()
delete this.connections[hash]
}
public closeAllConnections() {
Object.keys(this.connections)
.forEach(hash => this.removeConnection(hash))
}
}
const connectionManager = new ConnectionManager()
connectionManager.manageConnections()
class UpdateNotifier {
public onCheckUpdateRequest = new EventDispatcher<void, UpdateNotifier>(this)
constructor() {
backendEvents.subscribe(checkForUpdates, () => {
this.onCheckUpdateRequest.dispatch()
})
}
public notify(updateInfo: UpdateInfo) {
backendEvents.emit(updateAvailable, updateInfo)
}
}
export const updateNotifier = new UpdateNotifier()
-75
View File
@@ -1,75 +0,0 @@
const { autoUpdater } = require("electron-updater")
const log = require('electron-log');
autoUpdater.logger = log;
autoUpdater.logger.transports.file.level = 'info';
log.info('App starting...');
// Modules to control application life and create native browser window
const {app, BrowserWindow} = require('electron')
try {
require('./backend/build/backend/src/index.js')
} catch (err) {
console.error(err)
}
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let mainWindow
function createWindow () {
// Create the browser window.
mainWindow = new BrowserWindow({
width: 1024,
height: 700,
webPreferences: {
nodeIntegration: true
}
})
// and load the index.html of the app.
mainWindow.loadFile('app/index.html')
// Open the DevTools.
// mainWindow.webContents.openDevTools()
// Emitted when the window is closed.
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.quit()
})
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', () => {
createWindow()
try {
autoUpdater.checkForUpdatesAndNotify()
} catch (error) {
console.error(error)
}
})
// Quit when all windows are closed.
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()
}
})
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.
+39 -5
View File
@@ -1,10 +1,17 @@
import { Event } from './Events'
import { ipcMain, ipcRenderer, IpcRenderer, IpcMain } from 'electron'
import { IpcMain, IpcRenderer, ipcMain, ipcRenderer } from 'electron'
interface EventBusInterface {
import { Event } from './Events'
export interface EventBusInterface {
subscribe<MessageType>(event: Event<MessageType>, callback:(msg: MessageType) => void): void
unsubscribeAll<MessageType>(event: Event<MessageType>): void
emit<MessageType>(event: Event<MessageType>, msg: MessageType): void
unsubscribe<MessageType>(event: Event<MessageType>, callback: any): void
}
interface CallbackStore {
wrappedCallback: any
callback: any
}
class IpcMainEventBus implements EventBusInterface {
@@ -26,25 +33,52 @@ class IpcMainEventBus implements EventBusInterface {
this.ipc.removeAllListeners(event.topic)
}
public unsubscribe<MessageType>(event: Event<MessageType>, callback: any) {
throw new Error('Not implemented') // Todo: implement
}
public emit<MessageType>(event: Event<MessageType>, msg: MessageType) {
this.client.send(event.topic, msg)
if (!this.client.isDestroyed()) {
this.client.send(event.topic, msg)
}
}
}
class IpcRendererEventBus implements EventBusInterface {
private ipc: IpcRenderer
private callbacks: CallbackStore[] = []
constructor(ipc: IpcRenderer) {
this.ipc = ipc
}
public subscribe<MessageType>(event: Event<MessageType>, callback:(msg: MessageType) => void) {
this.ipc.on(event.topic, (_event: any, arg: any) => callback(arg))
const wrappedCallback = (_event: any, arg: any) => {
callback(arg)
}
this.ipc.on(event.topic, wrappedCallback)
this.callbacks.push({
callback,
wrappedCallback,
})
}
public unsubscribeAll<MessageType>(event: Event<MessageType>) {
this.ipc.removeAllListeners(event.topic)
}
public unsubscribe<MessageType>(event: Event<MessageType>, callback: any) {
const item = this.callbacks.find(store => store.callback === callback)
if (!item) {
return
}
this.ipc.removeListener(event.topic, item.wrappedCallback)
this.callbacks = this.callbacks.filter(a => a !== item)
}
public emit<MessageType>(event: Event<MessageType>, msg: MessageType) {
this.ipc.send(event.topic, msg)
}
+20 -6
View File
@@ -1,4 +1,8 @@
import { MqttOptions, DataSourceState } from '../backend/src/DataSource'
import { DataSourceState, MqttOptions } from '../backend/src/DataSource'
import { UpdateInfo } from 'builder-util-runtime'
export { UpdateInfo } from 'builder-util-runtime'
export interface Event<MessageType> {
topic: string
@@ -23,18 +27,28 @@ export function makeConnectionStateEvent(connectionId: string): Event<DataSource
}
}
export interface Message {
topic: string,
payload: any
export const checkForUpdates: Event<void> = {
topic: 'app/update/check',
}
export function makePublishEvent(connectionId: string): Event<Message> {
export const updateAvailable: Event<UpdateInfo> = {
topic: 'app/update/available',
}
export interface MqttMessage {
topic: string,
payload: any,
qos: 0 | 1 | 2,
retain: boolean
}
export function makePublishEvent(connectionId: string): Event<MqttMessage> {
return {
topic: `conn/publish/${connectionId}`,
}
}
export function makeConnectionMessageEvent(connectionId: string): Event<Message> {
export function makeConnectionMessageEvent(connectionId: string): Event<MqttMessage> {
return {
topic: `conn/${connectionId}`,
}
-9
View File
@@ -1,9 +0,0 @@
const { EventEmitter } = require('events');
const a = new EventEmitter()
a.on('test', () => console.log('test'))
a.on('test2', () => console.log('test2'))
a.removeAllListeners('test')
a.emit('test')
a.emit('test2')
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+214 -472
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
docker run --rm -ti \
--env-file <(env | grep -iE 'DEBUG|NODE_|ELECTRON_|YARN_|NPM_|CI|CIRCLE|TRAVIS|APPVEYOR_|CSC_|_TOKEN|_KEY|AWS_|STRIP|BUILD_') \
--env ELECTRON_CACHE="/root/.cache/electron" \
--env ELECTRON_BUILDER_CACHE="/root/.cache/electron-builder" \
-v ${PWD}:/project \
-v ~/.cache/electron:/root/.cache/electron \
-v ~/.cache/electron-builder:/root/.cache/electron-builder \
electronuserland/builder:wine node_modules/.bin/ts-node package.ts $@
+24 -16
View File
@@ -1,16 +1,17 @@
{
"name": "MQTT-Explorer",
"version": "0.0.2",
"version": "0.1.1",
"description": "Explore your message queues",
"main": "electron.js",
"main": "dist/src/electron.js",
"scripts": {
"start": "electron .",
"install": "cd app; npm install; cd ..",
"test": "npm run test-backend",
"build": "cd app; npm run build; cd ..; cd backend; npm run build; cd ..",
"build": "tsc && cd app && npm run build && cd ..",
"test-backend": "cd backend && npm run test && cd ..",
"prepare-release": "./prepare-release.sh",
"package": "ts-node package.ts"
"package": "ts-node package.ts",
"package-with-docker": "./package-with-docker.sh"
},
"repository": {
"type": "git",
@@ -40,26 +41,33 @@
"homepage": "https://github.com",
"license": "ISC",
"devDependencies": {
"electron": "^4.0.0",
"@types/chai": "^4.1.7",
"@types/mocha": "^5.2.5",
"@types/mustache": "^0.8.32",
"@types/node": "^10.12.18",
"@types/semver": "^5.5.0",
"@types/sha1": "^1.1.1",
"@types/socket.io": "^2.1.2",
"axios": "^0.18.0",
"chai": "^4.2.0",
"electron": "^4.0.1",
"electron-builder": "^20.38.4",
"mocha": "^5.2.0",
"mustache": "^3.0.1",
"nyc": "^13.1.0",
"redux-thunk": "^2.3.0",
"source-map-support": "^0.5.9",
"ts-node": "^7.0.1",
"tslint": "^5.12.0",
"tslint-config-airbnb": "^5.11.1",
"tslint-react": "^3.6.0",
"typescript": "^3.2.2",
"@types/chai": "^4.1.7",
"@types/mocha": "^5.2.5",
"@types/node": "^10.12.18",
"@types/sha1": "^1.1.1",
"@types/socket.io": "^2.1.2",
"chai": "^4.2.0",
"mocha": "^5.2.0",
"nyc": "^13.1.0",
"source-map-support": "^0.5.9",
"tslint-strict-null-checks": "^1.0.1"
"tslint-strict-null-checks": "^1.0.1",
"typescript": "^3.2.2"
},
"dependencies": {
"electron-debug": "^2.0.0",
"electron-log": "^2.2.17",
"electron-telemetry": "git+https://github.com/thomasnordquist/electron-telemetry.git",
"electron-updater": "^4.0.6",
"mqtt": "^2.18.8",
"sha1": "^1.1.1"
+33 -7
View File
@@ -4,10 +4,20 @@ const linux: builder.CliOptions = {
x64: true,
ia32: true,
armv7l: true,
arm64: true,
linux: ['AppImage', 'deb', 'snap'],
arm64: false,
linux: ['AppImage'],
projectDir: './build/clean',
publish: 'onTag',
publish: 'always',
}
const linuxSnap: builder.CliOptions = {
x64: true,
ia32: false,
armv7l: false,
arm64: false,
linux: ['snap'],
projectDir: './build/clean',
publish: 'always',
}
const win: builder.CliOptions = {
@@ -17,7 +27,7 @@ const win: builder.CliOptions = {
arm64: false,
win: ['portable', 'nsis'],
projectDir: './build/clean',
publish: 'onTag',
publish: 'always',
}
const mac: builder.CliOptions = {
@@ -27,10 +37,10 @@ const mac: builder.CliOptions = {
arm64: false,
mac: ['dmg'],
projectDir: './build/clean',
publish: 'onTag',
publish: 'always',
}
async function buildAll() {
async function executeBuild() {
console.log(process.argv[2])
switch (process.argv[2]) {
case 'win':
@@ -39,6 +49,13 @@ async function buildAll() {
case 'linux':
await builder.build(linux)
break
case 'snap':
try {
await builder.build(linuxSnap)
} catch {
// ignore
}
break
case 'mac':
await builder.build(mac)
break
@@ -47,4 +64,13 @@ async function buildAll() {
}
}
buildAll()
function build() {
try {
executeBuild()
} catch (error) {
console.error(error)
process.exit(1)
}
}
build()
-10
View File
@@ -26,13 +26,3 @@ cd ..
npm run build
rm -rf app/node_modules
cd "$ORIGINAL_DIR"
exit 0
docker run --rm -ti \
--env ELECTRON_CACHE="/root/.cache/electron" \
--env ELECTRON_BUILDER_CACHE="/root/.cache/electron-builder" \
--env GH_TOKEN="$GH_TOKEN" \
-v ${PWD}:/project \
-v ~/.cache/electron:/root/.cache/electron \
-v ~/.cache/electron-builder:/root/.cache/electron-builder \
electronuserland/builder:wine node_modules/.bin/ts-node build.ts
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+72
View File
@@ -0,0 +1,72 @@
import { Menu, app, BrowserWindow, webContents } from 'electron'
const applicationMenu = {
label: 'Application',
submenu: [
{
label: 'About Application',
selector: 'orderFrontStandardAboutPanel:',
},
{
type: 'separator' as 'separator',
},
{
label: 'Dev Tools',
accelerator: 'CmdOrCtrl+Alt+I',
role: 'toggleDevTools',
},
{
label: 'Quit',
accelerator: 'Command+Q',
click: () => {
app.quit()
},
},
],
}
const editMenu = {
label: 'Edit',
submenu: [
{
label: 'Undo',
accelerator: 'CmdOrCtrl+Z',
selector: 'undo:',
},
{
label: 'Redo',
accelerator: 'Shift+CmdOrCtrl+Z',
selector: 'redo:',
},
{
type: 'separator' as 'separator',
},
{
label: 'Cut',
accelerator: 'CmdOrCtrl+X',
selector: 'cut:',
},
{
label: 'Copy',
accelerator: 'CmdOrCtrl+C',
selector: 'copy:',
},
{
label: 'Paste',
accelerator: 'CmdOrCtrl+V',
selector: 'paste:',
},
{
label: 'Select All',
accelerator: 'CmdOrCtrl+A',
selector: 'selectAll:',
},
],
}
const template = [
applicationMenu,
editMenu,
]
export const menuTemplate = Menu.buildFromTemplate(template)
+110
View File
@@ -0,0 +1,110 @@
import { UpdateInfo } from '../events'
import { BrowserWindow, app, Menu } from 'electron'
import * as path from 'path'
import { menuTemplate } from './MenuTemplate'
import { autoUpdater } from 'electron-updater'
import * as log from 'electron-log'
import { ConnectionManager, updateNotifier } from '../backend/src/index'
import { electronTelemetryFactory } from 'electron-telemetry'
const electronTelemetry = electronTelemetryFactory('9b0c8ca04a361eb8160d98c5')
const isDebugEnabled = Boolean(process.argv.find(arg => arg === 'debug'))
require('electron-debug')({ enabled: isDebugEnabled })
autoUpdater.logger = log
log.info('App starting...')
const connectionManager = new ConnectionManager()
connectionManager.manageConnections()
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let mainWindow: BrowserWindow | undefined
function createWindow() {
const iconPath = path.join(__dirname, 'icon.png')
// Create the browser window.
mainWindow = new BrowserWindow({
width: 1024,
height: 700,
show: false,
webPreferences: {
nodeIntegration: true,
devTools: true,
},
icon: iconPath,
})
mainWindow.once('ready-to-show', () => {
mainWindow && mainWindow.show()
})
console.log('icon path', iconPath)
// and load the index.html of the app.
mainWindow.loadFile('app/build/index.html')
// Emitted when the window is closed.
mainWindow.on('close', () => {
connectionManager.closeAllConnections()
})
// Emitted when the window is closed.
mainWindow.on('closed', () => {
// 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 = undefined
app.quit()
})
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', () => {
Menu.setApplicationMenu(menuTemplate)
createWindow()
let updateInfo: UpdateInfo
autoUpdater.on('update-available', (info: UpdateInfo) => {
console.log('there is an update')
updateInfo = info
})
autoUpdater.on('error', () => {
console.log('could not update due to error')
if (updateInfo) {
updateNotifier.notify(updateInfo)
}
})
updateNotifier.onCheckUpdateRequest.subscribe(() => {
try {
autoUpdater.checkForUpdatesAndNotify()
} catch (error) {
console.error(error)
}
})
})
// Quit when all windows are closed.
app.on('window-all-closed', () => {
// 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', () => {
// 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()
}
})
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.
+7 -3
View File
@@ -3,9 +3,13 @@
"compilerOptions": {
"noImplicitAny": true,
"strictNullChecks": true,
"outDir": "./build",
"outDir": "./dist",
"strict": true,
"lib": ["es2017"],
"module": "commonjs",
"moduleResolution": "node",
"sourceRoot": "src/",
"lib": ["es2017", "dom"],
"sourceMap": true
}
},
"include": ["src/electron.ts"]
}
+2 -1
View File
@@ -7,6 +7,7 @@
"no-else-after-return": false,
"align": false,
"variable-name": false,
"jsx-no-lambda": false
"jsx-no-lambda": false,
"indent": [true, "spaces", 2]
}
}
+64
View File
@@ -0,0 +1,64 @@
import * as mustache from 'mustache'
import { readFileSync, writeFileSync } from 'fs'
import axios from 'axios'
interface Release {
name: string
assets: Asset[]
}
interface Asset {
name: string
browser_download_url: string
}
function createUrl(title: string, path: string) {
return `[${title}](${path})`
}
function fileExtension(file: string): string {
const match = file.match(/\.([a-zA-Z]+)$/)
return (match && match[1]) || ''
}
async function createReadme(): Promise<void> {
const release: Release = (await axios.get('https://api.github.com/repos/thomasnordquist/mqtt-explorer/releases/latest')).data
const linux64 = release.assets.filter(asset => /(x86_64|amd64)\.(AppImage|deb|rpm)$/.test(asset.name))
const windowsInstaller = release.assets.find(asset => /Setup-.+\.exe$/.test(asset.name))
const windowsPortable = release.assets.find(asset => /-(?!Setup).+\.exe$/.test(asset.name))
const macDmg = release.assets.find(asset => /\.dmg$/.test(asset.name))
if (linux64.length === 0 || !windowsInstaller || !windowsPortable || !macDmg) {
console.error('failed retrieving')
process.exit(1)
return
}
const linuxTargets = linux64
.map(item => createUrl(fileExtension(item.browser_download_url), item.browser_download_url))
.join(', ')
const windowsTargets = [
createUrl('portable', windowsPortable.browser_download_url),
createUrl('installer', windowsInstaller.browser_download_url),
].join(', ')
const macTargets = [
createUrl('dmg', macDmg.browser_download_url),
].join(', ')
const template = readFileSync('./Readme.tpl.md').toString()
const rendered = mustache.render(template, {
linuxTargets,
windowsTargets,
macTargets,
version: release.name,
})
writeFileSync('./Readme.md', rendered)
}
createReadme()