Compare commits

..
Author SHA1 Message Date
Thomas Nordquist 8318215776 Update readme 2019-02-18 23:48:12 +01:00
Thomas Nordquist cf3ad3b91f Prepare alpha release 2019-02-18 23:10:42 +01:00
Thomas Nordquist 6e17fc06b3 Fix asset upload 2019-02-18 23:07:42 +01:00
Thomas Nordquist 1655ee6e13 Fix about window icon 2019-02-18 22:52:06 +01:00
Thomas Nordquist 4a6b31bc4c Fix tests 2019-02-18 22:48:04 +01:00
Thomas Nordquist 8b18689456 Add about window 2019-02-18 22:35:21 +01:00
Thomas Nordquist b81e9b2b79 Remove empty topics from tree 2019-02-18 22:21:09 +01:00
Thomas Nordquist 8acf6fe27c Merge branch 'master' into allow-recursive-topic-deletion 2019-02-18 21:31:24 +01:00
Thomas Nordquist 55fbc642c4 Add connection health indicator 2019-02-18 21:03:27 +01:00
Thomas Nordquist 4b5d023d19 Fix disconnect unsubscribe 2019-02-18 20:36:22 +01:00
Thomas Nordquist ff599c470a WiP 2019-02-18 19:58:17 +01:00
Thomas Nordquist ca90f4d32d Clean up 2019-02-18 16:36:53 +01:00
Thomas Nordquist 9c6846a281 Fix disconnect unsubscribe 2019-02-18 16:36:41 +01:00
Thomas Nordquist 50be3e8b0b WiP 2019-02-18 16:02:21 +01:00
Thomas Nordquist e0708a5288 Add author information 2019-02-18 14:54:45 +01:00
Thomas Nordquist d44a6e3159 Add clipboard tooltip 2019-02-18 14:54:29 +01:00
Thomas Nordquist 09afe2a30c Fix title 2019-02-18 14:54:09 +01:00
Thomas Nordquist 160b0b5a04 Persist and restore settings 2019-02-18 13:50:58 +01:00
Thomas Nordquist 590c24a3bd Add "Show Activity" switch 2019-02-18 13:01:22 +01:00
Thomas Nordquist e0978ee64b Optimize recording 2019-02-18 12:26:05 +01:00
Thomas Nordquist 09811eafb7 Update test-recording 2019-02-18 09:59:23 +01:00
Thomas Nordquist 795c410a92 Fix grey start of demo gif 2019-02-17 23:42:23 +01:00
Thomas Nordquist 6c25b42a94 Fix ui-test xpath selector 2019-02-17 23:13:04 +01:00
Thomas Nordquist 9207af0aaa Improve settings storage
- add error reporting
- refactor
2019-02-17 21:02:17 +01:00
Thomas Nordquist 0ad91872a1 Refactor 2019-02-17 18:36:02 +01:00
Thomas Nordquist 8b64818b4c Fix text input update issue 2019-02-17 18:35:08 +01:00
Thomas Nordquist 03462f7ec8 Update React & Material-UI 2019-02-17 17:51:42 +01:00
Thomas Nordquist 3f52944f18 Store settings in lowdb 2019-02-17 17:06:46 +01:00
Thomas Nordquist 1740df6218 Fix legacy connection profile migration 2019-02-17 13:14:02 +01:00
Thomas Nordquist aa32349727 Refactor 2019-02-17 12:55:51 +01:00
Thomas Nordquist 6d81520ff9 Migrate legacy connections 2019-02-17 12:54:51 +01:00
Thomas Nordquist 7d165bb342 Remove hivemq broker 2019-02-17 11:12:07 +01:00
Thomas Nordquist 6f3a5beeaa Fix style 2019-02-17 11:11:52 +01:00
Thomas Nordquist 804c96d041 Preview connection URI 2019-02-17 10:36:56 +01:00
Thomas Nordquist 9c863c8339 Subscribe to configures topics 2019-02-17 10:15:04 +01:00
Thomas Nordquist 3cb89fe502 Show broker stats only if compatible format is used 2019-02-17 09:59:49 +01:00
Thomas Nordquist 1339c1a292 Change expander color 2019-02-17 09:58:09 +01:00
Thomas Nordquist 4b8356632c Refactor 2019-02-17 09:57:54 +01:00
Thomas Nordquist e34a38c1f0 Improve tree node title style 2019-02-17 08:35:25 +01:00
Thomas Nordquist e6ecfde339 Improve stpedded rendering 2019-02-17 08:35:05 +01:00
Thomas Nordquist 688abbd999 Improve look&feel 2019-02-17 00:54:42 +01:00
Thomas Nordquist 5d758c8e6d Fix text overflow 2019-02-16 18:19:40 +01:00
Thomas Nordquist ef6946bdd4 Add default connection profiles 2019-02-16 18:04:40 +01:00
Thomas Nordquist 93ea829987 Add connection profiles (#63)
* Add connection setup

* Refactor

* Fix lifecycle
2019-02-16 05:36:02 -08:00
Thomas Nordquist f316d5699d Improve readme for mobile preview 2019-02-14 11:47:12 +01:00
Thomas Nordquist a58049d678 Update license reference in readme 2019-02-05 12:27:03 +01:00
Thomas Nordquist 873df765a0 Create LICENSE
Delete LICENSE

Create LICENSE
2019-02-05 12:25:55 +01:00
58 changed files with 2206 additions and 715 deletions
+65 -78
View File
@@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@@ -7,17 +7,15 @@
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+5 -5
View File
@@ -16,8 +16,8 @@ The hierarchical view allows for a quick understanding what is going on on your
The app is available for Windows ([portable](https:&#x2F;&#x2F;github.com&#x2F;thomasnordquist&#x2F;MQTT-Explorer&#x2F;releases&#x2F;download&#x2F;v0.1.3&#x2F;MQTT-Explorer-0.1.3.exe), [installer](https:&#x2F;&#x2F;github.com&#x2F;thomasnordquist&#x2F;MQTT-Explorer&#x2F;releases&#x2F;download&#x2F;v0.1.3&#x2F;MQTT-Explorer-Setup-0.1.3.exe)), Linux ([AppImage](https:&#x2F;&#x2F;github.com&#x2F;thomasnordquist&#x2F;MQTT-Explorer&#x2F;releases&#x2F;download&#x2F;v0.1.3&#x2F;MQTT-Explorer-0.1.3-x86_64.AppImage)) and Mac ([dmg](https:&#x2F;&#x2F;github.com&#x2F;thomasnordquist&#x2F;MQTT-Explorer&#x2F;releases&#x2F;download&#x2F;v0.1.3&#x2F;MQTT-Explorer-0.1.3.dmg)).
## Demo
![screencast](https://user-images.githubusercontent.com/7721625/51978075-5bc0eb00-2489-11e9-953a-0965b154557b.gif)
## Screenshots
![screen1](https://user-images.githubusercontent.com/7721625/51770198-6c6a0d80-20e5-11e9-94d5-a0174634253c.png)
## Download
@@ -29,8 +29,8 @@ The app is available for Windows ([portable](https:&#x2F;&#x2F;github.com&#x2F;t
[More Downloads](https://github.com/thomasnordquist/MQTT-Explorer/releases)
## Screenshots
![screen1](https://user-images.githubusercontent.com/7721625/51770198-6c6a0d80-20e5-11e9-94d5-a0174634253c.png)
## Demo
![screencast](https://user-images.githubusercontent.com/7721625/52979302-3f073b80-33d5-11e9-9953-b70ebb349439.gif)
## Performance
This App is optimized to handle thousands of topics and at least hundreds of thousands messages per minute.
@@ -80,4 +80,4 @@ Example telemetry:
```
## License
Not yet decided which license exactly, but the basic idea is: "You may do whatever you want with this tool, except sell it."
[AGPL 3](./LICENSE)
+5 -5
View File
@@ -16,8 +16,8 @@ The hierarchical view allows for a quick understanding what is going on on your
The app is available for Windows ({{windowsTargets}}), Linux ({{linuxTargets}}) and Mac ({{macTargets}}).
## Demo
![screencast](https://user-images.githubusercontent.com/7721625/51978075-5bc0eb00-2489-11e9-953a-0965b154557b.gif)
## Screenshots
![screen1](https://user-images.githubusercontent.com/7721625/51770198-6c6a0d80-20e5-11e9-94d5-a0174634253c.png)
## Download
@@ -29,8 +29,8 @@ The app is available for Windows ({{windowsTargets}}), Linux ({{linuxTargets}})
[More Downloads](https://github.com/thomasnordquist/MQTT-Explorer/releases)
## Screenshots
![screen1](https://user-images.githubusercontent.com/7721625/51770198-6c6a0d80-20e5-11e9-94d5-a0174634253c.png)
## Demo
![screencast](https://user-images.githubusercontent.com/7721625/52979302-3f073b80-33d5-11e9-9953-b70ebb349439.gif)
## Performance
This App is optimized to handle thousands of topics and at least hundreds of thousands messages per minute.
@@ -80,4 +80,4 @@ Example telemetry:
```
## License
Not yet decided which license exactly, but the basic idea is: "You may do whatever you want with this tool, except sell it."
[AGPL 3](./LICENSE)
+4 -4
View File
@@ -2,7 +2,7 @@
<html>
<head>
<meta charset="UTF-8" />
<title>MQTT-Explorer</title>
<title>MQTT Explorer</title>
<script src="./bugtracking.bundle.js"></script>
<style>
@@ -13,7 +13,7 @@
@keyframes example {
0% {background-color: none;}
25% {background-color: #3f51b5;}
25% {background-color: #3f51b5 ;}
50% {background-color: #3f51b5;}
100% {background-color: none;}
}
@@ -28,11 +28,11 @@
}
::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 6px rgba(30,30,30,0.3);
-webkit-box-shadow: inset 0 0 6px rgba(30,30,30,0.3);
}
::-webkit-scrollbar-thumb {
background-color: rgba(140,140,140,0.8);
background-color: rgba(140,140,140,0.8);
}
</style>
<style>
+4 -2
View File
@@ -9,7 +9,7 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@material-ui/core": "^3.9.0",
"@material-ui/core": "^4.0.0-alpha.0",
"@material-ui/icons": "^3.0.1",
"@material-ui/styles": "^3.0.0-alpha.8",
"@types/node": "^10.12.18",
@@ -20,6 +20,7 @@
"@types/react-split-pane": "^0.1.67",
"@types/sha1": "^1.1.1",
"@types/socket.io-client": "^1.4.32",
"@types/uuid": "^3.4.4",
"@types/vis": "^4.21.9",
"awesome-typescript-loader": "^5.2.1",
"compare-versions": "^3.4.0",
@@ -33,7 +34,7 @@
"lodash.throttle": "^4.1.1",
"moving-average": "^1.0.0",
"number-abbreviate": "^2.0.0",
"react": "^16.8.0-alpha.1",
"react": "16.8",
"react-ace": "^6.3.2",
"react-dom": "^16.7.0",
"react-json-view": "^1.19.1",
@@ -48,6 +49,7 @@
"source-map-loader": "^0.2.4",
"style-loader": "^0.23.1",
"typescript": "^3.2.2",
"uuid": "^3.3.2",
"webpack": "^4.28.2",
"webpack-bundle-analyzer": "^3.0.3",
"webpack-cli": "^3.1.2",
+40 -10
View File
@@ -1,25 +1,29 @@
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 ConnectionSetup from './components/ConnectionSetup/ConnectionSetup'
import CssBaseline from '@material-ui/core/CssBaseline'
const Settings = React.lazy(() => import('./components/Settings'))
import ErrorBoundary from './ErrorBoundary'
import Notification from './components/Notification'
import Sidebar from './components/Sidebar/Sidebar'
import TitleBar from './components/TitleBar'
import Tree from './components/Tree/Tree'
import UpdateNotifier from './UpdateNotifier'
import { AppState } from './reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import ErrorBoundary from './ErrorBoundary'
import { default as SplitPane } from 'react-split-pane'
import { globalActions, settingsActions } from './actions'
import { Theme, withStyles } from '@material-ui/core/styles'
const Settings = React.lazy(() => import('./components/Settings'))
interface Props {
name: string
connectionId: string
classes: any
settingsVisible: boolean
error?: string
actions: typeof globalActions
settingsActions: typeof settingsActions
}
class App extends React.PureComponent<Props, {}> {
@@ -28,6 +32,22 @@ class App extends React.PureComponent<Props, {}> {
this.state = { }
}
public componentDidMount() {
this.props.settingsActions.loadSettings()
}
private renderError() {
if (this.props.error) {
const error = typeof this.props.error === 'string' ? this.props.error : JSON.stringify(this.props.error)
return (
<Notification
message={error}
onClose={() => { this.props.actions.showError(undefined) }}
/>
)
}
}
public render() {
const { settingsVisible } = this.props
const { content, contentShift, centerContent, paneDefaults, heightProperty } = this.props.classes
@@ -36,6 +56,7 @@ class App extends React.PureComponent<Props, {}> {
<div className={centerContent}>
<CssBaseline />
<ErrorBoundary>
{this.renderError()}
<React.Suspense fallback={<div>Loading...</div>}>
<Settings />
</React.Suspense>
@@ -66,7 +87,7 @@ class App extends React.PureComponent<Props, {}> {
</div>
</div>
<UpdateNotifier />
<Connection />
<ConnectionSetup />
</ErrorBoundary>
</div >
)
@@ -77,6 +98,8 @@ const mapStateToProps = (state: AppState) => {
return {
settingsVisible: state.settings.visible,
connectionId: state.connection.connectionId,
error: state.globalState.error,
highlightTopicUpdates: state.settings.highlightTopicUpdates,
}
}
@@ -122,4 +145,11 @@ const styles = (theme: Theme) => {
}
}
export default withStyles(styles)(connect(mapStateToProps)(App))
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(globalActions, dispatch),
settingsActions: bindActionCreators(settingsActions, dispatch),
}
}
export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(App))
+7 -8
View File
@@ -1,5 +1,9 @@
import * as React from 'react'
import PersistantStorage from './PersistantStorage'
import SentimentDissatisfied from '@material-ui/icons/SentimentDissatisfied'
import Warning from '@material-ui/icons/Warning'
import { electronRendererTelementry } from 'electron-telemetry'
import { Theme, withStyles } from '@material-ui/core/styles'
import {
Button,
Modal,
@@ -8,11 +12,6 @@ import {
Typography,
} from '@material-ui/core'
import Warning from '@material-ui/icons/Warning'
import SentimentDissatisfied from '@material-ui/icons/SentimentDissatisfied'
import { Theme, withStyles } from '@material-ui/core/styles'
interface State {
error?: Error
}
@@ -41,7 +40,7 @@ class ErrorBoundary extends React.Component<Props, State> {
}
private clearStorage = () => {
localStorage.clear()
PersistantStorage.clear()
window.location = window.location
}
@@ -89,7 +88,7 @@ const styles = (theme: Theme) => ({
maxWidth: 650,
backgroundColor: theme.palette.background.default,
margin: '10vh auto auto auto',
padding: `${2 * theme.spacing.unit}px`,
padding: theme.spacing(2),
outline: 'none',
},
title: {
@@ -105,7 +104,7 @@ const styles = (theme: Theme) => ({
},
buttonPositioning: {
textAlign: 'center' as 'center',
marginTop: `${theme.spacing.unit * 2}px`,
marginTop: theme.spacing(2),
},
})
+87
View File
@@ -0,0 +1,87 @@
import { rendererEvents } from '../../events'
import { v4 } from 'uuid'
import {
storageStoreEvent,
makeStorageResponseEvent,
storageLoadEvent,
storageClearEvent,
makeStorageAcknoledgementEvent,
} from '../../events/StorageEvents'
export interface StorageIdentifier<Model> {
id: string
}
export interface PersistantStorage {
store<Model>(identifier: StorageIdentifier<Model>, data: Model): Promise<void>
load<Model>(identifier: StorageIdentifier<Model>): Promise<Model | undefined>
clear(): Promise<void>
}
class RemoteStorage implements PersistantStorage {
private timeoutCallback(event: any, callback: any, reject: any) {
setTimeout(() => {
reject('remote storage timeout')
rendererEvents.unsubscribe(event, callback)
}, 10000)
}
private expectAck(transactionId: string): Promise<void> {
const ack = makeStorageAcknoledgementEvent(transactionId)
return new Promise<void>((resolve, reject) => {
const callback = (msg: any) => {
if (msg && msg.error) {
reject(msg.error)
} else {
resolve()
}
rendererEvents.unsubscribe(ack, callback)
}
rendererEvents.subscribe(ack, callback)
this.timeoutCallback(ack, callback, reject)
})
}
public store<Model>(identifier: StorageIdentifier<Model>, data: Model): Promise<void> {
const transactionId = v4()
const expectation = this.expectAck(transactionId)
rendererEvents.emit(storageStoreEvent, { data, transactionId, store: identifier.id })
return expectation
}
public load<Model>(identifier: StorageIdentifier<Model>): Promise<Model | undefined> {
const transactionId = v4()
const responseEvent = makeStorageResponseEvent(transactionId)
const promise = new Promise<Model>((resolve, reject) => {
const callback = (msg: any) => {
if (msg.error) {
reject(msg.error)
} else {
resolve(msg.data)
}
rendererEvents.unsubscribe(responseEvent, callback)
}
rendererEvents.subscribe(responseEvent, callback)
this.timeoutCallback(responseEvent, callback, reject)
})
rendererEvents.emit(storageLoadEvent, {
transactionId,
store: identifier.id,
})
return promise
}
public clear(): Promise<void> {
const transactionId = v4()
const expectation = this.expectAck(transactionId)
rendererEvents.emit(storageClearEvent, { transactionId })
return expectation
}
}
export default new RemoteStorage()
+5 -6
View File
@@ -115,7 +115,6 @@ class UpdateNotifier extends React.Component<Props, State> {
vertical: 'top',
horizontal: 'right',
}
console.log(this.state.newerVersions)
return (
<Snackbar
@@ -232,14 +231,14 @@ const styles = (theme: Theme) => ({
color: theme.typography.button.color,
},
close: {
padding: theme.spacing.unit / 2,
padding: '4px',
},
root: {
minWidth: '350px',
maxWidth: '500px',
backgroundColor: theme.palette.background.default,
margin: '20vh auto auto auto',
padding: `${2 * theme.spacing.unit}px`,
padding: theme.spacing(2),
outline: 'none',
},
title: {
@@ -252,7 +251,7 @@ const styles = (theme: Theme) => ({
maxHeight: '28vh',
},
paper: {
padding: `${theme.spacing.unit * 2}px`,
padding: theme.spacing(2),
color: theme.palette.text.secondary,
},
download: {
@@ -266,8 +265,8 @@ const styles = (theme: Theme) => ({
const mapStateToProps = (state: AppState) => {
return {
showUpdateNotification: state.tooBigReducer.showUpdateNotification,
showUpdateDetails: state.tooBigReducer.showUpdateDetails,
showUpdateNotification: state.globalState.showUpdateNotification,
showUpdateDetails: state.globalState.showUpdateDetails,
}
}
+34 -11
View File
@@ -1,12 +1,18 @@
import { ActionTypes, Action, ConnectionState } from '../reducers/Connection'
import { MqttOptions } from '../../../backend/src/DataSource'
import { Dispatch } from 'redux'
import { rendererEvents, addMqttConnectionEvent, makeConnectionStateEvent, removeConnection } from '../../../events'
import { AppState } from '../reducers'
import * as q from '../../../backend/src/Model'
import { showTree } from './Tree'
import * as url from 'url'
import { Action, ActionTypes } from '../reducers/Connection'
import {
addMqttConnectionEvent,
makeConnectionStateEvent,
removeConnection,
rendererEvents,
} from '../../../events'
import { AppState } from '../reducers'
import { Dispatch } from 'redux'
import { MqttOptions, DataSourceState } from '../../../backend/src/DataSource'
import { showTree } from './Tree'
import { TopicViewModel } from '../TopicViewModel'
import { showError } from './Global'
export const connect = (options: MqttOptions, connectionId: string) => (dispatch: Dispatch<any>, getState: () => AppState) => {
dispatch(connecting(connectionId))
@@ -15,6 +21,7 @@ export const connect = (options: MqttOptions, connectionId: string) => (dispatch
const host = url.parse(options.url).hostname
rendererEvents.subscribe(event, (dataSourceState) => {
console.log(dataSourceState)
if (dataSourceState.connected) {
const tree = new q.Tree<TopicViewModel>()
tree.updateWithConnection(rendererEvents, connectionId)
@@ -24,6 +31,27 @@ export const connect = (options: MqttOptions, connectionId: string) => (dispatch
dispatch(showError(dataSourceState.error))
dispatch(disconnect())
}
dispatch(updateHealth(dataSourceState))
})
}
const updateHealth = (dataSourceState: DataSourceState) => (dispatch: Dispatch<any>, getState: () => AppState) => {
let state
if (dataSourceState.connecting) {
state = 'connecting'
} else if (!dataSourceState.connected) {
state = 'offline'
} else if (dataSourceState.connected) {
state = 'online'
} else {
state = undefined
}
console.log(state)
dispatch({
type: ActionTypes.CONNECTION_SET_HEALTH,
health: state,
})
}
@@ -38,11 +66,6 @@ export const connecting: (connectionId: string) => Action = (connectionId: strin
type: ActionTypes.CONNECTION_SET_CONNECTING,
})
export const showError = (error?: string) => ({
error,
type: ActionTypes.CONNECTION_SET_SHOW_ERROR,
})
export const disconnect = () => (dispatch: Dispatch<any>, getState: () => AppState) => {
const { connectionId, tree } = getState().connection
rendererEvents.emit(removeConnection, connectionId)
+119
View File
@@ -0,0 +1,119 @@
import { AppState } from '../reducers'
import { clearLegacyConnectionOptions, loadLegacyConnectionOptions } from '../model/LegacyConnectionSettings'
import { ConnectionOptions, createEmptyConnection, makeDefaultConnections } from '../model/ConnectionOptions'
import { default as persistantStorage, StorageIdentifier } from '../PersistantStorage'
import { Dispatch } from 'redux'
import { showError } from './Global'
import {
ActionTypes,
Action,
} from '../reducers/ConnectionManager'
const storedConnectionsIdentifier: StorageIdentifier<{[s: string]: ConnectionOptions}> = {
id: 'ConnectionManager_connections',
}
export const loadConnectionSettings = () => async (dispatch: Dispatch<any>, getState: () => AppState) => {
let connections
try {
await ensureConnectionsHaveBeenInitialized()
connections = await persistantStorage.load(storedConnectionsIdentifier)
} catch (error) {
dispatch(showError(error))
}
if (!connections) {
return
}
dispatch(setConnections(connections))
const firstKey = Object.keys(connections)[0]
if (firstKey) {
dispatch(selectConnection(firstKey))
}
}
export const saveConnectionSettings = () => async (dispatch: Dispatch<any>, getState: () => AppState) => {
try {
console.log('store settings')
await persistantStorage.store(storedConnectionsIdentifier, getState().connectionManager.connections)
} catch (error) {
dispatch(showError(error))
}
}
export const updateConnection = (connectionId: string, changeSet: any): Action => ({
connectionId,
changeSet,
type: ActionTypes.CONNECTION_MANAGER_UPDATE_CONNECTION,
})
export const addSubscription = (subscription: string, connectionId: string): Action => ({
connectionId,
subscription,
type: ActionTypes.CONNECTION_MANAGER_ADD_SUBSCRIPTION,
})
export const deleteSubscription = (subscription: string, connectionId: string): Action => ({
connectionId,
subscription,
type: ActionTypes.CONNECTION_MANAGER_DELETE_SUBSCRIPTION,
})
export const createConnection = () => (dispatch: Dispatch<any>, getState: () => AppState) => {
const newConnection = createEmptyConnection()
dispatch(addConnection(newConnection))
dispatch(selectConnection(newConnection.id))
}
export const setConnections = (connections: {[s: string]: ConnectionOptions}): Action => ({
connections,
type: ActionTypes.CONNECTION_MANAGER_SET_CONNECTIONS,
})
export const selectConnection = (connectionId: string): Action => ({
selected: connectionId,
type: ActionTypes.CONNECTION_MANAGER_SELECT_CONNECTION,
})
export const addConnection = (connection: ConnectionOptions): Action => ({
connection,
type: ActionTypes.CONNECTION_MANAGER_ADD_CONNECTION,
})
export const toggleAdvancedSettings = (): Action => ({
type: ActionTypes.CONNECTION_MANAGER_TOGGLE_ADVANCED_SETTINGS,
})
export const deleteConnection = (connectionId: string) => (dispatch: Dispatch<any>, getState: () => AppState) => {
const connectionIds = Object.keys(getState().connectionManager.connections)
const connectionIdLocation = connectionIds.indexOf(connectionId)
const remainingIds = connectionIds.filter(id => id !== connectionId)
const nextSelectedConnectionIndex = Math.min(remainingIds.length - 1, connectionIdLocation)
const nextSelectedConnection = remainingIds[nextSelectedConnectionIndex]
dispatch({
connectionId,
type: ActionTypes.CONNECTION_MANAGER_DELETE_CONNECTION,
})
if (nextSelectedConnection) {
dispatch(selectConnection(nextSelectedConnection))
}
}
async function ensureConnectionsHaveBeenInitialized() {
const connections = await persistantStorage.load(storedConnectionsIdentifier)
const requiresInitialization = !connections
if (requiresInitialization) {
const migratedConnection = loadLegacyConnectionOptions()
const defaultConnections = makeDefaultConnections()
persistantStorage.store(storedConnectionsIdentifier, {
...migratedConnection,
...defaultConnections,
})
clearLegacyConnectionOptions()
}
}
+6
View File
@@ -0,0 +1,6 @@
import { ActionTypes } from '../reducers'
export const showError = (error?: string) => ({
error,
type: ActionTypes.showError,
})
+60 -15
View File
@@ -1,31 +1,76 @@
import { Action, ActionTypes, TopicOrder } from '../reducers/Settings'
import { ActionTypes as TreeActionTypes } from '../reducers/Tree'
import { Dispatch } from 'redux'
import { showTree } from './Tree'
import { AppState } from '../reducers'
import * as q from '../../../backend/src/Model'
import { batchActions } from 'redux-batched-actions'
import { AppState } from '../reducers'
import { autoExpandLimitSet } from '../components/Settings'
import { batchActions } from 'redux-batched-actions'
import { default as persistantStorage, StorageIdentifier } from '../PersistantStorage'
import { Dispatch } from 'redux'
import { showError } from './Global'
import { showTree } from './Tree'
import { TopicViewModel } from '../TopicViewModel'
import {
ActionTypes,
SettingsState,
TopicOrder,
} from '../reducers/Settings'
export const setAutoExpandLimit = (autoExpandLimit: number = 0): Action => {
return {
const settingsIdentifier: StorageIdentifier<Partial<SettingsState>> = {
id: 'Settings',
}
export const loadSettings = () => async (dispatch: Dispatch<any>, _getState: () => AppState) => {
try {
const settings = await persistantStorage.load(settingsIdentifier)
dispatch({
settings,
type: ActionTypes.SETTINGS_DID_LOAD_SETTINGS,
})
} catch (error) {
dispatch(showError(error))
}
}
export const storeSettings = () => async (dispatch: Dispatch<any>, getState: () => AppState) => {
const settings = {
...getState().settings,
topicFilter: undefined,
visible: undefined,
}
try {
await persistantStorage.store(settingsIdentifier, settings)
} catch (error) {
dispatch(showError(error))
}
}
export const setAutoExpandLimit = (autoExpandLimit: number = 0) => (dispatch: Dispatch<any>, getState: () => AppState) => {
dispatch({
autoExpandLimit,
type: ActionTypes.SETTINGS_SET_AUTO_EXPAND_LIMIT,
}
})
dispatch(storeSettings())
}
export const toggleSettingsVisibility = (): Action => {
return {
export const toggleSettingsVisibility = () => (dispatch: Dispatch<any>, _getState: () => AppState) => {
dispatch({
type: ActionTypes.SETTINGS_TOGGLE_VISIBILITY,
}
})
dispatch(storeSettings())
}
export const setTopicOrder = (topicOrder: TopicOrder = TopicOrder.none): Action => {
return {
export const togglehighlightTopicUpdates = () => (dispatch: Dispatch<any>, _getState: () => AppState) => {
dispatch({
type: ActionTypes.SETTINGS_TOGGLE_HIGHLIGHT_ACTIVITY,
})
dispatch(storeSettings())
}
export const setTopicOrder = (topicOrder: TopicOrder = TopicOrder.none) => (dispatch: Dispatch<any>, _getState: () => AppState) => {
dispatch({
topicOrder,
type: ActionTypes.SETTINGS_SET_TOPIC_ORDER,
}
})
dispatch(storeSettings())
}
export const filterTopics = (filterStr: string) => (dispatch: Dispatch<any>, getState: () => AppState) => {
+29 -4
View File
@@ -1,21 +1,46 @@
import { Dispatch, Action } from 'redux'
import { AppState } from '../reducers'
import { makePublishEvent, rendererEvents } from '../../../events'
import * as q from '../../../backend/src/Model'
export const clearRetainedTopic = () => (dispatch: Dispatch<Action>, getState: () => AppState) => {
export const clearRetainedTopic = () => (dispatch: Dispatch<any>, getState: () => AppState) => {
const { selectedTopic } = getState().tree
const { connectionId } = getState().connection
if (!selectedTopic) {
return
}
if (!selectedTopic || !connectionId) {
dispatch(clearTopic(selectedTopic, false))
}
export const clearTopic = (topic: q.TreeNode<any>, recursive: boolean, subtopicClearLimit = 50) => (dispatch: Dispatch<any>, getState: () => AppState) => {
const { connectionId } = getState().connection
if (!connectionId) {
return
}
const publishEvent = makePublishEvent(connectionId)
const mqttMessage = {
topic: selectedTopic.path(),
topic: topic.path(),
payload: null,
retain: true,
qos: 0 as 0,
}
console.log('deleting', topic.path())
rendererEvents.emit(publishEvent, mqttMessage)
if (recursive) {
topic.childTopics()
.filter(topic => Boolean(topic.message && topic.message.value))
.slice(0, subtopicClearLimit)
.forEach((topic) => {
console.log('deleting', topic.path())
const mqttMessage = {
topic: topic.path(),
payload: null,
retain: true,
qos: 0 as 0,
}
rendererEvents.emit(publishEvent, mqttMessage)
})
}
}
+3 -1
View File
@@ -4,5 +4,7 @@ import * as treeActions from './Tree'
import * as updateNotifierActions from './UpdateNotifier'
import * as connectionActions from './Connection'
import * as sidebarActons from './Sidebar'
import * as connectionManagerActions from './ConnectionManager'
import * as globalActions from './Global'
export { settingsActions, treeActions, publishActions, updateNotifierActions, connectionActions, sidebarActons }
export { settingsActions, treeActions, publishActions, updateNotifierActions, connectionActions, sidebarActons, connectionManagerActions, globalActions }
+1 -2
View File
@@ -24,7 +24,6 @@ const styles: StyleRulesCallback = theme => ({
height: '224px',
backgroundColor: 'rebeccapurple',
marginBottom: 0,
marginTop: 'auto',
padding: '8px',
},
})
@@ -42,7 +41,7 @@ class BrokerStatistics extends React.Component<Props, {}> {
public render() {
const { tree, classes } = this.props
if (!tree || !tree.findNode('$SYS')) {
if (!tree || !tree.findNode('$SYS/broker/clients/total')) {
return null
}
@@ -0,0 +1,54 @@
import * as React from 'react'
import DeviceHubOutlined from '@material-ui/icons/DeviceHubOutlined'
import { AppState } from '../reducers'
import { connect } from 'react-redux'
import { ConnectionHealth } from '../reducers/Connection'
import { green, orange, red } from '@material-ui/core/colors'
import { StyleRulesCallback, withStyles } from '@material-ui/core/styles'
import { Tooltip } from '@material-ui/core'
const styles: StyleRulesCallback = theme => ({
offline: {
color: red[700],
},
online: {
color: green[500],
},
connecting: {
color: orange[600],
},
})
interface Props {
classes: any
connected: boolean
health?: ConnectionHealth
}
class ConnectionHealthIndicator extends React.Component<Props, {}> {
constructor(props: any) {
super(props)
}
public render() {
const { classes, health, connected } = this.props
if (!health || !connected) {
return null
}
return (
<Tooltip title={`Connection health "${health}"`}>
<DeviceHubOutlined className={classes[health]} />
</Tooltip>
)
}
}
const mapStateToProps = (state: AppState) => {
return {
health: state.connection.health,
connected: state.connection.connected || state.connection.connecting,
}
}
export default connect(mapStateToProps)(withStyles(styles)(ConnectionHealthIndicator))
@@ -0,0 +1,32 @@
import * as React from 'react'
import Add from '@material-ui/icons/Add'
import { Fab } from '@material-ui/core'
import { Theme, withStyles } from '@material-ui/core/styles'
const styles = (theme: Theme) => ({
addButton: {
height: theme.spacing(4),
width: theme.spacing(4),
minHeight: '0',
},
addIcon: {
height: theme.spacing(2),
},
})
export const AddButton = withStyles(styles)((props: {
classes: any,
action: any,
}) => {
return (
<Fab
size="small"
color="secondary"
aria-label="Add"
className={props.classes.addButton}
onClick={props.action}
>
<Add className={props.classes.addIcon} />
</Fab>
)
})
@@ -0,0 +1,154 @@
import * as React from 'react'
import Add from '@material-ui/icons/Add'
import Delete from '@material-ui/icons/Delete'
import Undo from '@material-ui/icons/Undo'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { ConnectionOptions } from '../../model/ConnectionOptions'
import { StyleRulesCallback, Theme, withStyles } from '@material-ui/core/styles'
import {
Button,
Grid,
IconButton,
TextField,
List,
ListItem,
ListItemText,
} from '@material-ui/core'
interface Props {
connection: ConnectionOptions
classes: any
managerActions: typeof connectionManagerActions
}
interface State {
subscription: string
}
class ConnectionSettings extends React.Component<Props, State> {
constructor(props: any) {
super(props)
this.state = { subscription: '' }
}
private handleChange = (name: string) => (event: any) => {
this.props.managerActions.updateConnection(this.props.connection.id, {
[name]: event.target.value,
})
}
public render() {
const { classes } = this.props
return (
<div>
<form className={classes.container} noValidate={true} autoComplete="off">
<Grid container={true} spacing={3}>
<Grid item={true} xs={10} className={classes.gridPadding}>
<TextField
className={classes.fullWidth}
label="Subscription"
margin="normal"
onChange={(event: React.ChangeEvent<HTMLInputElement>) => this.setState({ subscription: event.target.value })}
/>
</Grid>
<Grid item={true} xs={2} className={classes.gridPadding}>
<Button
className={classes.button}
color="secondary"
onClick={() => this.props.managerActions.addSubscription(this.state.subscription, this.props.connection.id)}
variant="contained"
>
<Add /> Add
</Button>
</Grid>
<Grid item={true} xs={12} style={{ padding: 0 }}>
<List
className={classes.topicList}
component="nav"
>
<div className={this.props.classes.list}>
{this.renderSubscriptions()}
</div>
</List>
</Grid>
<Grid item={true} xs={9} className={classes.gridPadding}>
<TextField
className={classes.fullWidth}
label="MQTT Client ID"
margin="normal"
onChange={this.handleChange('clientId')}
/>
</Grid>
<Grid item={true} xs={3} className={classes.gridPadding}>
<Button
variant="contained"
className={classes.button}
onClick={this.props.managerActions.toggleAdvancedSettings}
>
<Undo /> Back
</Button>
</Grid>
</Grid>
</form>
</div>
)
}
private renderSubscriptions() {
const connection = this.props.connection
return connection.subscriptions.map(subscription => (
<Subscription
deleteAction={() => this.props.managerActions.deleteSubscription(subscription, connection.id)}
subscription={subscription}
key={subscription}
/>
))
}
}
const Subscription = (props: {
subscription: string,
deleteAction: any,
}) => {
return (
<ListItem style={{ padding: '0 0 0 8px' }}>
<ListItemText>
<IconButton onClick={props.deleteAction} style={{ padding: '6px' }}>
<Delete />
</IconButton>
{props.subscription}</ListItemText>
</ListItem>
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
managerActions: bindActionCreators(connectionManagerActions, dispatch),
}
}
const styles: StyleRulesCallback<string> = (theme: Theme) => {
return {
fullWidth: {
width: '100%',
},
gridPadding: {
padding: '0 12px !important',
},
topicList: {
height: '180px',
overflowY: 'scroll' as 'scroll',
margin: '8px 16px',
backgroundColor: theme.palette.background.default,
},
button: {
marginTop: theme.spacing(3),
float: 'right',
},
}
}
export default connect(undefined, mapDispatchToProps)(withStyles(styles)(ConnectionSettings))
@@ -1,395 +0,0 @@
import * as React from 'react'
import {
Button,
CircularProgress,
FormControl,
FormControlLabel,
Grid,
IconButton,
Input,
InputAdornment,
InputLabel,
MenuItem,
Modal,
Paper,
Switch,
TextField,
Toolbar,
Typography,
} from '@material-ui/core'
import { connect } from 'react-redux'
import { MqttOptions } from '../../../../backend/src/DataSource'
import { StyleRulesCallback, Theme, withStyles } from '@material-ui/core/styles'
import Notification from './Notification'
import Visibility from '@material-ui/icons/Visibility'
import VisibilityOff from '@material-ui/icons/VisibilityOff'
const sha1 = require('sha1')
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connectionActions } from '../../actions'
interface Props {
classes: {[s: string]: string}
actions: typeof connectionActions,
visible: boolean
connected: boolean
connecting: boolean
error?: string
}
const protocols = [
'mqtt://',
'ws://',
]
interface State {
showPassword: boolean
connectionSettings: ConnectionSettings
}
interface ConnectionSettings {
host: string
protocol: string
port: number
tls: boolean
certValidation: boolean
clientId: string
connectionId?: string
username: string
password: string
}
declare var window: any
class Connection extends React.Component<Props, State> {
private randomClientId: string
private defaultConnectionSettings: ConnectionSettings = {
host: 'iot.eclipse.org',
protocol: protocols[0],
port: 1883,
tls: false,
certValidation: true,
clientId: '',
username: '',
password: '',
connectionId: undefined,
}
constructor(props: any) {
super(props)
const clientIdSha = sha1(`${Math.random()}`).slice(0, 8)
this.randomClientId = `mqtt-explorer-${clientIdSha}`
this.state = {
connectionSettings: this.loadConnectionSettings(),
showPassword: false,
}
}
private loadConnectionSettings(): ConnectionSettings {
let storedSettings: ConnectionSettings | undefined
const storedSettingsString = window.localStorage.getItem('connectionSettings')
try {
storedSettings = storedSettingsString ? JSON.parse(storedSettingsString) : undefined
} catch {
window.localStorage.setItem('connectionSettings', undefined)
}
return storedSettings || this.defaultConnectionSettings
}
private saveConnectionSettings() {
window.localStorage.setItem('connectionSettings', JSON.stringify(this.state.connectionSettings))
}
private handleClickShowPassword = () => {
this.setState({ showPassword: !this.state.showPassword })
}
private optionsFromState(): MqttOptions {
const protocol = this.state.connectionSettings.protocol === 'tcp://' ? 'mqtt://' : this.state.connectionSettings.protocol
const url = `${protocol}${this.state.connectionSettings.host}:${this.state.connectionSettings.port}`
return {
url,
username: this.state.connectionSettings.username || undefined,
password: this.state.connectionSettings.password || undefined,
clientId: this.state.connectionSettings.clientId || this.randomClientId,
tls: this.state.connectionSettings.tls,
certValidation: this.state.connectionSettings.certValidation,
}
}
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,
},
inputFormControl: {
marginTop: '16px',
},
}
}
private handleChange = (name: string) => (event: any) => {
this.setState({
connectionSettings: {
...this.state.connectionSettings,
[name]: event.target.value,
},
})
}
public render() {
const { classes } = this.props
const passwordVisibilityButton = (
<InputAdornment position="end">
<IconButton
aria-label="Toggle password visibility"
onClick={this.handleClickShowPassword}
>
{this.state.showPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
</InputAdornment>
)
let renderError = null
if (this.props.error) {
renderError = (
<Notification
message={this.props.error}
onClose={() => { this.props.actions.showError(undefined) }}
/>
)
}
return (
<div>
{renderError}
<Modal open={this.props.visible} disableAutoFocus={true}>
<Paper className={classes.root}>
<Toolbar>
<Typography className={classes.title} variant="h6" color="inherit">MQTT Connection</Typography>
</Toolbar>
<form className={classes.container} noValidate={true} autoComplete="off">
<Grid container={true} spacing={24}>
<Grid item={true} xs={2}>
{this.renderProtocols()}
</Grid>
<Grid item={true} xs={7}>
<TextField
label="Host"
className={classes.textField}
value={this.state.connectionSettings.host}
onChange={this.handleChange('host')}
margin="normal"
/>
</Grid>
<Grid item={true} xs={3}>
<TextField
label="Port"
className={classes.textField}
value={this.state.connectionSettings.port}
onChange={this.handleChange('port')}
margin="normal"
/>
</Grid>
<Grid item={true} xs={5}>
<TextField
label="Username"
className={classes.textField}
value={this.state.connectionSettings.username}
onChange={this.handleChange('username')}
margin="normal"
/>
</Grid>
<Grid item={true} xs={5}>
<FormControl className={`${classes.textField} ${classes.inputFormControl}`}>
<InputLabel htmlFor="adornment-password">Password</InputLabel>
<Input
id="adornment-password"
type={this.state.showPassword ? 'text' : 'password'}
value={this.state.connectionSettings.password}
onChange={this.handleChange('password')}
endAdornment={passwordVisibilityButton}
/>
</FormControl>
</Grid>
<Grid item={true} xs={5}>
<FormControl className={`${classes.textField} ${classes.inputFormControl}`}>
<InputLabel htmlFor="client-id">Client ID</InputLabel>
<Input
placeholder={this.randomClientId}
className={classes.textField}
value={this.state.connectionSettings.clientId || ''}
onChange={this.handleChange('clientId')}
startAdornment={<span />}
/>
</FormControl>
</Grid>
<Grid item={true} xs={4}>
{this.renderCertValidationSwitch()}
</Grid>
<Grid item={true} xs={3}>
{this.renderTlsSwitch()}
</Grid>
</Grid>
<br />
<div style={{ textAlign: 'right' }}>
<Button variant="contained" color="secondary" className={classes.button} onClick={() => this.saveConnectionSettings()}>
Save
</Button>
{this.renderConnectButton()}
</div>
</form>
</Paper>
</Modal>
</div>
)
}
private renderProtocols() {
const { classes } = this.props
const protocolItems = protocols.map((value: string) => (
<MenuItem key={value} value={value}>
{value}
</MenuItem>
))
return (
<TextField
select={true}
label="Protocol"
className={classes.textField}
value={this.state.connectionSettings.protocol}
onChange={this.handleChange('protocol')}
margin="normal"
>
{protocolItems}
</TextField>
)
}
private renderCertValidationSwitch() {
const { classes } = this.props
const certSwitch = (
<Switch
checked={this.state.connectionSettings.certValidation}
onChange={this.toggleCertValidation}
color="primary"
/>
)
return (
<div className={classes.switch}>
<FormControlLabel
control={certSwitch}
label="Validate certificate"
labelPlacement="bottom"
/>
</div>
)
}
private toggleCertValidation = () => this.setState({
connectionSettings: {
...this.state.connectionSettings,
certValidation: !this.state.connectionSettings.certValidation,
},
})
private renderTlsSwitch() {
const { classes } = this.props
const tlsSwitch = (
<Switch
checked={this.state.connectionSettings.tls}
onChange={this.toggleTls}
color="primary"
/>
)
return (
<div className={classes.switch}>
<FormControlLabel
control={tlsSwitch}
label="Encryption (tls)"
labelPlacement="bottom"
/>
</div>
)
}
private toggleTls = () => this.setState({
connectionSettings: {
...this.state.connectionSettings,
tls: !this.state.connectionSettings.tls,
},
})
private renderConnectButton() {
const { classes, actions } = this.props
if (this.props.connecting) {
return (
<Button variant="contained" color="primary" className={classes.button} onClick={actions.disconnect}>
<CircularProgress size={22} style={{ marginRight: '10px' }} color="secondary" /> Abort
</Button>
)
}
return (
<Button variant="contained" color="primary" className={classes.button} onClick={this.onClickConnect}>
Connect
</Button>
)
}
private onClickConnect = () => {
const connectionId = String(sha1(String(Math.random())).slice(0, 8))
const options = this.optionsFromState()
this.props.actions.connect(options, connectionId)
}
}
const mapStateToProps = (state: AppState) => {
return {
visible: !state.connection.connected,
connected: state.connection.connected,
connecting: state.connection.connecting,
error: state.connection.error,
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(connectionActions, dispatch),
}
}
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(Connection.styles)(Connection))
@@ -0,0 +1,341 @@
import * as React from 'react'
import Delete from '@material-ui/icons/Delete'
import Settings from '@material-ui/icons/Settings'
import PowerSettingsNew from '@material-ui/icons/PowerSettingsNew'
import Save from '@material-ui/icons/Save'
import Visibility from '@material-ui/icons/Visibility'
import VisibilityOff from '@material-ui/icons/VisibilityOff'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionActions, connectionManagerActions } from '../../actions'
import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions'
import { StyleRulesCallback, Theme, withStyles } from '@material-ui/core/styles'
import {
Button,
FormControl,
FormControlLabel,
Grid,
IconButton,
Input,
InputAdornment,
InputLabel,
MenuItem,
Switch,
TextField,
} from '@material-ui/core'
import ConnectionHealthIndicator from '../ConnectionHealthIndicator'
interface Props {
connection: ConnectionOptions
classes: {[s: string]: string}
actions: typeof connectionActions,
managerActions: typeof connectionManagerActions
connected: boolean
connecting: boolean
}
const protocols = [
'mqtt',
'ws',
]
interface State {
showPassword: boolean
}
class ConnectionSettings extends React.Component<Props, State> {
constructor(props: any) {
super(props)
this.state = {
showPassword: false,
}
}
private handleClickShowPassword = () => {
this.setState({ showPassword: !this.state.showPassword })
}
private requiresBasePath() {
return this.props.connection.protocol !== 'mqtt'
}
private renderBasePathInput() {
return (
<Grid item={true} xs={4}>
<TextField
label="Basepath"
className={this.props.classes.textField}
value={this.props.connection.basePath}
onChange={this.handleChange('basePath')}
margin="normal"
/>
</Grid>
)
}
private handleChange = (name: string) => (event: any) => {
if (!this.props.connection) {
return
}
this.updateConnection(name, event.target.value)
}
private updateConnection(name: string, value: any) {
this.props.managerActions.updateConnection(this.props.connection.id, {
[name]: value,
})
}
public render() {
const { classes, connection } = this.props
const passwordVisibilityButton = (
<InputAdornment position="end">
<IconButton
aria-label="Toggle password visibility"
onClick={this.handleClickShowPassword}
>
{this.state.showPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
</InputAdornment>
)
return (
<div>
<form className={classes.container} noValidate={true} autoComplete="off">
<Grid container={true} spacing={3}>
<Grid item={true} xs={5}>
<TextField
label="Name"
className={classes.textField}
value={connection.name}
onChange={this.handleChange('name')}
margin="normal"
/>
</Grid>
<Grid item={true} xs={4}>
{this.renderCertValidationSwitch()}
</Grid>
<Grid item={true} xs={3}>
{this.renderTlsSwitch()}
</Grid>
<Grid item={true} xs={2}>
{this.renderProtocols()}
</Grid>
<Grid item={true} xs={7}>
<TextField
label="Host"
className={classes.textField}
value={connection.host}
onChange={this.handleChange('host')}
margin="normal"
/>
</Grid>
<Grid item={true} xs={3}>
<TextField
label="Port"
className={classes.textField}
value={connection.port}
onChange={this.handleChange('port')}
margin="normal"
/>
</Grid>
{this.requiresBasePath() ? this.renderBasePathInput() : null}
<Grid item={true} xs={this.requiresBasePath() ? 4 : 6}>
<TextField
label="Username"
className={classes.textField}
value={connection.username}
onChange={this.handleChange('username')}
margin="normal"
/>
</Grid>
<Grid item={true} xs={this.requiresBasePath() ? 4 : 6}>
<FormControl className={`${classes.textField} ${classes.inputFormControl}`}>
<InputLabel htmlFor="adornment-password">Password</InputLabel>
<Input
id="adornment-password"
type={this.state.showPassword ? 'text' : 'password'}
value={connection.password}
onChange={this.handleChange('password')}
endAdornment={passwordVisibilityButton}
/>
</FormControl>
</Grid>
</Grid>
<br />
<div>
<div style={{ float: 'left' }}>
<Button variant="contained" className={classes.button} onClick={() => this.props.managerActions.deleteConnection(this.props.connection.id)}>
Delete <Delete />
</Button>
<Button variant="contained" className={classes.button} onClick={this.props.managerActions.toggleAdvancedSettings}>
<Settings /> Advanced
</Button>
</div>
<div style={{ float : 'right' }}>
<Button variant="contained" color="secondary" className={classes.button} onClick={this.props.managerActions.saveConnectionSettings}>
<Save /> Save
</Button>
{this.renderConnectButton()}
</div>
</div>
</form>
</div>
)
}
private renderProtocols() {
const { classes, connection } = this.props
const protocolItems = protocols.map((value: string) => (
<MenuItem key={value} value={value}>
{value}://
</MenuItem>
))
return (
<TextField
select={true}
label="Protocol"
className={classes.textField}
value={connection.protocol}
onChange={this.updateProtocol}
margin="normal"
>
{protocolItems}
</TextField>
)
}
private updateProtocol = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
this.updateConnection('protocol', value)
if (event.target.value === 'mqtt') {
this.updateConnection('basePath', undefined)
} else {
this.updateConnection('basePath', 'ws')
}
}
private renderCertValidationSwitch() {
const { classes, connection } = this.props
const certSwitch = (
<Switch
checked={connection.certValidation}
onChange={this.toggleCertValidation}
color="primary"
/>
)
return (
<div className={classes.switch}>
<FormControlLabel
control={certSwitch}
label="Validate certificate"
labelPlacement="bottom"
/>
</div>
)
}
private toggleCertValidation = () => {
this.props.managerActions.updateConnection(this.props.connection.id, {
certValidation: !this.props.connection.certValidation,
})
}
private renderTlsSwitch() {
const { classes, connection } = this.props
const tlsSwitch = (
<Switch
checked={connection.encryption}
onChange={this.toggleTls}
color="primary"
/>
)
return (
<div className={classes.switch}>
<FormControlLabel
control={tlsSwitch}
label="Encryption (tls)"
labelPlacement="bottom"
/>
</div>
)
}
private toggleTls = () => {
this.props.managerActions.updateConnection(this.props.connection.id, {
encryption: !this.props.connection.encryption,
})
}
private renderConnectButton() {
const { classes, actions } = this.props
if (this.props.connecting) {
return (
<Button variant="contained" color="primary" className={classes.button} onClick={actions.disconnect}>
<ConnectionHealthIndicator />&nbsp;&nbsp;Abort
</Button>
)
}
return (
<Button variant="contained" color="primary" className={classes.button} onClick={this.onClickConnect}>
<PowerSettingsNew /> Connect
</Button>
)
}
private onClickConnect = () => {
if (!this.props.connection) {
return
}
const mqttOptions = toMqttConnection(this.props.connection)
if (mqttOptions) {
this.props.actions.connect(mqttOptions, this.props.connection.id)
}
}
}
const mapStateToProps = (state: AppState) => {
return {
connected: state.connection.connected,
connecting: state.connection.connecting,
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(connectionActions, dispatch),
managerActions: bindActionCreators(connectionManagerActions, dispatch),
}
}
const styles: StyleRulesCallback<string> = (theme: Theme) => {
return {
textField: {
width: '100%',
},
switch: {
marginTop: 0,
},
button: {
margin: theme.spacing(1),
},
inputFormControl: {
marginTop: '16px',
},
}
}
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ConnectionSettings))
@@ -0,0 +1,127 @@
import * as React from 'react'
import ConnectionSettings from './ConnectionSettings'
import ProfileList from './ProfileList'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions'
import { Theme, withStyles } from '@material-ui/core/styles'
import {
Modal,
Paper,
Toolbar,
Typography,
Collapse,
} from '@material-ui/core'
import AdvancedConnectionSettings from './AdvancedConnectionSettings'
interface Props {
actions: any
classes: any
connection?: ConnectionOptions
visible: boolean
showAdvancedSettings: boolean
}
class ConnectionSetup extends React.Component<Props, {}> {
constructor(props: Props) {
super(props)
}
public componentDidMount() {
this.props.actions.loadConnectionSettings()
}
public render() {
const { classes, visible, connection } = this.props
const mqttConnection = connection && toMqttConnection(connection)
return (
<div>
<Modal open={visible} disableAutoFocus={true}>
<Paper className={classes.root}>
<div className={classes.left}><ProfileList /></div>
<div className={classes.right} key={connection && connection.id}>
<Toolbar>
<Typography className={classes.title} variant="h6" color="inherit">MQTT Connection</Typography>
<Typography className={classes.connectionUri}>
{mqttConnection && mqttConnection.url}
</Typography>
</Toolbar>
{this.renderSettings()}
</div>
</Paper>
</Modal>
</div>
)
}
private renderSettings() {
const { connection, showAdvancedSettings } = this.props
if (!connection) {
return null
}
return (
<div>
<Collapse in={!showAdvancedSettings}><ConnectionSettings connection={connection} /></Collapse>
<Collapse in={showAdvancedSettings}><AdvancedConnectionSettings connection={connection} /></Collapse>
</div>
)
}
}
const styles = (theme: Theme) => ({
title: {
color: theme.palette.text.primary,
whiteSpace: 'nowrap' as 'nowrap',
},
root: {
margin: '13vw auto 0 auto',
minWidth: '800px',
maxWidth: '850px',
height: '440px',
outline: 'none' as 'none',
display: 'flex' as 'flex',
},
left: {
borderRightStyle: 'dotted' as 'dotted',
borderRadius: `${theme.shape.borderRadius}px 0 0 ${theme.shape.borderRadius}px`,
paddingTop: theme.spacing(2),
flex: 3,
overflow: 'hidden',
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
},
right: {
borderRadius: `0 ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0`,
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(2),
flex: 10,
},
connectionUri: {
width: '27em',
textOverflow: 'ellipsis' as 'ellipsis',
whiteSpace: 'nowrap' as 'nowrap',
overflow: 'hidden' as 'hidden',
color: theme.palette.text.hint,
fontSize: '0.9em',
marginLeft: theme.spacing(4),
},
})
const mapStateToProps = (state: AppState) => {
return {
visible: !state.connection.connected,
showAdvancedSettings: state.connectionManager.showAdvancedSettings,
connection: state.connectionManager.selected ? state.connectionManager.connections[state.connectionManager.selected] : undefined,
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(connectionManagerActions, dispatch),
}
}
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ConnectionSetup))
@@ -0,0 +1,113 @@
import * as React from 'react'
import { AddButton } from './AddButton'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions'
import { Theme, withStyles } from '@material-ui/core/styles'
import {
List,
ListItem,
ListSubheader,
Typography,
} from '@material-ui/core'
interface Props {
classes: any
selected?: string
connections: {[s: string]: ConnectionOptions}
actions: any
}
class ProfileList extends React.Component<Props, {}> {
constructor(props: Props) {
super(props)
}
private addConnectionButton() {
return <span id="addProfileButton" style={{ marginRight: '12px' }}><AddButton action={this.props.actions.createConnection} /></span>
}
public render() {
return (
<List
style={{ height: '100%' }}
component="nav"
subheader={<ListSubheader component="div">{this.addConnectionButton()}Connections</ListSubheader>}
>
<div className={this.props.classes.list}>
{Object.values(this.props.connections).map(connection => <ConnectionItem connection={connection} key={connection.id} selected={this.props.selected === connection.id} />)}
</div>
</List>
)
}
}
const styles = (theme: Theme) => ({
list: {
marginTop: theme.spacing(1),
height: `calc(100% - ${theme.spacing(6)})`,
overflowY: 'auto' as 'auto',
},
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(connectionManagerActions, dispatch),
}
}
interface ConnectionItemProps {
connection: ConnectionOptions,
actions: any,
selected: boolean,
classes: any
}
const connectionItemStyle = (theme: Theme) => ({
name: {
width: '100%',
textOverflow: 'ellipsis' as 'ellipsis',
whiteSpace: 'nowrap' as 'nowrap',
overflow: 'hidden' as 'hidden',
},
details: {
width: '100%',
textOverflow: 'ellipsis' as 'ellipsis',
whiteSpace: 'nowrap' as 'nowrap',
overflow: 'hidden' as 'hidden',
color: theme.palette.text.hint,
fontSize: '0.7em',
},
})
const connectionItemRenderer = withStyles(connectionItemStyle)((props: ConnectionItemProps) => {
const connection = props.connection.host && toMqttConnection(props.connection)
return (
<ListItem
button={true}
selected={props.selected}
style={{ display: 'block' }}
onClick={() => props.actions.selectConnection(props.connection.id)}
>
<Typography className={props.classes.name}>
{props.connection.name || 'mqtt broker'}
</Typography>
<Typography className={props.classes.details}>
{connection && connection.url}
</Typography>
</ListItem>
)
})
const ConnectionItem = connect(null, mapDispatchToProps)(connectionItemRenderer)
const mapStateToProps = (state: AppState) => {
return {
connections: state.connectionManager.connections,
selected: state.connectionManager.selected,
}
}
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ProfileList))
+28 -19
View File
@@ -1,9 +1,10 @@
import * as React from 'react'
import { Snackbar, SnackbarContent } from '@material-ui/core'
import { Snackbar, SnackbarContent, Tooltip } 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'
import CustomIconButton from './CustomIconButton';
const copy = require('copy-text-to-clipboard')
@@ -32,28 +33,36 @@ class Copy extends React.Component<Props, State> {
public render() {
const icon = !this.state.didCopy
? <FileCopy fontSize="inherit" style={{ cursor: 'pointer' }} onClick={this.handleClick} />
? <FileCopy fontSize="inherit" />
: <Check fontSize="inherit" style={{ cursor: 'default' }} />
return <span>
<span style={{ fontSize: '16px' }}>{icon}</span>
return (
<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>
<Tooltip placement="top" title="Copy to clipboard">
<span style={{ fontSize: '16px' }}>
<CustomIconButton onClick={this.handleClick} >
{icon}
</CustomIconButton>
</span>
</Tooltip>
<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>
</span>
)
}
private handleClick = (event: React.MouseEvent) => {
+34
View File
@@ -0,0 +1,34 @@
import * as React from 'react'
import { IconButton } from '@material-ui/core'
import { withStyles, Theme } from '@material-ui/core/styles'
interface Props {
onClick: any,
classes: any
}
const styles = (theme: Theme) => ({
button: {
padding: '6px',
fontSize: '1.2em',
},
})
class CustomIconButton extends React.Component<Props, {}> {
constructor(props: Props) {
super(props)
}
public render() {
return (
<IconButton className={this.props.classes.button} onClick={this.onClick}>{this.props.children}</IconButton>
)
}
private onClick = (event: React.MouseEvent) => {
event.stopPropagation()
this.props.onClick(event)
}
}
export default withStyles(styles)(CustomIconButton)
+40 -4
View File
@@ -10,15 +10,18 @@ import {
MenuItem,
Select,
Typography,
Switch,
Tooltip,
} 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, treeActions } from '../actions'
import { settingsActions } from '../actions'
import { TopicOrder } from '../reducers/Settings'
import BrokerStatistics from './BrokerStatistics'
import { shell } from 'electron';
export const autoExpandLimitSet = [{
limit: 0,
@@ -47,17 +50,23 @@ const styles: StyleRulesCallback = theme => ({
},
title: {
color: theme.palette.text.primary,
paddingTop: `${theme.spacing.unit}px`,
paddingTop: theme.spacing(1),
...theme.mixins.toolbar,
},
input: {
minWidth: '150px',
margin: `auto ${theme.spacing.unit}px auto ${2 * theme.spacing.unit}px`,
margin: `auto ${theme.spacing(1)} auto ${theme.spacing(2)}px`,
},
author: {
margin: 'auto 8px 8px auto',
color: theme.palette.text.hint,
cursor: 'pointer' as 'pointer',
},
})
interface Props {
autoExpandLimit: number
highlightTopicUpdates: boolean
visible: boolean
store?: any
topicOrder: TopicOrder
@@ -96,12 +105,38 @@ class Settings extends React.Component<Props, {}> {
{this.renderAutoExpand()}
{this.renderNodeOrder()}
{this.renderhighlightTopicUpdates()}
</div>
<Tooltip placement="top" title="App Author">
<Typography className={classes.author} onClick={this.openGithubPage}>
by Thomas Nordquist
</Typography>
</Tooltip>
<BrokerStatistics />
</Drawer>
)
}
private openGithubPage = () => {
shell.openExternal('https://github.com/thomasnordquist')
}
private renderhighlightTopicUpdates() {
const { highlightTopicUpdates, actions } = this.props
return (
<div style={{ padding: '8px', display: 'flex' }}>
<InputLabel htmlFor="toggle-highlight-activity" style={{ flex: '1', marginTop: '8px' }}>Show Activity</InputLabel>
<Switch
name="toggle-highlight-activity"
checked={highlightTopicUpdates}
onChange={actions.togglehighlightTopicUpdates}
color="primary"
/>
</div>
)
}
private renderAutoExpand() {
const { classes, autoExpandLimit } = this.props
@@ -132,7 +167,7 @@ class Settings extends React.Component<Props, {}> {
return (
<div style={{ padding: '8px', display: 'flex' }}>
<InputLabel htmlFor="auto-expand" style={{ flex: '1', marginTop: '8px' }}>Topic order</InputLabel>
<InputLabel htmlFor="auto-expand" style={{ flex: '1', marginTop: '8px' }}>Topic Order</InputLabel>
<Select
value={topicOrder}
onChange={this.onChangeSorting}
@@ -160,6 +195,7 @@ const mapStateToProps = (state: AppState) => {
autoExpandLimit: state.settings.autoExpandLimit,
topicOrder: state.settings.topicOrder,
visible: state.settings.visible,
highlightTopicUpdates: state.settings.highlightTopicUpdates,
}
}
+3 -3
View File
@@ -4,15 +4,15 @@ import { Badge, Typography } from '@material-ui/core'
import { Theme, withStyles } from '@material-ui/core/styles'
interface HistoryItem {
title: string
value: string
title: JSX.Element | string
value: string | any
}
interface Props {
items: HistoryItem[]
onClick?: (index: number, element: EventTarget) => void
classes: any
contentTypeIndicator?: String
contentTypeIndicator?: JSX.Element
}
interface State {
@@ -61,7 +61,7 @@ class MessageHistory extends React.Component<Props, State> {
<div>
<History
items={historyElements}
contentTypeIndicator={showPlot ? <BarChart /> : null}
contentTypeIndicator={showPlot ? <BarChart /> : undefined}
onClick={this.displayMessage}
>
{showPlot ? this.renderPlot(numericMessages) : null}
+67 -20
View File
@@ -1,5 +1,22 @@
import * as React from 'react'
import * as q from '../../../../backend/src/Model'
import * as React from 'react'
import Clear from '@material-ui/icons/Clear'
import Copy from '../Copy'
import CustomIconButton from '../CustomIconButton'
import DateFormatter from '../helper/DateFormatter'
import Delete from '@material-ui/icons/Delete'
import DeleteForever from '@material-ui/icons/DeleteForever'
import ExpandMore from '@material-ui/icons/ExpandMore'
import MessageHistory from './MessageHistory'
import NodeStats from './NodeStats'
import Topic from './Topic'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { default as ReactResizeDetector } from 'react-resize-detector'
import { sidebarActons } from '../../actions'
import { StyleRulesCallback, Theme, withStyles } from '@material-ui/core/styles'
import { TopicViewModel } from '../../TopicViewModel'
import {
Button,
@@ -11,23 +28,9 @@ import {
Popper,
Typography,
Tooltip,
Badge,
} from '@material-ui/core'
import { StyleRulesCallback, Theme, withStyles } from '@material-ui/core/styles'
import { sidebarActons } from '../../actions'
import { AppState } from '../../reducers'
import Copy from '../Copy'
import DateFormatter from '../helper/DateFormatter'
import ExpandMore from '@material-ui/icons/ExpandMore'
import Clear from '@material-ui/icons/Clear'
import MessageHistory from './MessageHistory'
import NodeStats from './NodeStats'
import Topic from './Topic'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import { TopicViewModel } from '../../TopicViewModel'
import { default as ReactResizeDetector } from 'react-resize-detector'
const throttle = require('lodash.throttle')
const Publish = React.lazy(() => import('./Publish/Publish'))
@@ -92,17 +95,63 @@ class Sidebar extends React.Component<Props, State> {
private detailsStyle = { padding: '0px 16px 8px 8px', display: 'block' }
private renderTopicDeleteButton() {
if (!this.props.node) {
return null
}
return (
<CustomIconButton onClick={() => this.deleteTopic(this.props.node)}>
<Tooltip title="Clear this topic">
<Delete />
</Tooltip>
</CustomIconButton>
)
}
private renderRecursiveTopicDeleteButton() {
const deleteLimit = 50
const topicCount = this.props.node ? this.props.node.childTopicCount() : 0
if (!this.props.node || topicCount === 0) {
return null
}
return (
<CustomIconButton onClick={() => this.deleteTopic(this.props.node, true, deleteLimit)}>
<Tooltip title={`Deletes up to ${deleteLimit} sub-topics with a single click`}>
<Badge
classes={{ badge: this.props.classes.badge }}
badgeContent={<span style={{ whiteSpace: 'nowrap' }}>{topicCount >= deleteLimit ? '50+' : topicCount}</span>}
color="secondary"
>
<DeleteForever color="action" />
</Badge>
</Tooltip>
</CustomIconButton>
)
}
private deleteTopic = (topic?: q.TreeNode<TopicViewModel>, recursive: boolean = false, maxCount = 50) => {
if (!topic) {
return
}
this.props.actions.clearTopic(topic, recursive, maxCount)
}
private renderNode() {
const { classes, node } = this.props
const copyTopic = node ? <Copy value={node.path()} /> : null
const deleteTopic = this.renderTopicDeleteButton()
const deleteRecursiveTopic = this.renderRecursiveTopicDeleteButton()
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>
<Typography className={classes.heading}>Topic {copyTopic} {deleteTopic} {deleteRecursiveTopic}</Typography>
</ExpansionPanelSummary>
<ExpansionPanelDetails style={this.detailsStyle}>
<Topic node={this.props.node} didSelectNode={this.updateNode} />
@@ -147,7 +196,6 @@ class Sidebar extends React.Component<Props, State> {
}
private valueRenderWidthChange = (width: number) => {
console.log(width)
this.setState({ valueRenderWidth: width })
}
@@ -170,7 +218,6 @@ class Sidebar extends React.Component<Props, State> {
size="small"
color="secondary"
variant="contained"
mini={true}
style={{ marginTop: '-3px', padding: '0px 4px', minHeight: '24px' }}
onClick={this.props.actions.clearRetainedTopic}
>
@@ -230,7 +277,7 @@ const styles: StyleRulesCallback<string> = (theme: Theme) => {
height: '100%',
},
valuePaper: {
margin: `${theme.spacing.unit}px ${theme.spacing.unit}px ${theme.spacing.unit}px ${theme.spacing.unit}px`,
margin: theme.spacing(1),
},
heading: {
fontSize: theme.typography.pxToRem(15),
+22 -15
View File
@@ -1,17 +1,23 @@
import * as React from 'react'
import { AppBar, Button, IconButton, InputBase, Toolbar, Typography } from '@material-ui/core'
import { StyleRulesCallback, withStyles } from '@material-ui/core/styles'
import ClearAdornment from './helper/ClearAdornment'
import CloudOff from '@material-ui/icons/CloudOff'
import Menu from '@material-ui/icons/Menu'
import Search from '@material-ui/icons/Search'
import {
AppBar,
Button,
IconButton,
InputBase,
Toolbar,
Typography,
} from '@material-ui/core'
import { AppState } from '../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionActions, settingsActions } from '../actions'
import { fade } from '@material-ui/core/styles/colorManipulator'
import { settingsActions, connectionActions } from '../actions'
import { AppState } from '../reducers'
import ClearAdornment from './helper/ClearAdornment'
import { StyleRulesCallback, withStyles } from '@material-ui/core/styles'
import ConnectionHealthIndicator from './ConnectionHealthIndicator';
const styles: StyleRulesCallback = theme => ({
title: {
@@ -27,16 +33,16 @@ const styles: StyleRulesCallback = theme => ({
'&:hover': {
backgroundColor: fade(theme.palette.common.white, 0.25),
},
marginRight: theme.spacing.unit * 2,
marginRight: theme.spacing(2),
marginLeft: 0,
width: '100%',
[theme.breakpoints.up('sm')]: {
marginLeft: theme.spacing.unit * 3,
marginLeft: theme.spacing(3),
width: 'auto',
},
},
searchIcon: {
width: theme.spacing.unit * 8,
width: theme.spacing(8),
height: '100%',
position: 'absolute',
pointerEvents: 'none',
@@ -49,10 +55,10 @@ const styles: StyleRulesCallback = theme => ({
width: '100%',
},
inputInput: {
paddingTop: theme.spacing.unit,
paddingRight: theme.spacing.unit,
paddingBottom: theme.spacing.unit,
paddingLeft: theme.spacing.unit * 10,
paddingTop: theme.spacing(1),
paddingRight: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(10),
transition: theme.transitions.create('width'),
width: '100%',
[theme.breakpoints.up('md')]: {
@@ -89,11 +95,12 @@ class TitleBar extends React.Component<Props, {}> {
<IconButton className={classes.menuButton} color="inherit" aria-label="Menu" onClick={actions.settings.toggleSettingsVisibility}>
<Menu />
</IconButton>
<Typography className={classes.title} variant="h6" color="inherit">MQTT-Explorer</Typography>
<Typography className={classes.title} variant="h6" color="inherit">MQTT Explorer</Typography>
{this.renderSearch()}
<Button style={{ margin: 'auto 8px auto auto' }} onClick={actions.connection.disconnect}>
Disconnect <CloudOff style={{ marginRight: '8px', paddingLeft: '8px' }}/>
</Button>
<ConnectionHealthIndicator />
</Toolbar>
</AppBar>
)
+3 -1
View File
@@ -22,9 +22,9 @@ interface Props {
tree?: q.Tree<TopicViewModel>
filter: string
host?: string
topicOrder: TopicOrder
autoExpandLimit: number
highlightTopicUpdates: boolean
}
interface State {
@@ -111,6 +111,7 @@ class Tree extends React.PureComponent<Props, State> {
topicOrder={this.props.topicOrder}
lastUpdate={tree.lastUpdate}
didSelectTopic={this.props.actions.selectTopic}
highlightTopicUpdates={this.props.highlightTopicUpdates}
/>
</div>
)
@@ -128,6 +129,7 @@ const mapStateToProps = (state: AppState) => {
host: state.connection.host,
autoExpandLimit: state.settings.autoExpandLimit,
topicOrder: state.settings.topicOrder,
highlightTopicUpdates: state.settings.highlightTopicUpdates,
}
}
+19 -15
View File
@@ -30,10 +30,10 @@ const styles = (theme: Theme) => {
marginTop: '-1px',
},
selected: {
backgroundColor: 'rgba(120, 120, 120, 0.55)',
backgroundColor: 'rgba(170, 170, 170, 0.55)',
},
hover: {
backgroundColor: 'rgba(80, 80, 80, 0.55)',
backgroundColor: 'rgba(100, 100, 100, 0.55)',
},
}
}
@@ -51,6 +51,7 @@ interface Props {
autoExpandLimit: number
lastUpdate: number
didSelectTopic: any
highlightTopicUpdates: boolean
}
interface State {
@@ -64,12 +65,10 @@ class TreeNode extends React.Component<Props, State> {
private dirtyEdges: boolean = true
private dirtyMessage: boolean = true
private animationDirty: boolean = false
private lastRenderTime = 0
private cssAnimationWasSetAt?: number
private willUpdateTime: number = performance.now()
private titleRef?: React.RefObject<HTMLDivElement> = React.createRef<HTMLDivElement>()
private nodeRef?: React.RefObject<HTMLDivElement> = React.createRef<HTMLDivElement>()
private subnodesDidchange = () => {
@@ -129,7 +128,6 @@ class TreeNode extends React.Component<Props, State> {
public componentWillUnmount() {
const { treeNode } = this.props
this.removeSubscriber(treeNode)
this.titleRef = undefined
this.nodeRef = undefined
}
@@ -182,28 +180,29 @@ class TreeNode extends React.Component<Props, State> {
const isDirty = this.dirtyEdges || this.dirtyMessage || this.dirtySubnodes
this.dirtyEdges = this.dirtyMessage = this.dirtySubnodes = false
const shouldStartAnimation = (isDirty && !this.animationDirty) && !this.props.isRoot
const shouldStartAnimation = (isDirty && !this.animationDirty) && !this.props.isRoot && this.props.highlightTopicUpdates
const animation = shouldStartAnimation ? { willChange: 'auto', translateZ: 0, animation: 'example 0.5s' } : {}
this.animationDirty = shouldStartAnimation
const highlightClass = this.state.selected ? this.props.classes.selected : (this.state.mouseOver ? this.props.classes.hover : '')
return (
<div
key={this.props.treeNode.hash()}
className={`${classes.node} ${this.props.className} ${highlightClass}`}
className={`${classes.node} ${this.props.className}`}
onClick={this.didClickNode}
onMouseOver={this.mouseOver}
onMouseOut={this.mouseOut}
ref={this.nodeRef}
>
<span ref={this.titleRef} style={animation}>
<TreeNodeTitle
collapsed={this.collapsed()}
treeNode={this.props.treeNode}
name={this.props.name}
didSelectNode={this.didSelectTopic}
/>
</span>
<TreeNodeTitle
style={animation}
collapsed={this.collapsed()}
treeNode={this.props.treeNode}
name={this.props.name}
didSelectNode={this.didSelectTopic}
className={highlightClass}
/>
{this.renderNodes()}
</div>
)
@@ -234,6 +233,10 @@ class TreeNode extends React.Component<Props, State> {
}
private renderNodes() {
if (this.collapsed()) {
return null
}
return (
<TreeNodeSubnodes
animateChanges={this.props.animateChages}
@@ -243,6 +246,7 @@ class TreeNode extends React.Component<Props, State> {
topicOrder={this.props.topicOrder}
lastUpdate={this.props.treeNode.lastUpdate}
didSelectTopic={this.props.didSelectTopic}
highlightTopicUpdates={this.props.highlightTopicUpdates}
/>
)
}
+6 -10
View File
@@ -1,10 +1,7 @@
import * as React from 'react'
import * as q from '../../../../backend/src/Model'
import { AppState } from '../../reducers'
import TreeNode from './TreeNode'
import { connect } from 'react-redux'
import { TopicOrder } from '../../reducers/Settings'
import { Theme, withStyles } from '@material-ui/core'
import { TopicViewModel } from '../../TopicViewModel'
@@ -15,13 +12,12 @@ export interface Props {
filter?: string
collapsed?: boolean | undefined
classes: any
lastUpdate: number
topicOrder: TopicOrder
selectedTopic?: q.TreeNode<TopicViewModel>
autoExpandLimit: number
didSelectTopic: any
highlightTopicUpdates: boolean
}
interface State {
@@ -55,13 +51,13 @@ class TreeNodeSubnodes extends React.Component<Props, State> {
}
private renderMore() {
this.renderMoreAnimationFrame = window.requestAnimationFrame(() => {
this.renderMoreAnimationFrame = window.requestIdleCallback(() => {
this.setState({ ...this.state, alreadyAdded: this.state.alreadyAdded * 1.5 })
})
}, { timeout: 500 })
}
public componentWillUnmount() {
window.cancelAnimationFrame(this.renderMoreAnimationFrame)
window.cancelIdleCallback(this.renderMoreAnimationFrame)
}
public render() {
@@ -71,7 +67,6 @@ class TreeNodeSubnodes extends React.Component<Props, State> {
}
if (this.state.alreadyAdded < edges.length) {
const delta = Math.min(this.state.alreadyAdded, edges.length - this.state.alreadyAdded)
this.renderMore()
}
@@ -87,6 +82,7 @@ class TreeNodeSubnodes extends React.Component<Props, State> {
autoExpandLimit={this.props.autoExpandLimit}
lastUpdate={node.lastUpdate}
didSelectTopic={this.props.didSelectTopic}
highlightTopicUpdates={this.props.highlightTopicUpdates}
/>
)
})
@@ -105,7 +101,7 @@ const styles = (theme: Theme) => ({
clear: 'both' as 'both',
},
listItem: {
padding: '3px 0px 0px 8px',
padding: `0px 0px 0px ${theme.spacing(1)}`,
},
})
+15 -5
View File
@@ -26,9 +26,14 @@ class TreeNodeTitle extends React.Component<TreeNodeProps, {}> {
}, 5)
public render() {
const { classes, treeNode, style, className } = this.props
return (
<span className={this.props.classes.title} onMouseOver={this.props.treeNode.message ? this.mouseOver : undefined}>
{this.renderExpander()} {this.renderSourceEdge()} {this.renderCollapsedSubnodes()} {this.renderValue()}
<span
className={`${classes.title} ${className}`}
onMouseOver={treeNode.message ? this.mouseOver : undefined}
style={style}
>
<span className={classes.expander}>{this.renderExpander()}</span> {this.renderSourceEdge()} {this.renderCollapsedSubnodes()} {this.renderValue()}
</span>
)
}
@@ -69,17 +74,22 @@ const styles = (theme: Theme) => ({
overflow: 'hidden' as 'hidden',
textOverflow: 'ellipsis' as 'ellipsis',
padding: '0',
marginLeft: '5px',
display: 'inline-block' as 'inline-block',
},
sourceEdge: {
fontWeight: 'bold' as 'bold',
overflow: 'hidden' as 'hidden',
display: 'inline-block' as 'inline-block',
},
expander: {
color: theme.palette.type === 'light' ? '#222' : '#eee',
},
title: {
borderRadius: '4px',
lineHeight: '1em',
display: 'inline-block' as 'inline-block',
whiteSpace: 'nowrap' as 'nowrap',
padding: '1px 4px 0px 4px',
height: '16px',
margin: '1px 0px 2px 0px',
},
collapsedSubnodes: {
color: theme.palette.text.secondary,
@@ -8,6 +8,9 @@ interface Props {
style?: React.CSSProperties
}
/**
* Clear button for text input fields
*/
class ClearAdornment extends React.Component<Props, {}> {
public render() {
if (this.props.value) {
+73
View File
@@ -0,0 +1,73 @@
import { MqttOptions } from '../../../backend/src/DataSource'
import { v4 } from 'uuid'
const sha1 = require('sha1')
export interface ConnectionOptions {
type: 'mqtt'
id: string
host: string
protocol: 'mqtt' | 'ws'
basePath?: string
port: number
name: string
username?: string
password?: string
encryption: boolean
certValidation: boolean
clientId?: string
subscriptions: string[]
}
export function toMqttConnection(options: ConnectionOptions): MqttOptions | undefined {
if (options.type !== 'mqtt') {
return
}
return {
url: `${options.protocol}://${options.host}:${options.port}/${options.basePath || ''}`,
username: options.username,
password: options.password,
tls: options.encryption,
certValidation: options.certValidation,
subscriptions: options.subscriptions,
}
}
export function generateClienId() {
const clientIdSha = sha1(`${Math.random()}`).slice(0, 8)
return `mqtt-explorer-${clientIdSha}`
}
export function createEmptyConnection(): ConnectionOptions {
return {
certValidation: true,
clientId: generateClienId(),
id: v4() as string,
name: 'new connection',
encryption: false,
password: undefined,
username: undefined,
subscriptions: ['#', '$SYS/#'],
type: 'mqtt',
host: '',
port: 1883,
protocol: 'mqtt',
}
}
export function makeDefaultConnections() {
return {
'iot.eclipse.org': {
...createEmptyConnection(),
id: 'iot.eclipse.org',
name: 'iot.eclipse.org',
host: 'iot.eclipse.org',
},
'test.mosquitto.org': {
...createEmptyConnection(),
id: 'test.mosquitto.org',
name: 'test.mosquitto.org',
host: 'test.mosquitto.org',
},
}
}
+59
View File
@@ -0,0 +1,59 @@
import { ConnectionOptions, createEmptyConnection } from './ConnectionOptions'
import { v4 } from 'uuid'
interface LegacyConnectionSettings {
host: string
protocol: string
port: number
tls: boolean
certValidation: boolean
clientId: string
connectionId?: string
username: string
password: string
}
export function clearLegacyConnectionOptions() {
window.localStorage.setItem('connectionSettings', '')
}
export function loadLegacyConnectionOptions(): {[s: string]: ConnectionOptions} | {} {
const legacySettingsString = window.localStorage.getItem('connectionSettings')
if (!legacySettingsString) {
return {}
}
let legacyConnection
try {
legacyConnection = JSON.parse(legacySettingsString) as LegacyConnectionSettings
} catch {
return {}
}
const protocolMap: {[s: string]: string} = {
'tcp://': 'mqtt',
'ws://': 'ws',
'mqtt://': 'mqtt',
}
const migratedOptions: Partial<ConnectionOptions> = {
certValidation: legacyConnection.certValidation,
host: legacyConnection.host,
name: legacyConnection.host,
protocol: protocolMap[legacyConnection.protocol] as any,
port: legacyConnection.port,
username: legacyConnection.username,
password: legacyConnection.password,
clientId: legacyConnection.clientId,
encryption: legacyConnection.tls,
}
const emptyConnection = createEmptyConnection()
return {
[emptyConnection.id]: {
...emptyConnection,
...migratedOptions,
},
}
}
+17
View File
@@ -4,6 +4,7 @@ import * as q from '../../../backend/src/Model'
import { MqttOptions } from '../../../backend/src/DataSource'
import { TopicViewModel } from '../TopicViewModel'
export type ConnectionHealth = 'offline' | 'online' | 'connecting'
export interface ConnectionState {
host?: string
tree?: q.Tree<TopicViewModel>
@@ -12,6 +13,7 @@ export interface ConnectionState {
error?: string
connected: boolean
connecting: boolean
health?: ConnectionHealth
}
export type Action = SetConnecting | SetConnected | SetDisconnected | ShowError
@@ -21,6 +23,7 @@ export enum ActionTypes {
CONNECTION_SET_CONNECTED = 'CONNECTION_SET_CONNECTED',
CONNECTION_SET_DISCONNECTED = 'CONNECTION_SET_DISCONNECTED',
CONNECTION_SET_SHOW_ERROR = 'CONNECTION_SET_SHOW_ERROR',
CONNECTION_SET_HEALTH = 'CONNECTION_SET_HEALTH',
}
export interface SetConnecting {
@@ -38,6 +41,11 @@ export interface SetDisconnected {
type: ActionTypes.CONNECTION_SET_DISCONNECTED
}
export interface SetHealth {
health: ConnectionHealth
type: ActionTypes.CONNECTION_SET_DISCONNECTED
}
export interface ShowError {
type: ActionTypes.CONNECTION_SET_SHOW_ERROR
error?: Error
@@ -46,6 +54,7 @@ export interface ShowError {
const initialState: ConnectionState = {
connected: false,
connecting: false,
health: undefined,
}
export const connectionReducer = createReducer(initialState, {
@@ -53,6 +62,7 @@ export const connectionReducer = createReducer(initialState, {
CONNECTION_SET_CONNECTED: setConnected,
CONNECTION_SET_DISCONNECTED: setDisconnected,
CONNECTION_SET_SHOW_ERROR: showError,
CONNECTION_SET_HEALTH: setHealth,
})
function setConnecting(state: ConnectionState, action: SetConnecting): ConnectionState {
@@ -64,6 +74,13 @@ function setConnecting(state: ConnectionState, action: SetConnecting): Connectio
}
}
function setHealth(state: ConnectionState, action: SetHealth): ConnectionState {
return {
...state,
health: action.health,
}
}
function setConnected(state: ConnectionState, action: SetConnected): ConnectionState {
return {
...state,
+174
View File
@@ -0,0 +1,174 @@
import { Action } from 'redux'
import { ConnectionOptions } from '../model/ConnectionOptions'
import { createReducer } from './lib'
export interface ConnectionManagerState {
connections: {[s:string]: ConnectionOptions},
selected?: string
showAdvancedSettings: boolean
}
const initialState: ConnectionManagerState = {
connections: {},
selected: undefined,
showAdvancedSettings: false,
}
export type Action = SetConnections | SelectConnection | UpdateConnection | AddConnection | DeleteConnection | ToggleAdvancedSettings | DeleteSubscription | AddSubscription
export enum ActionTypes {
CONNECTION_MANAGER_SET_CONNECTIONS = 'CONNECTION_MANAGER_SET_CONNECTIONS',
CONNECTION_MANAGER_SELECT_CONNECTION = 'CONNECTION_MANAGER_SELECT_CONNECTION',
CONNECTION_MANAGER_UPDATE_CONNECTION = 'CONNECTION_MANAGER_UPDATE_CONNECTION',
CONNECTION_MANAGER_ADD_CONNECTION = 'CONNECTION_MANAGER_ADD_CONNECTION',
CONNECTION_MANAGER_DELETE_CONNECTION = 'CONNECTION_MANAGER_DELETE_CONNECTION',
CONNECTION_MANAGER_TOGGLE_ADVANCED_SETTINGS = 'CONNECTION_MANAGER_TOGGLE_ADVANCED_SETTINGS',
CONNECTION_MANAGER_ADD_SUBSCRIPTION = 'CONNECTION_MANAGER_ADD_SUBSCRIPTION',
CONNECTION_MANAGER_DELETE_SUBSCRIPTION = 'CONNECTION_MANAGER_DELETE_SUBSCRIPTION',
}
export interface SetConnections {
type: ActionTypes.CONNECTION_MANAGER_SET_CONNECTIONS
connections: {[s:string]: ConnectionOptions}
}
export interface SelectConnection {
type: ActionTypes.CONNECTION_MANAGER_SELECT_CONNECTION
selected: string
}
export interface AddSubscription {
type: ActionTypes.CONNECTION_MANAGER_ADD_SUBSCRIPTION
subscription: string
connectionId: string
}
export interface DeleteSubscription {
type: ActionTypes.CONNECTION_MANAGER_DELETE_SUBSCRIPTION
subscription: string
connectionId: string
}
export interface UpdateConnection {
type: ActionTypes.CONNECTION_MANAGER_UPDATE_CONNECTION
connectionId: string
changeSet: any
}
export interface AddConnection {
type: ActionTypes.CONNECTION_MANAGER_ADD_CONNECTION
connection: ConnectionOptions
}
export interface DeleteConnection {
type: ActionTypes.CONNECTION_MANAGER_DELETE_CONNECTION
connectionId: string
}
export interface ToggleAdvancedSettings {
type: ActionTypes.CONNECTION_MANAGER_TOGGLE_ADVANCED_SETTINGS
}
export const connectionManagerReducer = createReducer(initialState, {
CONNECTION_MANAGER_SET_CONNECTIONS: setConnections,
CONNECTION_MANAGER_SELECT_CONNECTION: selectConnection,
CONNECTION_MANAGER_UPDATE_CONNECTION: updateConnection,
CONNECTION_MANAGER_ADD_CONNECTION: addConnection,
CONNECTION_MANAGER_DELETE_CONNECTION: deleteConnection,
CONNECTION_MANAGER_TOGGLE_ADVANCED_SETTINGS: toggleAdvancedSettings,
CONNECTION_MANAGER_DELETE_SUBSCRIPTION: deleteSubscription,
CONNECTION_MANAGER_ADD_SUBSCRIPTION: addSubscription,
})
function setConnections(state: ConnectionManagerState, action: SetConnections): ConnectionManagerState {
return {
...state,
connections: action.connections,
}
}
function selectConnection(state: ConnectionManagerState, action: SelectConnection): ConnectionManagerState {
return {
...state,
selected: action.selected,
}
}
function toggleAdvancedSettings(state: ConnectionManagerState, action: ToggleAdvancedSettings): ConnectionManagerState {
return {
...state,
showAdvancedSettings: !state.showAdvancedSettings,
}
}
function addConnection(state: ConnectionManagerState, action: AddConnection): ConnectionManagerState {
return {
...state,
connections: {
...state.connections,
[action.connection.id]: action.connection,
},
}
}
function addSubscription(state: ConnectionManagerState, action: AddSubscription): ConnectionManagerState {
const connection = state.connections[action.connectionId]
const alreadyExists = connection.subscriptions.indexOf(action.subscription) !== -1
if (alreadyExists) {
return state
}
const newSubscriptions = connection.subscriptions.slice()
newSubscriptions.push(action.subscription)
return {
...state,
connections: {
...state.connections,
[action.connectionId]: {
...connection,
subscriptions: newSubscriptions,
},
},
}
}
function deleteSubscription(state: ConnectionManagerState, action: AddSubscription): ConnectionManagerState {
const connection = state.connections[action.connectionId]
const newSubscriptions = connection.subscriptions.filter(s => s !== action.subscription)
return {
...state,
connections: {
...state.connections,
[action.connectionId]: {
...connection,
subscriptions: newSubscriptions,
},
},
}
}
function deleteConnection(state: ConnectionManagerState, action: DeleteConnection): ConnectionManagerState {
const connections = { ...state.connections }
delete connections[action.connectionId]
return {
...state,
connections,
}
}
function updateConnection(state: ConnectionManagerState, action: UpdateConnection): ConnectionManagerState {
let connection = state.connections[action.connectionId]
connection = {
...connection,
...action.changeSet,
}
return {
...state,
connections: {
...state.connections,
[action.connectionId]: connection,
},
}
}
+34 -5
View File
@@ -13,20 +13,24 @@ export interface SettingsState {
visible: boolean
topicOrder: TopicOrder
topicFilter?: string
highlightTopicUpdates: boolean
}
export type Action = SetAutoExpandLimit | ToggleVisibility | SetTopicOrder | FilterTopics
export type Action = SetAutoExpandLimit | ToggleVisibility | SetTopicOrder | FilterTopics | TogglehighlightTopicUpdates
export enum ActionTypes {
SETTINGS_SET_AUTO_EXPAND_LIMIT = 'SETTINGS_SET_AUTO_EXPAND_LIMIT',
SETTINGS_TOGGLE_VISIBILITY = 'SETTINGS_TOGGLE_VISIBILITY',
SETTINGS_SET_TOPIC_ORDER = 'SETTINGS_SET_TOPIC_ORDER',
SETTINGS_FILTER_TOPICS = 'SETTINGS_FILTER_TOPICS',
SETTINGS_TOGGLE_HIGHLIGHT_ACTIVITY = 'SETTINGS_TOGGLE_HIGHLIGHT_ACTIVITY',
SETTINGS_DID_LOAD_SETTINGS = 'SETTINGS_DID_LOAD_SETTINGS',
}
const initialState: SettingsState = {
autoExpandLimit: 0,
topicOrder: TopicOrder.none,
visible: false,
highlightTopicUpdates: true,
}
export const settingsReducer = createReducer(initialState, {
@@ -34,12 +38,19 @@ export const settingsReducer = createReducer(initialState, {
SETTINGS_TOGGLE_VISIBILITY: toggleVisibility,
SETTINGS_SET_TOPIC_ORDER: setTopicOrder,
SETTINGS_FILTER_TOPICS: filterTopics,
SETTINGS_TOGGLE_HIGHLIGHT_ACTIVITY: togglehighlightTopicUpdates,
SETTINGS_DID_LOAD_SETTINGS: didLoadSettings,
})
function setAutoExpandLimit(state: SettingsState, action: SetAutoExpandLimit) {
export interface DidLoadSettings {
type: ActionTypes.SETTINGS_DID_LOAD_SETTINGS
settings: Partial<SettingsState>
}
function didLoadSettings(state: SettingsState, action: DidLoadSettings) {
return {
...state,
autoExpandLimit: action.autoExpandLimit,
...action.settings,
}
}
@@ -48,10 +59,21 @@ export interface SetAutoExpandLimit {
autoExpandLimit: number
}
function toggleVisibility(state: SettingsState, action: ToggleVisibility) {
function setAutoExpandLimit(state: SettingsState, action: SetAutoExpandLimit) {
return {
...state,
visible: !state.visible,
autoExpandLimit: action.autoExpandLimit,
}
}
export interface TogglehighlightTopicUpdates {
type: ActionTypes.SETTINGS_TOGGLE_HIGHLIGHT_ACTIVITY
}
function togglehighlightTopicUpdates(state: SettingsState, _action: TogglehighlightTopicUpdates) {
return {
...state,
highlightTopicUpdates: !state.highlightTopicUpdates,
}
}
@@ -59,6 +81,13 @@ export interface ToggleVisibility {
type: ActionTypes.SETTINGS_TOGGLE_VISIBILITY
}
function toggleVisibility(state: SettingsState, action: ToggleVisibility) {
return {
...state,
visible: !state.visible,
}
}
function setTopicOrder(state: SettingsState, action: SetTopicOrder) {
return {
...state,
+17 -5
View File
@@ -5,36 +5,41 @@ import { PublishState, publishReducer } from './Publish'
import { ConnectionState, connectionReducer } from './Connection'
import { SettingsState, settingsReducer } from './Settings'
import { TreeState, treeReducer } from './Tree'
import { ConnectionManagerState, connectionManagerReducer } from './ConnectionManager'
export enum ActionTypes {
showUpdateNotification = 'SHOW_UPDATE_NOTIFICATION',
showUpdateDetails = 'SHOW_UPDATE_DETAILS',
showError = 'SHOW_ERROR',
}
export interface CustomAction extends Action {
type: ActionTypes,
showUpdateNotification?: boolean
showUpdateDetails?: boolean
error?: string
}
export interface AppState {
tooBigReducer: TooBigOfState
globalState: GlobalState
tree: TreeState
settings: SettingsState,
publish: PublishState
connection: ConnectionState
connectionManager: ConnectionManagerState
}
export interface TooBigOfState {
export interface GlobalState {
showUpdateNotification?: boolean
showUpdateDetails: boolean
error?: string
}
const initialBigState: TooBigOfState = {
const initialBigState: GlobalState = {
showUpdateDetails: false,
}
const tooBigReducer: Reducer<TooBigOfState | undefined, CustomAction> = (state = initialBigState, action) => {
const globalState: Reducer<GlobalState | undefined, CustomAction> = (state = initialBigState, action) => {
if (!state) {
throw Error('No initial state')
}
@@ -47,6 +52,12 @@ const tooBigReducer: Reducer<TooBigOfState | undefined, CustomAction> = (state =
showUpdateNotification: action.showUpdateNotification,
}
case ActionTypes.showError:
return {
...state,
error: action.error,
}
case ActionTypes.showUpdateDetails:
if (action.showUpdateDetails === undefined) {
return state
@@ -62,11 +73,12 @@ const tooBigReducer: Reducer<TooBigOfState | undefined, CustomAction> = (state =
}
const reducer = combineReducers({
tooBigReducer,
globalState,
publish: publishReducer,
connection: connectionReducer,
settings: settingsReducer,
tree: treeReducer,
connectionManager: connectionManagerReducer,
})
export default reducer
+51 -26
View File
@@ -21,18 +21,18 @@
resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.7.1.tgz#9833722341379fb7d67f06a4b00ab3c37913da53"
integrity sha512-OYpa/Sg+2GDX+jibUfpZVn1YqSVRpYmTLF2eyAfrFTIJSbwyIrc+YscayoykvaOME/wV4BV0Sa0yqdMrgse6mA==
"@material-ui/core@^3.9.0":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-3.9.1.tgz#994fa54f0936092556d231d1055b8f76b12be06e"
integrity sha512-26GtjuwxPPfSsUnYrTOC8zuCuhWPVhc4SsUSFTZq0n1QvpGi9UEZRFe8yp6FykQE+PmqyyY+eWdrfiXKSUKZ0w==
"@material-ui/core@^4.0.0-alpha.0":
version "4.0.0-alpha.0"
resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-4.0.0-alpha.0.tgz#7342e6c672d2351a7657af9b92fe7a5411b7dd00"
integrity sha512-2bg325ZdSLRHbHMluoXvUzrnLu6WwMyiSfQhDZtwlpaFgWRZSwGskWQLGxNp/O9Lwu/SMYA2A0YHJtaMbvKqFw==
dependencies:
"@babel/runtime" "^7.2.0"
"@material-ui/system" "^3.0.0-alpha.0"
"@material-ui/utils" "^3.0.0-alpha.2"
"@material-ui/system" "^4.0.0-alpha.0"
"@material-ui/utils" "^4.0.0-alpha.0"
"@types/jss" "^9.5.6"
"@types/react-transition-group" "^2.0.8"
brcast "^3.0.1"
classnames "^2.2.5"
clsx "^1.0.2"
csstype "^2.5.2"
debounce "^1.1.0"
deepmerge "^3.0.0"
@@ -51,7 +51,6 @@
prop-types "^15.6.0"
react-event-listener "^0.6.2"
react-transition-group "^2.2.1"
recompose "0.28.0 - 0.30.0"
warning "^4.0.1"
"@material-ui/icons@^3.0.1":
@@ -84,10 +83,10 @@
prop-types "^15.6.0"
warning "^4.0.1"
"@material-ui/system@^3.0.0-alpha.0":
version "3.0.0-alpha.2"
resolved "https://registry.yarnpkg.com/@material-ui/system/-/system-3.0.0-alpha.2.tgz#096e80c8bb0f70aea435b9e38ea7749ee77b4e46"
integrity sha512-odmxQ0peKpP7RQBQ8koly06YhsPzcoVib1vByVPBH4QhwqBXuYoqlCjt02846fYspAqkrWzjxnWUD311EBbxOA==
"@material-ui/system@^4.0.0-alpha.0":
version "4.0.0-alpha.0"
resolved "https://registry.yarnpkg.com/@material-ui/system/-/system-4.0.0-alpha.0.tgz#9972a797f04e1b96b84ad462243086fe89038f73"
integrity sha512-qJLLx+rBmj1AsnuOLja9eN0x/ajZWM6FgCUVV08ecQ2FHEb+TT5QK6b+Lcw5TNnHxakGC8wkmUkIYafwcqGA5g==
dependencies:
"@babel/runtime" "^7.2.0"
deepmerge "^3.0.0"
@@ -103,6 +102,15 @@
prop-types "^15.6.0"
react-is "^16.6.3"
"@material-ui/utils@^4.0.0-alpha.0":
version "4.0.0-alpha.0"
resolved "https://registry.yarnpkg.com/@material-ui/utils/-/utils-4.0.0-alpha.0.tgz#b8ed6953ae07d36856a5867ffe759bc0e08147b4"
integrity sha512-w8zSXDuAyS0J5Z8mnU0+HgFf+GP3vGno8wecHT011DM7P3ZoS/2ngU2SqJGwRxuNk7N65tWX1h8NKvoevDPUyQ==
dependencies:
"@babel/runtime" "^7.2.0"
prop-types "^15.6.0"
react-is "^16.8.0"
"@types/jss@^9.5.6":
version "9.5.7"
resolved "https://registry.yarnpkg.com/@types/jss/-/jss-9.5.7.tgz#fa57a6d0b38a3abef8a425e3eb6a53495cb9d5a0"
@@ -177,6 +185,13 @@
resolved "https://registry.yarnpkg.com/@types/socket.io-client/-/socket.io-client-1.4.32.tgz#988a65a0386c274b1c22a55377fab6a30789ac14"
integrity sha512-Vs55Kq8F+OWvy1RLA31rT+cAyemzgm0EWNeax6BWF8H7QiiOYMJIdcwSDdm5LVgfEkoepsWkS+40+WNb7BUMbg==
"@types/uuid@^3.4.4":
version "3.4.4"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.4.tgz#7af69360fa65ef0decb41fd150bf4ca5c0cefdf5"
integrity sha512-tPIgT0GUmdJQNSHxp0X2jnpQfBSTfGxUMc/2CXBU2mnyTFVYVa2ojpoQ74w0U2yn2vw3jnC640+77lkFFpdVDw==
dependencies:
"@types/node" "*"
"@types/vis@^4.21.9":
version "4.21.9"
resolved "https://registry.yarnpkg.com/@types/vis/-/vis-4.21.9.tgz#3f28e5ec4c029306756d9688d9670d502267d407"
@@ -1017,6 +1032,11 @@ cliui@^4.0.0:
strip-ansi "^4.0.0"
wrap-ansi "^2.0.0"
clsx@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.0.2.tgz#2e0d63a900d7fe33218d7a53dee9e3a0c7300e1d"
integrity sha512-NQZV7ri2Gfufu9q1P9JDV4MHhdJvUukOadjAoN12pK37P12nrYp/mC05BSoekv0KX/5hGHAe2WQeOlhaWhXC5Q==
code-point-at@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
@@ -4485,6 +4505,11 @@ react-is@^16.6.3, react-is@^16.7.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.7.0.tgz#c1bd21c64f1f1364c6f70695ec02d69392f41bfa"
integrity sha512-Z0VRQdF4NPDoI0tsXVMLkJLiwEBa+RP66g0xDHxgxysxSoCUccSten4RTF/UFvZF1dZvZ9Zu1sx+MDXwcOR34g==
react-is@^16.8.0:
version "16.8.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.2.tgz#09891d324cad1cb0c1f2d91f70a71a4bee34df0f"
integrity sha512-D+NxhSR2HUCjYky1q1DwpNUD44cDpUXzSmmFyC3ug1bClcU/iDNy0YNn1iwme28fn+NFhpA13IndOd42CrFb+Q==
react-json-view@^1.19.1:
version "1.19.1"
resolved "https://registry.yarnpkg.com/react-json-view/-/react-json-view-1.19.1.tgz#95d8e59e024f08a25e5dc8f076ae304eed97cf5c"
@@ -4590,6 +4615,16 @@ react-vis@^1.11.6:
prop-types "^15.5.8"
react-motion "^0.5.2"
react@16.8:
version "16.8.2"
resolved "https://registry.yarnpkg.com/react/-/react-16.8.2.tgz#83064596feaa98d9c2857c4deae1848b542c9c0c"
integrity sha512-aB2ctx9uQ9vo09HVknqv3DGRpI7OIGJhCx3Bt0QqoRluEjHSaObJl+nG12GDdYH6sTgE7YiPJ6ZUyMx9kICdXw==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
prop-types "^15.6.2"
scheduler "^0.13.2"
react@^16.6.3:
version "16.7.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.7.0.tgz#b674ec396b0a5715873b350446f7ea0802ab6381"
@@ -4600,16 +4635,6 @@ react@^16.6.3:
prop-types "^15.6.2"
scheduler "^0.12.0"
react@^16.8.0-alpha.1:
version "16.8.0-alpha.1"
resolved "https://registry.yarnpkg.com/react/-/react-16.8.0-alpha.1.tgz#c2b32689f3b466d3ce85a634dd9035f789d2cd97"
integrity sha512-vLwwnhM2dXrCsiQmcSxF2UdZVV5xsiXjK5Yetmy8dVqngJhQ3aw3YJhZN/YmyonxwdimH40wVqFQfsl4gSu2RA==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
prop-types "^15.6.2"
scheduler "^0.13.0-alpha.1"
"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6:
version "2.3.6"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
@@ -4879,10 +4904,10 @@ scheduler@^0.12.0:
loose-envify "^1.1.0"
object-assign "^4.1.1"
scheduler@^0.13.0-alpha.1:
version "0.13.0-alpha.1"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.0-alpha.1.tgz#753977fb4fb35d8cdd559868a11e46b640955556"
integrity sha512-W0sH0848sVuPKg+I18vTYQyzVtA4X1lrVgSeXK6KnOPUltFdJcY5nkbTkjGUeS/E0x+eBsNYfSdhJtGjT95njw==
scheduler@^0.13.2:
version "0.13.2"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.2.tgz#969eaee2764a51d2e97b20a60963b2546beff8fa"
integrity sha512-qK5P8tHS7vdEMCW5IPyt8v9MJOHqTrOUgPXib7tqm9vh834ibBX5BNhwkplX/0iOzHW5sXyluehYfS9yrkz9+w==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
+67
View File
@@ -0,0 +1,67 @@
import * as FileAsync from 'lowdb/adapters/FileAsync'
import * as lowdb from 'lowdb'
import { backendEvents } from '../../events'
import {
makeStorageResponseEvent,
storageClearEvent,
storageLoadEvent,
storageStoreEvent,
makeStorageAcknoledgementEvent,
} from '../../events/StorageEvents'
export default class ConfigStorage {
private file: string
private database: any
constructor(file: string) {
this.file = file
}
private async getDb() {
const adapter = new FileAsync(this.file)
if (!this.database) {
this.database = await lowdb(adapter)
}
return this.database
}
public async init() {
backendEvents.subscribe(storageStoreEvent, async (event) => {
const ack = makeStorageAcknoledgementEvent(event.transactionId)
try {
const db = await this.getDb()
await db.set(event.store, event.data).write()
backendEvents.emit(ack, undefined)
} catch (error) {
backendEvents.emit(ack, { error, transactionId: event.transactionId, store: event.store })
throw error
}
})
backendEvents.subscribe(storageLoadEvent, async (event) => {
const responseEvent = makeStorageResponseEvent(event.transactionId)
try {
const db = await this.getDb()
const data = await db.get(event.store).value()
backendEvents.emit(responseEvent, { data, transactionId: event.transactionId, store: event.store })
} catch (error) {
backendEvents.emit(responseEvent, { error, transactionId: event.transactionId, store: event.store })
throw error
}
})
backendEvents.subscribe(storageClearEvent, async (event) => {
try {
const db = await this.getDb()
const keys = await db.keys().value()
for (const key of keys) {
await db.unset(key).write()
}
backendEvents.emit(makeStorageAcknoledgementEvent(event.transactionId), undefined)
} catch (error) {
backendEvents.emit(makeStorageAcknoledgementEvent(event.transactionId), { error, transactionId: event.transactionId })
throw error
}
})
}
}
+4 -3
View File
@@ -25,16 +25,17 @@ export class DataSourceStateMachine {
public setError(error: Error) {
this.state = {
...this.state,
error: error.message,
connected: false,
connecting: false,
// connected: false,
// connecting: false,
}
this.onUpdate.dispatch(this.state)
}
public setConnecting() {
this.state = {
error: undefined,
...this.state,
connected: false,
connecting: true,
}
+11 -10
View File
@@ -11,13 +11,13 @@ export interface MqttOptions {
tls: boolean
certValidation: boolean
clientId?: string
subscriptions: string[]
}
export class MqttSource implements DataSource<MqttOptions> {
public stateMachine: DataSourceStateMachine = new DataSourceStateMachine()
private client: Client | undefined
private messageCallback?: (topic: string, message: Buffer, packet: any) => void
private rootSubscription = '#'
public topicSeparator = '/'
public onMessage(messageCallback: (topic: string, message: Buffer, packet: any) => void) {
@@ -55,21 +55,22 @@ export class MqttSource implements DataSource<MqttOptions> {
this.stateMachine.setConnected(false)
})
client.on('end', () => {
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) {
this.stateMachine.setError(err)
}
})
client.subscribe('$SYS/#', (err: Error) => {
if (err) {
console.error('failed to subscribe to sys topic', err)
}
options.subscriptions.forEach((subscription) => {
client.subscribe(subscription, (err: Error) => {
if (err) {
this.stateMachine.setError(err)
}
})
})
})
+35
View File
@@ -47,6 +47,9 @@ export class TreeNode<ViewModel> {
this.lastUpdate = Date.now()
})
this.onEdgesChange.subscribe(() => {
this.cachedChildTopics = undefined
this.cachedChildTopicCount = undefined
this.cachedLeafMessageCount = undefined
this.lastUpdate = Date.now()
})
this.onMessage.subscribe(() => {
@@ -102,6 +105,34 @@ export class TreeNode<ViewModel> {
return previous.branch().concat([this])
}
private isTopicEmptyLeaf() {
const hasNoMessage = (!this.message || !this.message.value)
return hasNoMessage && this.isLeaf()
}
private isLeaf() {
return this.edgeArray.length === 0
}
private removeFromParent() {
const previous = this.previous()
if (!previous || !this.sourceEdge) {
return
}
previous.removeEdge(this.sourceEdge)
}
public removeEdge(edge: Edge<any>) {
delete this.edges[edge.name]
this.edgeArray = Object.values(this.edges)
this.onMerge.dispatch()
if (this.isTopicEmptyLeaf()) {
debugger
this.removeFromParent()
}
}
public updateWithNode(node: TreeNode<ViewModel>) {
if (node.message) {
this.setMessage(node.message)
@@ -109,6 +140,10 @@ export class TreeNode<ViewModel> {
this.mqttMessage = node.mqttMessage
}
if (this.isTopicEmptyLeaf()) {
this.removeFromParent()
}
this.mergeEdges(node)
this.onMerge.dispatch()
}
+2 -2
View File
@@ -43,10 +43,10 @@ describe('TreeNode', () => {
it('updateWithNode should add nodes to the tree', () => {
const topics1 = 'foo/bar'.split('/')
const leaf1 = TreeNodeFactory.fromEdgesAndValue(topics1, undefined)
const leaf1 = TreeNodeFactory.fromEdgesAndValue(topics1, 'foo')
const topics2 = 'foo/bar/baz'.split('/')
const leaf2 = TreeNodeFactory.fromEdgesAndValue(topics2, undefined)
const leaf2 = TreeNodeFactory.fromEdgesAndValue(topics2, 'bar')
leaf1.firstNode().updateWithNode(leaf2.firstNode())
+6 -2
View File
@@ -12,7 +12,7 @@ import {
updateAvailable,
} from '../../events'
import { DataSource, MqttSource } from './DataSource'
import ConfigStorage from './ConfigStorage'
import { UpdateInfo } from 'builder-util-runtime'
export class ConnectionManager {
@@ -20,7 +20,10 @@ export class ConnectionManager {
public manageConnections() {
backendEvents.subscribe(addMqttConnectionEvent, this.handleConnectionRequest)
backendEvents.subscribe(removeConnection, (connectionId: string) => this.removeConnection(connectionId))
backendEvents.subscribe(removeConnection, (connectionId: string) => {
backendEvents.unsubscribeAll(makePublishEvent(connectionId))
this.removeConnection(connectionId)
})
}
private handleConnectionRequest = (event: AddMqttConnection) => {
@@ -58,6 +61,7 @@ export class ConnectionManager {
if (connection) {
connection.disconnect()
delete this.connections[hash]
connection.stateMachine.onUpdate.removeAllListeners()
}
}
+4 -3
View File
@@ -21,15 +21,16 @@ class IpcMainEventBus implements EventBusInterface {
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) => {
public subscribe<MessageType>(subscribeEvent: Event<MessageType>, callback:(msg: MessageType) => void) {
console.log('subscribing', subscribeEvent.topic)
this.ipc.on(subscribeEvent.topic, (event: any, arg: any) => {
this.client = event.sender
callback(arg)
})
}
public unsubscribeAll<MessageType>(event: Event<MessageType>) {
console.log('unsubscribeAll', event.topic)
this.ipc.removeAllListeners(event.topic)
}
+39
View File
@@ -0,0 +1,39 @@
import { Event } from './'
interface StorageEvent {
transactionId: string
}
export interface StoreCommand extends StorageEvent {
store?: string,
data?: any
error?: any
}
export interface LoadCommand extends StorageEvent {
store: string,
}
export const storageStoreEvent: Event<StoreCommand> = {
topic: 'storage/store',
}
export const storageLoadEvent: Event<LoadCommand> = {
topic: 'storage/load',
}
export function makeStorageAcknoledgementEvent(transactionId: string): Event<StoreCommand> {
return {
topic: `storage/ack/${transactionId}`,
}
}
export function makeStorageResponseEvent(transactionId: string): Event<StoreCommand> {
return {
topic: `storage/response/${transactionId}`,
}
}
export const storageClearEvent: Event<StorageEvent> = {
topic: 'storage/clear',
}
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "MQTT-Explorer",
"version": "0.1.3",
"version": "0.2.0-alpha",
"description": "Explore your message queues",
"main": "dist/src/electron.js",
"scripts": {
@@ -44,6 +44,7 @@
"license": "ISC",
"devDependencies": {
"@types/chai": "^4.1.7",
"@types/lowdb": "^1.0.6",
"@types/mime": "^2.0.0",
"@types/mocha": "^5.2.5",
"@types/mustache": "^0.8.32",
@@ -70,11 +71,13 @@
"webdriverio": "5.4"
},
"dependencies": {
"about-window": "^1.12.1",
"electron-debug": "^2.0.0",
"electron-is-dev": "^1.0.1",
"electron-log": "^2.2.17",
"electron-telemetry": "git+https://github.com/thomasnordquist/electron-telemetry.git#dist",
"electron-updater": "^4.0.6",
"lowdb": "^1.0.0",
"mqtt": "^2.18.8",
"sha1": "^1.1.1"
}
+11 -8
View File
@@ -1,21 +1,24 @@
#!/bin/bash
cp app.mp4 original.mp4
ffmpeg -s:v 1024x700 -r 20 -f rawvideo -pix_fmt yuv420p -i qrawvideorgb24.yuv app2.mp4
# The video starts with a few blank frames, we want to know when the stop
ffprobe -f lavfi -i "movie=app.mp4,blackdetect[out0]" -show_entries tags=lavfi.black_start,lavfi.black_end -of default=nw=1 -v quiet > ffmpeg_info
ffprobe -f lavfi -i "movie=app2.mp4,blackdetect[out0]" -show_entries tags=lavfi.black_start,lavfi.black_end -of default=nw=1 -v quiet > ffmpeg_info
END_OF_BLACK=`cat ffmpeg_info | grep end | head -n1 | cut -d'=' -f2`
# Remove grey frames at the beginning (app start and splash screen)
END_OF_BLACK=`awk "BEGIN {print $END_OF_BLACK+0.8; exit}"`
# Trim black frames at start
ffmpeg -ss $END_OF_BLACK -i app.mp4 app2.mp4
mv app2.mp4 app.mp4
ffmpeg -s:v 1024x700 -r 20 -f rawvideo -pix_fmt yuv420p -i qrawvideorgb24.yuv -ss $END_OF_BLACK app.mp4
# Generate gif palette
ffmpeg -y -i app.mp4 -vf fps=10,scale=720:-1:flags=lanczos,palettegen palette.png
ffmpeg -y -s:v 1024x700 -r 20 -f rawvideo -pix_fmt yuv420p -i qrawvideorgb24.yuv -vf fps=10,scale=1024:-1:flags=lanczos,palettegen palette1024.png
# Create gif
ffmpeg -i app.mp4 -i palette.png -filter_complex "fps=10,scale=720:-1:flags=lanczos[x];[x][1:v]paletteuse" app.gif
ffmpeg -s:v 1024x700 -r 20 -f rawvideo -pix_fmt yuv420p -i qrawvideorgb24.yuv -i palette1024.png -ss $END_OF_BLACK -filter_complex "fps=10,scale=1024:-1:flags=lanczos[x];[x][1:v]paletteuse" app1024.gif
# Clean up
rm ffmpeg_info palette.png
rm ffmpeg_info palette*.png qrawvideorgb24.yuv
mv app.mp4 ui-test.mp4
mv app.gif ui-test.gif
mv app1024.gif ui-test.gif
+3 -2
View File
@@ -17,7 +17,7 @@ export PID_XVFB=$!
sleep 2
# Debug with VNC
while [ "$TEST_EXIT_CODE" = "" ]; do x11vnc -passwd "bierbier" -display :$SCR; done &
while [ "$TEST_EXIT_CODE" = "" ]; do x11vnc -localhost -passwd "bierbier" -display :$SCR; done &
export PID_VNC=$!
# Start mqtt broker
@@ -32,7 +32,8 @@ sleep 2
rm ./app.mp4 || echo no need to delete ./app.mp4
# Start recoring in tmux
tmux new-session -d -s record ffmpeg -f x11grab -draw_mouse 0 -video_size $DIMENSIONS -i :$SCR -codec:v libx264 -r 20 ./app.mp4
#tmux new-session -d -s record ffmpeg -f x11grab -draw_mouse 0 -video_size $DIMENSIONS -i :$SCR -codec:v libx264 -r 20 ./app.mp4
tmux new-session -d -s record ffmpeg -f x11grab -draw_mouse 0 -video_size $DIMENSIONS -i :$SCR -r 20 -vcodec rawvideo -pix_fmt yuv420p qrawvideorgb24.yuv
# Start tests
node dist/src/spec/webdriverio.js
+12 -1
View File
@@ -1,11 +1,22 @@
import { Menu, app, BrowserWindow, webContents } from 'electron'
import openAboutWindow from 'about-window'
import * as path from 'path'
const applicationMenu = {
label: 'Application',
submenu: [
{
label: 'About Application',
selector: 'orderFrontStandardAboutPanel:',
click: () => {
console.log(path.join(__dirname, 'icon.png'))
openAboutWindow({
icon_path: path.join(__dirname, '..', '..', 'icon.png'),
license: 'AGPL-3.0',
homepage: 'https://thomasnordquist.github.io/MQTT-Explorer/',
bug_report_url: 'https://github.com/thomasnordquist/MQTT-Explorer/issues',
description: 'Author: Thomas Nordquist',
})
},
},
{
type: 'separator' as 'separator',
+9 -5
View File
@@ -1,11 +1,12 @@
import { UpdateInfo } from '../events'
import { BrowserWindow, app, Menu } from 'electron'
import * as path from 'path'
import { menuTemplate } from './MenuTemplate'
import { autoUpdater } from 'electron-updater'
import * as log from 'electron-log'
import * as path from 'path'
import ConfigStorage from '../backend/src/ConfigStorage'
import { app, BrowserWindow, Menu } from 'electron'
import { autoUpdater } from 'electron-updater'
import { ConnectionManager, updateNotifier } from '../backend/src/index'
import { electronTelemetryFactory } from 'electron-telemetry'
import { menuTemplate } from './MenuTemplate'
import { UpdateInfo } from '../events'
const isDev = require('electron-is-dev')
let electronTelemetry: any
@@ -24,6 +25,9 @@ log.info('App starting...')
const connectionManager = new ConnectionManager()
connectionManager.manageConnections()
const configStorage = new ConfigStorage(path.join(app.getPath('appData'), app.getName(), 'settings.json'))
configStorage.init()
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let mainWindow: BrowserWindow | undefined
+1 -1
View File
@@ -24,6 +24,6 @@ async function topicMatches(topics: string[], browser: Browser<void>) {
}
function topicSelector(topics: string[]) {
const suffix = topics.map(topic => `*[contains(text(), "${topic}")]`).join('/../../..//')
const suffix = topics.map(topic => `*[contains(text(), "${topic}")]`).join('/../..//')
return `//${suffix}`
}
+37 -2
View File
@@ -119,6 +119,18 @@
resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.1.7.tgz#1b8e33b61a8c09cbe1f85133071baa0dbf9fa71a"
integrity sha512-2Y8uPt0/jwjhQ6EiluT0XCri1Dbplr0ZxfFXUz+ye13gaqE8u5gL5ppao1JrUYr9cIip5S6MvQzBS7Kke7U9VA==
"@types/lodash@*":
version "4.14.121"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.121.tgz#9327e20d49b95fc2bf983fc2f045b2c6effc80b9"
integrity sha512-ORj7IBWj13iYufXt/VXrCNMbUuCTJfhzme5kx9U/UtcIPdJYuvPDUAlHlbNhz/8lKCLy9XGIZnGrqXOtQbPGoQ==
"@types/lowdb@^1.0.6":
version "1.0.6"
resolved "https://registry.yarnpkg.com/@types/lowdb/-/lowdb-1.0.6.tgz#0e7adecb87cd79c1e97d50043d9835b65d59023f"
integrity sha512-C/p2p3ud6buHPUaj5QTN3gGera9Pi39aCQoQ1ngRZ2hsWeoqok4aCF/Jjj8FDsnSOTaQHrKI92/KHGt6S+Oy+Q==
dependencies:
"@types/lodash" "*"
"@types/mime@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.0.tgz#5a7306e367c539b9f6543499de8dd519fac37a8b"
@@ -177,6 +189,11 @@
dependencies:
"@wdio/config" "^5.4.6"
about-window@^1.12.1:
version "1.12.1"
resolved "https://registry.yarnpkg.com/about-window/-/about-window-1.12.1.tgz#b81c10f6de58d38bce2aa5f31f61a82e6c3d6c2b"
integrity sha512-P/fJACq7RBFpv5Ql0kIqpOYgFqQ8WdoZoB7wrKmDvwTSKVOMbUZMYnQYtnOW6dHmMCYuNrmQFf+MxvuhE+KrzQ==
ajv-keywords@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.2.0.tgz#e86b819c602cf8821ad637413698f1dec021847a"
@@ -1589,7 +1606,7 @@ got@^6.7.1:
unzip-response "^2.0.1"
url-parse-lax "^1.0.0"
graceful-fs@^4.1.0, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6:
graceful-fs@^4.1.0, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.1.6:
version "4.1.15"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00"
integrity sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==
@@ -2171,7 +2188,7 @@ lodash.zip@^4.2.0:
resolved "https://registry.yarnpkg.com/lodash.zip/-/lodash.zip-4.2.0.tgz#ec6662e4896408ed4ab6c542a3990b72cc080020"
integrity sha1-7GZi5IlkCO1KtsVCo5kLcswIACA=
lodash@^4.17.10, lodash@^4.3.0, lodash@^4.8.0, lodash@~4.17.10:
lodash@4, lodash@^4.17.10, lodash@^4.3.0, lodash@^4.8.0, lodash@~4.17.10:
version "4.17.11"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==
@@ -2194,6 +2211,17 @@ loud-rejection@^1.0.0:
currently-unhandled "^0.4.1"
signal-exit "^3.0.0"
lowdb@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/lowdb/-/lowdb-1.0.0.tgz#5243be6b22786ccce30e50c9a33eac36b20c8064"
integrity sha512-2+x8esE/Wb9SQ1F9IHaYWfsC9FIecLOPrK4g17FGEayjUWH172H6nwicRovGvSE2CPZouc2MCIqCI7h9d+GftQ==
dependencies:
graceful-fs "^4.1.3"
is-promise "^2.1.0"
lodash "4"
pify "^3.0.0"
steno "^0.4.1"
lowercase-keys@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
@@ -3296,6 +3324,13 @@ stat-mode@^0.2.2:
resolved "https://registry.yarnpkg.com/stat-mode/-/stat-mode-0.2.2.tgz#e6c80b623123d7d80cf132ce538f346289072502"
integrity sha1-5sgLYjEj19gM8TLOU480YokHJQI=
steno@^0.4.1:
version "0.4.4"
resolved "https://registry.yarnpkg.com/steno/-/steno-0.4.4.tgz#071105bdfc286e6615c0403c27e9d7b5dcb855cb"
integrity sha1-BxEFvfwobmYVwEA8J+nXtdy4Vcs=
dependencies:
graceful-fs "^4.1.3"
stream-shift@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952"