diff --git a/app/package.json b/app/package.json index 1e2d76d..6fc85ce 100644 --- a/app/package.json +++ b/app/package.json @@ -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", @@ -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", diff --git a/app/src/App.tsx b/app/src/App.tsx index 93262b5..0d8c325 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,19 +1,17 @@ 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 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 { connect } from 'react-redux' -import ErrorBoundary from './ErrorBoundary' import { default as SplitPane } from 'react-split-pane' +import { Theme, withStyles } from '@material-ui/core/styles' + +const Settings = React.lazy(() => import('./components/Settings')) interface Props { name: string @@ -66,7 +64,7 @@ class App extends React.PureComponent { - + ) diff --git a/app/src/PersistantStorage.ts b/app/src/PersistantStorage.ts new file mode 100644 index 0000000..34bafb8 --- /dev/null +++ b/app/src/PersistantStorage.ts @@ -0,0 +1,21 @@ +export interface StorageIdentifier { + id: string +} + +export interface PersistantStorage { + store(identifier: StorageIdentifier, data: Model): void + load(identifier: StorageIdentifier): Model | undefined +} + +class LocalStorage implements PersistantStorage { + public store(identifier: StorageIdentifier, data: Model) { + localStorage.setItem(identifier.id, JSON.stringify(data)) + } + + public load(identifier: StorageIdentifier): Model | undefined { + const data = localStorage.getItem(identifier.id) + return data && JSON.parse(data) + } +} + +export default new LocalStorage() diff --git a/app/src/actions/Connection.ts b/app/src/actions/Connection.ts index df15229..9f34c32 100644 --- a/app/src/actions/Connection.ts +++ b/app/src/actions/Connection.ts @@ -1,11 +1,16 @@ -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 } from '../../../backend/src/DataSource' +import { showTree } from './Tree' import { TopicViewModel } from '../TopicViewModel' export const connect = (options: MqttOptions, connectionId: string) => (dispatch: Dispatch, getState: () => AppState) => { @@ -15,6 +20,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() tree.updateWithConnection(rendererEvents, connectionId) diff --git a/app/src/actions/ConnectionManager.ts b/app/src/actions/ConnectionManager.ts new file mode 100644 index 0000000..c20a243 --- /dev/null +++ b/app/src/actions/ConnectionManager.ts @@ -0,0 +1,108 @@ +import { AppState } from '../reducers' +import { ConnectionOptions, createEmptyConnection, defaultConnections } from '../model/ConnectionOptions' +import { default as persistantStorage, StorageIdentifier } from '../PersistantStorage' +import { Dispatch } from 'redux' +import { loadLegacyConnectionSettings } from '../model/LegacyConnectionSettings' +import { + ActionTypes, + Action, +} from '../reducers/ConnectionManager' + +const storedConnectionsIdentifier: StorageIdentifier<{[s: string]: ConnectionOptions}> = { + id: 'ConnectionManager_connections', +} + +export const loadConnectionSettings = () => (dispatch: Dispatch, getState: () => AppState) => { + const connections = persistantStorage.load(storedConnectionsIdentifier) + if (!connections) { + return + } + + dispatch(setConnections(connections)) + const firstKey = Object.keys(connections)[0] + if (firstKey) { + dispatch(selectConnection(firstKey)) + } +} + +export const saveConnectionSettings = () => (_dispatch: Dispatch, getState: () => AppState) => { + persistantStorage.store(storedConnectionsIdentifier, getState().connectionManager.connections) +} + +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, 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, 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)) + } +} + +export function migrateLegacyConfiguration() { + const storage = persistantStorage.load(storedConnectionsIdentifier) + if (storage) { + return + } + const connections = loadLegacyConnectionSettings() + defaultConnections() +} + +export function addDefaultConnections() { + const storage = persistantStorage.load(storedConnectionsIdentifier) + if (storage) { + return + } + defaultConnections() +} diff --git a/app/src/actions/index.ts b/app/src/actions/index.ts index ba297d0..8e078d4 100644 --- a/app/src/actions/index.ts +++ b/app/src/actions/index.ts @@ -4,5 +4,6 @@ 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' -export { settingsActions, treeActions, publishActions, updateNotifierActions, connectionActions, sidebarActons } +export { settingsActions, treeActions, publishActions, updateNotifierActions, connectionActions, sidebarActons, connectionManagerActions } diff --git a/app/src/components/ConnectionSetup/AddButton.tsx b/app/src/components/ConnectionSetup/AddButton.tsx new file mode 100644 index 0000000..a06992a --- /dev/null +++ b/app/src/components/ConnectionSetup/AddButton.tsx @@ -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.unit * 4}px`, + width: `${theme.spacing.unit * 4}px`, + minHeight: '0', + }, + addIcon: { + height: `${theme.spacing.unit * 2}px`, + }, +}) + +export const AddButton = withStyles(styles)((props: { + classes: any, + action: any, +}) => { + return ( + + + + ) +}) diff --git a/app/src/components/ConnectionSetup/AdvancedConnectionSettings.tsx b/app/src/components/ConnectionSetup/AdvancedConnectionSettings.tsx new file mode 100644 index 0000000..a6efbce --- /dev/null +++ b/app/src/components/ConnectionSetup/AdvancedConnectionSettings.tsx @@ -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 { + 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 ( +
+
+ + + ) => this.setState({ subscription: event.target.value })} + /> + + + + + + +
+ {this.renderSubscriptions()} +
+
+
+ + + + + + +
+
+
+ ) + } + + private renderSubscriptions() { + const connection = this.props.connection + return connection.subscriptions.map(subscription => ( + this.props.managerActions.deleteSubscription(subscription, connection.id)} + subscription={subscription} + key={subscription} + /> + )) + } +} + +const Subscription = (props: { + subscription: string, + deleteAction: any, +}) => { + return ( + + + + + + {props.subscription} + + ) +} + +const mapDispatchToProps = (dispatch: any) => { + return { + managerActions: bindActionCreators(connectionManagerActions, dispatch), + } +} + +const styles: StyleRulesCallback = (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.unit * 3 + 2}px`, + float: 'right', + }, + } +} + +export default connect(undefined, mapDispatchToProps)(withStyles(styles)(ConnectionSettings)) diff --git a/app/src/components/ConnectionSetup/Connection.tsx b/app/src/components/ConnectionSetup/Connection.tsx deleted file mode 100644 index f6670b5..0000000 --- a/app/src/components/ConnectionSetup/Connection.tsx +++ /dev/null @@ -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 { - 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 = (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 = ( - - - {this.state.showPassword ? : } - - - ) - - let renderError = null - if (this.props.error) { - renderError = ( - { this.props.actions.showError(undefined) }} - /> - ) - } - - return ( -
- {renderError} - - - - MQTT Connection - -
- - - {this.renderProtocols()} - - - - - - - - - - - - - Password - - - - - - Client ID - } - /> - - - - {this.renderCertValidationSwitch()} - - - {this.renderTlsSwitch()} - - -
-
- - {this.renderConnectButton()} -
-
-
-
-
- ) - } - - private renderProtocols() { - const { classes } = this.props - const protocolItems = protocols.map((value: string) => ( - - {value} - - )) - - return ( - - {protocolItems} - - ) - } - - private renderCertValidationSwitch() { - const { classes } = this.props - const certSwitch = ( - - ) - - return ( -
- -
- ) - } - - private toggleCertValidation = () => this.setState({ - connectionSettings: { - ...this.state.connectionSettings, - certValidation: !this.state.connectionSettings.certValidation, - }, - }) - - private renderTlsSwitch() { - const { classes } = this.props - const tlsSwitch = ( - - ) - - return ( -
- -
- ) - } - - 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 ( - - ) - } - return ( - - ) - } - - 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)) diff --git a/app/src/components/ConnectionSetup/ConnectionSettings.tsx b/app/src/components/ConnectionSetup/ConnectionSettings.tsx new file mode 100644 index 0000000..dcc11b7 --- /dev/null +++ b/app/src/components/ConnectionSetup/ConnectionSettings.tsx @@ -0,0 +1,356 @@ +import * as React from 'react' +import Delete from '@material-ui/icons/Delete' +import Settings from '@material-ui/icons/Settings' +import Notification from './Notification' +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, + CircularProgress, + FormControl, + FormControlLabel, + Grid, + IconButton, + Input, + InputAdornment, + InputLabel, + MenuItem, + Switch, + TextField, +} from '@material-ui/core' + +interface Props { + connection: ConnectionOptions + classes: {[s: string]: string} + actions: typeof connectionActions, + managerActions: typeof connectionManagerActions + connected: boolean + connecting: boolean + error?: string +} + +const protocols = [ + 'mqtt', + 'ws', +] + +interface State { + showPassword: boolean +} + +class ConnectionSettings extends React.Component { + 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 ( + + + + ) + } + + 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 = ( + + + {this.state.showPassword ? : } + + + ) + + let renderError = null + if (this.props.error) { + renderError = ( + { this.props.actions.showError(undefined) }} + /> + ) + } + + return ( +
+ {renderError} +
+ + + + + + {this.renderCertValidationSwitch()} + + + {this.renderTlsSwitch()} + + + {this.renderProtocols()} + + + + + + + + {this.requiresBasePath() ? this.renderBasePathInput() : null} + + + + + + Password + + + + +
+
+
+ + +
+
+ + {this.renderConnectButton()} +
+
+
+
+ ) + } + + private renderProtocols() { + const { classes, connection } = this.props + + const protocolItems = protocols.map((value: string) => ( + + {value}:// + + )) + + return ( + + {protocolItems} + + ) + } + + private updateProtocol = (event: React.ChangeEvent) => { + 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 = ( + + ) + + return ( +
+ +
+ ) + } + + private toggleCertValidation = () => { + this.props.managerActions.updateConnection(this.props.connection.id, { + certValidation: !this.props.connection.certValidation, + }) + } + + private renderTlsSwitch() { + const { classes, connection } = this.props + + const tlsSwitch = ( + + ) + + return ( +
+ +
+ ) + } + + 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 ( + + ) + } + return ( + + ) + } + + private onClickConnect = () => { + if (!this.props.connection) { + return + } + + const mqttOptions = toMqttConnection(this.props.connection) + if (mqttOptions) { + console.log(mqttOptions) + this.props.actions.connect(mqttOptions, this.props.connection.id) + } + } +} + +const mapStateToProps = (state: AppState) => { + return { + connected: state.connection.connected, + connecting: state.connection.connecting, + error: state.connection.error, + } +} + +const mapDispatchToProps = (dispatch: any) => { + return { + actions: bindActionCreators(connectionActions, dispatch), + managerActions: bindActionCreators(connectionManagerActions, dispatch), + } +} + +const styles: StyleRulesCallback = (theme: Theme) => { + return { + textField: { + width: '100%', + }, + switch: { + marginTop: 0, + }, + button: { + margin: theme.spacing.unit, + }, + inputFormControl: { + marginTop: '16px', + }, + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ConnectionSettings)) diff --git a/app/src/components/ConnectionSetup/ConnectionSetup.tsx b/app/src/components/ConnectionSetup/ConnectionSetup.tsx new file mode 100644 index 0000000..b4aa10c --- /dev/null +++ b/app/src/components/ConnectionSetup/ConnectionSetup.tsx @@ -0,0 +1,111 @@ +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 } 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 { + constructor(props: Props) { + super(props) + } + + public componentDidMount() { + this.props.actions.loadConnectionSettings() + } + + public render() { + const { classes, visible } = this.props + return ( +
+ + +
+
+ + MQTT Connection + + {this.renderSettings()} +
+
+
+
+ ) + } + + private renderSettings() { + const { connection, showAdvancedSettings } = this.props + if (!connection) { + return null + } + + return ( +
+ + +
+ ) + } +} + +const styles = (theme: Theme) => ({ + title: { + color: theme.palette.text.primary, + }, + root: { + margin: '13vw 10vw 0 10vw', + minWidth: '550px', + 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: `${2 * theme.spacing.unit}px`, + flex: 3, + 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: `${2 * theme.spacing.unit}px`, + flex: 10, + }, +}) + +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)) diff --git a/app/src/components/ConnectionSetup/ProfileList.tsx b/app/src/components/ConnectionSetup/ProfileList.tsx new file mode 100644 index 0000000..21c70a7 --- /dev/null +++ b/app/src/components/ConnectionSetup/ProfileList.tsx @@ -0,0 +1,88 @@ +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 } from '../../model/ConnectionOptions' +import { Theme, withStyles } from '@material-ui/core/styles' +import { + List, + ListItem, + ListItemText, + ListSubheader, +} from '@material-ui/core' + +interface Props { + classes: any + selected?: string + connections: {[s: string]: ConnectionOptions} + actions: any +} + +class ProfileList extends React.Component { + constructor(props: Props) { + super(props) + } + + private addConnectionButton() { + return + } + + public render() { + return ( + {this.addConnectionButton()} Connections} + > +
+ {Object.values(this.props.connections).map(connection => )} +
+
+ ) + } +} + +const styles = (theme: Theme) => ({ + list: { + marginTop: `${theme.spacing.unit}px`, + height: `calc(100% - ${theme.spacing.unit * 6}px)`, + overflowY: 'auto' as 'auto', + }, +}) + +const mapDispatchToProps = (dispatch: any) => { + return { + actions: bindActionCreators(connectionManagerActions, dispatch), + } +} + +interface ConnectionItemProps { + connection: ConnectionOptions, + actions: any, + selected: boolean, +} + +const connectionItemRenderer = (props: ConnectionItemProps) => { + return ( + props.actions.selectConnection(props.connection.id)} + > + + + ) +} + +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)) diff --git a/app/src/components/TitleBar.tsx b/app/src/components/TitleBar.tsx index 666b3fb..599e6f8 100644 --- a/app/src/components/TitleBar.tsx +++ b/app/src/components/TitleBar.tsx @@ -1,17 +1,22 @@ 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' const styles: StyleRulesCallback = theme => ({ title: { diff --git a/app/src/components/helper/ClearAdornment.tsx b/app/src/components/helper/ClearAdornment.tsx index 7a7a759..a7f9dc1 100644 --- a/app/src/components/helper/ClearAdornment.tsx +++ b/app/src/components/helper/ClearAdornment.tsx @@ -8,6 +8,9 @@ interface Props { style?: React.CSSProperties } +/** + * Clear button for text input fields + */ class ClearAdornment extends React.Component { public render() { if (this.props.value) { diff --git a/app/src/model/ConnectionOptions.ts b/app/src/model/ConnectionOptions.ts new file mode 100644 index 0000000..7928dee --- /dev/null +++ b/app/src/model/ConnectionOptions.ts @@ -0,0 +1,82 @@ +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' | 'wss' + 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, + } +} + +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 defaultConnections() { + return [ + { + ...createEmptyConnection(), + id: 'iot.eclipse.org', + name: 'iot.eclipse.org', + host: 'iot.eclipse.org', + }, + { + ...createEmptyConnection(), + id: 'test.mosquitto.org', + name: 'test.mosquitto.org', + host: 'test.mosquitto.org', + }, + { + ...createEmptyConnection(), + id: 'wss://broker.hivemq.com:8000/mqtt', + name: 'broker.hivemq.com', + host: 'broker.hivemq.com', + basePath: 'mqtt', + encryption: true, + protocol: 'ws', + port: 8000, + }, + ] +} diff --git a/app/src/model/LegacyConnectionSettings.ts b/app/src/model/LegacyConnectionSettings.ts new file mode 100644 index 0000000..f4be43b --- /dev/null +++ b/app/src/model/LegacyConnectionSettings.ts @@ -0,0 +1,51 @@ +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 loadLegacyConnectionSettings(): ConnectionOptions | undefined { + 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 = { + 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, + } + + return { + ...createEmptyConnection(), + ...migratedOptions, + } +} diff --git a/app/src/reducers/ConnectionManager.ts b/app/src/reducers/ConnectionManager.ts new file mode 100644 index 0000000..71a38c6 --- /dev/null +++ b/app/src/reducers/ConnectionManager.ts @@ -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, + }, + } +} diff --git a/app/src/reducers/index.ts b/app/src/reducers/index.ts index 7e377ee..d120348 100644 --- a/app/src/reducers/index.ts +++ b/app/src/reducers/index.ts @@ -5,6 +5,7 @@ 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', @@ -23,6 +24,7 @@ export interface AppState { settings: SettingsState, publish: PublishState connection: ConnectionState + connectionManager: ConnectionManagerState } export interface TooBigOfState { @@ -67,6 +69,7 @@ const reducer = combineReducers({ connection: connectionReducer, settings: settingsReducer, tree: treeReducer, + connectionManager: connectionManagerReducer, }) export default reducer diff --git a/app/yarn.lock b/app/yarn.lock index 279bf00..26d610b 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -177,6 +177,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"