Compare commits

..
63 Commits
Author SHA1 Message Date
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
Thomas Nordquist 683a6a7245 Update build settings 2019-01-13 05:31:41 +01:00
Thomas Nordquist 37bb0b6ea1 Prepare travis builds 2019-01-13 04:41:58 +01:00
Thomas Nordquist 7e28865a73 Improve build 2019-01-13 03:13:00 +01:00
Thomas Nordquist c40949f38d Set scroll corners background 2019-01-13 02:27:05 +01:00
Thomas Nordquist 5cf5029530 Try catch autoupdater errors 2019-01-13 02:25:50 +01:00
Thomas Nordquist ab87b13eed Fix scrollbars for windows 2019-01-13 01:48:43 +01:00
Thomas Nordquist e83fe78755 Add "diff" for historic messages 2019-01-12 21:39:18 +01:00
Thomas Nordquist 8fa8ea5aa7 Update tslint.json 2019-01-12 20:26:56 +01:00
Thomas Nordquist d75122d44d Fix app style 2019-01-12 20:26:36 +01:00
Thomas Nordquist a677fb7a0c Add message history to frontend 2019-01-12 20:25:52 +01:00
Thomas Nordquist 6fbf04cd00 Improve node selection via MouseOver 2019-01-12 14:29:56 +01:00
Thomas Nordquist 48b7754a2b Add message history to TreeNode 2019-01-12 14:29:21 +01:00
Thomas Nordquist a8e082f084 Refactor 2019-01-11 19:07:56 +01:00
Thomas Nordquist b52b2d7696 Refactor 2019-01-11 18:52:12 +01:00
Thomas Nordquist 1586d0121c Fix layout 2019-01-11 10:32:38 +01:00
Thomas Nordquist 5425a80ada Fix topic selection 2019-01-11 10:32:29 +01:00
Thomas Nordquist f7843bc348 Add publish history & fix topic input 2019-01-11 10:17:02 +01:00
Thomas Nordquist c956afdee0 Fix node title updates 2019-01-11 09:10:41 +01:00
Thomas Nordquist 4e8727432d Fix button handler 2019-01-11 09:10:19 +01:00
Thomas Nordquist ea87c24fb5 Fix import 2019-01-11 00:42:00 +01:00
Thomas Nordquist c3ef9335c0 Add topic publishing sidebar 2019-01-11 00:40:05 +01:00
Thomas Nordquist eb375073f9 Fix Sidebar & Mouse event target area
Fix clipboard
Fix invalid state in sidebar due to missing event termination
2019-01-10 10:34:09 +01:00
Thomas Nordquist 269061bdc8 Work in progress 2019-01-08 23:58:24 +01:00
Thomas Nordquist b44f352804 WiP #broken 2019-01-08 16:39:18 +01:00
Thomas Nordquist 25cc7ad277 Prepare auto-update 2019-01-08 03:53:45 +01:00
Thomas Nordquist 2a800dc7ca Clean up 2019-01-08 03:29:18 +01:00
Thomas Nordquist 723666e2cb Remmove react dev tools 2019-01-08 02:23:25 +01:00
Thomas Nordquist bb0f96028d Improve connection making 2019-01-08 02:21:55 +01:00
Thomas Nordquist e72696dc57 Adapt redux
Add hover-effect on nodes
Add Setting drawer
Ass auto expansion setting
2019-01-07 22:47:22 +01:00
Thomas Nordquist e945721221 Prepare releases 2019-01-07 13:59:22 +01:00
Thomas Nordquist 5123207ea1 Add error notification 2019-01-07 13:54:13 +01:00
Thomas Nordquist d6a245893a Add proper copy to clipboard 2019-01-07 12:24:01 +01:00
Thomas Nordquist e2192b11c7 Screw up looks, greatly improve performance 2019-01-07 03:32:28 +01:00
Thomas Nordquist 260f31fea0 Replace EventEmitter with typed EventDispatcher 2019-01-06 13:52:12 +01:00
Thomas Nordquist 32c3079821 Clean up & Add connection setup 2019-01-06 13:30:35 +01:00
Thomas Nordquist ad7794b30d Add connection setup form 2019-01-04 02:33:32 +01:00
Thomas Nordquist c6cad9dd9e Fix warning 2019-01-04 02:32:04 +01:00
Thomas Nordquist cd540cade3 Tweak performance 2019-01-04 00:28:42 +01:00
Thomas Nordquist 87dafc9c89 Improve ui & performance 2019-01-03 11:14:49 +01:00
Thomas Nordquist 2b7e9a5ef7 Enforce codestyle 2019-01-02 16:37:36 +01:00
Thomas Nordquist 48aa317c7c Improve data model & fix tests 2019-01-02 15:58:44 +01:00
Thomas Nordquist 5697b2daea Prepare electron releases 2019-01-01 23:48:35 +01:00
Thomas Nordquist b2badfd43f Add electron 2019-01-01 15:31:33 +01:00
Thomas Nordquist 4e09ea3d30 Remove root nodes source edge 2019-01-01 13:37:59 +01:00
Thomas Nordquist 6d9d5fcb0d Remove dead code 2019-01-01 13:37:20 +01:00
Thomas Nordquist f1a60659e8 Work in progress 2019-01-01 13:29:04 +01:00
Thomas Nordquist 0af3a2ede3 Initial commit 2018-12-28 15:25:25 +01:00
97 changed files with 18453 additions and 1469 deletions
+6 -2
View File
@@ -1,4 +1,8 @@
node_modules
backend/coverage
backend/.nyc_output
build
.bundle
_site
.DS_Store
test.dot
test.png
.awcache
+26
View File
@@ -0,0 +1,26 @@
language: node_js
cache:
directories:
- node_modules
- $HOME/.cache/electron
- $HOME/.cache/electron-builder
- $HOME/.npm/_prebuilds
node_js:
- "10"
os:
- linux
- osx
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
-1
View File
@@ -1 +0,0 @@
mqtt-explorer.com
-150
View File
@@ -1,150 +0,0 @@
[back](./)
# 0.4.0-beta1
- Fix Copy & Paste
- Allow setting QoS for subscriptions
- Show Meassge ID (for QoS > 0)
- Improve JSON editor
- Allow multiple connections to the same broker from MQTT Explorer (random client ids)
- Remove recursive topic removal limit
- Retain focus on publish input fields when publishing
- Fix editor layout issue
- Fix password input
- Fix charts where json properties contain periods
- Fix publish sidebar width
- Fix plot range selection
- Update Electron
A downgrade to a version prior to 0.4.0 is due to a new configuration format not possible.
# 0.3.5
- Add keyboard support
- Navigate with arrow keys and tab return and escape
- Delete topic topics with the delete key
- Add time range options to chart panel
- Add "Clear Data" option to charts
- Add support for client certificates
- Add support for SNI (encryption-related)
- Make connection profile list scrollable
- Improve the browsability of previous messages
- various small fixes
# 0.3.2
- Ubuntu performance fix
# 0.3.1
- Ubuntu bugfix release
- Add wayland support (for snap builds)
# 0.3.0
- Add functionality to plot JSON data
- Add chart panel, containing multiple plots (bottom panel)
- Chart panel and settings will be restored on restart
- Charts can be paused
- Panels can be hidden/overlap others completely
- Plot range can be customized
- Plot interpolation can be customized
- Migrated public broker from iot.eclipse.org to mqtt.eclipse.org
- Fix unrounded time intervals in the message history
- Fix weird plot curve interpolations
# 0.2.6
- Add a pause function
- Add light / dark themes
- Add localized date formatting
- Allow pinning self-signed-certificates
- Allow copying values from the message history
- Buffer changes while in pause mode
- Improve layout
- Improve numeric plot layout and smooth curves
- Improve scroll behavior message history
- Improve performance and memory footprint by ~30%
- Improve text selection behavior (Ctrl+A) for value preview and history
- Immediately apply changed settings
- Fix memory leak on search and disconnect- Deactivate auto-update for Windows portable releases.
- Fix OSX code-signing and auto-update
# 0.2.3
- Highlight differences in the last received message with a "diff" view
- Magnify app content with "Ctrl +", "Ctrl -", "Ctrl 0"
- Make user interactions more predictable by removing "select topic on mouse over"
- Add "Quick Preview" setting, to enable topic selection on mouse over
- Add JSON formatter
- Show error when connection to the mqtt broker is lost
- Fix bug where mqtt explorer resets the tree after a reconnect
- Fix MQTT-Client id
# 0.2.1, 0.2.2
- Release for Mac App Store
- Release for Microsoft Store
# 0.2.0
- Support multiple connection profiles
- Support custom subscriptions
- Delete Topics recursively
- Add option to disable activity indicator
- Add connection health indicator
- Remember settings after exiting the app
- Fixed ws:// connections
- Fixed exception after failed connection
- Rework topic selection indicator
- Fix scrollbar (draggable scroll handle)
- Improve look & feel
- Reduce binary size by ~60%
# 0.1.3
- Add search function
- Fix a crash on windows
- Greatly increase performance
- Show broker statistics (Settings sidebar)
- Show more content in topic preview
- Make right sidebar resizable
- Fixed minor visual imperfections.
# 0.1.0
- Improved error handling


- Fix plot
# 0.0.9
- Plot topics in a graph
- Delete retained topics with click
- Show message retention and QoS
- Set message retain and QoS
- Subscribed to broker statistics: $SYS topic
- Improved error handling


