Add quality of service option to subscriptions
Fixes #323, #14, #334 Fixes #132
This commit is contained in:
@@ -12,10 +12,11 @@ import { showError } from './Global'
|
||||
import { remote } from 'electron'
|
||||
import { promises as fsPromise } from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
import { ActionTypes, Action } from '../reducers/ConnectionManager'
|
||||
import { Subscription } from '../../../backend/src/DataSource/MqttSource'
|
||||
import { connectionsMigrator } from './migrations/Connection'
|
||||
|
||||
interface ConnectionDictionary {
|
||||
export interface ConnectionDictionary {
|
||||
[s: string]: ConnectionOptions
|
||||
}
|
||||
const storedConnectionsIdentifier: StorageIdentifier<ConnectionDictionary> = {
|
||||
@@ -27,6 +28,12 @@ export const loadConnectionSettings = () => async (dispatch: Dispatch<any>, getS
|
||||
try {
|
||||
await ensureConnectionsHaveBeenInitialized()
|
||||
connections = await persistentStorage.load(storedConnectionsIdentifier)
|
||||
|
||||
// Apply migrations
|
||||
if (connections && connectionsMigrator.isMigrationNecessary(connections)) {
|
||||
connections = connectionsMigrator.applyMigrations(connections)
|
||||
await persistentStorage.store(storedConnectionsIdentifier, connections)
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch(showError(error))
|
||||
}
|
||||
@@ -101,13 +108,13 @@ export const updateConnection = (connectionId: string, changeSet: Partial<Connec
|
||||
type: ActionTypes.CONNECTION_MANAGER_UPDATE_CONNECTION,
|
||||
})
|
||||
|
||||
export const addSubscription = (subscription: string, connectionId: string): Action => ({
|
||||
export const addSubscription = (subscription: Subscription, connectionId: string): Action => ({
|
||||
connectionId,
|
||||
subscription,
|
||||
type: ActionTypes.CONNECTION_MANAGER_ADD_SUBSCRIPTION,
|
||||
})
|
||||
|
||||
export const deleteSubscription = (subscription: string, connectionId: string): Action => ({
|
||||
export const deleteSubscription = (subscription: Subscription, connectionId: string): Action => ({
|
||||
connectionId,
|
||||
subscription,
|
||||
type: ActionTypes.CONNECTION_MANAGER_DELETE_SUBSCRIPTION,
|
||||
@@ -174,31 +181,4 @@ async function ensureConnectionsHaveBeenInitialized() {
|
||||
|
||||
clearLegacyConnectionOptions()
|
||||
}
|
||||
|
||||
// Migrate connections, rewrite dictionary to "keep" it "ordered" (dictionaries do not have a guaranteed order)
|
||||
const mayNeedMigrations = connections && connections['iot.eclipse.org']
|
||||
if (connections && mayNeedMigrations) {
|
||||
const newConnections = {}
|
||||
for (const connection of Object.values(connections)) {
|
||||
addMigratedConnection(newConnections, connection)
|
||||
}
|
||||
|
||||
await persistentStorage.store(storedConnectionsIdentifier, newConnections)
|
||||
}
|
||||
}
|
||||
|
||||
function addMigratedConnection(newConnections: { [key: string]: ConnectionOptions }, connection: ConnectionOptions) {
|
||||
// The host has been renamed, only change the host if it has not been changed
|
||||
// Also check for ssl since SSL is not yet working
|
||||
if (
|
||||
connection.id === 'iot.eclipse.org' &&
|
||||
connection.host === 'iot.eclipse.org' &&
|
||||
connection.port === 1883 &&
|
||||
!connection.encryption
|
||||
) {
|
||||
connection.id = 'mqtt.eclipse.org'
|
||||
connection.host = 'mqtt.eclipse.org'
|
||||
connection.name = 'mqtt.eclipse.org'
|
||||
}
|
||||
newConnections[connection.id] = connection
|
||||
}
|
||||
|
||||
94
app/src/actions/migrations/Connection.ts
Normal file
94
app/src/actions/migrations/Connection.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { ConfigMigrator, Migration } from '../../utils/ConfigMigrator'
|
||||
import { ConnectionDictionary } from '../ConnectionManager'
|
||||
import { ConnectionOptions } from '../../model/ConnectionOptions'
|
||||
|
||||
export interface ConnectionOptionsV0 {
|
||||
type: 'mqtt'
|
||||
id: string
|
||||
host: string
|
||||
protocol: 'mqtt' | 'ws'
|
||||
basePath?: string
|
||||
port: number
|
||||
name: string
|
||||
username?: string
|
||||
password?: string
|
||||
encryption: boolean
|
||||
certValidation: boolean
|
||||
// selfSignedCertificate?: CertificateParameters
|
||||
// clientCertificate?: CertificateParameters
|
||||
// clientKey?: CertificateParameters
|
||||
clientId?: string
|
||||
subscriptions: Array<string>
|
||||
}
|
||||
|
||||
let migrations: Migration[] = [
|
||||
// iot.eclipse.org ha moved to mqtt.eclipse.org
|
||||
{
|
||||
from: undefined,
|
||||
apply: (connection: ConnectionOptionsV0): ConnectionOptionsV0 => {
|
||||
if (connection.id == 'iot.eclipse.org' && connection.host == 'iot.eclipse.org' && connection.port == 1883) {
|
||||
return {
|
||||
...connection,
|
||||
id: 'mqtt.eclipse.org',
|
||||
host: 'mqtt.eclipse.org',
|
||||
name: 'mqtt.eclipse.org',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...connection,
|
||||
}
|
||||
},
|
||||
},
|
||||
// Remove stored clientId if it is the default generated client id. This allows to connect multiple instances of mqtt explorer to the same broker.
|
||||
// A randomly generated clientId will be used if no clientId is set.
|
||||
{
|
||||
from: undefined,
|
||||
apply: (connection: ConnectionOptionsV0): ConnectionOptionsV0 => {
|
||||
if (connection.clientId && /mqtt-explorer-[0-9a-f]{8}/.test(connection.clientId)) {
|
||||
return {
|
||||
...connection,
|
||||
clientId: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...connection,
|
||||
}
|
||||
},
|
||||
},
|
||||
// Added QoS level to subscription options
|
||||
{
|
||||
from: undefined,
|
||||
apply: (connection: ConnectionOptionsV0): ConnectionOptions => {
|
||||
return {
|
||||
...connection,
|
||||
configVersion: 1,
|
||||
subscriptions: connection.subscriptions.map(topic => ({ topic, qos: 0 })),
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const connectionMigrator = new ConfigMigrator(migrations)
|
||||
|
||||
function isMigrationNecessary(connections: ConnectionDictionary): boolean {
|
||||
return Object.values(connections)
|
||||
.map(connection => connectionMigrator.isMigrationNecessary(connection))
|
||||
.reduce((a, b) => a || b, false)
|
||||
}
|
||||
|
||||
function applyMigrations(connections: ConnectionDictionary): ConnectionDictionary {
|
||||
let newConnectionDictionary: ConnectionDictionary = {}
|
||||
Object.keys(connections).forEach(key => {
|
||||
let newConnection = connectionMigrator.applyMigrations(connections[key]) as any
|
||||
newConnectionDictionary[newConnection.id] = newConnection
|
||||
})
|
||||
|
||||
return newConnectionDictionary
|
||||
}
|
||||
|
||||
export const connectionsMigrator = {
|
||||
isMigrationNecessary,
|
||||
applyMigrations,
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from 'react'
|
||||
import { useState, useCallback, memo } from 'react'
|
||||
import Add from '@material-ui/icons/Add'
|
||||
import ClearAdornment from '../helper/ClearAdornment'
|
||||
import Delete from '@material-ui/icons/Delete'
|
||||
import Lock from '@material-ui/icons/Lock'
|
||||
import Undo from '@material-ui/icons/Undo'
|
||||
import { bindActionCreators } from 'redux'
|
||||
@@ -9,8 +8,10 @@ import { connect } from 'react-redux'
|
||||
import { connectionManagerActions } from '../../actions'
|
||||
import { ConnectionOptions } from '../../model/ConnectionOptions'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
|
||||
import { Button, Grid, IconButton, TextField, List, ListItem, ListItemText, Tooltip } from '@material-ui/core'
|
||||
import { Button, Grid, TextField, Tooltip } from '@material-ui/core'
|
||||
import { QosSelect } from '../QosSelect'
|
||||
import { QoS } from '../../../../backend/src/DataSource/MqttSource'
|
||||
import Subscriptions from './Subscriptions'
|
||||
|
||||
interface Props {
|
||||
connection: ConnectionOptions
|
||||
@@ -18,116 +19,93 @@ interface Props {
|
||||
managerActions: typeof connectionManagerActions
|
||||
}
|
||||
|
||||
interface State {
|
||||
subscription: string
|
||||
}
|
||||
const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
|
||||
const [qos, setQos] = useState<QoS>(0)
|
||||
const [topic, setTopic] = useState('')
|
||||
const { classes } = props
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
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} advanced-connection-settings-topic-list`} component="nav">
|
||||
<div className={classes.list}>{this.renderSubscriptions()}</div>
|
||||
</List>
|
||||
</Grid>
|
||||
<Grid item={true} xs={7} className={classes.gridPadding}>
|
||||
<TextField
|
||||
className={classes.fullWidth}
|
||||
label="MQTT Client ID"
|
||||
margin="normal"
|
||||
value={this.props.connection.clientId}
|
||||
onChange={this.handleChange('clientId')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={3} className={classes.gridPadding}>
|
||||
<div>
|
||||
<Tooltip title="Manage tls connection certificates" placement="top">
|
||||
<Button
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={() => this.props.managerActions.toggleCertificateSettings()}
|
||||
>
|
||||
<Lock /> Certificates
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Grid>
|
||||
<Grid item={true} xs={2} className={classes.gridPadding}>
|
||||
<Button
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={this.props.managerActions.toggleAdvancedSettings}
|
||||
>
|
||||
<Undo /> Back
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 updateSubscription = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => setTopic(event.target.value),
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
const handleChange = useCallback(
|
||||
(name: string) => (event: any) => {
|
||||
props.managerActions.updateConnection(props.connection.id, {
|
||||
[name]: event.target.value,
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form className={classes.container} noValidate={true} autoComplete="off">
|
||||
<Grid container={true} spacing={3}>
|
||||
<Grid item={true} xs={8} className={classes.gridPadding}>
|
||||
<TextField
|
||||
className={classes.fullWidth}
|
||||
label="Topic"
|
||||
placeholder="example/topic"
|
||||
margin="normal"
|
||||
value={topic}
|
||||
onChange={updateSubscription}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={2} className={classes.gridPadding}>
|
||||
<div className={classes.qos}>
|
||||
<QosSelect label="QoS" selected={qos} onChange={setQos} />
|
||||
</div>
|
||||
</Grid>
|
||||
<Grid item={true} xs={2} className={classes.gridPadding}>
|
||||
<Button
|
||||
className={classes.button}
|
||||
color="secondary"
|
||||
onClick={() => props.managerActions.addSubscription({ topic, qos }, props.connection.id)}
|
||||
variant="contained"
|
||||
>
|
||||
<Add /> Add
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item={true} xs={12} style={{ padding: 0 }}>
|
||||
<Subscriptions connection={props.connection} />
|
||||
</Grid>
|
||||
<Grid item={true} xs={7} className={classes.gridPadding}>
|
||||
<TextField
|
||||
className={classes.fullWidth}
|
||||
label="MQTT Client ID"
|
||||
margin="normal"
|
||||
value={props.connection.clientId}
|
||||
onChange={handleChange('clientId')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={3} className={classes.gridPadding}>
|
||||
<div>
|
||||
<Tooltip title="Manage tls connection certificates" placement="top">
|
||||
<Button
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={() => props.managerActions.toggleCertificateSettings()}
|
||||
>
|
||||
<Lock /> Certificates
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Grid>
|
||||
<Grid item={true} xs={2} className={classes.gridPadding}>
|
||||
<Button
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={props.managerActions.toggleAdvancedSettings}
|
||||
>
|
||||
<Undo /> Back
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
@@ -142,16 +120,13 @@ const styles = (theme: Theme) => ({
|
||||
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' as 'right',
|
||||
},
|
||||
qos: {
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(withStyles(styles)(ConnectionSettings))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import ConnectButton from './ConnectButton'
|
||||
import Delete from '@material-ui/icons/Delete'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import Save from '@material-ui/icons/Save'
|
||||
import Delete from '@material-ui/icons/Delete'
|
||||
import Settings from '@material-ui/icons/Settings'
|
||||
import Visibility from '@material-ui/icons/Visibility'
|
||||
import VisibilityOff from '@material-ui/icons/VisibilityOff'
|
||||
|
||||
90
app/src/components/ConnectionSetup/Subscriptions.tsx
Normal file
90
app/src/components/ConnectionSetup/Subscriptions.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import Delete from '@material-ui/icons/Delete'
|
||||
import { connectionManagerActions } from '../../actions'
|
||||
import { ConnectionOptions } from '../../model/ConnectionOptions'
|
||||
import {
|
||||
IconButton,
|
||||
TableContainer,
|
||||
Table,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableBody,
|
||||
Paper,
|
||||
Theme,
|
||||
} from '@material-ui/core'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { withStyles } from '@material-ui/styles'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
function Subscriptions(props: {
|
||||
classes: any
|
||||
connection: ConnectionOptions
|
||||
managerActions: typeof connectionManagerActions
|
||||
}) {
|
||||
const { classes, connection, managerActions } = props
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper} className={`${classes.topicList} advanced-connection-settings-topic-list`}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="left" padding="checkbox" className={classes.tableTitleCell}></TableCell>
|
||||
<TableCell className={classes.tableTitleCell}>Topic</TableCell>
|
||||
<TableCell align="right" className={classes.tableTitleCell}>
|
||||
QoS
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{connection.subscriptions.map(subscription => (
|
||||
<TableRow key={subscription.topic + '_qos_' + subscription.qos}>
|
||||
<TableCell align="right" className={classes.tableCell}>
|
||||
<IconButton
|
||||
onClick={() => managerActions.deleteSubscription(subscription, connection.id)}
|
||||
style={{ padding: '6px' }}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
|
||||
<TableCell component="th" scope="row" className={classes.tableCell}>
|
||||
{subscription.topic}
|
||||
</TableCell>
|
||||
<TableCell align="right" className={classes.tableCell}>
|
||||
{subscription.qos}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
managerActions: bindActionCreators(connectionManagerActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
tableCell: {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
wordBbreak: 'break-word',
|
||||
},
|
||||
tableTitleCell: {
|
||||
paddingTop: `${theme.spacing(0.5)}px`,
|
||||
paddingBottom: `${theme.spacing(0.5)}px`,
|
||||
},
|
||||
topicList: {
|
||||
height: '196px',
|
||||
overflowY: 'scroll' as 'scroll',
|
||||
margin: `${theme.spacing(1)}px ${theme.spacing(1)}px 0 ${theme.spacing(1)}px`,
|
||||
backgroundColor: theme.palette.background.default,
|
||||
width: 'auto',
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(withStyles(styles)(Subscriptions))
|
||||
@@ -1,18 +1,8 @@
|
||||
import * as React from 'react'
|
||||
import { TextField, MenuItem, Tooltip } from '@material-ui/core'
|
||||
import { connect } from 'react-redux'
|
||||
import { AppState } from '../../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { publishActions } from '../../../actions'
|
||||
import { QoS } from '../../../backend/src/DataSource/MqttSource'
|
||||
|
||||
interface Props {
|
||||
qos: 0 | 1 | 2
|
||||
actions: {
|
||||
publish: typeof publishActions
|
||||
}
|
||||
}
|
||||
|
||||
function QosSelect(props: Props) {
|
||||
export function QosSelect(props: { selected: QoS; onChange: (value: QoS) => void; label?: string }) {
|
||||
const tooltipStyle = { textAlign: 'center' as 'center', width: '100%' }
|
||||
const itemStyle = { padding: '0' }
|
||||
|
||||
@@ -22,16 +12,16 @@ function QosSelect(props: Props) {
|
||||
if (value !== 0 && value !== 1 && value !== 2) {
|
||||
return
|
||||
}
|
||||
|
||||
props.actions.publish.setQoS(value)
|
||||
props.onChange(value)
|
||||
},
|
||||
[props.actions.publish]
|
||||
[props.onChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<TextField
|
||||
select={true}
|
||||
value={props.qos}
|
||||
label={props.label}
|
||||
value={props.selected}
|
||||
margin="normal"
|
||||
style={{ margin: '8px 0 8px 8px' }}
|
||||
onChange={onChangeQos}
|
||||
@@ -55,18 +45,4 @@ function QosSelect(props: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
publish: bindActionCreators(publishActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
qos: state.publish.qos,
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(QosSelect)
|
||||
export default React.memo(QosSelect)
|
||||
34
app/src/components/Sidebar/Publish/QosPublishOption.tsx
Normal file
34
app/src/components/Sidebar/Publish/QosPublishOption.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { AppState } from '../../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { publishActions } from '../../../actions'
|
||||
import { QosSelect } from '../../QosSelect'
|
||||
import { QoS } from '../../../../../backend/src/DataSource/MqttSource'
|
||||
|
||||
interface Props {
|
||||
qos: QoS
|
||||
actions: {
|
||||
publish: typeof publishActions
|
||||
}
|
||||
}
|
||||
|
||||
function QosPublishOption(props: Props) {
|
||||
return <QosSelect onChange={props.actions.publish.setQoS} selected={props.qos} />
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
publish: bindActionCreators(publishActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
qos: state.publish.qos,
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(QosPublishOption)
|
||||
@@ -1,4 +1,4 @@
|
||||
import QosSelect from './QosSelect'
|
||||
import QosSelect from './QosPublishOption'
|
||||
import React from 'react'
|
||||
import { Checkbox, FormControlLabel, Tooltip } from '@material-ui/core'
|
||||
import { publishActions } from '../../../actions'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MqttOptions } from '../../../backend/src/DataSource'
|
||||
import { v4 } from 'uuid'
|
||||
import { Subscription } from '../../../backend/src/DataSource/MqttSource'
|
||||
const sha1 = require('sha1')
|
||||
|
||||
export interface CertificateParameters {
|
||||
@@ -7,7 +8,9 @@ export interface CertificateParameters {
|
||||
/** @property data base64 encoded data */
|
||||
data: string
|
||||
}
|
||||
|
||||
export interface ConnectionOptions {
|
||||
configVersion: 1
|
||||
type: 'mqtt'
|
||||
id: string
|
||||
host: string
|
||||
@@ -23,7 +26,7 @@ export interface ConnectionOptions {
|
||||
clientCertificate?: CertificateParameters
|
||||
clientKey?: CertificateParameters
|
||||
clientId?: string
|
||||
subscriptions: Array<string>
|
||||
subscriptions: Array<Subscription>
|
||||
}
|
||||
|
||||
export function toMqttConnection(options: ConnectionOptions): MqttOptions | undefined {
|
||||
@@ -52,6 +55,7 @@ function generateClientId() {
|
||||
|
||||
export function createEmptyConnection(): ConnectionOptions {
|
||||
return {
|
||||
configVersion: 1,
|
||||
certValidation: true,
|
||||
clientId: generateClientId(),
|
||||
id: v4() as string,
|
||||
@@ -59,7 +63,10 @@ export function createEmptyConnection(): ConnectionOptions {
|
||||
encryption: false,
|
||||
password: undefined,
|
||||
username: undefined,
|
||||
subscriptions: ['#', '$SYS/#'],
|
||||
subscriptions: [
|
||||
{ topic: '#', qos: 0 },
|
||||
{ topic: '$SYS/#', qos: 0 },
|
||||
],
|
||||
type: 'mqtt',
|
||||
host: '',
|
||||
port: 1883,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ConnectionOptions } from '../model/ConnectionOptions'
|
||||
import { createReducer } from './lib'
|
||||
import { Subscription } from '../../../backend/src/DataSource/MqttSource'
|
||||
|
||||
export interface ConnectionManagerState {
|
||||
connections: { [s: string]: ConnectionOptions }
|
||||
@@ -50,13 +51,13 @@ export interface SelectConnection {
|
||||
|
||||
export interface AddSubscription {
|
||||
type: ActionTypes.CONNECTION_MANAGER_ADD_SUBSCRIPTION
|
||||
subscription: string
|
||||
subscription: Subscription
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
export interface DeleteSubscription {
|
||||
type: ActionTypes.CONNECTION_MANAGER_DELETE_SUBSCRIPTION
|
||||
subscription: string
|
||||
subscription: Subscription
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
@@ -159,8 +160,12 @@ function addSubscription(state: ConnectionManagerState, action: AddSubscription)
|
||||
}
|
||||
|
||||
function deleteSubscription(state: ConnectionManagerState, action: AddSubscription): ConnectionManagerState {
|
||||
function subscriptionsEqual(v1: Subscription, v2: Subscription): boolean {
|
||||
return v1.topic == v2.topic && v1.qos == v2.qos
|
||||
}
|
||||
|
||||
const connection = state.connections[action.connectionId]
|
||||
const newSubscriptions = connection.subscriptions.filter(s => s !== action.subscription)
|
||||
const newSubscriptions = connection.subscriptions.filter(s => !subscriptionsEqual(s, action.subscription))
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
||||
34
app/src/utils/ConfigMigrator.ts
Normal file
34
app/src/utils/ConfigMigrator.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export interface Migration {
|
||||
from: number | undefined
|
||||
apply: (config: any) => any
|
||||
}
|
||||
|
||||
interface MigrationSubject {
|
||||
configVersion: number | undefined
|
||||
}
|
||||
|
||||
export class ConfigMigrator {
|
||||
private migrations: Migration[]
|
||||
|
||||
constructor(migrations: Migration[]) {
|
||||
this.migrations = migrations
|
||||
}
|
||||
|
||||
public applyMigrations(subject: MigrationSubject): Partial<MigrationSubject> {
|
||||
while (this.isMigrationNecessary(subject)) {
|
||||
subject = this.applyableMigrations(subject)
|
||||
.filter(migration => migration.from === subject.configVersion)
|
||||
.reduce((currentSubject, migration) => migration.apply(currentSubject as any), subject)
|
||||
}
|
||||
|
||||
return subject
|
||||
}
|
||||
|
||||
public isMigrationNecessary(subject: MigrationSubject): boolean {
|
||||
return this.applyableMigrations(subject).length > 0
|
||||
}
|
||||
|
||||
private applyableMigrations(subject: MigrationSubject): Migration[] {
|
||||
return this.migrations.filter(migration => migration.from === subject.configVersion)
|
||||
}
|
||||
}
|
||||
115
app/src/utils/spec/ConfigMigrator.spec.ts
Normal file
115
app/src/utils/spec/ConfigMigrator.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { expect } from 'chai'
|
||||
import { ConfigMigrator } from '../ConfigMigrator'
|
||||
import 'mocha'
|
||||
|
||||
describe('ConfigMigrator', () => {
|
||||
it('applies migrations to a subject with undefined configVersion', () => {
|
||||
let migrator = new ConfigMigrator([
|
||||
{
|
||||
from: undefined,
|
||||
apply: subject => {
|
||||
return {
|
||||
...subject,
|
||||
configVersion: 1,
|
||||
drink: 'Beer',
|
||||
}
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
let config = {
|
||||
drink: 'water',
|
||||
}
|
||||
|
||||
let migratedConfig = migrator.applyMigrations(config as any) as any
|
||||
|
||||
expect(migratedConfig.drink).to.eq('Beer')
|
||||
expect(migratedConfig.configVersion).to.eq(1)
|
||||
})
|
||||
|
||||
it('applies multiple migrations from the same version', () => {
|
||||
let migrator = new ConfigMigrator([
|
||||
{
|
||||
from: undefined,
|
||||
apply: subject => {
|
||||
return {
|
||||
...subject,
|
||||
drink: 'Beer',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
from: undefined,
|
||||
apply: subject => {
|
||||
return {
|
||||
...subject,
|
||||
configVersion: 1,
|
||||
}
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
let config = {
|
||||
drink: 'water',
|
||||
}
|
||||
|
||||
let migratedConfig = migrator.applyMigrations(config as any) as any
|
||||
|
||||
expect(migratedConfig.drink).to.eq('Beer')
|
||||
expect(migratedConfig.configVersion).to.eq(1)
|
||||
})
|
||||
|
||||
it('applies multiple migrations with ascending versions', () => {
|
||||
let migrator = new ConfigMigrator([
|
||||
{
|
||||
from: undefined,
|
||||
apply: subject => {
|
||||
return {
|
||||
...subject,
|
||||
configVersion: 1,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
from: 1,
|
||||
apply: subject => {
|
||||
return {
|
||||
...subject,
|
||||
configVersion: 2,
|
||||
}
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
let config = {
|
||||
drink: 'water',
|
||||
}
|
||||
|
||||
let migratedConfig = migrator.applyMigrations(config as any)
|
||||
|
||||
expect(migratedConfig.configVersion).to.eq(2)
|
||||
})
|
||||
|
||||
it('does not apply non-matching migrations', () => {
|
||||
let migrator = new ConfigMigrator([
|
||||
{
|
||||
from: 2,
|
||||
apply: subject => {
|
||||
return {
|
||||
...subject,
|
||||
configVersion: 3,
|
||||
}
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
let config = {
|
||||
configVersion: 10,
|
||||
drink: 'water',
|
||||
}
|
||||
|
||||
let migratedConfig = migrator.applyMigrations(config as any)
|
||||
|
||||
expect(migratedConfig.configVersion).to.eq(10)
|
||||
})
|
||||
})
|
||||
71
app/src/utils/spec/ConnectionsMigration.spec.ts
Normal file
71
app/src/utils/spec/ConnectionsMigration.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'mocha'
|
||||
import { expect } from 'chai'
|
||||
import { connectionsMigrator } from '../../actions/migrations/Connection'
|
||||
|
||||
describe('ConnectionsMigrator', () => {
|
||||
it('applies migrations', () => {
|
||||
let connections: any = {
|
||||
'763b2e5c-c1ed-4c9b-ac9a-0970b3be29a7': {
|
||||
certValidation: true,
|
||||
clientId: 'mqtt-explorer-2783f48c',
|
||||
encryption: false,
|
||||
host: 'nodered',
|
||||
id: '763b2e5c-c1ed-4c9b-ac9a-0970b3be29a7',
|
||||
name: 'nodered',
|
||||
port: 1883,
|
||||
protocol: 'mqtt',
|
||||
subscriptions: ['#', '$SYS/#'],
|
||||
type: 'mqtt',
|
||||
},
|
||||
'iot.eclipse.org': {
|
||||
certValidation: true,
|
||||
clientId: 'mqtt-explorer-d913aad3',
|
||||
encryption: false,
|
||||
host: 'iot.eclipse.org',
|
||||
id: 'iot.eclipse.org',
|
||||
name: 'iot.eclipse.org',
|
||||
port: 1883,
|
||||
protocol: 'mqtt',
|
||||
subscriptions: ['#', '$SYS/#'],
|
||||
type: 'mqtt',
|
||||
},
|
||||
}
|
||||
|
||||
let migratedConnections: any = {
|
||||
'763b2e5c-c1ed-4c9b-ac9a-0970b3be29a7': {
|
||||
configVersion: 1,
|
||||
certValidation: true,
|
||||
clientId: undefined,
|
||||
encryption: false,
|
||||
host: 'nodered',
|
||||
id: '763b2e5c-c1ed-4c9b-ac9a-0970b3be29a7',
|
||||
name: 'nodered',
|
||||
port: 1883,
|
||||
protocol: 'mqtt',
|
||||
subscriptions: [
|
||||
{ topic: '#', qos: 0 },
|
||||
{ topic: '$SYS/#', qos: 0 },
|
||||
],
|
||||
type: 'mqtt',
|
||||
},
|
||||
'mqtt.eclipse.org': {
|
||||
configVersion: 1,
|
||||
certValidation: true,
|
||||
clientId: undefined,
|
||||
encryption: false,
|
||||
host: 'mqtt.eclipse.org',
|
||||
id: 'mqtt.eclipse.org',
|
||||
name: 'mqtt.eclipse.org',
|
||||
port: 1883,
|
||||
protocol: 'mqtt',
|
||||
subscriptions: [
|
||||
{ topic: '#', qos: 0 },
|
||||
{ topic: '$SYS/#', qos: 0 },
|
||||
],
|
||||
type: 'mqtt',
|
||||
},
|
||||
}
|
||||
|
||||
expect(connectionsMigrator.applyMigrations(connections)).to.deep.eq(migratedConnections)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user