- Fixed Copy&Paste
- Crash on launch
# 0.0.7
- Fix auto-updater fallback
- Add debugging capability
- Show receive date next to message value
- Fix certificate validation switch
# 0.0.6
- Fix issue with mqtt authentication
# 0.0.5
- Fix performance bottleneck
- Fix missing icons
# 0.0.3
- Add splash screen
# 0.0.2
- Fixed bugs
- Added bugs
# 0.0.1
- Initial Release
-2
View File
@@ -1,2 +0,0 @@
source 'https://rubygems.org'
gem 'github-pages', group: :jekyll_plugins
-248
View File
@@ -1,248 +0,0 @@
GEM
remote: https://rubygems.org/
specs:
activesupport (4.2.10)
i18n (~> 0.7)
minitest (~> 5.1)
thread_safe (~> 0.3, >= 0.3.4)
tzinfo (~> 1.1)
addressable (2.5.2)
public_suffix (>= 2.0.2, < 4.0)
coffee-script (2.4.1)
coffee-script-source
execjs
coffee-script-source (1.11.1)
colorator (1.1.0)
commonmarker (0.17.13)
ruby-enum (~> 0.5)
concurrent-ruby (1.1.5)
dnsruby (1.61.2)
addressable (~> 2.5)
em-websocket (0.5.1)
eventmachine (>= 0.12.9)
http_parser.rb (~> 0.6.0)
ethon (0.12.0)
ffi (>= 1.3.0)
eventmachine (1.2.7)
execjs (2.7.0)
faraday (0.15.4)
multipart-post (>= 1.2, < 3)
ffi (1.10.0)
forwardable-extended (2.6.0)
gemoji (3.0.0)
github-pages (197)
activesupport (= 4.2.10)
github-pages-health-check (= 1.16.1)
jekyll (= 3.7.4)
jekyll-avatar (= 0.6.0)
jekyll-coffeescript (= 1.1.1)
jekyll-commonmark-ghpages (= 0.1.5)
jekyll-default-layout (= 0.1.4)
jekyll-feed (= 0.11.0)
jekyll-gist (= 1.5.0)
jekyll-github-metadata (= 2.12.1)
jekyll-mentions (= 1.4.1)
jekyll-optional-front-matter (= 0.3.0)
jekyll-paginate (= 1.1.0)
jekyll-readme-index (= 0.2.0)
jekyll-redirect-from (= 0.14.0)
jekyll-relative-links (= 0.6.0)
jekyll-remote-theme (= 0.3.1)
jekyll-sass-converter (= 1.5.2)
jekyll-seo-tag (= 2.5.0)
jekyll-sitemap (= 1.2.0)
jekyll-swiss (= 0.4.0)
jekyll-theme-architect (= 0.1.1)
jekyll-theme-cayman (= 0.1.1)
jekyll-theme-dinky (= 0.1.1)
jekyll-theme-hacker (= 0.1.1)
jekyll-theme-leap-day (= 0.1.1)
jekyll-theme-merlot (= 0.1.1)
jekyll-theme-midnight (= 0.1.1)
jekyll-theme-minimal (= 0.1.1)
jekyll-theme-modernist (= 0.1.1)
jekyll-theme-primer (= 0.5.3)
jekyll-theme-slate (= 0.1.1)
jekyll-theme-tactile (= 0.1.1)
jekyll-theme-time-machine (= 0.1.1)
jekyll-titles-from-headings (= 0.5.1)
jemoji (= 0.10.2)
kramdown (= 1.17.0)
liquid (= 4.0.0)
listen (= 3.1.5)
mercenary (~> 0.3)
minima (= 2.5.0)
nokogiri (>= 1.8.5, < 2.0)
rouge (= 2.2.1)
terminal-table (~> 1.4)
github-pages-health-check (1.16.1)
addressable (~> 2.3)
dnsruby (~> 1.60)
octokit (~> 4.0)
public_suffix (~> 3.0)
typhoeus (~> 1.3)
html-pipeline (2.11.0)
activesupport (>= 2)
nokogiri (>= 1.4)
http_parser.rb (0.6.0)
i18n (0.9.5)
concurrent-ruby (~> 1.0)
jekyll (3.7.4)
addressable (~> 2.4)
colorator (~> 1.0)
em-websocket (~> 0.5)
i18n (~> 0.7)
jekyll-sass-converter (~> 1.0)
jekyll-watch (~> 2.0)
kramdown (~> 1.14)
liquid (~> 4.0)
mercenary (~> 0.3.3)
pathutil (~> 0.9)
rouge (>= 1.7, < 4)
safe_yaml (~> 1.0)
jekyll-avatar (0.6.0)
jekyll (~> 3.0)
jekyll-coffeescript (1.1.1)
coffee-script (~> 2.2)
coffee-script-source (~> 1.11.1)
jekyll-commonmark (1.3.1)
commonmarker (~> 0.14)
jekyll (>= 3.7, < 5.0)
jekyll-commonmark-ghpages (0.1.5)
commonmarker (~> 0.17.6)
jekyll-commonmark (~> 1)
rouge (~> 2)
jekyll-default-layout (0.1.4)
jekyll (~> 3.0)
jekyll-feed (0.11.0)
jekyll (~> 3.3)
jekyll-gist (1.5.0)
octokit (~> 4.2)
jekyll-github-metadata (2.12.1)
jekyll (~> 3.4)
octokit (~> 4.0, != 4.4.0)
jekyll-mentions (1.4.1)
html-pipeline (~> 2.3)
jekyll (~> 3.0)
jekyll-optional-front-matter (0.3.0)
jekyll (~> 3.0)
jekyll-paginate (1.1.0)
jekyll-readme-index (0.2.0)
jekyll (~> 3.0)
jekyll-redirect-from (0.14.0)
jekyll (~> 3.3)
jekyll-relative-links (0.6.0)
jekyll (~> 3.3)
jekyll-remote-theme (0.3.1)
jekyll (~> 3.5)
rubyzip (>= 1.2.1, < 3.0)
jekyll-sass-converter (1.5.2)
sass (~> 3.4)
jekyll-seo-tag (2.5.0)
jekyll (~> 3.3)
jekyll-sitemap (1.2.0)
jekyll (~> 3.3)
jekyll-swiss (0.4.0)
jekyll-theme-architect (0.1.1)
jekyll (~> 3.5)
jekyll-seo-tag (~> 2.0)
jekyll-theme-cayman (0.1.1)
jekyll (~> 3.5)
jekyll-seo-tag (~> 2.0)
jekyll-theme-dinky (0.1.1)
jekyll (~> 3.5)
jekyll-seo-tag (~> 2.0)
jekyll-theme-hacker (0.1.1)
jekyll (~> 3.5)
jekyll-seo-tag (~> 2.0)
jekyll-theme-leap-day (0.1.1)
jekyll (~> 3.5)
jekyll-seo-tag (~> 2.0)
jekyll-theme-merlot (0.1.1)
jekyll (~> 3.5)
jekyll-seo-tag (~> 2.0)
jekyll-theme-midnight (0.1.1)
jekyll (~> 3.5)
jekyll-seo-tag (~> 2.0)
jekyll-theme-minimal (0.1.1)
jekyll (~> 3.5)
jekyll-seo-tag (~> 2.0)
jekyll-theme-modernist (0.1.1)
jekyll (~> 3.5)
jekyll-seo-tag (~> 2.0)
jekyll-theme-primer (0.5.3)
jekyll (~> 3.5)
jekyll-github-metadata (~> 2.9)
jekyll-seo-tag (~> 2.0)
jekyll-theme-slate (0.1.1)
jekyll (~> 3.5)
jekyll-seo-tag (~> 2.0)
jekyll-theme-tactile (0.1.1)
jekyll (~> 3.5)
jekyll-seo-tag (~> 2.0)
jekyll-theme-time-machine (0.1.1)
jekyll (~> 3.5)
jekyll-seo-tag (~> 2.0)
jekyll-titles-from-headings (0.5.1)
jekyll (~> 3.3)
jekyll-watch (2.2.1)
listen (~> 3.0)
jemoji (0.10.2)
gemoji (~> 3.0)
html-pipeline (~> 2.2)
jekyll (~> 3.0)
kramdown (1.17.0)
liquid (4.0.0)
listen (3.1.5)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
ruby_dep (~> 1.2)
mercenary (0.3.6)
mini_portile2 (2.4.0)
minima (2.5.0)
jekyll (~> 3.5)
jekyll-feed (~> 0.9)
jekyll-seo-tag (~> 2.1)
minitest (5.11.3)
multipart-post (2.0.0)
nokogiri (1.10.2)
mini_portile2 (~> 2.4.0)
octokit (4.14.0)
sawyer (~> 0.8.0, >= 0.5.3)
pathutil (0.16.2)
forwardable-extended (~> 2.6)
public_suffix (3.0.3)
rb-fsevent (0.10.3)
rb-inotify (0.10.0)
ffi (~> 1.0)
rouge (2.2.1)
ruby-enum (0.7.2)
i18n
ruby_dep (1.5.0)
rubyzip (1.2.2)
safe_yaml (1.0.5)
sass (3.7.4)
sass-listen (~> 4.0.0)
sass-listen (4.0.0)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
sawyer (0.8.1)
addressable (>= 2.3.5, < 2.6)
faraday (~> 0.8, < 1.0)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
thread_safe (0.3.6)
typhoeus (1.3.1)
ethon (>= 0.9.0)
tzinfo (1.2.5)
thread_safe (~> 0.1)
unicode-display_width (1.5.0)
PLATFORMS
ruby
DEPENDENCIES
github-pages
BUNDLED WITH
2.0.1
+16 -127
View File
@@ -1,135 +1,24 @@
[![Downloads](https://img.shields.io/github/release/thomasnordquist/mqtt-explorer.svg)](https://github.com/thomasnordquist/MQTT-Explorer/releases)
[![Downloads](https://img.shields.io/github/downloads/thomasnordquist/mqtt-explorer/total.svg)](https://github.com/thomasnordquist/MQTT-Explorer/releases)
[![Build_Status](https://travis-ci.org/thomasnordquist/MQTT-Explorer.svg?branch=master)](https://travis-ci.org/thomasnordquist/MQTT-Explorer)
[![Build status](https://ci.appveyor.com/api/projects/status/c35tkm29rm4m5364/branch/master?svg=true)](https://ci.appveyor.com/project/thomasnordquist/mqtt-explorer/branch/master)
[![Codacy Badge](https://api.codacy.com/project/badge/Grade/47b26e03fce543ceac7914214482334a)](https://app.codacy.com/app/thomasnordquist/MQTT-Explorer?utm_source=github.com&utm_medium=referral&utm_content=thomasnordquist/MQTT-Explorer&utm_campaign=Badge_Grade_Dashboard)
<a href="#" onClick="a=window;a.location='ht'+'tps:'+'/'+'/pay'+'pal.me/'+'ThomasNordquist'"><img src="https://img.shields.io/badge/Sponsor-PayPal-Flat.svg?color=0070ba&logo=data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgICB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIKICAgIHZlcnNpb249IjEuMSIgYmFzZVByb2ZpbGU9ImZ1bGwiIHZpZXdCb3g9IjAgMCAyNCAzMiIgd2lkdGg9IjI0IiBoZWlnaHQ9IjMyIiA+CiAgICA8cGF0aCB0cmFuc2Zvcm09InNjYWxlKDIpIiBmaWxsPSIjZWE0YWFhIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05IDJjLS45NyAwLTEuNjkuNDItMi4yIDEtLjUxLjU4LS43OC45Mi0uOCAxLS4wMi0uMDgtLjI4LS40Mi0uOC0xLS41Mi0uNTgtMS4xNy0xLTIuMi0xLTEuNjMyLjA4Ni0yLjk1NCAxLjMzMy0zIDMgMCAuNTIuMDkgMS41Mi42NyAyLjY3QzEuMjUgOC44MiAzLjAxIDEwLjYxIDYgMTNjMi45OC0yLjM5IDQuNzctNC4xNyA1LjM0LTUuMzNDMTEuOTEgNi41MSAxMiA1LjUgMTIgNWMtLjA0Ny0xLjY5LTEuMzQyLTIuOTEzLTMtM3oiPjwvcGF0aD4KPC9zdmc+Cgo=" /></a>
# MQTT-Explorer
<div id="carouselExampleIndicators" class="carousel slide" data-ride="carousel" data-interval="2500">
<ol class="carousel-indicators">
<li data-target="#carouselExampleIndicators" data-slide-to="0" class="active"></li>
<li data-target="#carouselExampleIndicators" data-slide-to="1"></li>
<li data-target="#carouselExampleIndicators" data-slide-to="2"></li>
</ol>
<div class="carousel-inner">
<div class="carousel-item active">
<a href="./img/screen2.png"><img src="./img/screen2_small.png" /></a>
</div>
<div class="carousel-item">
<a href="./img/screen3.png"><img src="./img/screen3_small.png" /></a>
</div>
<div class="carousel-item">
<a href="./img/screen-composite.png"><img src="./img/screen-composite_small.png" /></a>
</div>
</div>
<a class="carousel-control-prev" href="#carouselExampleIndicators" role="button" data-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="sr-only">Previous</span>
</a>
<a class="carousel-control-next" href="#carouselExampleIndicators" role="button" data-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="sr-only">Next</span>
</a>
</div>
<span class="slider">
<a href="./img/screen2.png"><img src="./img/screen2_small.png" /></a>
<a href="./img/screen3.png"><img src="./img/screen3_small.png" /></a>
<a href="./img/screen-composite.png"><img src="./img/screen-composite_small.png" /></a>
</span>
## Version 0.4.0-beta.6
MQTT Explorer is a comprehensive MQTT client that provides a structured overview of your MQTT topics and makes working with devices/services on your broker dead-simple.
### Features
- Visualize topics and topic activity
- Delete retained topics
- Search/filter topics
- Delete topics recursively
- Diff view of current and previous received messages
- Publish topics
- Plot numeric topics
- Retain a history of each topic
- Dark/Light Themes
- ... [See Changelog to see all features](./Changelog)
The hierarchical view makes this tool so easy to use and differentiates the **MQTT Explorer** from other great MQTT clients like [MQTTLens](https://chrome.google.com/webstore/detail/mqttlens/hemojaaeigabkbcookmlgmdigohjobjm), [MQTTBox](http://workswithweb.com/mqttbox.html) and [MQTT.fx](https://mqttfx.jensd.de/).
This MQTT Client strives to be a MQTT swiss-army-knife, the perfect tool to integrate new services and IoT devices on your network.
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, Linux and Mac. [Downloads](https://github.com/thomasnordquist/MQTT-Explorer/releases)
Developing this tool takes a lot of effort, sweat and time, please consider rating the App on the Windows or Mac app store <img src="./res/star.svg" width="16" /><img src="./res/star.svg" width="16" /><img src="./res/star.svg" width="16" /><img src="./res/star.svg" width="16" /><img src="./res/star.svg" width="16" />.
If you feel like a feature is missing or you found a bug, please leave me a [comment / issue](https://github.com/thomasnordquist/MQTT-Explorer/issues) and I'll see what I can do.
| Platform | | Downloads |
|:----------:|:-------------:|:------:|
| ![windows](https://user-images.githubusercontent.com/7721625/51445407-b4172080-1d04-11e9-8c70-d8413d1d6d8b.png) | **Windows** | <a href="https://www.microsoft.com/store/apps/9PP8SFM082WD?ocid=badge"><img src="https://get.microsoft.com/images/en-us%20dark.svg" width="165" /></a><br />**[portable](https:&#x2F;&#x2F;github.com&#x2F;thomasnordquist&#x2F;MQTT-Explorer&#x2F;releases&#x2F;download&#x2F;v0.4.0-beta.6&#x2F;MQTT-Explorer-0.4.0-beta.6.exe), [installer](https:&#x2F;&#x2F;github.com&#x2F;thomasnordquist&#x2F;MQTT-Explorer&#x2F;releases&#x2F;download&#x2F;v0.4.0-beta.6&#x2F;MQTT-Explorer-Setup-0.4.0-beta.6.exe)** |
| ![mac](https://user-images.githubusercontent.com/7721625/51445390-921d9e00-1d04-11e9-8339-351469ef20ae.png) | **Mac** | <a href="https://itunes.apple.com/app/apple-store/id1455214828?pt=118225860&ct=mqtt-explorer.com&mt=8"><img src="https://developer.apple.com/app-store/marketing/guidelines/images/badge-download-on-the-mac-app-store.svg" height="40"/></a><br />**[Intel](https:&#x2F;&#x2F;github.com&#x2F;thomasnordquist&#x2F;MQTT-Explorer&#x2F;releases&#x2F;download&#x2F;v0.4.0-beta.6&#x2F;MQTT-Explorer-0.4.0-beta.6.dmg), [ARM](https:&#x2F;&#x2F;github.com&#x2F;thomasnordquist&#x2F;MQTT-Explorer&#x2F;releases&#x2F;download&#x2F;v0.4.0-beta.6&#x2F;MQTT-Explorer-0.4.0-beta.6-arm64.dmg)** <br /> *[How to start untrusted apps](https://support.apple.com/guide/mac-help/open-a-mac-app-from-an-unidentified-developer-mh40616/mac)*|
| ![ubuntu](https://user-images.githubusercontent.com/7721625/51445401-a5306e00-1d04-11e9-9b9b-20e196b82142.png) | **Ubuntu**<br />*debian, mint, neon, fedora, etc...* | <a href="https://snapcraft.io/mqtt-explorer" title="Get it from the Snap Store"><img src="https://snapcraft.io/static/images/badges/en/snap-store-black.svg" width="165" /></a><br />`snap install mqtt-explorer`<br />[Ubuntu Store](snap://mqtt-explorer) |
| ![linux](https://user-images.githubusercontent.com/7721625/51445392-947ff800-1d04-11e9-8c7f-a30efb755651.png) | **Linux**<br />*almost every linux* | **[AppImage](https:&#x2F;&#x2F;github.com&#x2F;thomasnordquist&#x2F;MQTT-Explorer&#x2F;releases&#x2F;download&#x2F;v0.4.0-beta.6&#x2F;MQTT-Explorer-0.4.0-beta.6.AppImage), [deb](https:&#x2F;&#x2F;github.com&#x2F;thomasnordquist&#x2F;MQTT-Explorer&#x2F;releases&#x2F;download&#x2F;v0.4.0-beta.6&#x2F;MQTT-Explorer_0.4.0-beta.6_amd64.deb)**<br />*Run AppImage:<br />Make it executable and double-click it.* |
[More Downloads](https://github.com/thomasnordquist/MQTT-Explorer/releases)
## Video
<div class="osx-frame">
<div class="titlebar">
<div class="buttons">
<div class="close"></div>
<div class="minimize"></div>
<div class="zoom"></div>
</div>
MQTT Explorer
</div>
<div class="content">
<video style="width: 100%" autoplay muted loop>
<source type="video/mp4" src="./video.mp4">
![screencast](https://user-images.githubusercontent.com/7721625/53954172-b7f4db80-40d5-11e9-852c-f8d85e511a00.gif)
</video></div>
</div>
<br>
## Performance
This MQTT Client is optimized to handle thousands of topics and at hundreds of thousands messages per minute.
Custom subscriptions can limit the amount of messages **MQTT Explorer** needs to process, subscriptions can be managed in the advanced connection settings.
In very large productive environments brokers may handle an extreme load of topics, subscribing with a wildcard topic is in this scenario not advised.
## IoT Applications
List of useful IoT applications using MQTT to integrate devices / services
- [Home Assistant](https://www.home-assistant.io/) - Open source home automation gateway
- [OpenHAB](https://www.openhab.org/) - Smart Home Gateway
- [Node-RED](https://nodered.org/) - Flow-based programming for the Internet of Things
- [zigbee2mqtt](https://github.com/Koenkk/zigbee2mqtt) - A ZigBee to MQTT bridge
- [Tasmota](https://github.com/arendst/Sonoff-Tasmota) - ESP8266 firmware with MQTT support
## Feedback
<form onsubmit="if(!this.elements.noshow.value){ this.action='https://docs.google.com/forms/d/e/1FAIpQLSePeIHoZxpbB_-ku4H97go8JPhfdrlxNc-nHPGQuWB6g-U3HQ/formResponse?embedded=true' }">
### What do you like?
<textarea style="width: 100%; border-radius: 5px; box-shadow: 5px 5px;" name="entry.1944371594"></textarea>
### What can be improved?
<textarea style="width: 100%; border-radius: 5px; box-shadow: 5px 5px;" name="entry.22814098"></textarea>
### Email (optional)
<span style="display: flex"><input style="padding: 8px; flex: 2; border-radius: 5px; box-shadow: 5px 5px; border: 1px solid" type="email" name="entry.2055444782" /><button type="submit" style="padding: 8px; margin-left: 16px; border: 1px solid #2879d0; border-radius: 5px; background-color: #eee; box-shadow: 5px 5px #ccc; text-align: right; color: #2879d0;">Submit</button></span>
<input class="noshow" name="noshow" value="">
</form>
![screenshot](https://user-images.githubusercontent.com/7721625/51086078-340c1c00-1742-11e9-83a9-07f932055467.png)
<div style="text-align: center; width: 100%">
Ideas and bugs can also be reported on [github](https://github.com/thomasnordquist/MQTT-Explorer/issues).
</div>
## Develop
PRs and issues are welcome
## Telemetry
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 licens exactly, but the basic idea is: "You may do whatever you want with this tool, except sell it."
App telemetry has been removed, we rely on your bug reports to [GitHub issues](https://github.com/thomasnordquist/MQTT-Explorer/issues)
-135
View File
@@ -1,135 +0,0 @@
[![Downloads](https://img.shields.io/github/release/thomasnordquist/mqtt-explorer.svg)](https://github.com/thomasnordquist/MQTT-Explorer/releases)
[![Downloads](https://img.shields.io/github/downloads/thomasnordquist/mqtt-explorer/total.svg)](https://github.com/thomasnordquist/MQTT-Explorer/releases)
[![Build_Status](https://travis-ci.org/thomasnordquist/MQTT-Explorer.svg?branch=master)](https://travis-ci.org/thomasnordquist/MQTT-Explorer)
[![Build status](https://ci.appveyor.com/api/projects/status/c35tkm29rm4m5364/branch/master?svg=true)](https://ci.appveyor.com/project/thomasnordquist/mqtt-explorer/branch/master)
[![Codacy Badge](https://api.codacy.com/project/badge/Grade/47b26e03fce543ceac7914214482334a)](https://app.codacy.com/app/thomasnordquist/MQTT-Explorer?utm_source=github.com&utm_medium=referral&utm_content=thomasnordquist/MQTT-Explorer&utm_campaign=Badge_Grade_Dashboard)
<a href="#" onClick="a=window;a.location='ht'+'tps:'+'/'+'/pay'+'pal.me/'+'ThomasNordquist'"><img src="https://img.shields.io/badge/Sponsor-PayPal-Flat.svg?color=0070ba&logo=data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgICB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIKICAgIHZlcnNpb249IjEuMSIgYmFzZVByb2ZpbGU9ImZ1bGwiIHZpZXdCb3g9IjAgMCAyNCAzMiIgd2lkdGg9IjI0IiBoZWlnaHQ9IjMyIiA+CiAgICA8cGF0aCB0cmFuc2Zvcm09InNjYWxlKDIpIiBmaWxsPSIjZWE0YWFhIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05IDJjLS45NyAwLTEuNjkuNDItMi4yIDEtLjUxLjU4LS43OC45Mi0uOCAxLS4wMi0uMDgtLjI4LS40Mi0uOC0xLS41Mi0uNTgtMS4xNy0xLTIuMi0xLTEuNjMyLjA4Ni0yLjk1NCAxLjMzMy0zIDMgMCAuNTIuMDkgMS41Mi42NyAyLjY3QzEuMjUgOC44MiAzLjAxIDEwLjYxIDYgMTNjMi45OC0yLjM5IDQuNzctNC4xNyA1LjM0LTUuMzNDMTEuOTEgNi41MSAxMiA1LjUgMTIgNWMtLjA0Ny0xLjY5LTEuMzQyLTIuOTEzLTMtM3oiPjwvcGF0aD4KPC9zdmc+Cgo=" /></a>
<div id="carouselExampleIndicators" class="carousel slide" data-ride="carousel" data-interval="2500">
<ol class="carousel-indicators">
<li data-target="#carouselExampleIndicators" data-slide-to="0" class="active"></li>
<li data-target="#carouselExampleIndicators" data-slide-to="1"></li>
<li data-target="#carouselExampleIndicators" data-slide-to="2"></li>
</ol>
<div class="carousel-inner">
<div class="carousel-item active">
<a href="./img/screen2.png"><img src="./img/screen2_small.png" /></a>
</div>
<div class="carousel-item">
<a href="./img/screen3.png"><img src="./img/screen3_small.png" /></a>
</div>
<div class="carousel-item">
<a href="./img/screen-composite.png"><img src="./img/screen-composite_small.png" /></a>
</div>
</div>
<a class="carousel-control-prev" href="#carouselExampleIndicators" role="button" data-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="sr-only">Previous</span>
</a>
<a class="carousel-control-next" href="#carouselExampleIndicators" role="button" data-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="sr-only">Next</span>
</a>
</div>
<span class="slider">
<a href="./img/screen2.png"><img src="./img/screen2_small.png" /></a>
<a href="./img/screen3.png"><img src="./img/screen3_small.png" /></a>
<a href="./img/screen-composite.png"><img src="./img/screen-composite_small.png" /></a>
</span>
## Version {{ version }}
MQTT Explorer is a comprehensive MQTT client that provides a structured overview of your MQTT topics and makes working with devices/services on your broker dead-simple.
### Features
- Visualize topics and topic activity
- Delete retained topics
- Search/filter topics
- Delete topics recursively
- Diff view of current and previous received messages
- Publish topics
- Plot numeric topics
- Retain a history of each topic
- Dark/Light Themes
- ... [See Changelog to see all features](./Changelog)
The hierarchical view makes this tool so easy to use and differentiates the **MQTT Explorer** from other great MQTT clients like [MQTTLens](https://chrome.google.com/webstore/detail/mqttlens/hemojaaeigabkbcookmlgmdigohjobjm), [MQTTBox](http://workswithweb.com/mqttbox.html) and [MQTT.fx](https://mqttfx.jensd.de/).
This MQTT Client strives to be a MQTT swiss-army-knife, the perfect tool to integrate new services and IoT devices on your network.
## Download
Developing this tool takes a lot of effort, sweat and time, please consider rating the App on the Windows or Mac app store <img src="./res/star.svg" width="16" /><img src="./res/star.svg" width="16" /><img src="./res/star.svg" width="16" /><img src="./res/star.svg" width="16" /><img src="./res/star.svg" width="16" />.
If you feel like a feature is missing or you found a bug, please leave me a [comment / issue](https://github.com/thomasnordquist/MQTT-Explorer/issues) and I'll see what I can do.
| Platform | | Downloads |
|:----------:|:-------------:|:------:|
| ![windows](https://user-images.githubusercontent.com/7721625/51445407-b4172080-1d04-11e9-8c70-d8413d1d6d8b.png) | **Windows** | <a href="https://www.microsoft.com/store/apps/9PP8SFM082WD?ocid=badge"><img src="https://get.microsoft.com/images/en-us%20dark.svg" width="165" /></a><br />**{{windowsTargets}}** |
| ![mac](https://user-images.githubusercontent.com/7721625/51445390-921d9e00-1d04-11e9-8339-351469ef20ae.png) | **Mac** | <a href="https://itunes.apple.com/app/apple-store/id1455214828?pt=118225860&ct=mqtt-explorer.com&mt=8"><img src="https://developer.apple.com/app-store/marketing/guidelines/images/badge-download-on-the-mac-app-store.svg" height="40"/></a><br />**{{macTargets}}** <br /> *[How to start untrusted apps](https://support.apple.com/guide/mac-help/open-a-mac-app-from-an-unidentified-developer-mh40616/mac)*|
| ![ubuntu](https://user-images.githubusercontent.com/7721625/51445401-a5306e00-1d04-11e9-9b9b-20e196b82142.png) | **Ubuntu**<br />*debian, mint, neon, fedora, etc...* | <a href="https://snapcraft.io/mqtt-explorer" title="Get it from the Snap Store"><img src="https://snapcraft.io/static/images/badges/en/snap-store-black.svg" width="165" /></a><br />`snap install mqtt-explorer`<br />[Ubuntu Store](snap://mqtt-explorer) |
| ![linux](https://user-images.githubusercontent.com/7721625/51445392-947ff800-1d04-11e9-8c7f-a30efb755651.png) | **Linux**<br />*almost every linux* | **{{linuxTargets}}**<br />*Run AppImage:<br />Make it executable and double-click it.* |
[More Downloads](https://github.com/thomasnordquist/MQTT-Explorer/releases)
## Video
<div class="osx-frame">
<div class="titlebar">
<div class="buttons">
<div class="close"></div>
<div class="minimize"></div>
<div class="zoom"></div>
</div>
MQTT Explorer
</div>
<div class="content">
<video style="width: 100%" autoplay muted loop>
<source type="video/mp4" src="./video.mp4">
![screencast](https://user-images.githubusercontent.com/7721625/53954172-b7f4db80-40d5-11e9-852c-f8d85e511a00.gif)
</video></div>
</div>
<br>
## Performance
This MQTT Client is optimized to handle thousands of topics and at hundreds of thousands messages per minute.
Custom subscriptions can limit the amount of messages **MQTT Explorer** needs to process, subscriptions can be managed in the advanced connection settings.
In very large productive environments brokers may handle an extreme load of topics, subscribing with a wildcard topic is in this scenario not advised.
## IoT Applications
List of useful IoT applications using MQTT to integrate devices / services
- [Home Assistant](https://www.home-assistant.io/) - Open source home automation gateway
- [OpenHAB](https://www.openhab.org/) - Smart Home Gateway
- [Node-RED](https://nodered.org/) - Flow-based programming for the Internet of Things
- [zigbee2mqtt](https://github.com/Koenkk/zigbee2mqtt) - A ZigBee to MQTT bridge
- [Tasmota](https://github.com/arendst/Sonoff-Tasmota) - ESP8266 firmware with MQTT support
## Feedback
<form onsubmit="if(!this.elements.noshow.value){ this.action='https://docs.google.com/forms/d/e/1FAIpQLSePeIHoZxpbB_-ku4H97go8JPhfdrlxNc-nHPGQuWB6g-U3HQ/formResponse?embedded=true' }">
### What do you like?
<textarea style="width: 100%; border-radius: 5px; box-shadow: 5px 5px;" name="entry.1944371594"></textarea>
### What can be improved?
<textarea style="width: 100%; border-radius: 5px; box-shadow: 5px 5px;" name="entry.22814098"></textarea>
### Email (optional)
<span style="display: flex"><input style="padding: 8px; flex: 2; border-radius: 5px; box-shadow: 5px 5px; border: 1px solid" type="email" name="entry.2055444782" /><button type="submit" style="padding: 8px; margin-left: 16px; border: 1px solid #2879d0; border-radius: 5px; background-color: #eee; box-shadow: 5px 5px #ccc; text-align: right; color: #2879d0;">Submit</button></span>
<input class="noshow" name="noshow" value="">
</form>
<div style="text-align: center; width: 100%">
Ideas and bugs can also be reported on [github](https://github.com/thomasnordquist/MQTT-Explorer/issues).
</div>
## Telemetry
App telemetry has been removed, we rely on your bug reports to [GitHub issues](https://github.com/thomasnordquist/MQTT-Explorer/issues)
-15
View File
@@ -1,15 +0,0 @@
theme: jekyll-theme-architect
title: MQTT Explorer
description: An all-round MQTT client that provides a structured topic overview
author:
name: Thomas Nordquist
picture: https://avatars1.githubusercontent.com/u/7721625?s=460&v=4
github_username: thomasnordquist
logo: https://user-images.githubusercontent.com/7721625/52227945-5280b580-28b1-11e9-804c-75a842537393.png
plugins:
- jekyll-seo-tag
google_analytics: UA-133696837-1
markdown: kramdown
kramdown:
parse_block_html: true
-192
View File
@@ -1,192 +0,0 @@
<!DOCTYPE html>
<html lang="{{ site.lang | default: "en-US" }}">
<head>
<meta charset='utf-8'>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<script
src="https://code.jquery.com/jquery-3.4.1.slim.min.js"
integrity="sha256-pasqAKBDmFT4eHoN2ndd6lN370kFiGUFyTiUHWhU7k8="
crossorigin="anonymous"></script>
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
<link rel="stylesheet" href="{{ '/assets/css/style.css?v=' | append: site.github.build_revision | relative_url }}" media="screen" type="text/css">
<link rel="stylesheet" href="{{ '/assets/css/print.css' | relative_url }}" media="print" type="text/css">
<link rel="stylesheet" href="./osx-frame.css">
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
<![endif]-->
<style>
.noshow {display: none}
@media (min-width: 768px) {
.slider {
display: flex;
}
.carousel {
display: none;
}
}
@media (max-width: 768px) {
.slider {
display: none;
}
.carousel {
display: block;
}
}
form>h3::before {
content: '' !important;
margin-left: 0 !important;
}
.carousel-control-next-icon, .carousel-control-prev-icon {
display: inline-block;
width: 32px;
height: 32px;
background-size: 70%;
background-repeat: no-repeat;
background-color: grey;
border-radius: 5px;
}
.inner img {
border-radius: 8px;
}
.slider a {
padding: 4px;
}
.carousel-item, .slider img {
box-shadow: 0px 0px 5px #6b6b6b;
}
</style>
{% seo %}
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-TRN5K6H');</script>
<!-- End Google Tag Manager -->
<script>
function trackConversion(url) {
const conversions = {
'snap://mqtt-explorer': 'snap',
'https://snapcraft.io/mqtt-explorer': 'snap',
'https://geo.itunes.apple.com/us/app/mqtt-explorer/id1455214828?mt=12&app=apps': 'Mac App Store',
'https://www.microsoft.com/store/apps/9PP8SFM082WD?ocid=badge': 'Microsoft Store',
'https://github.com/thomasnordquist/MQTT-Explorer/releases/download/': 'binary download'
}
const conversion = Object.keys(conversions).find(function(conversionUrlPrefix) { return url.startsWith(conversionUrlPrefix) })
if (conversion) {
ga('send', 'event', 'outbound', 'download', conversions[conversion], {
'transport': 'beacon',
'hitCallback': function(){ document.location = url; }
});
} else {
ga('send', 'event', 'outbound', 'click', url, {
'transport': 'beacon',
'hitCallback': function(){ document.location = url; }
});
}
}
function didLoad() {
if (!Array.prototype.filter || !Array.prototype.find || !String.prototype.startsWith) {
return
}
Object.values(document.getElementsByTagName('a'))
.filter(function(a) { return !a.href.startsWith(a.baseURI)})
.forEach(function(a) { a.onclick = function() { trackConversion(a.href) }})
}
</script>
</head>
<body onload="didLoad()">
<header>
<div class="inner">
<a href="{{ '/' | absolute_url }}">
<h1 style="display: inline">
<img src="./icon.png" style="
height: 1em;
display: inline;
vertical-align: top;
margin-top: -0.04em;
"></img>
{{ site.title | default: site.github.repository_name }}
</h1>
</a>
<h2>{{ site.description | default: site.github.project_tagline }}</h2>
{% if site.github.is_project_page %}
<a href="{{ site.github.repository_url }}" style="box-sizing: initial" class="button"><small>View project on</small> GitHub</a>
{% endif %}
{% if site.github.is_user_page %}
<a href="{{ site.github.owner_url }}" class="button"><small>Follow me on</small> GitHub</a>
{% endif %}
</div>
</header>
<div id="content-wrapper">
<div class="inner clearfix">
<section id="main-content">
{{ content }}
</section>
<aside id="sidebar">
{% if site.show_downloads %}
<a href="{{ site.github.zip_url }}" class="button">
<small>Download</small>
.zip file
</a>
<a href="{{ site.github.tar_url }}" class="button">
<small>Download</small>
.tar.gz file
</a>
{% endif %}
{% if site.github.is_project_page %}
<h2>Author</h2>
<img src="{{ site.author.picture }}" width="200" />
<h3>{{ site.author.name }}</h3>
<p class="repo-owner"><a href="{{ site.github.owner_url }}">{{ site.github.owner_name }}</a></p>
{% endif %}
<br />
<h3>In the media</h3>
<blockquote class="twitter-tweet" data-width="200">
<p lang="en" dir="ltr">If youre seeking a comprehensive and easy to use
<a href="https://twitter.com/hashtag/MQTT?src=hash&amp;ref_src=twsrc%5Etfw">#MQTT</a> client for
<a href="https://twitter.com/hashtag/Ubuntu?src=hash&amp;ref_src=twsrc%5Etfw">#Ubuntu</a> then look no further than MQTT Explorer.
<br>
<br>snap install mqtt-explorer
<a href="https://t.co/1nd0Z8VQeR">https://t.co/1nd0Z8VQeR</a>
<a href="https://t.co/8XKenpQu0p">pic.twitter.com/8XKenpQu0p</a>
</p>
&mdash; Ubuntu (@ubuntu)
<a href="https://twitter.com/ubuntu/status/1125747632910077953?ref_src=twsrc%5Etfw">May 7, 2019</a>
</blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
</aside>
</div>
</div>
{% if site.google_analytics %}
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
ga('create', '{{ site.google_analytics }}', 'auto');
ga('send', 'pageview');
</script>
{% endif %}
</body>
</html>
-3
View File
@@ -1,3 +0,0 @@
require 'webrick'
include WEBrick
WEBrick::HTTPUtils::DefaultMimeTypes.store 'mp4', 'video/mpeg'
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>MQTT-Explorer</title>
<style>
body, html {
margin: 0;
padding: 0;
}
@keyframes example {
0% {background-color: inherit;}
25% {background-color: #3f51b5;}
50% {background-color: #3f51b5;}
100% {background-color: inherit;}
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-corner {
background-color: rgba(0, 0, 0, 0.0);
}
::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 6px rgba(30,30,30,0.3);
}
::-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;}
}
</style>
<script src="http://localhost:35729/livereload.js"></script>
</head>
<body>
<div id="splash"><div id="splash1"></div>
<!-- <div id="splash1">
<div id="splash2" style="color: #ddd" class="la-square-jelly-box la-3x">
<div>a</div>
<div>ads</div>
</div> -->
</div>
</div>
<div id="app" style="font:-webkit-control"></div>
<script src="./build/bundle.js"></script>
</body>
</html>
+6279
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "mqtt-explorer-app",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "webpack --mode production"
},
"author": "",
"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",
"electron-nucleus": "^1.11.0",
"react": "^16.7.0",
"react-dom": "^16.7.0",
"react-redux": "^6.0.0",
"redux": "^4.0.1",
"webpack-livereload-plugin": "^2.2.0"
},
"dependencies": {
"@material-ui/core": "^3.7.1",
"@types/node": "^10.12.18",
"@types/sha1": "^1.1.1",
"@types/socket.io-client": "^1.4.32",
"@types/vis": "^4.21.9",
"awesome-typescript-loader": "^5.2.1",
"copy-text-to-clipboard": "^1.0.4",
"jquery": "^3.3.1",
"lodash.throttle": "^4.1.1",
"moving-average": "^1.0.0",
"react-ace": "^6.3.2",
"react-json-view": "^1.19.1",
"sha1": "^1.1.1",
"socket.io-client": "^2.2.0",
"source-map-loader": "^0.2.4",
"typescript": "^3.2.2",
"webpack": "^4.28.2",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.14"
}
}
+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

+115
View File
@@ -0,0 +1,115 @@
import * as React from 'react'
import * as q from '../../backend/src/Model'
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'
import Settings from './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'
interface State {
selectedNode?: q.TreeNode,
connectionId?: string
}
interface Props {
name: string
theme: Theme
settingsVisible: boolean
}
class App extends React.Component<Props, State> {
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,
},
}
}
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 })
}} />
</div>
<div style={this.getStyles().right}>
<Sidebar connectionId={this.state.connectionId} />
</div>
</div>
</div>
<UpdateNotifier />
<Connection onConnection={(connectionId: string) => this.setState({ connectionId })}/>
</div >
)
}
}
const mapStateToProps = (state: AppState) => {
return {
settingsVisible: state.settings.visible,
}
}
export default withStyles({}, { withTheme: true })(connect(mapStateToProps)(App))
+219
View File
@@ -0,0 +1,219 @@
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 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) {
console.log(updateInfo)
return updateInfo.files
.map((file: UpdateFileInfo, index: number) => (
<div key={index}>
<Button
className={this.props.classes.download}
href={file.url}
>
<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.showUpdateNotification,
showUpdateDetails: state.showUpdateDetails,
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(updateNotifierActions, dispatch),
}
}
export default withStyles(styles, { withTheme: true })(connect(mapStateToProps, mapDispatchToProps)(UpdateNotifier))
+21
View File
@@ -0,0 +1,21 @@
import { ActionTypes, NodeOrder, CustomAction } from '../reducers'
export const setAutoExpandLimit = (autoExpandLimit: number = 0): CustomAction => {
return {
autoExpandLimit,
type: ActionTypes.setAutoExpandLimit,
}
}
export const toggleSettingsVisibility = (): CustomAction => {
return {
type: ActionTypes.toggleSettingsVisibility,
}
}
export const setNodeOrder = (nodeOrder: NodeOrder = NodeOrder.none): CustomAction => {
return {
nodeOrder,
type: ActionTypes.setNodeOrder,
}
}
+15
View File
@@ -0,0 +1,15 @@
import { ActionTypes, CustomAction } from '../reducers'
export const setPublishTopic = (topic: string): CustomAction => {
return {
publishTopic: topic,
type: ActionTypes.setPublishTopic,
}
}
export const setPublishPayload = (payload: string): CustomAction => {
return {
publishPayload: payload,
type: ActionTypes.setPublishPayload,
}
}
+9
View File
@@ -0,0 +1,9 @@
import { ActionTypes, CustomAction } from '../reducers'
import * as q from '../../../backend/src/Model'
export const selectTopic = (topic: q.TreeNode): CustomAction => {
return {
selectedTopic: topic,
type: ActionTypes.selectTopic,
}
}
+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,
}
}
+6
View File
@@ -0,0 +1,6 @@
import * as settingsActions from './Settings'
import * as sidebarActions from './Sidebar'
import * as treeActions from './Tree'
import * as updateNotifierActions from './UpdateNotifier'
export { settingsActions, treeActions, sidebarActions, updateNotifierActions }
@@ -0,0 +1,292 @@
import * as React from 'react'
import {
Button,
CircularProgress,
FormControlLabel,
Grid,
MenuItem,
Modal,
Paper,
Switch,
TextField,
Toolbar,
Typography,
} from '@material-ui/core'
import { DataSourceState, MqttOptions } from '../../../../backend/src/DataSource'
import { StyleRulesCallback, Theme, withStyles } from '@material-ui/core/styles'
import { addMqttConnectionEvent, makeConnectionStateEvent, removeConnection, rendererEvents } from '../../../../events'
import sha1 = require('sha1')
interface Props {
classes: {[s: string]: string}
theme: Theme
onAbort: () => void,
onConnection: (connectionId: string) => void
}
const protocols = [
'mqtt://',
'ws://',
]
interface State {
connecting: boolean
connectionId?: string
error?: Error
visible: boolean
host: string
protocol: string
port: number
tls: boolean
certValidation: boolean
clientId: string
username: string
password: string
}
declare var window: any
class Connection extends React.Component<Props, State> {
constructor(props: any) {
super(props)
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: 'iot.eclipse.org',
protocol: protocols[0],
port: 1883,
tls: false,
certValidation: true,
clientId: '',
username: '',
password: '',
connecting: false,
connectionId: undefined,
}
this.state = Object.assign({}, defaultState, storedSettings)
}
private saveConnectionSettings() {
window.localStorage.setItem('connectionSettings', JSON.stringify(this.state))
}
private optionsFromState(): MqttOptions {
const protocol = this.state.protocol === 'tcp://' ? 'mqtt://' : this.state.protocol
const url = `${protocol}${this.state.host}:${this.state.port}`
return {
url,
username: this.state.username || undefined,
password: this.state.username || undefined,
tls: this.state.tls,
certValidation: this.state.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: {
minWidth: 550,
maxWidth: 650,
backgroundColor: theme.palette.background.default,
margin: '14vh auto auto auto',
padding: `${2 * theme.spacing.unit}px`,
outline: 'none',
},
title: {
color: theme.palette.text.primary,
},
paper: {
padding: theme.spacing.unit * 2,
textAlign: 'center',
color: theme.palette.text.secondary,
},
textField: {
width: '100%',
},
switch: {
marginTop: `${1 * theme.spacing.unit}px`,
},
button: {
margin: theme.spacing.unit,
},
}
}
private handleChange = (name: string) => (event: any) => {
const state: any = {
[name]: event.target.value,
}
this.setState(state)
}
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"
/>
)}
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"
/>
)}
label="Encryption (tls)"
labelPlacement="bottom"
/>
</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>
}
private renderConnectButton() {
const { classes } = this.props
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 <Button variant="contained" color="primary" className={classes.button} onClick={this.onClickConnect}>
Connect
</Button>
}
private onClickConnect = () => {
this.connect()
}
private onClickAbort = () => {
this.disconnect()
}
}
export default withStyles(Connection.styles, { withTheme: true })(Connection)
+70
View File
@@ -0,0 +1,70 @@
import * as React from 'react'
import { Snackbar, SnackbarContent } from '@material-ui/core'
import FileCopy from '@material-ui/icons/FileCopy'
import Check from '@material-ui/icons/Check'
import green from '@material-ui/core/colors/green'
import { withStyles, Theme } from '@material-ui/core/styles'
const copy = require('copy-text-to-clipboard')
interface Props {
value: string
classes: any
}
interface State {
didCopy: boolean
snackBarOpen: boolean
}
const styles = (theme: Theme) => ({
snackbar: {
backgroundColor: green[600],
color: theme.typography.button.color,
},
})
class Copy extends React.Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { didCopy: false, snackBarOpen: false }
}
public render() {
const icon = !this.state.didCopy
? <FileCopy fontSize="inherit" style={{ cursor: 'pointer' }} onClick={this.handleClick} />
: <Check fontSize="inherit" style={{ cursor: 'default' }} />
return <span>
<span style={{ fontSize: '16px' }}>{icon}</span>
<span>
<Snackbar
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
open={this.state.snackBarOpen}
autoHideDuration={2000}
onClose={() => { this.setState({ snackBarOpen: false }) }}
>
<SnackbarContent
className={this.props.classes.snackbar}
message="Copied to clipboard"
/>
</Snackbar>
</span>
</span>
}
private handleClick = (event: React.MouseEvent) => {
event.stopPropagation()
copy(this.props.value)
this.setState({ didCopy: true, snackBarOpen: true })
setTimeout(() => {
this.setState({ didCopy: false })
}, 1500)
}
}
export default withStyles(styles)(Copy)
+153
View File
@@ -0,0 +1,153 @@
import * as React from 'react'
import * as q from '../../../backend/src/Model'
import { AppState, NodeOrder } 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 { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { settingsActions } from '../actions'
const styles: StyleRulesCallback = theme => ({
drawer: {
backgroundColor: theme.palette.background.default,
flexShrink: 0,
},
paper: {
width: '300px',
},
title: {
color: theme.palette.text.primary,
paddingTop: `${theme.spacing.unit}px`,
...theme.mixins.toolbar,
},
input: {
minWidth: '150px',
margin: `auto ${theme.spacing.unit}px auto ${2 * theme.spacing.unit}px`,
},
})
interface Props {
actions?: any
autoExpandLimit: number
visible: boolean
store?: any
classes: any
nodeOrder: NodeOrder
}
class Settings extends React.Component<Props, {}> {
constructor(props: any) {
super(props)
this.state = {}
}
public render() {
const { classes, actions, visible } = this.props
return (
<Drawer
anchor="left"
className={classes.drawer}
open={visible}
variant="persistent"
>
<div
className={classes.paper}
tabIndex={0}
role="button"
>
<Typography className={classes.title} variant="h6" color="inherit">
<IconButton onClick={actions.toggleSettingsVisibility}>
<ChevronRight />
</IconButton>
Settings
</Typography>
<Divider />
{this.renderAutoExpand()}
{this.renderNodeOrder()}
</div>
</Drawer>
)
}
private renderAutoExpand() {
const { classes, autoExpandLimit } = this.props
return (
<div style={{ padding: '8px' }}>
<InputLabel htmlFor="auto-expand">Auto Expand</InputLabel>
<Select
value={autoExpandLimit}
onChange={this.onChangeAutoExpand}
input={<Input name="auto-expand" id="auto-expand-label-placeholder" />}
name="auto-expand"
className={classes.input}
>
<MenuItem value={0}><em>Collapsed</em></MenuItem>
<MenuItem value={2}>Few</MenuItem>
<MenuItem value={3}>Some</MenuItem>
<MenuItem value={10}>Most</MenuItem>
<MenuItem value={1E6}>All</MenuItem>
</Select>
</div>
)
}
private onChangeAutoExpand = (e: React.ChangeEvent<HTMLSelectElement>) => {
this.props.actions.setAutoExpandLimit(e.target.value)
}
private renderNodeOrder() {
const { classes, nodeOrder } = this.props
return (
<div style={{ padding: '8px' }}>
<InputLabel htmlFor="auto-expand">Topic order</InputLabel>
<Select
value={nodeOrder}
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>
</div>)
}
private onChangeSorting = (e: React.ChangeEvent<HTMLSelectElement>) => {
this.props.actions.setNodeOrder(e.target.value)
}
}
const mapStateToProps = (state: AppState) => {
return {
autoExpandLimit: state.settings.autoExpandLimit,
nodeOrder: state.settings.nodeOrder,
visible: state.settings.visible,
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(settingsActions, dispatch),
}
}
export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(Settings))
+89
View File
@@ -0,0 +1,89 @@
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'
interface HistoryItem {
title: string
value: string
}
interface Props {
items: HistoryItem[]
onClick?: (index: number, element: EventTarget) => void
classes: any
}
interface State {
collapsed: boolean
}
class MessageHistory extends React.Component<Props, State> {
constructor(props: any) {
super(props)
this.state = {
collapsed: true,
}
}
public renderHistory() {
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',
}}
onClick={(event: React.MouseEvent) => this.props.onClick && this.props.onClick(index, event.target)}
>
<div><i>{element.title}</i></div>
<div style={messageStyle}>
<span><pre>{element.value}</pre></span>
</div>
</div>
))
return (
<div style={{ backgroundColor: 'rgba(60, 60, 60, 0.6)', marginTop: '16px' }}>
<Typography
onClick={this.toggle}
style={{ cursor: 'pointer' }}
>
{this.state.collapsed ? '▶' : '▼'} History
</Typography>
<div style={{ maxHeight: '230px', overflowY: 'scroll' }}>
{this.state.collapsed ? null : elements}
</div>
</div>
)
}
public render() {
const visible = this.props.items.length > 0 && this.state.collapsed
return (
<Badge
style={{ display: 'block', width: '100%' }}
invisible={!visible}
badgeContent={this.props.items.length}
color="primary"
classes={{ badge: this.props.classes.badge }}
>
{this.renderHistory()}
</Badge>
)
}
private toggle = () => {
this.setState({ collapsed: !this.state.collapsed })
}
}
const styles = (theme: Theme) => ({
badge: { top: '-8px', left:'64px' },
})
export default withStyles(styles)(MessageHistory)
@@ -0,0 +1,74 @@
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 History from './History'
import Paper from '@material-ui/core/Paper'
import Popper from '@material-ui/core/Popper'
import ValueRenderer from './ValueRenderer'
interface Props {
node?: q.TreeNode
theme: Theme
onSelect: (message: q.Message) => void
}
interface State {
displayMessage?: q.Message,
anchorEl?: HTMLElement
}
class MessageHistory extends React.Component<Props, State> {
constructor(props: any) {
super(props)
this.state = { }
}
private updateNode = () => {
this.setState(this.state)
}
public componentWillReceiveProps(nextProps: Props) {
this.props.node && this.props.node.onMessage.unsubscribe(this.updateNode)
nextProps.node && nextProps.node.onMessage.subscribe(this.updateNode)
}
public componentWillMount() {
this.props.node && this.props.node.onMessage.subscribe(this.updateNode)
}
public componentWillUnMount() {
this.props.node && this.props.node.onMessage.unsubscribe(this.updateNode)
}
public render() {
const { node } = this.props
if (!node) {
return null
}
const history = node.messageHistory.toArray()
const historyElements = history.map(message => ({
title: message.received.toGMTString(),
value: message.value,
}))
return (
<div>
<History
items={historyElements}
onClick={this.displayMessage}
/>
</div>
)
}
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)
+27
View File
@@ -0,0 +1,27 @@
import * as React from 'react'
import * as q from '../../../../backend/src/Model'
import { Typography } from '@material-ui/core'
interface Props {
node: q.TreeNode
}
class NodeStats extends React.Component<Props, {}> {
constructor(props: any) {
super(props)
}
public render() {
const { node } = this.props
return (
<div>
<Typography>Messages: #{node.messages}</Typography>
<Typography>Subtopics: {node.leafCount()}</Typography>
<Typography>Messages Subtopics: #{node.leafMessageCount()}</Typography>
</div>
)
}
}
export default NodeStats
@@ -0,0 +1,7 @@
interface Message {
topic: string
payload?: string
sent: Date
}
export default Message
@@ -0,0 +1,234 @@
// tslint:disable-next-line
import 'react-ace'
import 'brace/mode/json'
import 'brace/mode/text'
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,
} from '@material-ui/core'
import { makePublishEvent, rendererEvents } from '../../../../../events'
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 { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { sidebarActions } from '../../../actions'
interface Props {
node?: q.TreeNode
connectionId?: string
topic?: string
payload?: string
actions: any
}
interface State {
mode: string
history: Message[]
}
class Publish extends React.Component<Props, State> {
constructor(props: any) {
super(props)
this.state = { mode: 'json', history: [] }
}
private updatePayload = (value: string, event?: any) => {
this.props.actions.setPublishPayload(value)
}
private updateTopic = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value)
this.props.actions.setPublishTopic(e.target.value)
}
private updateMode = (e: React.ChangeEvent<{}>, value: string) => {
this.setState({ mode: value })
}
private publish = (e: React.MouseEvent) => {
e.stopPropagation()
const topic = this.currentTopic() || ''
const payload = this.props.payload
if (this.props.connectionId && topic) {
rendererEvents.emit(makePublishEvent(this.props.connectionId), { topic, payload })
this.addMessageToHistory(topic, payload)
}
}
private addMessageToHistory(topic: string, payload?: string) {
// Remove duplicates
let filteredHistory = this.state.history.filter(e => e.payload !== payload || e.topic !== topic)
filteredHistory = filteredHistory.slice(-7)
const history: Message[] = [...filteredHistory, { topic, payload, sent: new Date() }]
this.setState({ history })
}
public render() {
return (
<div style={{ flexGrow: 1, marginLeft: '8px' }}>
{this.topic()}
{this.editor()}
{this.history()}
</div>
)
}
private currentTopic(): string | undefined {
const { node, topic } = this.props
const selectedNodePath = (node ? node.path() : undefined)
return (topic !== undefined) ? topic : selectedNodePath
}
private topic() {
const topicStr = this.currentTopic() || ''
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 }}
/>
</div>
)
}
private onTopicBlur = (e: React.FocusEvent<HTMLInputElement>) => {
if (!e.target.value) {
this.props.actions.setPublishTopic(undefined)
}
}
private editorOptions = {
showLineNumbers: false,
tabSize: 2,
}
private publishButton() {
return (
<Button
variant="contained"
size="small"
color="primary"
onClick={this.publish}
>
<Navigation style={{ marginRight: '8px' }} /> Publish
</Button>
)
}
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>
<div style={{ float: 'right', marginRight: '16px' }}>
{this.publishButton()}
</div>
</div>
</div>
)
}
private history() {
const items = this.state.history.map(message => ({
title: message.topic,
value: message.payload,
}))
return <History items={items} onClick={this.didSelectHistoryEntry} />
}
private didSelectHistoryEntry = (index: number) => {
let message = this.state.history[index]
this.props.actions.setPublishTopic(message.topic)
this.props.actions.setPublishPayload(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 }}
/>
</div>
)
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(sidebarActions, dispatch),
}
}
const mapStateToProps = (state: AppState) => {
return {
topic: state.sidebar.publishTopic,
payload: state.sidebar.publishPayload,
}
}
export default connect(mapStateToProps, mapDispatchToProps)(Publish)
+174
View File
@@ -0,0 +1,174 @@
import * as React from 'react'
import * as q from '../../../../backend/src/Model'
import {
ExpansionPanel,
ExpansionPanelDetails,
ExpansionPanelSummary,
Fade,
Paper,
Popper,
Typography,
} from '@material-ui/core'
import { StyleRulesCallback, Theme, withStyles } from '@material-ui/core/styles'
import { AppState } from '../../reducers'
import Copy from '../Copy'
import ExpandMore from '@material-ui/icons/ExpandMore'
import MessageHistory from './MessageHistory'
import NodeStats from './NodeStats'
import Publish from './Publish/Publish'
import Topic from './Topic'
import ValueRenderer from './ValueRenderer'
import { connect } from 'react-redux'
interface Props {
node?: q.TreeNode,
classes: any,
theme: Theme,
connectionId?: string,
}
interface State {
node: q.TreeNode,
compareMessage?: q.Message
}
class Sidebar extends React.Component<Props, State> {
private valueRef: any = React.createRef<HTMLDivElement>()
private updateNode = () => {
this.setState(this.state)
}
constructor(props: any) {
super(props)
console.error('Find and fix me #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)
this.props.node && this.setState({ node: this.props.node })
if (this.props.node !== nextProps.node) {
this.setState({ compareMessage: undefined })
}
}
private registerUpdateListener(node: q.TreeNode) {
node.onMerge.subscribe(this.updateNode)
node.onMessage.subscribe(this.updateNode)
}
private removeUpdateListener(node: q.TreeNode) {
node.onMerge.unsubscribe(this.updateNode)
node.onMessage.unsubscribe(this.updateNode)
}
public render() {
return (
<div className={this.props.classes.drawer}>
{this.renderNode()}
</div>
)
}
private detailsStyle = { padding: '0px 16px 8px 8px', display: 'block' }
private renderNode() {
const { classes, node } = this.props
const copyTopic = node ? <Copy value={node.path()} /> : null
const copyValue = node && node.message ? <Copy value={node.message.value} /> : null
const summeryStyle = { minHeight: '0' }
return (
<div>
<ExpansionPanel key="topic" defaultExpanded={true} disabled={!Boolean(this.props.node)}>
<ExpansionPanelSummary expandIcon={<ExpandMore />} style={summeryStyle}>
<Typography className={classes.heading}>Topic {copyTopic}</Typography>
</ExpansionPanelSummary>
<ExpansionPanelDetails style={this.detailsStyle}>
<Topic node={this.props.node} didSelectNode={this.updateNode} />
</ExpansionPanelDetails>
</ExpansionPanel>
<ExpansionPanel key="value" defaultExpanded={true}>
<ExpansionPanelSummary expandIcon={<ExpandMore />} style={summeryStyle}>
<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>
<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>
)}
</Popper>
</ExpansionPanelDetails>
</ExpansionPanel>
<ExpansionPanel defaultExpanded={true}>
<ExpansionPanelSummary expandIcon={<ExpandMore />} style={summeryStyle}>
<Typography className={classes.heading}>Publish</Typography>
</ExpansionPanelSummary>
<ExpansionPanelDetails style={this.detailsStyle}>
<Publish node={this.props.node} connectionId={this.props.connectionId} />
</ExpansionPanelDetails>
</ExpansionPanel>
<ExpansionPanel defaultExpanded={Boolean(this.props.node)}>
<ExpansionPanelSummary expandIcon={<ExpandMore />} style={summeryStyle}>
<Typography className={classes.heading}>Stats</Typography>
</ExpansionPanelSummary>
{this.renderNodeStats()}
</ExpansionPanel>
</div>
)
}
private handleMessageHistorySelect = (message: q.Message) => {
if (message !== this.state.compareMessage) {
this.setState({ compareMessage: message })
} else {
this.setState({ compareMessage: undefined })
}
}
private renderNodeStats() {
if (!this.props.node) {
return null
}
return (
<ExpansionPanelDetails style={this.detailsStyle}>
<NodeStats node={this.props.node} />
</ExpansionPanelDetails>
)
}
}
const mapStateToProps = (state: AppState) => {
return {
node: state.selectedTopic,
}
}
export default withStyles(Sidebar.styles, { withTheme: true })(connect(mapStateToProps)(Sidebar))
+59
View File
@@ -0,0 +1,59 @@
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'
interface Props {
classes: any
theme: Theme
node?: q.TreeNode
selected?: q.TreeNode
didSelectNode: (node: q.TreeNode) => void
}
const styles: StyleRulesCallback<string> = (theme: Theme) => ({
button: {
textTransform: 'none',
padding: '3px 5px 3px 5px',
minWidth: '30px',
},
})
class Topic extends React.Component<Props, {}> {
public render() {
const { node } = this.props
if (!node) {
return null
}
let key = 0
const breadCrumps = node.branch()
.map(node => node.sourceEdge)
.filter(edge => Boolean(edge))
.map(edge =>
[(
<Button
onClick={() => this.setState({ node: edge!.target })}
size="small"
color="secondary"
className={this.props.classes.button}
key={edge!.hash()}
>
{edge!.name}
</Button>
)],
)
if (breadCrumps.length === 0) {
return null
}
const joinedBreadCrumps = breadCrumps.reduce((prev, current) =>
prev.concat([<span key={key += 1}>/</span>]).concat(current),
)
return <span style={{ lineHeight: '2.2em' }}>{joinedBreadCrumps}</span>
}
}
export default withStyles(styles, { withTheme: true })(Topic)
@@ -0,0 +1,67 @@
import * as React from 'react'
import * as q from '../../../../backend/src/Model'
import { Theme, withTheme } from '@material-ui/core/styles'
import { default as ReactJson } from 'react-json-view'
interface Props {
message?: q.Message
theme: Theme
}
class ValueRenderer extends React.Component<Props, {}> {
public render() {
return <div>{this.renderValue()}</div>
}
public renderValue() {
const { message } = this.props
if (!message) {
return null
}
let json
try {
json = JSON.parse(message.value)
} catch (error) {
return this.renderRawValue(message.value)
}
if (typeof json === 'string') {
return this.renderRawValue(message.value)
} else if (typeof json === 'number') {
return this.renderRawValue(message.value)
} else if (typeof json === 'boolean') {
return this.renderRawValue(message.value)
} else {
const theme = (this.props.theme.palette.type === 'dark') ? 'monokai' : 'bright:inverted'
return (
<ReactJson
style={{ width: '100%' }}
src={json}
theme={theme}
/>
)
}
}
private renderRawValue(value: string) {
const style: React.CSSProperties = {
backgroundColor: 'rgba(80, 80, 80, 0.6)',
wordBreak: 'break-all',
width: '100%',
overflowX: 'scroll',
overflowY: 'clip',
display: 'block',
lineHeight: '1.2em',
padding: '12px 5px 12px 5px',
maxWidth: '40vw',
color: this.props.theme.palette.text.primary,
}
return <pre style={style}><code>{value}</code></pre>
}
}
export default withTheme()(ValueRenderer)
+3
View File
@@ -0,0 +1,3 @@
import Sidebar from './Sidebar'
export { Sidebar }
+124
View File
@@ -0,0 +1,124 @@
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 Menu from '@material-ui/icons/Menu'
import { withStyles, StyleRulesCallback } from '@material-ui/core/styles'
import { fade } from '@material-ui/core/styles/colorManipulator'
import { settingsActions } from '../actions'
const styles: StyleRulesCallback = theme => ({
title: {
display: 'none',
[theme.breakpoints.up('sm')]: {
display: 'block',
},
},
search: {
position: 'relative',
borderRadius: theme.shape.borderRadius,
backgroundColor: fade(theme.palette.common.white, 0.15),
'&:hover': {
backgroundColor: fade(theme.palette.common.white, 0.25),
},
marginRight: theme.spacing.unit * 2,
marginLeft: 0,
width: '100%',
[theme.breakpoints.up('sm')]: {
marginLeft: theme.spacing.unit * 3,
width: 'auto',
},
},
searchIcon: {
width: theme.spacing.unit * 9,
height: '100%',
position: 'absolute',
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
inputRoot: {
color: 'inherit',
width: '100%',
},
inputInput: {
paddingTop: theme.spacing.unit,
paddingRight: theme.spacing.unit,
paddingBottom: theme.spacing.unit,
paddingLeft: theme.spacing.unit * 10,
transition: theme.transitions.create('width'),
width: '100%',
[theme.breakpoints.up('md')]: {
width: 200,
},
},
menuButton: {
marginLeft: -12,
marginRight: 20,
},
})
interface Props {
classes: any
actions: any
}
interface State {
selectedNode?: q.TreeNode
settingsVisible: boolean
autoExpandLimit: number
}
class TitleBar extends React.Component<Props, State> {
constructor(props: any) {
super(props)
this.state = {
selectedNode: undefined,
settingsVisible: false,
autoExpandLimit: 0,
}
}
public render() {
const { classes } = this.props
return <AppBar position="static">
<Toolbar>
<IconButton className={classes.menuButton} color="inherit" aria-label="Menu" onClick={this.props.actions.toggleSettingsVisibility}>
<Menu />
</IconButton>
<Typography className={classes.title} variant="h6" color="inherit">MQTT-Xplorer</Typography>
</Toolbar>
</AppBar>
}
private renderSearch() {
const { classes } = this.props
return <div className={classes.search}>
<div className={classes.searchIcon}>
<Search />
</div>
<InputBase
placeholder="Search…"
classes={{
root: classes.inputRoot,
input: classes.inputInput,
}}
/>
</div>
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(settingsActions, dispatch),
}
}
export default withStyles(styles)(connect(null, mapDispatchToProps)(TitleBar))
+135
View File
@@ -0,0 +1,135 @@
import * as React from 'react'
import * as q from '../../../../backend/src/Model'
import { makeConnectionMessageEvent, rendererEvents } from '../../../../events'
import { AppState } from '../../reducers'
import TreeNode from './TreeNode'
import { connect } from 'react-redux'
const MovingAverage = require('moving-average')
declare const performance: any
declare const window: any
const timeInterval = 10 * 1000
const average = MovingAverage(timeInterval)
interface Props {
autoExpandLimit: number
didSelectNode?: (node: q.TreeNode) => void
connectionId?: string
}
interface TreeState {
tree: q.Tree
msg: any
}
class Tree extends React.Component<Props, TreeState> {
private updateTimer?: any
private lastUpdate: number = 0
private perf: number = 0
constructor(props: any) {
super(props)
const tree = new q.Tree()
this.state = { tree, msg: {} }
}
public time(): number {
const time = performance.now() - this.perf
this.perf = performance.now()
return time
}
public throttledStateUpdate(state: any) {
if (this.updateTimer) {
return
}
const expectedRenderTime = average.forecast()
const updateInterval = Math.max(expectedRenderTime * 20, 300)
const timeUntilNextUpdate = updateInterval - (performance.now() - this.lastUpdate)
this.updateTimer = setTimeout(() => {
window.requestAnimationFrame(() => {
this.lastUpdate = performance.now()
this.updateTimer && clearTimeout(this.updateTimer)
this.updateTimer = undefined
this.setState(state)
})
}, 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 style: React.CSSProperties = {
lineHeight: '1.1',
cursor: 'default',
}
const performanceCallback = (ms: number) => {
average.push(Date.now(), ms)
}
return (
<div style={style}>
<TreeNode
animateChages={true}
autoExpandLimit={this.props.autoExpandLimit}
isRoot={true}
didSelectNode={this.props.didSelectNode}
treeNode={this.state.tree}
name="/"
collapsed={false}
key="rootNode"
lastUpdate={this.state.tree.lastUpdate}
/>
</div>
)
}
}
const mapStateToProps = (state: AppState) => {
return {
autoExpandLimit: state.settings.autoExpandLimit,
}
}
export default connect(mapStateToProps)(Tree)
+217
View File
@@ -0,0 +1,217 @@
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 TreeNodeSubnodes from './TreeNodeSubnodes'
declare var performance: any
const styles = (theme: Theme) => {
return {
collapsedSubnodes: {
color: theme.palette.text.secondary,
},
displayBlock: {
display: 'block',
},
node: {
display: 'block',
marginLeft: '10px',
},
hover: {
'&:hover': {
backgroundColor: 'rgba(80, 80, 80, 0.35)',
},
},
}
}
interface Props {
actions: any
lastUpdate: number
animateChages: boolean
isRoot?: boolean
treeNode: q.TreeNode
name?: string | undefined
collapsed?: boolean | undefined
performanceCallback?: ((ms: number) => void) | undefined
didSelectNode?: (node: q.TreeNode) => void
classes: any
autoExpandLimit: number
style?: React.CSSProperties
}
interface State {
collapsedOverride: boolean | undefined
}
class TreeNode extends React.Component<Props, State> {
private dirtySubnodes: boolean = true
private dirtyEdges: boolean = true
private dirtyMessage: boolean = true
private animationDirty: boolean = false
private cssAnimationWasSetAt?: number
private willUpdateTime: number = performance.now()
private titleRef = React.createRef<HTMLDivElement>()
private subnodesDidchange = () => {
this.dirtySubnodes = true
}
private messageDidChange = () => {
this.dirtyMessage = true
}
private edgesDidChange = () => {
this.dirtyMessage = true
}
constructor(props: Props) {
super(props)
this.state = {
collapsedOverride: props.collapsed,
}
}
public componentDidMount() {
const { treeNode } = this.props
treeNode.onMerge.subscribe(this.subnodesDidchange)
treeNode.onEdgesChange.subscribe(this.edgesDidChange)
treeNode.onMessage.subscribe(this.messageDidChange)
}
public componentWillUnmount() {
const { treeNode } = this.props
treeNode.onMerge.unsubscribe(this.subnodesDidchange)
treeNode.onEdgesChange.unsubscribe(this.edgesDidChange)
treeNode.onMessage.unsubscribe(this.messageDidChange)
}
private stateHasChanged(newState: State) {
return this.state.collapsedOverride !== newState.collapsedOverride
}
private propsHasChanged(newProps: Props) {
return this.props.autoExpandLimit !== newProps.autoExpandLimit
}
public shouldComponentUpdate(nextProps: Props, nextState: State) {
const shouldRenderToRemoveCssAnimation = this.cssAnimationWasSetAt !== undefined
return this.stateHasChanged(nextState)
|| this.propsHasChanged(nextProps)
|| this.dirtyEdges
|| this.dirtyMessage
|| this.dirtySubnodes
|| this.animationDirty
|| shouldRenderToRemoveCssAnimation
}
public componentDidUpdate() {
if (this.props.performanceCallback) {
const renderTime = performance.now() - this.willUpdateTime
this.props.performanceCallback(renderTime)
}
// setTimeout(() => {
// this.setState(this.state)
// }, 500)
// this.addCssAnimation()
// setTimeout(this.removeCssAnimation, 500)
}
public componentWillUpdate() {
if (this.props.performanceCallback) {
this.willUpdateTime = performance.now()
}
}
private toggle() {
this.setState({ collapsedOverride: !this.collapsed() })
}
private collapsed() {
if (this.state.collapsedOverride !== undefined) {
return this.state.collapsedOverride
}
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' } : {}
this.animationDirty = shouldStartAnimation
return (
<div
key={this.props.treeNode.hash()}
className={`${classes.node} ${!this.props.isRoot ? classes.hover : ''}`}
onClick={this.didClickNode}
style={this.props.style}
>
<span ref={this.titleRef} style={animation}>
<TreeNodeTitle
collapsed={this.collapsed()}
treeNode={this.props.treeNode}
name={this.props.name}
lastUpdate={this.props.treeNode.lastUpdate}
/>
</span>
{this.renderNodes()}
</div>
)
}
private didClickNode = (event: React.MouseEvent) => {
event.stopPropagation()
this.toggle()
this.props.actions.selectTopic(this.props.treeNode)
}
private renderNodes() {
return (
<TreeNodeSubnodes
animateChanges={this.props.animateChages}
collapsed={this.collapsed()}
autoExpandLimit={this.props.autoExpandLimit}
didSelectNode={this.props.didSelectNode}
treeNode={this.props.treeNode}
lastUpdate={this.props.treeNode.lastUpdate}
/>
)
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(treeActions, dispatch),
}
}
export default withStyles(styles)(connect(null, mapDispatchToProps)(TreeNode))
@@ -0,0 +1,79 @@
import * as React from 'react'
import * as q from '../../../../backend/src/Model'
import { AppState, NodeOrder } from '../../reducers'
import { Theme, withTheme } from '@material-ui/core/styles'
import TreeNode from './TreeNode'
import { connect } from 'react-redux'
export interface Props {
lastUpdate: number
nodeOrder?: NodeOrder
animateChanges: boolean
treeNode: q.TreeNode
autoExpandLimit: number
collapsed?: boolean | undefined
didSelectNode?: (node: q.TreeNode) => void
theme: Theme
}
class TreeNodeSubnodes extends React.Component<Props, {}> {
private sortedNodes(): q.TreeNode[] {
const { nodeOrder, treeNode } = this.props
let edges = Object.values(treeNode.edges)
if (nodeOrder === NodeOrder.abc) {
edges = edges.sort((a, b) => a.name.localeCompare(b.name))
}
let nodes = edges.map(edge => edge.target)
if (nodeOrder === NodeOrder.messages) {
nodes = nodes.sort((a, b) => b.leafMessageCount() - a.leafMessageCount())
}
if (nodeOrder === NodeOrder.topics) {
nodes = nodes.sort((a, b) => b.leafCount() - a.leafCount())
}
return nodes
}
public render() {
const edges = Object.values(this.props.treeNode.edges)
if (edges.length === 0 || this.props.collapsed) {
return null
}
const listItemStyle = {
padding: '3px 0px 0px 8px',
}
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>
))
return (
<span style={{ display: 'block', clear: 'both' }} >
{listItems}
</span>
)
}
}
const mapStateToProps = (state: AppState) => {
return {
nodeOrder: state.settings.nodeOrder,
}
}
export default withTheme()(connect(mapStateToProps)(TreeNodeSubnodes))
+99
View File
@@ -0,0 +1,99 @@
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'
export interface TreeNodeProps extends React.HTMLAttributes<HTMLElement> {
treeNode: q.TreeNode
actions: any
name?: string | undefined
collapsed?: boolean | undefined
theme: Theme
lastUpdate: number
}
class TreeNodeTitle extends React.Component<TreeNodeProps, {}> {
private getStyles() {
const { theme } = this.props
return {
collapsedSubnodes: {
color: theme.palette.text.secondary,
},
container: {
display: 'block',
},
}
}
private didSelectNode = (event: React.MouseEvent) => {
event.stopPropagation()
if (this.props.treeNode.message) {
this.props.actions.selectTopic(this.props.treeNode)
}
}
public render() {
const style: React.CSSProperties = {
lineHeight: '1em',
whiteSpace: 'nowrap',
}
return (
<span style={style} onMouseOver={this.didSelectNode}>
{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>
}
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>
: null
}
private renderExpander() {
if (this.props.treeNode.edgeCount() === 0) {
return null
}
return this.props.collapsed ? '▶' : '▼'
}
private renderCollapsedSubnodes() {
if (this.props.treeNode.edgeCount() === 0 || !this.props.collapsed) {
return null
}
const messages = this.props.treeNode.leafMessageCount()
return <span style={this.getStyles().collapsedSubnodes}>({this.props.treeNode.leafCount()} nodes, {messages} messages)</span>
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(treeActions, dispatch),
}
}
export default withTheme()(connect(null, mapDispatchToProps)(TreeNodeTitle))
@@ -0,0 +1,12 @@
declare const window: any
declare const document: any
export function isElementInViewport(el: any) {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && /*or $(window).height() */
rect.right <= (window.innerWidth || document.documentElement.clientWidth) /*or $(window).width() */
)
}
+45
View File
@@ -0,0 +1,45 @@
import './tracking'
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles'
import reducers, { AppState, NodeOrder } from './reducers'
import App from './App'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
const initialAppState: AppState = {
settings: {
autoExpandLimit: 0,
nodeOrder: NodeOrder.none,
visible: false,
},
sidebar: {},
selectedTopic: undefined,
showUpdateDetails: false,
}
const store = createStore(reducers, initialAppState)
const theme = createMuiTheme({
palette: {
type: 'dark',
},
typography: { useNextVariants: true },
})
const splash = document.getElementById('splash')
if (splash) {
splash.style.animation = 'unsplash 1s ease-out 0s 1 normal forwards'
setTimeout(() => splash.remove(), 1100)
}
ReactDOM.render(
<MuiThemeProvider theme={theme}>
<Provider store={store}>
<App name="" />
</Provider>
</MuiThemeProvider>,
document.getElementById('app'),
)
+125
View File
@@ -0,0 +1,125 @@
import * as q from '../../../backend/src/Model'
import { Action, Reducer } from 'redux'
import { trackEvent } from '../tracking'
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
showUpdateNotification?: boolean
showUpdateDetails?: boolean
}
export interface SidebarState {
publishTopic?: string
publishPayload?: string
}
export interface AppState {
settings: SettingsState,
selectedTopic?: q.TreeNode
sidebar: SidebarState
showUpdateNotification?: boolean
showUpdateDetails: boolean
}
export interface SettingsState {
autoExpandLimit: number
visible: boolean
nodeOrder: NodeOrder
}
export enum NodeOrder {
none = 'none',
messages = '#messages',
abc = 'abc',
topics = '#topics',
}
const reducer: Reducer<AppState | undefined, CustomAction> = (state, action) => {
if (!state) {
throw Error('No initial state')
}
trackEvent(action.type)
console.log(action, state)
switch (action.type) {
case ActionTypes.setAutoExpandLimit:
if (action.autoExpandLimit === undefined) {
return state
}
return {
...state,
settings: {
...state.settings,
autoExpandLimit: action.autoExpandLimit,
},
}
case ActionTypes.setPublishTopic:
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, nodeOrder: action.nodeOrder },
}
case ActionTypes.showUpdateNotification:
return {
...state,
showUpdateNotification: action.showUpdateNotification,
}
case ActionTypes.showUpdateDetails:
if (action.showUpdateDetails === undefined) {
return state
}
return {
...state,
showUpdateDetails: action.showUpdateDetails,
}
default:
return state
}
}
export default reducer
+21
View File
@@ -0,0 +1,21 @@
let userId = window.localStorage.getItem('userId')
const sha1 = require('sha1')
if (!userId) {
userId = sha1(sha1(Math.random()) + sha1(performance.now()) + sha1(Date.now())).slice(0, 8) as string
window.localStorage.setItem('userId', userId)
}
const Nucleus = require('electron-nucleus')('5c3b3e0443b7cc00eec3782b', {
userId,
disableInDev: true,
})
export default Nucleus
export function trackEvent(name: string) {
if (name.match(/^@@redux/)) {
return
}
Nucleus.track(name)
}
+25
View File
@@ -0,0 +1,25 @@
{
"compileOnSave": true,
"compilerOptions": {
"noImplicitAny": true,
"strictNullChecks": true,
"strict": true,
"lib": ["es2017", "dom"],
"outDir": "./build/",
"sourceMap": true,
"module": "commonjs",
"target": "es5",
"jsx": "react"
},
"include": [
"./src/**/*"
],
"exclude": [
"**/*.d.ts"
],
"awesomeTypescriptLoaderOptions": {
"useCache": true,
"transpileModule": true,
"errorsAsWarnings": true
}
}
+49
View File
@@ -0,0 +1,49 @@
var LiveReloadPlugin = require('webpack-livereload-plugin');
module.exports = {
entry: "./src/index.tsx",
output: {
filename: "bundle.js",
path: __dirname + "/build"
},
optimization: {
removeAvailableModules: false,
removeEmptyChunks: false,
splitChunks: false,
},
target: 'electron-renderer',
mode: 'production',
// Enable sourcemaps for debugging webpack's output.
devtool: "source-map",
resolve: {
// Add '.ts' and '.tsx' as resolvable extensions.
extensions: [".ts", ".tsx", ".js", ".json"]
},
module: {
rules: [
// All files with a '.ts' or '.tsx' extension will be handled by 'awesome-typescript-loader'.
{ test: /\.tsx?$/, loader: "awesome-typescript-loader" },
// All output '.js' files will have any sourcemaps re-processed by 'source-map-loader'.
{ enforce: "pre", test: /\.js$/, loader: "source-map-loader" }
]
},
plugins: [
new LiveReloadPlugin({})
],
// When importing a module whose path matches one of the following, just
// assume a corresponding global variable exists and use that instead.
// This is important because it allows us to avoid bundling all of our
// dependencies, which allows browsers to cache those libraries between builds.
externals: {
// "react": "React",
// "react-dom": "ReactDOM"
}
};
+2677
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "mqtt-explorer-backend",
"version": "1.0.0",
"description": "",
"main": "build/index.js",
"scripts": {
"test": "mocha",
"build": "tsc",
"test-inspect": "mocha --inspect-brk",
"coverage": "nyc mocha",
"debug": "ts-node --inspect ./src/index.ts",
"postinstall": "npm run build"
},
"author": "",
"license": "ISC",
"nyc": {
"include": [
"src/**/*.ts",
"src/**/*.tsx"
],
"exclude": [
"src/**/spec/*.spec.ts"
],
"extension": [
".ts",
".tsx"
],
"require": [
"ts-node/register"
],
"reporter": [
"text-summary",
"html"
],
"sourceMap": true,
"instrument": true
}
}
+15
View File
@@ -0,0 +1,15 @@
import { DataSourceStateMachine } from './'
type MessageCallback = (topic: string, payload: Buffer) => void
// A DataSource should automatically reconnect if connection was broken
interface DataSource<DataSourceOptions> {
connect(options: DataSourceOptions): DataSourceStateMachine
disconnect(): void
onMessage(messageCallback: MessageCallback): void
publish(topic: string, payload: any): void
topicSeparator: string
stateMachine: DataSourceStateMachine
}
export { DataSource, MessageCallback }
+47
View File
@@ -0,0 +1,47 @@
import { EventDispatcher } from '../../../events'
export interface DataSourceState {
connecting: boolean
connected: boolean
error?: Error
}
export class DataSourceStateMachine {
public onUpdate = new EventDispatcher<DataSourceState, DataSourceStateMachine>(this)
private state: DataSourceState = {
error: undefined,
connected: false,
connecting: false,
}
public setConnected(connected: boolean) {
this.state = {
connected,
error: undefined,
connecting: false,
}
this.onUpdate.dispatch(this.state)
}
public setError(error: Error) {
this.state = {
error,
connected: false,
connecting: false,
}
this.onUpdate.dispatch(this.state)
}
public setConnecting() {
this.state = {
error: undefined,
connected: false,
connecting: true,
}
this.onUpdate.dispatch(this.state)
}
public toJSON() {
return this.state
}
}
+79
View File
@@ -0,0 +1,79 @@
import { Client, connect as mqttConnect } from 'mqtt'
import { DataSource, DataSourceStateMachine } from './'
import * as Url from 'url'
export interface MqttOptions {
url: string
username?: string
password?: string
tls: boolean
certValidation: boolean
}
export class MqttSource implements DataSource<MqttOptions> {
public stateMachine: DataSourceStateMachine = new DataSourceStateMachine()
private client: Client | undefined
private messageCallback?: (topic: string, message: Buffer) => void
private rootSubscription = '#'
public topicSeparator = '/'
public onMessage(messageCallback: (topic: string, message: Buffer) => void) {
this.messageCallback = messageCallback
}
public connect(options: MqttOptions): DataSourceStateMachine {
this.stateMachine.setConnecting()
const urlStr = options.tls ? options.url.replace(/^(mqtt|ws):/, '$1s:') : options.url
let url
try {
url = Url.parse(urlStr)
} catch (error) {
this.stateMachine.setError(error)
throw error
}
const client = mqttConnect(url, {
resubscribe: false,
rejectUnauthorized: !options.certValidation,
})
this.client = client
client.on('error', (error: Error) => {
console.log(error)
this.stateMachine.setError(error)
})
client.on('close', () => {
this.stateMachine.setConnected(false)
})
client.on('reconnect', () => {
this.stateMachine.setConnecting()
})
client.on('connect', () => {
this.stateMachine.setConnected(true)
client.subscribe(this.rootSubscription, (err: Error) => {
if (err) {
throw new Error('mqtt subscription failed')
}
})
})
client.on('message', (topic, message) => {
this.messageCallback && this.messageCallback(topic, message)
})
return this.stateMachine
}
public publish(topic: string, payload: any) {
this.client && this.client.publish(topic, payload)
}
public disconnect() {
this.client && this.client.end()
}
}
+3
View File
@@ -0,0 +1,3 @@
export { DataSource } from './DataSource'
export { DataSourceState, DataSourceStateMachine } from './DataSourceState'
export { MqttOptions, MqttSource } from './MqttSource'
+35
View File
@@ -0,0 +1,35 @@
import { Hashable, TreeNode } from './'
const sha1 = require('sha1')
export class Edge implements Hashable {
public name: string
public target!: TreeNode
public source?: TreeNode | undefined
private cachedHash?: string
constructor(name: string) {
this.name = name
}
public edges() {
return this.target ? Object.values(this.target.edges) : []
}
public hash(): string {
if (!this.cachedHash) {
const previousHash = (this.source && this.source.sourceEdge) ? this.source.sourceEdge.hash() : ''
this.cachedHash = `H${sha1(previousHash + this.name)}`
}
return this.cachedHash
}
public firstEdge(): Edge {
if (this.source && this.source.sourceEdge) {
return this.source.sourceEdge.firstEdge()
}
return this
}
}
+3
View File
@@ -0,0 +1,3 @@
export interface Hashable {
hash(): string
}
+5
View File
@@ -0,0 +1,5 @@
export interface Message {
value?: any | undefined
length: number
received: Date
}
+60
View File
@@ -0,0 +1,60 @@
interface Lengthwise {
length: number
}
export class RingBuffer<T extends Lengthwise> {
private capacity: number
private maxItems: number
private usage: number = 0
private items: (any)[] = []
private start: number = 0
private end: number = 0
constructor(capacity: number, maxItems = Infinity) {
this.capacity = capacity
this.maxItems = maxItems
}
public toArray() {
return this.items.slice(this.start, this.end)
}
public add(item: T) {
const size = item.length
this.enforceCapacityConstraints(size)
this.usage += size
this.items[this.end] = item
this.end += 1
}
private enforceCapacityConstraints(addedItemSize: number) {
const remainingSize = this.capacity - (this.usage + addedItemSize)
if (remainingSize < 0) {
this.freeSomeSpace(Math.abs(remainingSize))
}
while ((this.end - this.start) >= this.maxItems) {
this.dropFirst()
}
}
private freeSpace(): number {
return this.capacity - this.usage
}
private isEmpty(): boolean {
return this.usage === 0
}
private freeSomeSpace(requiredSpace: number) {
while (this.freeSpace() < requiredSpace && !this.isEmpty()) {
this.dropFirst()
}
}
private dropFirst() {
const freedSpace = this.items[this.start].length
this.usage -= freedSpace
delete this.items[this.start]
this.start += 1
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Edge, TreeNode } from './'
export class Tree extends TreeNode {
constructor() {
super(undefined, undefined)
}
}
+142
View File
@@ -0,0 +1,142 @@
import { Edge, Message, RingBuffer } from './'
import { EventDispatcher } from '../../../events'
export class TreeNode {
public sourceEdge?: Edge
public message?: Message
public messageHistory: RingBuffer<Message> = new RingBuffer<Message>(3000, 100)
public edges: {[s: string]: Edge} = {}
public collapsed = false
public messages: number = 0
public lastUpdate: number = Date.now()
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 cachedLeafMessageCount?: number
constructor(sourceEdge?: Edge, message?: Message) {
if (sourceEdge) {
this.sourceEdge = sourceEdge
sourceEdge.target = this
}
message && this.setMessage(message)
this.onMerge.subscribe(() => {
this.cachedLeafes = undefined
this.cachedLeafMessageCount = undefined
this.lastUpdate = Date.now()
})
this.onEdgesChange.subscribe(() => {
this.lastUpdate = Date.now()
})
this.onMessage.subscribe(() => {
this.lastUpdate = Date.now()
})
}
public setMessage(message: Message) {
this.messageHistory.add(message)
this.message = message
this.messages += 1
}
public hash(): string {
return `N${(this.sourceEdge ? this.sourceEdge.hash() : '')}`
}
public firstNode(): TreeNode {
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('/')
}
private previous(): TreeNode | undefined {
return this.sourceEdge ? this.sourceEdge.source || undefined : undefined
}
public addEdge(edge: Edge, emitUpdate: boolean = false) {
this.edges[edge.name] = edge
edge.source = this
if (emitUpdate) {
this.onEdgesChange.dispatch()
}
}
public branch(): TreeNode[] {
const previous = this.previous()
if (!previous) {
return [this]
}
return previous.branch().concat([this])
}
public updateWithNode(node: TreeNode) {
if (node.message) {
this.setMessage(node.message)
this.onMessage.dispatch(node.message)
}
this.mergeEdges(node)
this.onMerge.dispatch()
}
public leafMessageCount() {
if (this.cachedLeafMessageCount === undefined) {
this.cachedLeafMessageCount = this.leafes()
.map(leaf => leaf.messages)
.reduce((a, b) => a + b)
}
return this.cachedLeafMessageCount
}
public leafCount(): number {
return this.leafes().length
}
public edgeCount(): number {
return Object.values(this.edges).length
}
public leafes(): TreeNode[] {
if (this.cachedLeafes === undefined) {
if (Object.values(this.edges).length === 0) {
return [this]
}
this.cachedLeafes = Object.values(this.edges)
.map(e => e.target.leafes())
.reduce((a, b) => a.concat(b), [])
}
return this.cachedLeafes
}
private mergeEdges(node: TreeNode) {
const edgeKeys = Object.keys(node.edges)
let edgesDidUpdate = false
for (const edgeKey of edgeKeys) {
const matchingEdge = this.edges[edgeKey]
if (matchingEdge) {
matchingEdge.target.updateWithNode(node.edges[edgeKey].target)
} else {
this.addEdge(node.edges[edgeKey], false)
edgesDidUpdate = true
}
}
if (edgesDidUpdate) {
this.onEdgesChange.dispatch()
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Edge, Tree, TreeNode } from './'
interface HasLength {
length: number
}
export abstract class TreeNodeFactory {
public static fromEdgesAndValue<T extends HasLength>(edgeNames: string[], value: T): TreeNode {
let currentNode: TreeNode = new Tree()
for (const edgeName of edgeNames) {
const edge = new Edge(edgeName)
const newNode = new TreeNode(edge)
edge.target = newNode
currentNode.addEdge(edge)
currentNode = newNode
}
currentNode.setMessage({
value,
length: value.length,
received: new Date(),
})
return currentNode
}
}
+7
View File
@@ -0,0 +1,7 @@
export { Edge } from './Edge'
export { TreeNode } from './TreeNode'
export { Message } from './Message'
export { TreeNodeFactory } from './TreeNodeFactory'
export { Tree } from './Tree'
export { Hashable } from './Hashable'
export * from './RingBuffer'
+49
View File
@@ -0,0 +1,49 @@
import { Edge, TreeNode, TreeNodeFactory } from '../'
import { expect } from 'chai'
import 'mocha'
describe('Edge', () => {
it('should contain a name', () => {
const e = new Edge('foo')
expect(e.name).to.equal('foo')
})
it('firstEdge should retireve the first edge', () => {
const topics = 'foo/bar/baz'.split('/')
const leaf = TreeNodeFactory.fromEdgesAndValue(topics, 5)
const bazEdge = leaf.sourceEdge
if (!bazEdge) {
expect.fail('should not be undefined')
return
}
expect(bazEdge.name).to.eq('baz')
expect(bazEdge.firstEdge().name).to.eq('foo')
})
it('hash should not be empty', () => {
const e = new Edge('bar')
expect(e.hash().length).to.be.gt(0)
})
it('hash should be stable', () => {
const e = new Edge('bar')
const previousHash = e.hash()
expect(e.hash()).to.eq(previousHash)
})
it('hash should include change if parents are different', () => {
const topics1 = 'foo/bar/baz'.split('/')
const bazEdge1 = TreeNodeFactory.fromEdgesAndValue(topics1, 5).sourceEdge
const topics2 = 'foo/foo/baz'.split('/')
const bazEdge2 = TreeNodeFactory.fromEdgesAndValue(topics2, 5).sourceEdge
if (!bazEdge1 || !bazEdge2) {
throw Error('should not happen')
}
expect(bazEdge1.hash()).to.not.eq(bazEdge2.hash())
})
})
+46
View File
@@ -0,0 +1,46 @@
import { RingBuffer } from '../'
import { expect } from 'chai'
import 'mocha'
describe('RingBuffer', () => {
it('should add a new value', () => {
const b = new RingBuffer(30)
b.add('hello')
expect(b.toArray()[0]).to.eq('hello')
})
it('should add a new value after the first', () => {
const b = new RingBuffer(30)
b.add('hello')
b.add('world')
expect(b.toArray()[1]).to.eq('world')
})
it('should remove old values if buffer size is reached', () => {
const b = new RingBuffer(6)
b.add('hello')
b.add('world')
expect(b.toArray()[0]).to.eq('world')
})
it('items bigger then the buffer should be stored anyway', () => {
const b = new RingBuffer(3)
b.add('hello')
expect(b.toArray()[0]).to.eq('hello')
b.add('world')
expect(b.toArray()[0]).to.eq('world')
})
it('max item count should be respected', () => {
const b = new RingBuffer(100, 3)
b.add('a')
b.add('b')
b.add('c')
b.add('d')
b.add('e')
expect(b.toArray().length).to.eq(3)
expect(b.toArray()[0]).to.eq('c')
expect(b.toArray()[1]).to.eq('d')
expect(b.toArray()[2]).to.eq('e')
})
})
+18
View File
@@ -0,0 +1,18 @@
import { Tree, TreeNodeFactory } from '../'
import { expect } from 'chai'
import 'mocha'
import './TreeNode.findNode'
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)
tree.updateWithNode(leaf.firstNode())
const expectedNode = tree.findNode('foo/bar')
expect(expectedNode).to.eq(leaf)
})
})
@@ -0,0 +1,29 @@
import { TreeNodeFactory } from '../'
import { expect } from 'chai'
import 'mocha'
import './TreeNode.findNode'
describe('TreeNode.findNode', () => {
it('findNode should retrieve node', () => {
const topics = 'foo/bar/baz'.split('/')
const leaf = TreeNodeFactory.fromEdgesAndValue(topics, 5)
const root = leaf.firstNode()
expect(root.sourceEdge).to.eq(undefined)
const barNode = root.findNode('foo/bar')
if (!barNode) {
expect.fail('did not find node')
return
}
expect(barNode.sourceEdge && barNode.sourceEdge.name).to.eq('bar')
const bazNode = root.findNode('foo/bar/baz')
if (!bazNode) {
expect.fail('did not find node')
return
}
expect(bazNode.sourceEdge && bazNode.sourceEdge.name).to.eq('baz')
})
})
@@ -0,0 +1,19 @@
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
}
+59
View File
@@ -0,0 +1,59 @@
import { TreeNodeFactory } from '../'
import './TreeNode.findNode'
import { expect } from 'chai'
import 'mocha'
describe('TreeNode', () => {
it('firstNode should retrieve first node', () => {
const topics = 'foo/bar'.split('/')
const leaf = TreeNodeFactory.fromEdgesAndValue(topics, 3)
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 root = leaf.firstNode()
root.updateWithNode(updateLeave.firstNode())
expect(root.sourceEdge).to.eq(undefined)
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 topics2 = 'foo/bar'.split('/')
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(leaf.sourceEdge && leaf.sourceEdge.name).to.eq('baz')
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 topics2 = 'foo/bar/baz'.split('/')
const leaf2 = TreeNodeFactory.fromEdgesAndValue(topics2, 5)
leaf1.firstNode().updateWithNode(leaf2.firstNode())
const expectedNode = leaf1.firstNode().findNode('foo/bar/baz')
if (!expectedNode) {
expect.fail('merge seems to have failed')
return
}
})
})
@@ -0,0 +1,60 @@
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)
expect(leaf.firstNode().sourceEdge).to.eq(undefined)
})
it('should create node', () => {
const topic = 'foo/bar'
const edges = topic.split('/')
const node = TreeNodeFactory.fromEdgesAndValue(edges, 5)
if (!node.sourceEdge || !node.sourceEdge.source || !node.message) {
expect.fail('should not happen')
return
}
expect(node).to.not.eq(undefined)
expect(node.sourceEdge.name).to.eq('bar')
expect(node.message.value).to.eq(5)
const foo = node.firstNode().findNode('foo')
expect(foo && foo.sourceEdge && foo.sourceEdge.name).to.eq('foo')
})
it('node should contain edges in order', () => {
const topic = 'foo/bar/baz'
const edges = topic.split('/')
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.sourceEdge.name).to.eq('baz')
const barNode = node.sourceEdge.source
if (!barNode || !barNode.sourceEdge) {
expect.fail('should not fail')
return
}
expect(barNode.sourceEdge.name).to.eq('bar')
const fooNode = barNode.sourceEdge.source
if (!fooNode || !fooNode.sourceEdge) {
expect.fail('should not fail')
return
}
expect(fooNode.sourceEdge.name).to.eq('foo')
})
})
+74
View File
@@ -0,0 +1,74 @@
import {
AddMqttConnection,
EventDispatcher,
Message,
addMqttConnectionEvent,
backendEvents,
checkForUpdates,
makeConnectionMessageEvent,
makeConnectionStateEvent,
makePublishEvent,
removeConnection,
updateAvailable,
} from '../../events'
import { DataSource, MqttSource } from './DataSource'
import { UpdateInfo } from 'builder-util-runtime'
export class ConnectionManager {
private connections: {[s: string]: DataSource<any>} = {}
public manageConnections() {
backendEvents.subscribe(addMqttConnectionEvent, this.handleConnectionRequest)
backendEvents.subscribe(removeConnection, (connectionId: string) => this.removeConnection(connectionId))
}
private handleConnectionRequest = (event: AddMqttConnection) => {
const connectionId = event.id
const options = event.options
const connection = new MqttSource()
this.connections[connectionId] = connection
const connectionStateEvent = makeConnectionStateEvent(connectionId)
connection.stateMachine.onUpdate.subscribe((state) => {
backendEvents.emit(connectionStateEvent, state)
})
connection.connect(options)
this.handleNewMessagesForConnection(connectionId, connection)
backendEvents.subscribe(makePublishEvent(connectionId), (msg: Message) => {
this.connections[connectionId].publish(msg.topic, msg.payload)
})
}
private handleNewMessagesForConnection(connectionId: string, connection: MqttSource) {
const messageEvent = makeConnectionMessageEvent(connectionId)
connection.onMessage((topic: string, payload: Buffer) => {
let buffer = payload
if (buffer.length > 10000) {
buffer = buffer.slice(0, 10000)
}
backendEvents.emit(messageEvent, { topic, payload: buffer.toString('base64') })
})
}
public removeConnection(hash: string) {
const connection = this.connections[hash]
connection.disconnect()
delete this.connections[hash]
}
}
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()
+3
View File
@@ -0,0 +1,3 @@
--require ts-node/register
--require source-map-support/register
--recursive ./src/**/*.spec.ts
+21
View File
@@ -0,0 +1,21 @@
{
"compileOnSave": true,
"compilerOptions": {
"noImplicitAny": true,
"strictNullChecks": true,
"outDir": "./build",
"strict": true,
"lib": ["es2017", "dom"],
"sourceMap": true
},
"includes": [
"src/**/*.ts"
],
"exclude": [
"app",
"node_modules",
"src/**/*.spec.ts",
"**/*.d.ts",
"typings"
]
}
+87
View File
@@ -0,0 +1,87 @@
const { app, BrowserWindow } = require('electron')
const { autoUpdater } = require("electron-updater")
const log = require('electron-log');
const { ConnectionManager, updateNotifier } = require('./backend/build/backend/src/index.js')
autoUpdater.logger = log;
autoUpdater.logger.transports.file.level = 'info';
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
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()
let updateInfo
autoUpdater.on('update-available', (info) => {
updateInfo = info
})
autoUpdater.on('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', 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.
+55
View File
@@ -0,0 +1,55 @@
import { IpcMain, IpcRenderer, ipcMain, ipcRenderer } from 'electron'
import { Event } from './Events'
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
}
class IpcMainEventBus implements EventBusInterface {
private ipc: IpcMain
private client: any
constructor(ipc: IpcMain) {
this.ipc = ipc
}
public subscribe<MessageType>(event: Event<MessageType>, callback:(msg: MessageType) => void) {
console.log('subscribing', event.topic)
this.ipc.on(event.topic, (event: any, arg: any) => {
this.client = event.sender
callback(arg)
})
}
public unsubscribeAll<MessageType>(event: Event<MessageType>) {
this.ipc.removeAllListeners(event.topic)
}
public emit<MessageType>(event: Event<MessageType>, msg: MessageType) {
this.client.send(event.topic, msg)
}
}
class IpcRendererEventBus implements EventBusInterface {
private ipc: IpcRenderer
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))
}
public unsubscribeAll<MessageType>(event: Event<MessageType>) {
this.ipc.removeAllListeners(event.topic)
}
public emit<MessageType>(event: Event<MessageType>, msg: MessageType) {
this.ipc.send(event.topic, msg)
}
}
export const rendererEvents = new IpcRendererEventBus(ipcRenderer)
export const backendEvents = new IpcMainEventBus(ipcMain)
+47
View File
@@ -0,0 +1,47 @@
import { EventEmitter } from 'events'
interface CallbackStore {
wrappedCallback: any
callback: any
}
export class EventDispatcher<Message, Dispatcher> {
private emitter = new EventEmitter()
private dispatcher: Dispatcher
private callbacks: CallbackStore[] = []
constructor(dispatcher: Dispatcher) {
this.dispatcher = dispatcher
}
public dispatch(msg: Message) {
this.emitter.emit('event', msg)
}
public subscribe(callback: (msg: Message, dispatcher: Dispatcher) => void) {
const wrappedCallback = (msg: Message) => {
callback(msg, this.dispatcher)
}
this.emitter.on('event', wrappedCallback)
this.callbacks.push({
callback,
wrappedCallback,
})
}
public unsubscribe(callback: (msg: Message, dispatcher: Dispatcher) => void) {
const item = this.callbacks.find(store => store.callback === callback)
if (!item) {
return
}
this.emitter.removeListener('event', item.wrappedCallback)
this.callbacks = this.callbacks.filter(a => a !== item)
}
public removeAllListeners() {
this.emitter.removeAllListeners()
this.callbacks = []
}
}
+53
View File
@@ -0,0 +1,53 @@
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
}
export interface AddMqttConnection {
id: string,
options: MqttOptions
}
export const addMqttConnectionEvent: Event<AddMqttConnection> = {
topic: 'connection/add/mqtt',
}
export const removeConnection: Event<string> = {
topic: 'connection/remove',
}
export function makeConnectionStateEvent(connectionId: string): Event<DataSourceState> {
return {
topic: `conn/state/${connectionId}`,
}
}
export const checkForUpdates: Event<void> = {
topic: 'app/update/check',
}
export const updateAvailable: Event<UpdateInfo> = {
topic: 'app/update/available',
}
export interface Message {
topic: string,
payload: any
}
export function makePublishEvent(connectionId: string): Event<Message> {
return {
topic: `conn/publish/${connectionId}`,
}
}
export function makeConnectionMessageEvent(connectionId: string): Event<Message> {
return {
topic: `conn/${connectionId}`,
}
}
View File
+3
View File
@@ -0,0 +1,3 @@
export * from './Events'
export * from './EventDispatcher'
export * from './EventBus'
+9
View File
@@ -0,0 +1,9 @@
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.

Before

Width:  |  Height:  |  Size: 81 KiB

BIN
View File
Binary file not shown.
-14
View File
@@ -1,14 +0,0 @@
convert screen1.png -resize 33% screen1_small.png
convert screen2.png -resize 33% screen2_small.png
convert screen3.png -resize 33% screen3_small.png
convert screen_dark_mode.png -resize 33% screen_dark_mode_small.png
convert -size 2560x1600 xc:black -fill white -draw "polygon 0,0 0,2560 2200,0" framemask.png
convert screen_dark_mode.png framemask.png -alpha off -compose copy_opacity -composite first_half.png
convert -respect-parenthesis screen4.png \( framemask.png -negate \) -alpha off -compose copy_opacity -composite second_half.png
convert *_half.png -compose over -composite screen-composite.png
convert screen-composite.png -resize 33% screen-composite_small.png
rm framemask.png *_half.png
optipng *.png
Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

-152
View File
@@ -1,152 +0,0 @@
/* ©2015 Johannes Jakob
Made with <3 in Germany */
/* window BEGIN */
.osx-frame {
background: #fff;
width: 100%;
margin: auto;
border: 1px solid #acacac;
border-radius: 6px;
box-shadow: 0px 0px 20px #acacac;
}
.titlebar {
background: -webkit-gradient(linear, left top, left bottom, color-stop(0.0, #ebebeb, color-stop(1.0, #d5d5d5)));
background: -webkit-linear-gradient(top, #ebebeb, #d5d5d5);
background: -moz-linear-gradient(top, #ebebeb, #d5d5d5);
background: -ms-linear-gradient(top, #ebebeb, #d5d5d5);
background: -o-linear-gradient(top, #ebebeb, #d5d5d5);
background: linear-gradient(top, #ebebeb, #d5d5d5);
color: #4d494d;
font-size: 11pt;
line-height: 20px;
text-align: center;
width: 100%;
height: 20px;
border-top: 1px solid #f3f1f3;
border-bottom: 1px solid #b1aeb1;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
-o-user-select: none;
cursor: default;
}
.titlebar p {
margin-left: -61px;
display: inline-block;
}
.buttons {
padding-left: 10px;
padding-top: 3px;
float: left;
line-height: 0px;
}
.buttons div {
float: left;
}
.close {
background: #ff5c5c;
font-size: 9pt;
width: 11px;
height: 11px;
border: 1px solid #e33e41;
border-radius: 50%;
display: inline-block;
opacity: 1;
}
.close:active {
background: #c14645;
border: 1px solid #b03537;
}
.close:active .closebutton {
color: #4e0002;
}
.closebutton {
color: #820005;
visibility: hidden;
cursor: default;
}
.minimize {
background: #ffbd4c;
font-size: 9pt;
line-height: 11px;
margin-left: 6px;
width: 11px;
height: 11px;
border: 1px solid #e09e3e;
border-radius: 50%;
display: inline-block;
}
.minimize:active {
background: #c08e38;
border: 1px solid #af7c33;
}
.minimize:active .minimizebutton {
color: #5a2607;
}
.minimizebutton {
color: #9a5518;
visibility: hidden;
cursor: default;
}
.zoom {
background: #00ca56;
font-size: 9pt;
line-height: 11px;
margin-left: 6px;
width: 11px;
height: 11px;
border: 1px solid #14ae46;
border-radius: 50%;
display: inline-block;
}
.zoom:active {
background: #029740;
border: 1px solid #128435;
}
.zoom:active .zoombutton {
color: #003107;
}
.zoombutton {
color: #006519;
visibility: hidden;
cursor: default;
}
.content {
padding: 0;
margin: 0;
line-height: 0;
overflow: hidden;
border-radius: 0px 0px 5px 5px;
}
/* window END */
/* content BEGIN */
h3 {
margin-top: 0px;
}
/* content END */
+5452 -201
View File
File diff suppressed because it is too large Load Diff
+55 -19
View File
@@ -1,31 +1,67 @@
{
"name": "mqtt-explorer-gh-pages",
"version": "1.0.0",
"main": "index.js",
"description": "Github pages for MQTT Explorer",
"name": "MQTT-Explorer",
"version": "0.0.3",
"description": "Explore your message queues",
"main": "electron.js",
"scripts": {
"readme": "ts-node updateReadme.ts"
"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 ..",
"test-backend": "cd backend && npm run test && cd ..",
"prepare-release": "./prepare-release.sh",
"package": "ts-node package.ts"
},
"repository": {
"type": "git",
"url": "git+https://github.com/thomasnordquist/MQTT-Explorer.git"
"url": "https://github.com/thomasnordquist/MQTT-Explorer.git"
},
"author": "",
"build": {
"appId": "mqtt-explorer",
"nodeGypRebuild": false,
"mac": {
"category": "de.t7n.apps.mqtt-explorer",
"publish": [
"github"
]
},
"linux": {
"category": "Development",
"maintainer": "Thomas Nordquist"
},
"directories": {
"app": "./",
"buildResources": "res",
"output": "build"
}
},
"author": "Thomas Nordquist",
"email": "xxnerowingerxx@gmail.com",
"homepage": "https://github.com",
"license": "ISC",
"bugs": {
"url": "https://github.com/thomasnordquist/MQTT-Explorer/issues"
},
"homepage": "https://github.com/thomasnordquist/MQTT-Explorer#readme",
"devDependencies": {
"@types/mustache": "^0.8.32",
"@types/semver-sort": "^0.0.5",
"axios": "^0.18.0",
"mustache": "^3.0.1",
"ts-node": "^8.0.3",
"typescript": "^3.4.2"
"electron": "^4.0.0",
"electron-builder": "^20.38.4",
"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"
},
"dependencies": {
"@types/node": "^13.1.7",
"semver-sort": "^1.0.0"
"electron-log": "^2.2.17",
"electron-updater": "^4.0.6",
"mqtt": "^2.18.8",
"sha1": "^1.1.1"
}
}
+50
View File
@@ -0,0 +1,50 @@
import * as builder from 'electron-builder'
const linux: builder.CliOptions = {
x64: true,
ia32: true,
armv7l: true,
arm64: true,
linux: ['AppImage', 'deb', 'snap'],
projectDir: './build/clean',
publish: 'onTag',
}
const win: builder.CliOptions = {
x64: true,
ia32: true,
armv7l: false,
arm64: false,
win: ['portable', 'nsis'],
projectDir: './build/clean',
publish: 'onTag',
}
const mac: builder.CliOptions = {
x64: true,
ia32: true,
armv7l: false,
arm64: false,
mac: ['dmg'],
projectDir: './build/clean',
publish: 'onTag',
}
async function buildAll() {
console.log(process.argv[2])
switch (process.argv[2]) {
case 'win':
await builder.build(win)
break
case 'linux':
await builder.build(linux)
break
case 'mac':
await builder.build(mac)
break
default:
await builder.build(mac)
}
}
buildAll()
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
set -e
ORIGINAL_DIR=`pwd`
DIR=build/clean
rm -rf "$DIR" || echo "Directory did not exist"
mkdir -p "$DIR"
git clone .git "$DIR"
cd $DIR
# App
cd app
npm install
cd ..
# Backend
cd backend
npm install;
#npm run test
cd ..
# Build
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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

-6
View File
@@ -1,6 +0,0 @@
<svg version="1.1"
baseProfile="full"
width="200" height="200"
xmlns="http://www.w3.org/2000/svg">
<polygon points="100,10 40,198 190,78 10,78 160,198" fill="orange" style="fill-rule:nonzero;"/>
</svg>

Before

Width:  |  Height:  |  Size: 219 B

+3 -6
View File
@@ -3,12 +3,9 @@
"compilerOptions": {
"noImplicitAny": true,
"strictNullChecks": true,
"outDir": "./dist",
"outDir": "./build",
"strict": true,
"module": "commonjs",
"moduleResolution": "node",
"lib": ["es2017", "dom"],
"lib": ["es2017"],
"sourceMap": true
},
"include": ["updateReadme.ts"]
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": ["tslint-config-airbnb", "tslint-react"],
"rules": {
"semicolon": [true, "never"],
"max-line-length": [true, 200],
"member-access": true,
"no-else-after-return": false,
"align": false,
"variable-name": false,
"jsx-no-lambda": false
}
}
-81
View File
@@ -1,81 +0,0 @@
#!./node_modules/.bin/ts-node
import * as mustache from 'mustache';
import { readFileSync, writeFileSync } from 'fs';
import axios from 'axios';
import { desc } from 'semver-sort';
interface Release {
name: string;
tag_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 releases: Release[] = (
await axios.get('https://api.github.com/repos/thomasnordquist/mqtt-explorer/releases')
).data;
let latestRelease = desc(releases.map((release) => release.tag_name))[0];
const release = releases.find((release) => release.tag_name === latestRelease)!;
const linux64 = release.assets.filter((asset) =>
/^(?:(?!(armv7l|arm64|i386)).)*\.(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 macDmgIntel = release.assets.find(
(asset) => /\.dmg$/.test(asset.name) && !/arm64/.test(asset.name),
);
const macDmgM1 = release.assets.find(
(asset) => /\.dmg$/.test(asset.name) && /arm64/.test(asset.name),
);
if (linux64.length === 0 || !windowsInstaller || !windowsPortable || !macDmgIntel) {
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('Intel', macDmgIntel.browser_download_url),
macDmgM1 && createUrl('ARM', macDmgM1.browser_download_url),
]
.filter(Boolean)
.join(', ');
const template = readFileSync('./Readme.tpl.md').toString();
const rendered = mustache.render(template, {
linuxTargets,
windowsTargets,
macTargets,
version: release.name,
});
writeFileSync('./Readme.md', rendered);
}
createReadme();
BIN
View File
Binary file not shown.
-115
View File
@@ -1,115 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@types/mustache@^0.8.32":
version "0.8.32"
resolved "https://registry.npmjs.org/@types/mustache/-/mustache-0.8.32.tgz"
integrity sha512-RTVWV485OOf4+nO2+feurk0chzHkSjkjALiejpHltyuMf/13fGymbbNNFrSKdSSUg1TIwzszXdWsVirxgqYiFA==
"@types/node@^13.1.7":
version "13.13.52"
resolved "https://registry.npmjs.org/@types/node/-/node-13.13.52.tgz"
integrity sha512-s3nugnZumCC//n4moGGe6tkNMyYEdaDBitVjwPxXmR5lnMG5dHePinH2EdxkG3Rh1ghFHHixAG4NJhpJW1rthQ==
"@types/semver-sort@^0.0.5":
version "0.0.5"
resolved "https://registry.npmjs.org/@types/semver-sort/-/semver-sort-0.0.5.tgz"
integrity sha512-cIQG7kjdzHcsRrYYkP8N65BogntqItjMjG8uSgPEl1ej4dKj9QC8snCFJb3HHF/Cwqe0s0IflJjUU5wb1yrLDQ==
arg@^4.1.0:
version "4.1.3"
axios@^0.18.0:
version "0.18.1"
resolved "https://registry.npmjs.org/axios/-/axios-0.18.1.tgz"
integrity sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g==
dependencies:
follow-redirects "1.5.10"
is-buffer "^2.0.2"
buffer-from@^1.0.0:
version "1.1.2"
debug@=3.1.0:
version "3.1.0"
resolved "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz"
integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
dependencies:
ms "2.0.0"
diff@^4.0.1:
version "4.0.2"
follow-redirects@1.5.10:
version "1.5.10"
resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz"
integrity sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==
dependencies:
debug "=3.1.0"
is-buffer@^2.0.2:
version "2.0.5"
resolved "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz"
integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==
make-error@^1.1.1:
version "1.3.6"
ms@2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz"
integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
mustache@^3.0.1:
version "3.2.1"
resolved "https://registry.npmjs.org/mustache/-/mustache-3.2.1.tgz"
integrity sha512-RERvMFdLpaFfSRIEe632yDm5nsd0SDKn8hGmcUwswnyiE5mtdZLDybtHAz6hjJhawokF0hXvGLtx9mrQfm6FkA==
semver-regex@^3.1.2:
version "3.1.4"
resolved "https://registry.npmjs.org/semver-regex/-/semver-regex-3.1.4.tgz"
integrity sha512-6IiqeZNgq01qGf0TId0t3NvKzSvUsjcpdEO3AQNeIjR6A2+ckTnQlDpl4qu1bjRv0RzN3FP9hzFmws3lKqRWkA==
semver-sort@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/semver-sort/-/semver-sort-1.0.0.tgz"
integrity sha512-JicVlQKz/C//4BiPmbHEDou6HihXxo5xqB/8Hm9FaLJ6HHkRRvYgCECq4u/z0XF8kyJQ/KAZt++A/kYz/oOSSg==
dependencies:
semver "^5.0.3"
semver-regex "^3.1.2"
semver@^5.0.3:
version "5.7.2"
resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz"
integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
source-map-support@^0.5.17:
version "0.5.21"
dependencies:
buffer-from "^1.0.0"
source-map "^0.6.0"
source-map@^0.6.0:
version "0.6.1"
resolved "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
ts-node@^8.0.3:
version "8.10.2"
resolved "https://registry.npmjs.org/ts-node/-/ts-node-8.10.2.tgz"
integrity sha512-ISJJGgkIpDdBhWVu3jufsWpK3Rzo7bdiIXJjQc0ynKxVOVcg2oIrf2H2cejminGrptVc6q6/uynAHNCuWGbpVA==
dependencies:
arg "^4.1.0"
diff "^4.0.1"
make-error "^1.1.1"
source-map-support "^0.5.17"
yn "3.1.1"
typescript@^3.4.2, typescript@>=2.7:
version "3.9.10"
resolved "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz"
integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==
yn@3.1.1:
version "3.1.1"