Compare commits

..
Author SHA1 Message Date
Thomas Nordquist 901acf2bed Prepare release 2019-07-08 01:42:29 +02:00
Thomas Nordquist 4ec7c8ca75 Revert "Update material-ui"
This reverts commit 75c3619898.
2019-07-08 01:41:42 +02:00
Thomas Nordquist 75c3619898 Update material-ui 2019-07-08 01:31:27 +02:00
Thomas Nordquist 38f8d2e6ee Improve render performance 2019-07-08 01:22:16 +02:00
Thomas Nordquist e3584add7c Fix broker stats 2019-07-08 00:19:34 +02:00
Thomas Nordquist 77dcbccd5c Add time range support to charts 2019-07-07 23:54:28 +02:00
Thomas Nordquist 0ff6359a41 Allow scrolling in profile list 2019-07-07 23:52:51 +02:00
Thomas Nordquist 66bfcab256 Fix test 2019-07-07 23:11:13 +02:00
Thomas Nordquist 195dcf37d4 Add time range setting for charts 2019-07-07 22:14:49 +02:00
Thomas Nordquist 5830d99d45 Quit ui-tests eary if an error occurs 2019-07-07 16:50:29 +02:00
Thomas Nordquist d50cef7bb3 Repair reducer 2019-07-07 16:24:53 +02:00
Thomas Nordquist b79725bdf0 Refactor sidebar 2019-07-07 16:24:18 +02:00
Thomas Nordquist 6bead5b5a6 Fix animation glitch 2019-07-07 12:59:30 +02:00
Thomas Nordquist 183ea9d8c0 Allow publishing topics with ctrl+return 2019-07-07 12:44:34 +02:00
Thomas Nordquist 282736d2f6 Use default domain if plot data is empty 2019-07-07 12:31:07 +02:00
Thomas Nordquist 45b30e5997 Update dependencies 2019-07-07 12:04:27 +02:00
Thomas Nordquist 3d45e8ce6e Disable code signing for windows 2019-07-07 11:52:36 +02:00
Thomas Nordquist 393cf76839 Update dictionary 2019-07-02 15:34:43 +02:00
Thomas Nordquist 410bc6a39b Prepare release 2019-07-02 15:32:04 +02:00
Thomas Nordquist a56c00aeb3 Hotfix certificates selection 2019-07-02 15:27:38 +02:00
Thomas Nordquist c3d7d653fb Update electron-builder 2019-07-02 15:20:33 +02:00
Thomas Nordquist 9d72ef0122 Disable delete key 2019-07-02 15:20:16 +02:00
Thomas Nordquist b5aa22a6d8 Improve render performance 2019-07-02 15:10:46 +02:00
Thomas Nordquist aa05c16651 Report crashes 2019-07-02 13:38:34 +02:00
Thomas Nordquist c543d0fb85 Disable waylanf for snap builds 2019-07-02 11:11:32 +02:00
Thomas Nordquist de7fa2dd9c Fix tests 2019-06-27 10:04:56 +02:00
Thomas Nordquist f9829c2d5c Refactor 2019-06-26 17:41:21 +02:00
Thomas Nordquist e02091f645 Expose model interface 2019-06-26 17:41:21 +02:00
Thomas Nordquist 8d15018986 Refactor TreeNodeComponent 2019-06-26 17:41:21 +02:00
Thomas Nordquist 76c63d38cd Fix type mismatch 2019-06-26 17:41:21 +02:00
Thomas Nordquist 188b5c6c16 Fix typical bugs 2019-06-26 17:41:21 +02:00
greenkeeper[bot] fc5a5d2035 Update electron in group default to the latest version 🚀 (#136)
* chore(package): update electron to version 5.0.5

* chore(package): update electron to version 5.0.5

* chore(package): update lockfile yarn.lock
2019-06-25 21:29:50 +02:00
greenkeeper[bot] 838d106b62 Update spectron to the latest version 🚀 (#131)
* chore(package): update spectron to version 6.0.0

* chore(package): update lockfile yarn.lock
2019-06-25 21:29:09 +02:00
Thomas Nordquist d59a1af5d4 Refactor TreeNodeComponent 2019-06-25 17:44:34 +02:00
Thomas Nordquist 1638080e85 Improve keyboard arrow navigation 2019-06-25 14:30:47 +02:00
Thomas Nordquist f4051b4cdf Add tree navigation via arrow keys 2019-06-25 01:39:31 +02:00
Thomas Nordquist d054e64568 Focus on first input element of connection settings 2019-06-24 15:59:02 +02:00
Thomas Nordquist 759176c4fc Improve RangeSettings keyboard compatibility 2019-06-24 15:58:43 +02:00
Thomas Nordquist c2a9b7b1c6 Fix faulty electron version detection 2019-06-24 14:07:32 +02:00
Thomas Nordquist 2536b9fff7 Fix ui glitch 2019-06-24 14:07:15 +02:00
Thomas Nordquist b714547928 Fix typo 2019-06-24 12:58:05 +02:00
Thomas Nordquist 005fba5ca3 Prevent tab-event closing the chart range settings 2019-06-24 12:47:22 +02:00
Thomas Nordquist b27df0c0d8 Allow to select connection profile with arrow keys 2019-06-24 12:27:15 +02:00
Thomas Nordquist 341ab44f9e Refactor ProfileList 2019-06-24 12:10:02 +02:00
Thomas Nordquist e0d35548c5 Improve "search as you type" feature 2019-06-24 11:39:24 +02:00
Thomas Nordquist ba91730f43 Add support for keyboard events 2019-06-24 11:22:50 +02:00
Thomas Nordquist f691a7a0ae Add keycodes enum 2019-06-23 12:31:08 +02:00
Thomas Nordquist 5a43ecd8d2 Add dramatic pause to video 2019-06-23 12:30:53 +02:00
Thomas Nordquist 73f33c31bb Remove wrapper script from snaps 2019-06-23 12:30:35 +02:00
Thomas Nordquist ffdce6f825 Add keyboard events enter/escape to connect/disconnect 2019-06-23 12:29:52 +02:00
Thomas Nordquist 38b16daf51 Add client certificates 2019-06-21 01:59:38 +02:00
92 changed files with 2464 additions and 1486 deletions
+2
View File
@@ -1,6 +1,8 @@
{
"language": "en",
"words": [
"thomasnordquist",
"nowrap",
"subheader",
"basepath",
"webdriverio",
+1 -1
View File
@@ -35,4 +35,4 @@ script:
- if [[ "$TRAVIS_TAG" != "" ]]; then yarn run prepare-release; fi
- if [[ "$TRAVIS_OS_NAME" == "linux" ]] && [[ "$TRAVIS_TAG" != "" ]]; then yarn run package linux; fi
- if [[ "$TRAVIS_OS_NAME" == "osx" ]] && [[ "$TRAVIS_TAG" != "" ]]; then yarn run package -- mac; fi
- if [[ "$TRAVIS_OS_NAME" == "osx" ]] && [[ "$TRAVIS_TAG" != "" ]]; then yarn run package -- win; fi
- if [[ "$TRAVIS_OS_NAME" == "osx" ]] && [[ "$TRAVIS_TAG" != "" ]]; then CSC_LINK="" yarn run package -- win; fi
+4 -4
View File
@@ -22,10 +22,10 @@
background-color: none;
}
25% {
background-color: #3f51b5;
background-color: #4f5781;
}
50% {
background-color: #3f51b5;
background-color: #4f5781;
}
100% {
background-color: none;
@@ -38,11 +38,11 @@
color: inherit;
}
25% {
background-color: #bfc9c8;
background-color: #c0c8c0;
color: #000;
}
50% {
background-color: #bfc9c8;
background-color: #c0c8c0;
color: #000;
}
100% {
+2 -1
View File
@@ -35,6 +35,7 @@
"moment": "^2.24.0",
"moving-average": "^1.0.0",
"number-abbreviate": "^2.0.0",
"parse-duration": "^0.1.1",
"prismjs": "^1.15.0",
"react": "16.8",
"react-ace": "^7.0.1",
@@ -80,6 +81,6 @@
"webpack-dev-server": "^3.1.14"
},
"peerDependencies": {
"electron": "^5.0.4"
"electron": "^5.0.5"
}
}
+7 -2
View File
@@ -42,7 +42,8 @@ export const loadConnectionSettings = () => async (dispatch: Dispatch<any>, getS
}
}
export const selectCertificate = (connectionId: string) => async (
export type CertificateTypes = 'selfSignedCertificate' | 'clientCertificate' | 'clientKey'
export const selectCertificate = (type: CertificateTypes, connectionId: string) => async (
dispatch: Dispatch<any>,
getState: () => AppState
) => {
@@ -50,7 +51,7 @@ export const selectCertificate = (connectionId: string) => async (
const certificate = await openCertificate()
dispatch(
updateConnection(connectionId, {
selfSignedCertificate: certificate,
[type]: certificate,
})
)
} catch (error) {
@@ -147,6 +148,10 @@ export const toggleAdvancedSettings = (): Action => ({
type: ActionTypes.CONNECTION_MANAGER_TOGGLE_ADVANCED_SETTINGS,
})
export const toggleCertificateSettings = (): Action => ({
type: ActionTypes.CONNECTION_MANAGER_TOGGLE_CERTIFICATE_SETTINGS,
})
export const deleteConnection = (connectionId: string) => (dispatch: Dispatch<any>, getState: () => AppState) => {
const connectionIds = Object.keys(getState().connectionManager.connections)
const connectionIdLocation = connectionIds.indexOf(connectionId)
+5 -4
View File
@@ -1,5 +1,5 @@
import * as q from '../../../backend/src/Model'
import { ActionTypes, SettingsState, TopicOrder } from '../reducers/Settings'
import { ActionTypes, SettingsStateModel, TopicOrder } from '../reducers/Settings'
import { AppState } from '../reducers'
import { autoExpandLimitSet } from '../components/SettingsDrawer/Settings'
import { Base64Message } from '../../../backend/src/Model/Base64Message'
@@ -11,7 +11,7 @@ import { showError } from './Global'
import { showTree } from './Tree'
import { TopicViewModel } from '../model/TopicViewModel'
const settingsIdentifier: StorageIdentifier<Partial<SettingsState>> = {
const settingsIdentifier: StorageIdentifier<Partial<SettingsStateModel>> = {
id: 'Settings',
}
@@ -149,9 +149,10 @@ function autoExpandLimitForTree(tree: q.Tree<TopicViewModel>) {
if (!tree) {
return 0
}
function closestExistingLimit(i: number): number {
const sorted = autoExpandLimitSet.sort((a, b) => Math.abs(a.limit - i) - Math.abs(b.limit - i))
return sorted[0]!.limit
const sorted = [...autoExpandLimitSet].sort((a, b) => Math.abs(a.limit - i) - Math.abs(b.limit - i))
return sorted[0].limit
}
const count = tree.childTopicCount()
+3 -36
View File
@@ -2,7 +2,9 @@ import * as q from '../../../backend/src/Model'
import { ActionTypes } from '../reducers/Sidebar'
import { AppState } from '../reducers'
import { Dispatch } from 'redux'
import { makePublishEvent, rendererEvents } from '../../../events'
import { clearTopic } from './clearTopic'
export { clearTopic } from './clearTopic'
export const clearRetainedTopic = () => (dispatch: Dispatch<any>, getState: () => AppState) => {
const selectedTopic = getState().tree.get('selectedTopic')
@@ -19,38 +21,3 @@ export const setCompareMessage = (message?: q.Message) => (dispatch: Dispatch<an
type: ActionTypes.SIDEBAR_SET_COMPARE_MESSAGE,
})
}
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: topic.path(),
payload: null,
retain: true,
qos: 0 as 0,
}
rendererEvents.emit(publishEvent, mqttMessage)
if (recursive) {
topic
.childTopics()
.filter(topic => Boolean(topic.message && topic.message.value))
.slice(0, subtopicClearLimit)
.forEach(topic => {
const mqttMessage = {
topic: topic.path(),
payload: null,
retain: true,
qos: 0 as 0,
}
rendererEvents.emit(publishEvent, mqttMessage)
})
}
}
+4 -1
View File
@@ -4,10 +4,13 @@ import { ActionTypes as SidebarActionTypes } from '../reducers/Sidebar'
import { AnyAction, Dispatch } from 'redux'
import { AppState } from '../reducers'
import { batchActions } from 'redux-batched-actions'
import { globalActions } from './'
import { setTopic } from './Publish'
import { TopicViewModel } from '../model/TopicViewModel'
import { globalActions } from '.'
const debounce = require('lodash.debounce')
export { clearTopic } from './clearTopic'
export { moveSelectionUpOrDownwards, moveInward, moveOutward } from './visibleTreeTraversal'
export const selectTopic = (topic: q.TreeNode<TopicViewModel>) => (
dispatch: Dispatch<any>,
+41
View File
@@ -0,0 +1,41 @@
import * as q from '../../../backend/src/Model'
import { AppState } from '../reducers'
import { Dispatch } from 'redux'
import { makePublishEvent, rendererEvents } from '../../../events'
import { moveSelectionUpOrDownwards } from './visibleTreeTraversal'
export const clearTopic = (topic: q.TreeNode<any>, recursive: boolean, subtopicClearLimit = 50) => (
dispatch: Dispatch<any>,
getState: () => AppState
) => {
dispatch(moveSelectionUpOrDownwards('next'))
const { connectionId } = getState().connection
if (!connectionId) {
return
}
const publishEvent = makePublishEvent(connectionId)
const mqttMessage = {
topic: topic.path(),
payload: null,
retain: true,
qos: 0 as 0,
}
rendererEvents.emit(publishEvent, mqttMessage)
if (recursive) {
topic
.childTopics()
.filter(topic => Boolean(topic.message && topic.message.value))
.slice(0, subtopicClearLimit)
.forEach((topic, idx) => {
const mqttMessage = {
topic: topic.path(),
payload: null,
retain: true,
qos: 0 as 0,
}
// Rate limit deletion
setTimeout(() => rendererEvents.emit(publishEvent, mqttMessage), 20 * idx)
})
}
}
+135
View File
@@ -0,0 +1,135 @@
import * as q from '../../../backend/src/Model'
import { AppState } from '../reducers'
import { Dispatch } from 'redux'
import { selectTopic } from './Tree'
import { SettingsState } from '../reducers/Settings'
import { sortedNodes } from '../sortedNodes'
import { TopicViewModel } from '../model/TopicViewModel'
export const moveSelectionUpOrDownwards = (direction: 'next' | 'previous') => (
dispatch: Dispatch<any>,
getState: () => AppState
): any => {
const state = getState()
const selected = state.tree.get('selectedTopic')
const tree = state.tree.get('tree')
if (!selected || !tree) {
if (tree) {
dispatch(selectTopic(tree))
}
return
}
const nextTreeNode = nextVisibleElementInTree(state.settings, tree, selected, direction)
if (nextTreeNode && nextTreeNode.viewModel) {
dispatch(selectTopic(nextTreeNode))
}
}
export const moveInward = () => (dispatch: Dispatch<any>, getState: () => AppState): any => {
const state = getState()
const selected = state.tree.get('selectedTopic')
if (!selected || !selected.viewModel) {
return
}
if (!selected.viewModel.isExpanded() && selected.edgeCount() > 0) {
selected.viewModel.setExpanded(true, true)
} else {
dispatch(moveSelectionUpOrDownwards('next'))
}
}
export const moveOutward = () => (dispatch: Dispatch<any>, getState: () => AppState): any => {
const state = getState()
const selected = state.tree.get('selectedTopic')
if (!selected || !selected.viewModel) {
return
}
if (selected.viewModel.isExpanded() && selected.edgeCount() > 0) {
selected.viewModel.setExpanded(false, true)
} else {
dispatch(moveSelectionUpOrDownwards('previous'))
}
}
function isTreeNodeVisible(treeNode: q.TreeNode<any>) {
return Boolean(treeNode.viewModel)
}
function nextVisibleElementInTree(
settings: SettingsState,
tree: q.Tree<TopicViewModel>,
node: q.TreeNode<TopicViewModel>,
direction: 'next' | 'previous'
): q.TreeNode<TopicViewModel> | undefined {
if (direction === 'next') {
return findNextNodeDownward(settings, node)
} else {
return findNextNodeUpward(settings, node)
}
}
/** Not very efficient but easy to implement, complexity should not be an issue here */
function findNextNodeUpward(
settings: SettingsState,
treeNode: q.TreeNode<TopicViewModel>
): q.TreeNode<TopicViewModel> | undefined {
const parent = treeNode.sourceEdge && treeNode.sourceEdge.source
if (!parent) {
return undefined
}
const neighborNodes = sortedNodes(settings, parent)
const nodeIdx = neighborNodes.findIndex(n => n.path() === treeNode.path())
if (nodeIdx === 0) {
return parent
}
const upwardNeighbor = neighborNodes[nodeIdx - 1]
if (upwardNeighbor) {
return lastVisibleChild(settings, upwardNeighbor)
} else {
return findNextNodeUpward(settings, parent)
}
}
function lastVisibleChild(settings: SettingsState, treeNode: q.TreeNode<TopicViewModel>): q.TreeNode<TopicViewModel> {
const nodes = sortedNodes(settings, treeNode).filter(isTreeNodeVisible)
if (nodes.length === 0) {
return treeNode
}
return lastVisibleChild(settings, nodes[nodes.length - 1])
}
function findNextNodeDownward(
settings: SettingsState,
treeNode: q.TreeNode<TopicViewModel>
): q.TreeNode<TopicViewModel> | undefined {
const children = sortedNodes(settings, treeNode).filter(isTreeNodeVisible)
const firstChild = children[0]
if (firstChild) {
return firstChild
}
return findNextNodeDownwardNeighbor(settings, treeNode)
}
function findNextNodeDownwardNeighbor(
settings: SettingsState,
treeNode: q.TreeNode<TopicViewModel>
): q.TreeNode<TopicViewModel> | undefined {
const parent = treeNode.sourceEdge && treeNode.sourceEdge.source
if (!parent) {
return undefined
}
const neighborNodes = sortedNodes(settings, parent).filter(isTreeNodeVisible)
const nodeIdx = neighborNodes.findIndex(n => n.path() === treeNode.path())
const downwardNeighbor = neighborNodes[nodeIdx + 1]
if (downwardNeighbor) {
return downwardNeighbor
} else {
return findNextNodeDownwardNeighbor(settings, parent)
}
}
+30 -14
View File
@@ -1,7 +1,6 @@
import * as q from '../../../../backend/src/Model'
import * as React from 'react'
import React, { useState } from 'react'
import TopicPlot from '../TopicPlot'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { chartActions } from '../../actions'
import { ChartParameters } from '../../reducers/Charts'
@@ -20,32 +19,48 @@ interface Props {
}
/**
* Subscribes to onMessage of treeNode
* Subscribes to onMessages keeping track of additional data points
*/
function useMessageSubscriptionToUpdate(treeNode?: q.TreeNode<any>) {
const [lastUpdated, setLastUpdate] = React.useState(0)
const [lastUpdated, setLastUpdate] = useState(0)
const [messageHistory, setMessageHistory] = useState<q.MessageHistory | undefined>()
let amendMessageCallback: any
function subscribeToMessageUpdates() {
const onUpdateCallback = throttle(() => setLastUpdate(treeNode ? treeNode.lastUpdate : 0), 300)
treeNode && treeNode.onMessage.subscribe(onUpdateCallback)
const throttledUpdate = throttle(() => setLastUpdate(treeNode ? treeNode.lastUpdate : 0), 300)
if (treeNode) {
const newMessageHistory = treeNode.messageHistory.clone()
newMessageHistory.setCapacity(500, 2 * 500 * 10000)
amendMessageCallback = (message: q.Message) => {
newMessageHistory.add(message)
throttledUpdate()
}
treeNode.onMessage.subscribe(amendMessageCallback)
setMessageHistory(newMessageHistory)
}
return function cleanup() {
treeNode && treeNode.onMessage.unsubscribe(onUpdateCallback)
treeNode && treeNode.onMessage.unsubscribe(amendMessageCallback)
}
}
React.useEffect(subscribeToMessageUpdates, [treeNode])
return messageHistory
}
function Chart(props: Props) {
const { parameters, treeNode } = props
const [freezedHistory, setHistory] = React.useState<q.MessageHistory | undefined>()
useMessageSubscriptionToUpdate(treeNode)
const [frozenHistory, setFrozenHistory] = React.useState<q.MessageHistory | undefined>()
const messageHistory = useMessageSubscriptionToUpdate(treeNode)
const togglePause = React.useCallback(() => {
if (!props.treeNode) {
return
}
setHistory(freezedHistory ? undefined : props.treeNode.messageHistory.clone())
}, [props.treeNode, freezedHistory])
setFrozenHistory(frozenHistory ? undefined : messageHistory && messageHistory.clone())
}, [props.treeNode, frozenHistory])
const onRemove = React.useCallback(() => {
props.actions.chart.removeChart(props.parameters)
@@ -63,17 +78,18 @@ function Chart(props: Props) {
<ChartActions
parameters={parameters}
onRemove={onRemove}
paused={Boolean(freezedHistory)}
paused={Boolean(frozenHistory)}
togglePause={togglePause}
/>
</div>
</div>
{props.treeNode ? (
{messageHistory ? (
<TopicPlot
color={props.parameters.color}
interpolation={props.parameters.interpolation}
timeInterval={props.parameters.timeRange ? props.parameters.timeRange.until : undefined}
range={props.parameters.range ? [props.parameters.range.from, props.parameters.range.to] : undefined}
history={freezedHistory ? freezedHistory : props.treeNode.messageHistory}
history={frozenHistory || messageHistory}
dotPath={parameters.dotPath}
/>
) : (
@@ -1,60 +1,89 @@
import * as React from 'react'
import React, { useCallback, useState, ChangeEvent, MouseEvent, useRef, useEffect, useMemo } from 'react'
import { ChartParameters } from '../../../reducers/Charts'
import { Menu, MenuItem, TextField, Typography } from '@material-ui/core'
import { Menu, TextField, Typography } from '@material-ui/core'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import { chartActions } from '../../../actions'
import { KeyCodes } from '../../../utils/KeyCodes'
function RangeSettings(props: {
interface Props {
actions: { chart: typeof chartActions }
chart: ChartParameters
anchorEl?: Element
open: boolean
onClose: () => void
}) {
const dismissClick = React.useCallback((e: React.MouseEvent) => e.stopPropagation(), [])
const [rangeFrom, setRangeFrom] = React.useState<string | number | undefined>(
props.chart.range && props.chart.range.from
)
const [rangeTo, setRangeTo] = React.useState<string | number | undefined>(props.chart.range && props.chart.range.to)
}
React.useEffect(() => {
const from = parseFloat(rangeFrom as any)
const to = parseFloat(rangeTo as any)
props.actions.chart.updateChart({
topic: props.chart.topic,
dotPath: props.chart.dotPath,
range: {
from: isNaN(from) ? undefined : from,
to: isNaN(to) ? undefined : to,
},
})
}, [rangeFrom, rangeTo])
function RangeSettings(props: Props) {
const dismissClick = useCallback((e: MouseEvent) => e.stopPropagation(), [])
const [rangeFrom, setRangeFrom] = useState<string | number | undefined>(props.chart.range && props.chart.range.from)
const [rangeTo, setRangeTo] = useState<string | number | undefined>(props.chart.range && props.chart.range.to)
useRangeStateToFireUpdateAction(rangeFrom, rangeTo, props)
const rangeFromRef = useRef<HTMLInputElement>()
const rangeToRef = useRef<HTMLInputElement>()
const setFromHandler = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => setRangeFrom(e.target.value), [])
const setToHandler = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => setRangeTo(e.target.value), [])
return (
<Menu style={{ textAlign: 'center' }} anchorEl={props.anchorEl} open={props.open} onClose={props.onClose}>
<Typography>Define custom ranges for the Y-Axis</Typography>
<div style={{ padding: '0 16px' }}>
<TextField
style={{ marginTop: '0' }}
onClick={dismissClick}
label="from"
value={rangeFrom}
onChange={setFromHandler}
margin="normal"
/>
<TextField
style={{ marginLeft: '8px', marginTop: '0' }}
onClick={dismissClick}
label="to"
value={rangeTo}
onChange={setToHandler}
margin="normal"
/>
</div>
</Menu>
useEffect(() => {
rangeFromRef.current && rangeFromRef.current.focus()
}, [props.open])
const handleKeyEvents = (e: React.KeyboardEvent<any>) => {
if (e.keyCode === KeyCodes.tab) {
// Switch focus between those two
if (document.activeElement === rangeFromRef.current) {
rangeToRef.current && rangeToRef.current.focus()
} else {
rangeFromRef.current && rangeFromRef.current.focus()
}
// Prevent closing the menu
e.stopPropagation()
// Prevent default tab behavior (focus/blur)
e.preventDefault()
} else if (e.keyCode === KeyCodes.enter) {
props.onClose()
}
}
const setFromHandler = useCallback((e: ChangeEvent<HTMLInputElement>) => setRangeFrom(e.target.value), [])
const setToHandler = useCallback((e: ChangeEvent<HTMLInputElement>) => setRangeTo(e.target.value), [])
return useMemo(
() => (
<Menu
style={{ textAlign: 'center' }}
keepMounted={true}
anchorEl={props.anchorEl}
open={props.open}
onClose={props.onClose}
onKeyDownCapture={handleKeyEvents}
>
<Typography>Define custom ranges for the Y-Axis</Typography>
<div style={{ padding: '0 16px' }}>
<TextField
inputProps={{
ref: rangeFromRef,
}}
autoFocus={true}
style={{ marginTop: '0' }}
label="from"
value={rangeFrom}
onChange={setFromHandler}
margin="normal"
/>
<TextField
inputProps={{
ref: rangeToRef,
}}
style={{ marginLeft: '8px', marginTop: '0' }}
onClick={dismissClick}
label="to"
value={rangeTo}
onChange={setToHandler}
margin="normal"
/>
</div>
</Menu>
),
[rangeFrom, rangeTo, props.open]
)
}
@@ -70,3 +99,22 @@ export default connect(
undefined,
mapDispatchToProps
)(RangeSettings)
function useRangeStateToFireUpdateAction(
rangeFrom: string | number | undefined,
rangeTo: string | number | undefined,
props: Props
) {
React.useEffect(() => {
const from = parseFloat(rangeFrom as any)
const to = parseFloat(rangeTo as any)
props.actions.chart.updateChart({
topic: props.chart.topic,
dotPath: props.chart.dotPath,
range: {
from: isNaN(from) ? undefined : from,
to: isNaN(to) ? undefined : to,
},
})
}, [rangeFrom, rangeTo])
}
@@ -0,0 +1,102 @@
import React, { ChangeEvent, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { bindActionCreators } from 'redux'
import { Button, Menu, TextField, Typography } from '@material-ui/core'
import { chartActions } from '../../../actions'
import { ChartParameters } from '../../../reducers/Charts'
import { connect } from 'react-redux'
const parseDuration = require('parse-duration')
interface Props {
actions: { chart: typeof chartActions }
chart: ChartParameters
anchorEl?: Element
open: boolean
onClose: () => void
}
function TimeRangeSettings(props: Props) {
const dismissClick = useCallback((e: MouseEvent) => e.stopPropagation(), [])
const [value, setValue] = useState<string | undefined>(
props.chart.timeRange ? props.chart.timeRange.until : undefined
)
const ranges = ['all', '10s', '30s', '1m', '5m', '15m', '1h', '6h', '1d']
const manuallySetIntervalHandler = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value)
}, [])
useEffect(() => {
if (!value) {
props.actions.chart.updateChart({
...props.chart,
timeRange: undefined,
})
return
}
const canBeParsed = Boolean(parseDuration(value))
if (canBeParsed) {
props.actions.chart.updateChart({
...props.chart,
timeRange: {
until: value,
},
})
}
}, [value])
return useMemo(() => {
const createRangeHandler = (range: string) => (e: React.MouseEvent) => setValue(range === 'all' ? undefined : range)
return (
<Menu
style={{ textAlign: 'center' }}
keepMounted={true}
anchorEl={props.anchorEl}
open={props.open}
onClose={props.onClose}
>
<Typography>Chart data within a time interval</Typography>
<div style={{ padding: '0 16px', width: '275px', textAlign: 'center' }}>
{ranges.map(r => {
return (
<Button
style={{ margin: '4px', textTransform: 'none' }}
variant="contained"
key={r}
onClick={createRangeHandler(r)}
>
{r}
</Button>
)
})}
</div>
<Typography style={{ fontSize: '0.75em' }}>
<i>Limited to 500 data points</i>
</Typography>
<br />
<TextField
style={{ marginLeft: '8px', marginTop: '0' }}
onClick={dismissClick}
label="custom interval"
value={value || ''}
onChange={manuallySetIntervalHandler}
margin="normal"
/>
</Menu>
)
}, [value, props.open])
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
}
}
export default connect(
undefined,
mapDispatchToProps
)(TimeRangeSettings)
@@ -1,11 +1,12 @@
import * as React from 'react'
import ColorSettings from './ColorSettings'
import InterpolationSettings from './InterpolationSettings'
import { ChartParameters } from '../../../reducers/Charts'
import { Menu, MenuItem } from '@material-ui/core'
import MoveUp from './MoveUp'
import RangeSettings from './RangeSettings'
import Size from './Size'
import MoveUp from './MoveUp'
import ColorSettings from './ColorSettings'
import TimeRangeSettings from './TimeRangeSettings'
import { ChartParameters } from '../../../reducers/Charts'
import { Menu, MenuItem } from '@material-ui/core'
function ChartSettings(props: {
open: boolean
@@ -14,6 +15,7 @@ function ChartSettings(props: {
anchorEl: React.MutableRefObject<undefined>
}) {
const [rangeVisible, setRangeVisible] = React.useState(false)
const [timeRangeVisible, setTimeRangeVisible] = React.useState(false)
const [interpolationVisible, setInterpolationVisible] = React.useState(false)
const [sizeVisible, setSizeVisible] = React.useState(false)
const [colorVisible, setColorVisible] = React.useState(false)
@@ -25,6 +27,13 @@ function ChartSettings(props: {
setRangeVisible(!rangeVisible)
}, [rangeVisible, open])
const toggleTimeRange = React.useCallback(() => {
if (open) {
props.close()
}
setTimeRangeVisible(!timeRangeVisible)
}, [timeRangeVisible, open])
const toggleInterpolation = React.useCallback(() => {
if (open) {
props.close()
@@ -52,6 +61,9 @@ function ChartSettings(props: {
<MenuItem key="range" onClick={toggleRange}>
Set range
</MenuItem>
<MenuItem key="timeRange" onClick={toggleTimeRange}>
Time range
</MenuItem>
<MenuItem key="interpolation" onClick={toggleInterpolation}>
Curve interpolation
</MenuItem>
@@ -64,6 +76,12 @@ function ChartSettings(props: {
<MoveUp chart={props.chart} close={props.close} />
</Menu>
<RangeSettings chart={props.chart} anchorEl={props.anchorEl.current} open={rangeVisible} onClose={toggleRange} />
<TimeRangeSettings
chart={props.chart}
anchorEl={props.anchorEl.current}
open={timeRangeVisible}
onClose={toggleTimeRange}
/>
<InterpolationSettings
chart={props.chart}
anchorEl={props.anchorEl.current}
@@ -1,7 +1,8 @@
import * as q from '../../../../backend/src/Model'
import * as React from 'react'
import React from 'react'
import Chart from './Chart'
import { ChartParameters } from '../../reducers/Charts'
import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode'
interface Props {
tree?: q.Tree<any>
@@ -14,37 +15,6 @@ export function ChartWithTreeNode(props: Props) {
return null
}
const initialTreeNode = tree.findNode(parameters.topic)
const [treeNode, setTreeNode] = React.useState<q.TreeNode<any> | undefined>(initialTreeNode)
usePollingToFetchTreeNode(treeNode, tree, parameters.topic, setTreeNode)
const treeNode = usePollingToFetchTreeNode(tree, parameters.topic)
return <Chart treeNode={treeNode} parameters={parameters} />
}
/**
* If a node is not available when the plot is shown, keep polling until it has been created
*/
function usePollingToFetchTreeNode(
treeNode: q.TreeNode<any> | undefined,
tree: q.Tree<any>,
path: string,
setTreeNode: React.Dispatch<React.SetStateAction<q.TreeNode<any> | undefined>>
) {
function pollUntilTreeNodeHasBeenFound() {
let intervalTimer: any
if (!treeNode) {
intervalTimer = setInterval(() => {
const node = tree.findNode(path)
if (node) {
setTreeNode(node)
clearInterval(intervalTimer)
}
}, 500)
}
return function cleanup() {
intervalTimer && clearInterval(intervalTimer)
}
}
React.useEffect(pollUntilTreeNodeHasBeenFound, [])
}
@@ -44,29 +44,6 @@ class ConnectionSettings extends React.Component<Props, State> {
})
}
private renderCertificateInfo() {
if (!this.props.connection.selfSignedCertificate) {
return null
}
return (
<span>
<Tooltip title={this.props.connection.selfSignedCertificate.name}>
<Typography className={this.props.classes.certificateName}>
<ClearAdornment action={this.clearCertificate} value={this.props.connection.selfSignedCertificate.name} />
{this.props.connection.selfSignedCertificate.name}
</Typography>
</Tooltip>
</span>
)
}
private clearCertificate = () => {
this.props.managerActions.updateConnection(this.props.connection.id, {
selfSignedCertificate: undefined,
})
}
private renderSubscriptions() {
const connection = this.props.connection
return connection.subscriptions.map(subscription => (
@@ -122,16 +99,15 @@ class ConnectionSettings extends React.Component<Props, State> {
</Grid>
<Grid item={true} xs={3} className={classes.gridPadding}>
<div>
<Tooltip title="Select certificate to verify authenticity of a self-signed certificate" placement="top">
<Tooltip title="Manage tls connection certificates" placement="top">
<Button
variant="contained"
className={classes.button}
onClick={() => this.props.managerActions.selectCertificate(this.props.connection.id)}
onClick={() => this.props.managerActions.toggleCertificateSettings()}
>
<Lock /> Certificate
<Lock /> Certificates
</Button>
</Tooltip>
{this.renderCertificateInfo()}
</div>
</Grid>
<Grid item={true} xs={2} className={classes.gridPadding}>
@@ -186,14 +162,6 @@ const styles = (theme: Theme) => ({
marginTop: theme.spacing(3),
float: 'right' as 'right',
},
certificateName: {
width: '100%',
height: 'calc(1em + 4px)',
overflow: 'hidden' as 'hidden',
whiteSpace: 'nowrap' as 'nowrap',
textOverflow: 'ellipsis' as 'ellipsis',
color: theme.palette.text.hint,
},
})
export default connect(
@@ -0,0 +1,87 @@
import * as React 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 { bindActionCreators } from 'redux'
import { Button, Theme, Tooltip, Typography } from '@material-ui/core'
import { CertificateParameters, ConnectionOptions } from '../../model/ConnectionOptions'
import { CertificateTypes } from '../../actions/ConnectionManager'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { withStyles } from '@material-ui/styles'
function CertificateFileSelection(props: {
certificateType: CertificateTypes
title: string
certificate?: CertificateParameters
classes: any
actions: {
connectionManager: typeof connectionManagerActions
}
connection: ConnectionOptions
}) {
const clearCertificate = React.useCallback(() => {
props.actions.connectionManager.updateConnection(props.connection.id, {
[props.certificateType]: undefined,
})
}, [props.connection, props.certificateType])
return (
<span>
<Tooltip title="Select certificate" placement="top">
<Button
variant="contained"
className={props.classes.button}
onClick={() => props.actions.connectionManager.selectCertificate(props.certificateType, props.connection.id)}
>
<Lock /> {props.title}
</Button>
</Tooltip>
<ClearCertificate classes={props.classes} certificate={props.certificate} action={clearCertificate} />
</span>
)
}
function ClearCertificate(props: { classes: any; certificate?: CertificateParameters; action: () => void }) {
if (!props.certificate) {
return null
}
return (
<Tooltip title={props.certificate.name}>
<Typography className={props.classes.certificateName}>
<ClearAdornment action={props.action} value={props.certificate.name} />
{props.certificate.name}
</Typography>
</Tooltip>
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
connectionManager: bindActionCreators(connectionManagerActions, dispatch),
},
}
}
const styles = (theme: Theme) => ({
certificateName: {
width: '100%',
height: 'calc(1em + 4px)',
overflow: 'hidden' as 'hidden',
whiteSpace: 'nowrap' as 'nowrap',
textOverflow: 'ellipsis' as 'ellipsis',
color: theme.palette.text.hint,
},
button: {
marginTop: theme.spacing(3),
marginRight: theme.spacing(2),
},
})
export default connect(
undefined,
mapDispatchToProps
)(withStyles(styles)(CertificateFileSelection))
@@ -0,0 +1,110 @@
import * as React from 'react'
import CertificateFileSelection from './CertificateFileSelection'
import Undo from '@material-ui/icons/Undo'
import { bindActionCreators } from 'redux'
import { Button, Grid } from '@material-ui/core'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { ConnectionOptions } from '../../model/ConnectionOptions'
import { Theme, withStyles } from '@material-ui/core/styles'
interface Props {
connection: ConnectionOptions
classes: any
managerActions: typeof connectionManagerActions
}
interface State {
subscription: string
}
class Certificates 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 renderCertificateInfo() {
if (!this.props.connection.selfSignedCertificate) {
return null
}
return <span />
}
public render() {
const { classes } = this.props
return (
<div>
<form noValidate={true} autoComplete="off">
<Grid container={true} spacing={3}>
<Grid item={true} xs={12} className={classes.gridPadding}>
<CertificateFileSelection
connection={this.props.connection}
certificate={this.props.connection.selfSignedCertificate}
title="Server Certificate (CA)"
certificateType="selfSignedCertificate"
/>
</Grid>
<Grid item={true} xs={12} className={classes.gridPadding}>
<CertificateFileSelection
connection={this.props.connection}
certificate={this.props.connection.clientCertificate}
title="Client Certificate"
certificateType="clientCertificate"
/>
</Grid>
<Grid item={true} xs={12} className={classes.gridPadding}>
<CertificateFileSelection
connection={this.props.connection}
certificate={this.props.connection.clientKey}
title="Client Key"
certificateType="clientKey"
/>
</Grid>
<Grid item={true} xs={2} className={classes.gridPadding}>
<br />
<Button
variant="contained"
className={classes.button}
onClick={this.props.managerActions.toggleCertificateSettings}
>
<Undo /> Back
</Button>
</Grid>
</Grid>
</form>
</div>
)
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
managerActions: bindActionCreators(connectionManagerActions, dispatch),
}
}
const styles = (theme: Theme) => ({
fullWidth: {
width: '100%',
},
gridPadding: {
padding: '0 12px !important',
},
button: {
marginTop: theme.spacing(3),
marginRight: theme.spacing(2),
},
})
export default connect(
undefined,
mapDispatchToProps
)(withStyles(styles)(Certificates))
@@ -0,0 +1,25 @@
import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator'
import PowerSettingsNew from '@material-ui/icons/PowerSettingsNew'
import React from 'react'
import { Button } from '@material-ui/core'
function ConnectButton(props: { connecting: boolean; classes: any; toggle: () => void }) {
const { classes, toggle, connecting } = props
if (connecting) {
return (
<Button variant="contained" color="primary" className={classes.button} onClick={toggle}>
<ConnectionHealthIndicator />
&nbsp;&nbsp;Abort
</Button>
)
}
return (
<Button variant="contained" color="primary" className={classes.button} onClick={toggle}>
<PowerSettingsNew /> Connect
</Button>
)
}
export default ConnectButton
@@ -1,8 +1,8 @@
import * as React from 'react'
import ConnectButton from './ConnectButton'
import Delete from '@material-ui/icons/Delete'
import Settings from '@material-ui/icons/Settings'
import PowerSettingsNew from '@material-ui/icons/PowerSettingsNew'
import React, { useCallback, useState } from 'react'
import Save from '@material-ui/icons/Save'
import Settings from '@material-ui/icons/Settings'
import Visibility from '@material-ui/icons/Visibility'
import VisibilityOff from '@material-ui/icons/VisibilityOff'
import { AppState } from '../../reducers'
@@ -10,22 +10,21 @@ import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionActions, connectionManagerActions } from '../../actions'
import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions'
import { KeyCodes } from '../../utils/KeyCodes'
import { Theme, withStyles } from '@material-ui/core/styles'
import { ToggleSwitch } from './ToggleSwitch'
import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler'
import {
Button,
FormControl,
FormControlLabel,
Grid,
IconButton,
Input,
InputAdornment,
InputLabel,
MenuItem,
Switch,
TextField,
} from '@material-ui/core'
import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator'
interface Props {
connection: ConnectionOptions
@@ -38,57 +37,66 @@ interface Props {
const protocols = ['mqtt', 'ws']
interface State {
showPassword: boolean
}
function ConnectionSettings(props: Props) {
const [showPassword, setShowPassword] = useState(false)
class ConnectionSettings extends React.Component<Props, State> {
constructor(props: any) {
super(props)
this.state = {
showPassword: false,
const toggleConnect = useCallback(() => {
if (props.connecting) {
props.actions.disconnect()
return
}
if (!props.connection) {
return
}
const mqttOptions = toMqttConnection(props.connection)
if (mqttOptions) {
props.actions.connect(mqttOptions, props.connection.id)
}
}, [props.connection, props.connecting])
useGlobalKeyEventHandler(KeyCodes.escape, props.actions.disconnect)
useGlobalKeyEventHandler(KeyCodes.enter, toggleConnect, [props.connecting])
const handleClickShowPassword = useCallback(() => {
setShowPassword(!showPassword)
}, [showPassword])
function requiresBasePath() {
return props.connection.protocol !== 'mqtt'
}
private handleClickShowPassword = () => {
this.setState({ showPassword: !this.state.showPassword })
}
private requiresBasePath() {
return this.props.connection.protocol !== 'mqtt'
}
private renderBasePathInput() {
function renderBasePathInput() {
return (
<Grid item={true} xs={4}>
<TextField
label="Basepath"
className={this.props.classes.textField}
value={this.props.connection.basePath}
onChange={this.handleChange('basePath')}
className={props.classes.textField}
value={props.connection.basePath}
onChange={handleChange('basePath')}
margin="normal"
/>
</Grid>
)
}
private handleChange = (name: string) => (event: any) => {
if (!this.props.connection) {
const handleChange = (name: string) => (event: any) => {
if (!props.connection) {
return
}
this.updateConnection(name, event.target.value)
updateConnection(name, event.target.value)
}
private updateConnection(name: string, value: any) {
this.props.managerActions.updateConnection(this.props.connection.id, {
const updateConnection = (name: string, value: any) => {
props.managerActions.updateConnection(props.connection.id, {
[name]: value,
})
}
private renderProtocols() {
const { classes, connection } = this.props
const renderProtocols = () => {
const { classes, connection } = props
const protocolItems = protocols.map((value: string) => (
<MenuItem key={value} value={value}>
@@ -102,7 +110,7 @@ class ConnectionSettings extends React.Component<Props, State> {
label="Protocol"
className={classes.textField}
value={connection.protocol}
onChange={this.updateProtocol}
onChange={updateProtocol}
margin="normal"
>
{protocolItems}
@@ -110,191 +118,142 @@ class ConnectionSettings extends React.Component<Props, State> {
)
}
private updateProtocol = (event: React.ChangeEvent<HTMLInputElement>) => {
const updateProtocol = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
this.updateConnection('protocol', value)
updateConnection('protocol', value)
if (event.target.value === 'mqtt') {
this.updateConnection('basePath', undefined)
updateConnection('basePath', undefined)
} else {
this.updateConnection('basePath', 'ws')
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,
const toggleCertValidation = () => {
props.managerActions.updateConnection(props.connection.id, {
certValidation: !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,
const toggleTls = () => {
props.managerActions.updateConnection(props.connection.id, {
encryption: !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>
)
}
function PasswordVisibilityButton(props: { showPassword: boolean; toggle: () => void }) {
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)
}
}
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 aria-label="Toggle password visibility" onClick={props.toggle}>
{props.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>
)
}
const { classes, connection } = props
return (
<div>
<form className={classes.container} noValidate={true} autoComplete="off">
<Grid container={true} spacing={3}>
<Grid item={true} xs={5}>
<TextField
autoFocus={true}
label="Name"
className={classes.textField}
value={connection.name}
onChange={handleChange('name')}
margin="normal"
/>
</Grid>
<Grid item={true} xs={4}>
<ToggleSwitch
label="Validate certificate"
classes={classes}
value={connection.certValidation}
toggle={toggleCertValidation}
/>
</Grid>
<Grid item={true} xs={3}>
<ToggleSwitch label="Encryption (tls)" classes={classes} value={connection.encryption} toggle={toggleTls} />
</Grid>
<Grid item={true} xs={2}>
{renderProtocols()}
</Grid>
<Grid item={true} xs={7}>
<TextField
label="Host"
className={classes.textField}
value={connection.host}
onChange={handleChange('host')}
margin="normal"
/>
</Grid>
<Grid item={true} xs={3}>
<TextField
label="Port"
className={classes.textField}
value={connection.port}
onChange={handleChange('port')}
margin="normal"
/>
</Grid>
{requiresBasePath() ? renderBasePathInput() : null}
<Grid item={true} xs={requiresBasePath() ? 4 : 6}>
<TextField
label="Username"
className={classes.textField}
value={connection.username}
onChange={handleChange('username')}
margin="normal"
/>
</Grid>
<Grid item={true} xs={requiresBasePath() ? 4 : 6}>
<FormControl className={`${classes.textField} ${classes.inputFormControl}`}>
<InputLabel htmlFor="adornment-password">Password</InputLabel>
<Input
id="adornment-password"
type={showPassword ? 'text' : 'password'}
value={connection.password}
onChange={handleChange('password')}
endAdornment={<PasswordVisibilityButton showPassword={showPassword} toggle={handleClickShowPassword} />}
/>
</FormControl>
</Grid>
</Grid>
<br />
<div>
<div style={{ float: 'left' }}>
<Button
variant="contained"
className={classes.button}
onClick={() => props.managerActions.deleteConnection(props.connection.id)}
>
Delete <Delete />
</Button>
<Button
variant="contained"
className={classes.button}
onClick={props.managerActions.toggleAdvancedSettings}
>
<Settings /> Advanced
</Button>
</div>
<div style={{ float: 'right' }}>
<Button
variant="contained"
color="secondary"
className={classes.button}
onClick={props.managerActions.saveConnectionSettings}
>
<Save /> Save
</Button>
<ConnectButton toggle={toggleConnect} connecting={props.connecting} classes={classes} />
</div>
</div>
</form>
</div>
)
}
const mapStateToProps = (state: AppState) => {
@@ -9,6 +9,7 @@ import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptio
import { Theme, withStyles } from '@material-ui/core/styles'
import { Modal, Paper, Toolbar, Typography, Collapse } from '@material-ui/core'
import AdvancedConnectionSettings from './AdvancedConnectionSettings'
import Certificates from './Certificates'
interface Props {
actions: any
@@ -16,6 +17,7 @@ interface Props {
connection?: ConnectionOptions
visible: boolean
showAdvancedSettings: boolean
showCertificateSettings: boolean
}
class ConnectionSetup extends React.Component<Props, {}> {
@@ -24,19 +26,22 @@ class ConnectionSetup extends React.Component<Props, {}> {
}
private renderSettings() {
const { connection, showAdvancedSettings } = this.props
const { connection, showAdvancedSettings, showCertificateSettings } = this.props
if (!connection) {
return null
}
return (
<div>
<Collapse in={!showAdvancedSettings}>
<Collapse in={!showAdvancedSettings && !showCertificateSettings}>
<ConnectionSettings connection={connection} />
</Collapse>
<Collapse in={showAdvancedSettings}>
<Collapse in={showAdvancedSettings && !showCertificateSettings}>
<AdvancedConnectionSettings connection={connection} />
</Collapse>
<Collapse in={showCertificateSettings}>
<Certificates connection={connection} />
</Collapse>
</div>
)
}
@@ -90,9 +95,10 @@ const styles = (theme: Theme) => ({
borderRadius: `${theme.shape.borderRadius}px 0 0 ${theme.shape.borderRadius}px`,
paddingTop: theme.spacing(2),
flex: 3,
overflow: 'hidden',
overflow: 'hidden' as 'hidden',
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
overflowY: 'auto' as 'auto',
},
right: {
borderRadius: `0 ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0`,
@@ -115,6 +121,7 @@ const mapStateToProps = (state: AppState) => {
return {
visible: !state.connection.connected,
showAdvancedSettings: state.connectionManager.showAdvancedSettings,
showCertificateSettings: state.connectionManager.showCertificateSettings,
connection: state.connectionManager.selected
? state.connectionManager.connections[state.connectionManager.selected]
: undefined,
@@ -1,120 +0,0 @@
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))
@@ -16,8 +16,10 @@ const styles = (theme: Theme) => ({
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>
<span id="addProfileButton" style={{ marginRight: '12px' }}>
<Fab size="small" color="secondary" aria-label="Add" className={props.classes.addButton} onClick={props.action}>
<Add className={props.classes.addIcon} />
</Fab>
</span>
)
})
@@ -0,0 +1,57 @@
import React from 'react'
import { connect } from 'react-redux'
import { ListItem, Typography } from '@material-ui/core'
import { toMqttConnection, ConnectionOptions } from '../../../model/ConnectionOptions'
import { withStyles, Theme } from '@material-ui/core/styles'
import { bindActionCreators } from 'redux'
import { connectionManagerActions } from '../../../actions'
export interface Props {
connection: ConnectionOptions
actions: any
selected: boolean
classes: any
}
const ConnectionItem = (props: Props) => {
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>
)
}
export const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(connectionManagerActions, dispatch),
}
}
export 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',
},
})
export default connect(
null,
mapDispatchToProps
)(withStyles(connectionItemStyle)(ConnectionItem))
@@ -0,0 +1,83 @@
import ConnectionItem from './ConnectionItem'
import 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 { KeyCodes } from '../../../utils/KeyCodes'
import { List, ListSubheader } from '@material-ui/core'
import { Theme, withStyles } from '@material-ui/core/styles'
import { useGlobalKeyEventHandler } from '../../../effects/useGlobalKeyEventHandler'
interface Props {
classes: any
selected?: string
connections: { [s: string]: ConnectionOptions }
actions: typeof connectionManagerActions
}
function ProfileList(props: Props) {
const { actions, classes, connections, selected } = props
const selectConnection = (dir: 'next' | 'previous') => (event: KeyboardEvent) => {
if (!selected) {
return
}
const indexDirection = dir === 'next' ? 1 : -1
const connectionArray = Object.values(connections)
const selectedIndex = connectionArray.map(connection => connection.id).indexOf(selected)
const nextConnection = connectionArray[selectedIndex + indexDirection]
if (nextConnection) {
actions.selectConnection(nextConnection.id)
}
event.preventDefault()
}
useGlobalKeyEventHandler(KeyCodes.arrow_down, selectConnection('next'))
useGlobalKeyEventHandler(KeyCodes.arrow_up, selectConnection('previous'))
const createConnectionButton = (
<div style={{ padding: '8px 16px' }}>
<AddButton action={actions.createConnection} />
Connections
</div>
)
return (
<List style={{ height: '100%' }} component="nav" subheader={createConnectionButton}>
<div className={classes.list}>
{Object.values(connections).map(connection => (
<ConnectionItem connection={connection} key={connection.id} selected={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),
}
}
const mapStateToProps = (state: AppState) => {
return {
connections: state.connectionManager.connections,
selected: state.connectionManager.selected,
}
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(withStyles(styles)(ProfileList))
@@ -0,0 +1,12 @@
import React from 'react'
import { FormControlLabel, Switch } from '@material-ui/core'
export function ToggleSwitch(props: { value: boolean; classes: any; toggle: () => void; label: string }) {
const { classes, value, toggle, label } = props
const toggleSwitch = <Switch checked={value} onChange={toggle} color="primary" />
return (
<div className={classes.switch}>
<FormControlLabel control={toggleSwitch} label={label} labelPlacement="bottom" />
</div>
)
}
+1 -1
View File
@@ -1,7 +1,7 @@
import * as React from 'react'
import ChartPanel from '../ChartPanel'
import ReactSplitPane from 'react-split-pane'
import Tree from '../Tree/Tree'
import Tree from '../Tree'
import { AppState } from '../../reducers'
import { ChartParameters } from '../../reducers/Charts'
import { connect } from 'react-redux'
+146
View File
@@ -0,0 +1,146 @@
import React, { useCallback, useState, useRef } from 'react'
import ClearAdornment from '../helper/ClearAdornment'
import Search from '@material-ui/icons/Search'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { InputBase } from '@material-ui/core'
import { settingsActions } from '../../actions'
import { fade, Theme, withStyles } from '@material-ui/core/styles'
import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler'
import { KeyCodes } from '../../utils/KeyCodes'
function SearchBar(props: {
classes: any
topicFilter?: string
hasConnection: boolean
actions: {
settings: typeof settingsActions
}
}) {
const { actions, classes, hasConnection, topicFilter } = props
const [hasFocus, setHasFocus] = useState(false)
const inputRef = useRef<HTMLInputElement>()
const onFocus = useCallback(() => setHasFocus(true), [])
const onBlur = useCallback(() => setHasFocus(false), [])
const clearFilter = useCallback(() => {
actions.settings.filterTopics('')
}, [])
const onFilterChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
actions.settings.filterTopics(event.target.value)
}, [])
useGlobalKeyEventHandler(undefined, event => {
const isCharacter = event.key.length === 1
const isAllowedControlCharacter = event.keyCode === KeyCodes.backspace || event.keyCode === KeyCodes.delete
const tagNameBlacklist = ['INPUT', 'TEXTAREA', 'RADIO', 'CHECKBOX', 'OPTION', 'FORM']
const tagElementIsNotBlacklisted =
document.activeElement && tagNameBlacklist.indexOf(document.activeElement.tagName) === -1
if (
(isCharacter || isAllowedControlCharacter) &&
!event.defaultPrevented &&
!hasFocus &&
tagElementIsNotBlacklisted &&
hasConnection
) {
// Focus input field, no preventDefault the event will reach the input element after it has been focussed
inputRef.current && inputRef.current.focus()
}
})
return (
<div className={classes.search}>
<div className={classes.searchIcon}>
<Search />
</div>
<InputBase
value={topicFilter}
inputProps={{
onFocus,
onBlur,
ref: inputRef,
}}
onChange={onFilterChange}
placeholder="Search…"
endAdornment={
<div style={{ width: '24px', paddingRight: '8px' }}>
<ClearAdornment variant="primary" action={clearFilter} value={topicFilter} />
</div>
}
classes={{ root: classes.inputRoot, input: classes.inputInput }}
/>
</div>
)
}
const mapStateToProps = (state: AppState) => {
return {
topicFilter: state.settings.get('topicFilter'),
hasConnection: Boolean(state.connection.connectionId),
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
settings: bindActionCreators(settingsActions, dispatch),
},
}
}
const styles = (theme: Theme) => ({
search: {
position: 'relative' as 'relative',
borderRadius: theme.shape.borderRadius,
backgroundColor: fade(theme.palette.common.white, 0.15),
'&:hover': {
backgroundColor: fade(theme.palette.common.white, 0.25),
},
marginRight: theme.spacing(2),
marginLeft: 0,
flexGrow: 1,
maxWidth: '60%',
[theme.breakpoints.up('md')]: {
maxWidth: '30%',
marginLeft: theme.spacing(4),
width: 'auto' as 'auto',
},
[theme.breakpoints.up(750)]: {
marginLeft: theme.spacing(4),
width: 'auto' as 'auto',
},
},
searchIcon: {
width: theme.spacing(6),
height: '100%',
position: 'absolute' as 'absolute',
pointerEvents: 'none' as 'none',
display: 'flex' as 'flex',
alignItems: 'center' as 'center',
justifyContent: 'center' as 'center',
},
inputRoot: {
color: 'inherit' as 'inherit',
width: '100%',
},
inputInput: {
paddingTop: theme.spacing(1),
paddingRight: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(6),
transition: theme.transitions.create('width'),
width: '100%',
},
})
export default connect(
mapStateToProps,
mapDispatchToProps
)(withStyles(styles)(SearchBar))
+3 -80
View File
@@ -1,17 +1,15 @@
import * as React from 'react'
import ClearAdornment from '../helper/ClearAdornment'
import CloudOff from '@material-ui/icons/CloudOff'
import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator'
import Menu from '@material-ui/icons/Menu'
import PauseButton from './PauseButton'
import Search from '@material-ui/icons/Search'
import SearchBar from './SearchBar'
import { AppBar, Button, IconButton, Toolbar, Typography } from '@material-ui/core'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionActions, globalActions, settingsActions } from '../../actions'
import { fade } from '@material-ui/core/styles/colorManipulator'
import { Theme, withStyles } from '@material-ui/core/styles'
import { AppBar, Button, IconButton, InputBase, Toolbar, Typography } from '@material-ui/core'
const styles = (theme: Theme) => ({
title: {
@@ -21,29 +19,6 @@ const styles = (theme: Theme) => ({
},
whiteSpace: 'nowrap' as 'nowrap',
},
search: {
position: 'relative' as 'relative',
borderRadius: theme.shape.borderRadius,
backgroundColor: fade(theme.palette.common.white, 0.15),
'&:hover': {
backgroundColor: fade(theme.palette.common.white, 0.25),
},
marginRight: theme.spacing(2),
marginLeft: 0,
flexGrow: 1,
maxWidth: '60%',
[theme.breakpoints.up('md')]: {
maxWidth: '30%',
marginLeft: theme.spacing(4),
width: 'auto' as 'auto',
},
[theme.breakpoints.up(750)]: {
marginLeft: theme.spacing(4),
width: 'auto' as 'auto',
},
},
disconnectIcon: {
[theme.breakpoints.down('xs')]: {
display: 'none' as 'none',
@@ -51,27 +26,6 @@ const styles = (theme: Theme) => ({
marginRight: '8px',
paddingLeft: '8px',
},
searchIcon: {
width: theme.spacing(6),
height: '100%',
position: 'absolute' as 'absolute',
pointerEvents: 'none' as 'none',
display: 'flex' as 'flex',
alignItems: 'center' as 'center',
justifyContent: 'center' as 'center',
},
inputRoot: {
color: 'inherit' as 'inherit',
width: '100%',
},
inputInput: {
paddingTop: theme.spacing(1),
paddingRight: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(6),
transition: theme.transitions.create('width'),
width: '100%',
},
menuButton: {
marginLeft: -12,
marginRight: 20,
@@ -98,37 +52,6 @@ class TitleBar extends React.Component<Props, {}> {
this.state = {}
}
private renderSearch() {
const { classes, topicFilter } = this.props
return (
<div className={classes.search}>
<div className={classes.searchIcon}>
<Search />
</div>
<InputBase
value={topicFilter}
onChange={this.onFilterChange}
placeholder="Search…"
endAdornment={
<div style={{ width: '24px', paddingRight: '8px' }}>
<ClearAdornment variant="primary" action={this.clearFilter} value={topicFilter} />
</div>
}
classes={{ root: classes.inputRoot, input: classes.inputInput }}
/>
</div>
)
}
private clearFilter = () => {
this.props.actions.settings.filterTopics('')
}
private onFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
this.props.actions.settings.filterTopics(event.target.value)
}
public render() {
const { actions, classes } = this.props
@@ -146,7 +69,7 @@ class TitleBar extends React.Component<Props, {}> {
<Typography className={classes.title} variant="h6" color="inherit">
MQTT Explorer
</Typography>
{this.renderSearch()}
<SearchBar />
<PauseButton />
<Button className={classes.disconnect} onClick={actions.connection.disconnect}>
Disconnect <CloudOff className={classes.disconnectIcon} />
@@ -1,13 +1,12 @@
import * as q from '../../../../backend/src/Model'
import * as React from 'react'
import React, { useMemo } from 'react'
import { AppState } from '../../reducers'
import { Base64Message } from '../../../../backend/src/Model/Base64Message'
import { connect } from 'react-redux'
import { StyleRulesCallback, withStyles, Theme } from '@material-ui/core/styles'
import { Theme, withStyles } from '@material-ui/core/styles'
import { TopicViewModel } from '../../model/TopicViewModel'
import { Typography } from '@material-ui/core'
import { Base64Message } from '../../../../backend/src/Model/Base64Message'
import teal from '@material-ui/core/colors/teal'
import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode'
const abbreviate = require('number-abbreviate')
interface Stats {
@@ -16,10 +15,6 @@ interface Stats {
}
const styles = (theme: Theme) => ({
flex: {
display: 'flex',
width: '100%',
},
container: {
width: '100%',
height: '224px',
@@ -34,24 +29,12 @@ interface Props {
tree?: q.Tree<TopicViewModel>
}
class BrokerStatistics extends React.Component<Props, {}> {
constructor(props: any) {
super(props)
this.state = {}
}
function BrokerStatistics(props: Props) {
const { tree, classes } = props
const sysTopic = usePollingToFetchTreeNode(props.tree, '$SYS')
private renderPair(tree: q.Tree<TopicViewModel>, a: Stats, b: Stats) {
return (
<div className={this.props.classes.flex}>
<div style={{ flex: 1 }}>{this.renderStat(tree, a)}</div>
<div style={{ flex: 1 }}>{this.renderStat(tree, b)}</div>
</div>
)
}
public render() {
const { tree, classes } = this.props
if (!tree || !tree.findNode('$SYS/broker/clients/total')) {
return useMemo(() => {
if (!Boolean(sysTopic)) {
return null
}
@@ -94,38 +77,20 @@ class BrokerStatistics extends React.Component<Props, {}> {
},
}
return (
<div className={classes.container}>
{this.renderStat(tree, stats.broker)}
{this.renderPair(tree, stats.sent, stats.received)}
{this.renderPair(tree, stats.clients, stats.subscriptions)}
{this.renderPair(tree, stats.sent5m, stats.received5m)}
{this.renderPair(tree, stats.heap, stats.heapMax)}
</div>
)
}
public renderStat(tree: q.Tree<TopicViewModel>, stat: Stats) {
const node = tree.findNode(stat.topic)
if (!node || !node.message) {
if (!tree) {
return null
}
const str = node.message.value ? Base64Message.toUnicodeString(node.message.value) : ''
let value = node.message && node.message.value ? parseFloat(str) : NaN
value = !isNaN(value) ? abbreviate(value) : str
return (
<div key={stat.title}>
<Typography>
<b>{stat.title}</b>
</Typography>
<Typography style={{ paddingLeft: '8px' }}>
<i>{value}</i>
</Typography>
<div className={classes.container}>
{renderStat(tree, stats.broker)}
{renderPair(tree, stats.sent, stats.received)}
{renderPair(tree, stats.clients, stats.subscriptions)}
{renderPair(tree, stats.sent5m, stats.received5m)}
{renderPair(tree, stats.heap, stats.heapMax)}
</div>
)
}
}, [sysTopic && sysTopic.lastUpdate])
}
const mapStateToProps = (state: AppState) => {
@@ -135,3 +100,39 @@ const mapStateToProps = (state: AppState) => {
}
export default withStyles(styles)(connect(mapStateToProps)(BrokerStatistics))
function renderPair(tree: q.Tree<TopicViewModel>, a: Stats, b: Stats) {
return (
<div
style={{
display: 'flex',
width: '100%',
}}
>
<div style={{ flex: 1 }}>{renderStat(tree, a)}</div>
<div style={{ flex: 1 }}>{renderStat(tree, b)}</div>
</div>
)
}
function renderStat(tree: q.Tree<TopicViewModel>, stat: Stats) {
const node = tree.findNode(stat.topic)
if (!node || !node.message) {
return null
}
const str = node.message.value ? Base64Message.toUnicodeString(node.message.value) : ''
let value = node.message && node.message.value ? parseFloat(str) : NaN
value = !isNaN(value) ? abbreviate(value) : str
return (
<div key={stat.title}>
<Typography>
<b>{stat.title}</b>
</Typography>
<Typography style={{ paddingLeft: '8px' }}>
<i>{value}</i>
</Typography>
</div>
)
}
@@ -18,7 +18,7 @@ function changeAmount(props: Props) {
}
return (
<span style={{ display: 'block', marginBottom: '8px', float: 'right' }}>
<span style={{ display: 'block', textAlign: 'right' }}>
<span>
Comparing with <b>{props.nameOfCompareMessage}</b> message:&nbsp;
<span className={props.classes.additions}>
+7 -1
View File
@@ -34,6 +34,12 @@ class HistoryDrawer extends React.Component<Props, State> {
this.setState({ collapsed: !this.state.collapsed })
}
private createSelectionHandler = (index: number) => (event: React.MouseEvent) => {
this.props.onClick && this.props.onClick(index, event.target)
event.preventDefault()
event.stopPropagation()
}
private handleCtrlA = selectTextWithCtrlA({ targetSelector: 'pre' })
public renderHistory() {
@@ -51,7 +57,7 @@ class HistoryDrawer extends React.Component<Props, State> {
<div
key={element.key}
style={style(element)}
onClick={(event: React.MouseEvent) => this.props.onClick && this.props.onClick(index, event.target)}
onClick={this.createSelectionHandler(index)}
tabIndex={0}
onKeyDown={this.handleCtrlA}
>
+4 -1
View File
@@ -4,7 +4,7 @@ import { TopicViewModel } from '../../model/TopicViewModel'
import { Typography } from '@material-ui/core'
interface Props {
node: q.TreeNode<TopicViewModel>
node?: q.TreeNode<TopicViewModel>
}
class NodeStats extends React.Component<Props, {}> {
@@ -14,6 +14,9 @@ class NodeStats extends React.Component<Props, {}> {
public render() {
const { node } = this.props
if (!node) {
return null
}
return (
<div>
+33
View File
@@ -0,0 +1,33 @@
import React from 'react'
import ExpandMore from '@material-ui/icons/ExpandMore'
import { ExpansionPanel, ExpansionPanelDetails, ExpansionPanelSummary, Typography, Theme } from '@material-ui/core'
import { withStyles } from '@material-ui/styles'
const styles = (theme: Theme) => ({
summary: { minHeight: '0' },
details: { padding: '0px 16px 8px 8px', display: 'block' },
heading: {
fontSize: theme.typography.pxToRem(15),
fontWeight: theme.typography.fontWeightRegular,
},
})
const Panel = (props: {
classes: any
children: [React.ReactElement, React.ReactElement]
disabled?: boolean
detailsHidden?: boolean
}) => {
return (
<ExpansionPanel defaultExpanded={true} disabled={props.disabled}>
<ExpansionPanelSummary expandIcon={<ExpandMore />} className={props.classes.summary}>
<Typography className={props.classes.heading}>{props.children[0]}</Typography>
</ExpansionPanelSummary>
{props.detailsHidden ? null : (
<ExpansionPanelDetails className={props.classes.detail}>{props.children[1]}</ExpansionPanelDetails>
)}
</ExpansionPanel>
)
}
export default withStyles(styles)(Panel)
+10 -2
View File
@@ -12,6 +12,7 @@ interface Props {
theme: Theme
interpolation?: PlotCurveTypes
range?: [number?, number?]
timeRangeStart?: number
color?: string
}
@@ -55,10 +56,12 @@ export default withTheme((props: Props) => {
return React.useMemo(() => {
const data = props.data
const calculatedDomain = domainForData(data)
let yDomain: [number, number] = props.range
const yDomain: [number, number] = props.range
? [props.range[0] || calculatedDomain[0], props.range[1] || calculatedDomain[1]]
: calculatedDomain
const xDomain = props.timeRangeStart ? [Date.now() - props.timeRangeStart, Date.now()] : undefined
let color: string =
props.theme.palette.type === 'light' ? props.theme.palette.secondary.dark : props.theme.palette.primary.light
if (props.color) {
@@ -67,7 +70,7 @@ export default withTheme((props: Props) => {
return (
<div style={{ height: '150px', overflow: 'hidden' }}>
<XYPlot width={width} height={180} yDomain={yDomain}>
<XYPlot width={width} height={180} yDomain={yDomain} xDomain={xDomain}>
<HorizontalGridLines />
<XAxis />
<YAxis width={45} tickFormat={(num: number) => abbreviate(num)} />
@@ -88,6 +91,11 @@ export default withTheme((props: Props) => {
})
function domainForData(data: Array<{ x: number; y: number }>): [number, number] {
if (!data[0]) {
const defaultDomain: [number, number] = [-1, 1]
return defaultDomain
}
let max = data[0].y
let min = data[0].y
data.forEach(d => {
+80 -152
View File
@@ -1,32 +1,18 @@
import * as q from '../../../../../backend/src/Model'
import * as React from 'react'
import ClearAdornment from '../../helper/ClearAdornment'
import Editor from './Editor'
import FormatAlignLeft from '@material-ui/icons/FormatAlignLeft'
import History from '../HistoryDrawer'
import Message from './Model/Message'
import Navigation from '@material-ui/icons/Navigation'
import PublishHistory from './PublishHistory'
import React, { useCallback, useState, useMemo } from 'react'
import TopicInput from './TopicInput'
import { AppState } from '../../../reducers'
import { bindActionCreators } from 'redux'
import { Button, Fab, Theme, Tooltip, withTheme } from '@material-ui/core'
import { connect } from 'react-redux'
import { globalActions, publishActions } from '../../../actions'
import {
Button,
FormControlLabel,
FormControl,
InputLabel,
Input,
Checkbox,
Tooltip,
Fab,
Theme,
withTheme,
} from '@material-ui/core'
import { EditorModeSelect } from './EditorModeSelect'
import QosSelect from './QosSelect'
import Editor from './Editor'
const sha1 = require('sha1')
import { globalActions, publishActions } from '../../../actions'
import { KeyCodes } from '../../../utils/KeyCodes'
import RetainSwitch from './RetainSwitch'
interface Props {
connectionId?: string
@@ -39,104 +25,73 @@ interface Props {
theme: Theme
}
interface State {
history: Array<Message>
function useHistory(): [Array<Message>, (topic: string, payload?: string) => void] {
const [history, setHistory] = useState<Array<Message>>([])
const amendToHistory = useCallback(
(topic: string, payload?: string) => {
// Remove duplicates
let filteredHistory = history.filter(e => e.payload !== payload || e.topic !== topic)
filteredHistory = filteredHistory.slice(-7)
setHistory([...filteredHistory, { topic, payload, sent: new Date() }])
},
[history]
)
return [history, amendToHistory]
}
class Publish extends React.Component<Props, State> {
constructor(props: any) {
super(props)
this.state = { history: [] }
}
function Publish(props: Props) {
console.log(props.connectionId)
const updatePayload = props.actions.setPayload
const [history, amendToHistory] = useHistory()
private updatePayload = (payload: string) => {
this.props.actions.setPayload(payload)
}
const updateMode = useCallback((e: React.ChangeEvent<{}>, value: string) => {
props.actions.setEditorMode(value)
}, [])
private updateTopic = (e: React.ChangeEvent<HTMLInputElement>) => {
this.props.actions.setTopic(e.target.value)
}
private updateMode = (e: React.ChangeEvent<{}>, value: string) => {
this.props.actions.setEditorMode(value)
}
private publish = (e: React.MouseEvent) => {
e.stopPropagation()
if (!this.props.connectionId) {
const publish = useCallback(() => {
if (!props.connectionId) {
return
}
this.props.actions.publish(this.props.connectionId)
props.actions.publish(props.connectionId)
const topic = this.props.topic || ''
const payload = this.props.payload
if (this.props.connectionId && topic) {
this.addMessageToHistory(topic, payload)
const topic = props.topic || ''
const payload = props.payload
if (props.connectionId && topic) {
amendToHistory(topic, payload)
}
}
}, [props, props.connectionId, props.topic, props.payload, amendToHistory])
private addMessageToHistory(topic: string, payload?: string) {
// Remove duplicates
let filteredHistory = this.state.history.filter(e => e.payload !== payload || e.topic !== topic)
filteredHistory = filteredHistory.slice(-7)
const history: Array<Message> = [...filteredHistory, { topic, payload, sent: new Date() }]
this.setState({ history })
}
private clearTopic = () => {
this.props.actions.setTopic('')
}
private topic() {
const topicStr = this.props.topic || ''
const handleClickPublish = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
publish()
},
[publish]
)
const PublishButton = () => {
return (
<div>
<FormControl style={{ width: '100%' }}>
<InputLabel htmlFor="publish-topic">Topic</InputLabel>
<Input
id="publish-topic"
value={topicStr}
startAdornment={<span />}
endAdornment={<ClearAdornment action={this.clearTopic} value={topicStr} />}
onBlur={this.onTopicBlur}
onChange={this.updateTopic}
multiline={true}
placeholder="example/topic"
/>
</FormControl>
</div>
)
}
private onTopicBlur = (e: React.FocusEvent<HTMLInputElement>) => {
if (!e.target.value) {
this.props.actions.setTopic(undefined)
}
}
private publishButton() {
return (
<Button variant="contained" size="small" color="primary" onClick={this.publish} id="publish-button">
<Button variant="contained" size="small" color="primary" onClick={handleClickPublish} id="publish-button">
<Navigation style={{ marginRight: '8px' }} /> Publish
</Button>
)
}
private formatJson = () => {
if (this.props.payload) {
const formatJson = () => {
if (props.payload) {
try {
const str = JSON.stringify(JSON.parse(this.props.payload), undefined, ' ')
this.updatePayload(str)
const str = JSON.stringify(JSON.parse(props.payload), undefined, ' ')
updatePayload(str)
} catch (error) {
this.props.globalActions.showError(`Format error: ${error.message}`)
props.globalActions.showError(`Format error: ${error.message}`)
}
}
}
private renderFormatJson() {
if (this.props.editorMode !== 'json') {
const renderFormatJson = () => {
if (props.editorMode !== 'json') {
return null
}
@@ -144,7 +99,7 @@ class Publish extends React.Component<Props, State> {
<Tooltip title="Format JSON">
<Fab
style={{ width: '36px', height: '36px', marginLeft: '8px' }}
onClick={this.formatJson}
onClick={formatJson}
id="sidebar-publish-format-json"
>
<FormatAlignLeft style={{ fontSize: '20px' }} />
@@ -153,72 +108,45 @@ class Publish extends React.Component<Props, State> {
)
}
private editorMode() {
function EditorMode() {
return (
<div style={{ marginTop: '16px' }}>
<div style={{ width: '100%', lineHeight: '64px' }}>
<EditorModeSelect value={this.props.editorMode} onChange={this.updateMode} />
{this.renderFormatJson()}
<div style={{ float: 'right', marginRight: '16px' }}>{this.publishButton()}</div>
<EditorModeSelect value={props.editorMode} onChange={updateMode} />
{renderFormatJson()}
<div style={{ float: 'right', marginRight: '16px' }}>
<PublishButton />
</div>
</div>
</div>
)
}
private publishMode() {
const labelStyle = { margin: '0 8px 0 8px' }
const handleSubmit = useCallback(
(e: React.KeyboardEvent) => {
if (e.keyCode === KeyCodes.enter && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
e.stopPropagation()
publish()
}
},
[publish]
)
return (
<div style={{ marginTop: '8px', clear: 'both' }}>
<div style={{ width: '100%', textAlign: 'right' }}>
<FormControlLabel style={labelStyle} control={<QosSelect />} label="QoS" labelPlacement="start" />
<Tooltip
title="Retained messages only appear to be retained, when client subscribes after the initial publish."
placement="top"
>
<FormControlLabel
value="retain"
style={labelStyle}
control={
<Checkbox color="primary" checked={this.props.retain} onChange={this.props.actions.toggleRetain} />
}
label="retain"
labelPlacement="end"
/>
</Tooltip>
</div>
</div>
)
}
private history() {
const items = this.state.history.reverse().map(message => ({
key: sha1(message.topic + message.payload),
title: message.topic,
value: message.payload || '',
}))
return <History items={items} onClick={this.didSelectHistoryEntry} />
}
private didSelectHistoryEntry = (index: number) => {
const message = this.state.history[index]
this.props.actions.setTopic(message.topic)
this.props.actions.setPayload(message.payload)
}
public render() {
return (
<div style={{ flexGrow: 1, marginLeft: '8px' }}>
{this.topic()}
return useMemo(
() => (
<div style={{ flexGrow: 1, marginLeft: '8px' }} onKeyDown={handleSubmit}>
<TopicInput />
<div style={{ width: '100%', display: 'block' }}>
{this.editorMode()}
<Editor value={this.props.payload} editorMode={this.props.editorMode} onChange={this.updatePayload} />
{this.publishMode()}
<EditorMode />
<Editor value={props.payload} editorMode={props.editorMode} onChange={updatePayload} />
<RetainSwitch />
</div>
{this.history()}
<PublishHistory history={history} />
</div>
)
}
),
[props.payload, props.editorMode, history, handleSubmit, updatePayload]
)
}
const mapDispatchToProps = (dispatch: any) => {
@@ -0,0 +1,40 @@
import History from '../HistoryDrawer'
import Message from './Model/Message'
import React, { useCallback, useMemo } from 'react'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { publishActions } from '../../../actions'
const sha1 = require('sha1')
function PublishHistory(props: { history: Array<Message>; actions: typeof publishActions }) {
const didSelectHistoryEntry = useCallback(
(index: number) => {
const items = [...props.history].reverse()
const message = items[index]
props.actions.setTopic(message.topic)
props.actions.setPayload(message.payload)
},
[props.history]
)
return useMemo(() => {
const items = [...props.history].reverse().map(message => ({
key: sha1(message.topic + message.payload),
title: message.topic,
value: message.payload || '',
}))
return <History items={items} onClick={didSelectHistoryEntry} />
}, [props.history])
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(publishActions, dispatch),
}
}
export default connect(
undefined,
mapDispatchToProps
)(PublishHistory)
@@ -0,0 +1,47 @@
import QosSelect from './QosSelect'
import React from 'react'
import { Checkbox, FormControlLabel, Tooltip } from '@material-ui/core'
import { publishActions } from '../../../actions'
import { bindActionCreators } from 'redux'
import { AppState } from '../../../reducers'
import { connect } from 'react-redux'
export function RetainSwitch(props: { retain: boolean; actions: typeof publishActions }) {
const labelStyle = { margin: '0 8px 0 8px' }
return (
<div style={{ marginTop: '8px', clear: 'both' }}>
<div style={{ width: '100%', textAlign: 'right' }}>
<FormControlLabel style={labelStyle} control={<QosSelect />} label="QoS" labelPlacement="start" />
<Tooltip
title="Retained messages only appear to be retained, when client subscribes after the initial publish."
placement="top"
>
<FormControlLabel
value="retain"
style={labelStyle}
control={<Checkbox color="primary" checked={props.retain} onChange={props.actions.toggleRetain} />}
label="retain"
labelPlacement="end"
/>
</Tooltip>
</div>
</div>
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(publishActions, dispatch),
}
}
const mapStateToProps = (state: AppState) => {
return {
retain: state.publish.retain,
}
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(RetainSwitch)
@@ -0,0 +1,60 @@
import ClearAdornment from '../../helper/ClearAdornment'
import React, { useCallback } from 'react'
import { FormControl, Input, InputLabel } from '@material-ui/core'
import { publishActions } from '../../../actions'
import { bindActionCreators } from 'redux'
import { AppState } from '../../../reducers'
import { connect } from 'react-redux'
function TopicInput(props: { actions: typeof publishActions; topic?: string }) {
console.log(props.topic)
const updateTopic = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
props.actions.setTopic(e.target.value)
}, [])
const clearTopic = useCallback(() => {
props.actions.setTopic('')
}, [])
const onTopicBlur = useCallback((e: React.FocusEvent<HTMLInputElement>) => {
if (!e.target.value) {
props.actions.setTopic(undefined)
}
}, [])
const topicStr = props.topic || ''
return (
<div>
<FormControl style={{ width: '100%' }}>
<InputLabel htmlFor="publish-topic">Topic</InputLabel>
<Input
id="publish-topic"
value={topicStr}
startAdornment={<span />}
endAdornment={<ClearAdornment action={clearTopic} value={topicStr} />}
onBlur={onTopicBlur}
onChange={updateTopic}
multiline={true}
placeholder="example/topic"
/>
</FormControl>
</div>
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(publishActions, dispatch),
}
}
const mapStateToProps = (state: AppState) => {
return {
topic: state.publish.topic,
}
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(TopicInput)
+39 -143
View File
@@ -1,11 +1,7 @@
import * as q from '../../../../backend/src/Model'
import * as React from 'react'
import Copy from '../helper/Copy'
import CustomIconButton from '../helper/CustomIconButton'
import Delete from '@material-ui/icons/Delete'
import React, { useState, useEffect, useCallback } from 'react'
import ExpandMore from '@material-ui/icons/ExpandMore'
import NodeStats from './NodeStats'
import Topic from './Topic'
import ValuePanel from './ValueRenderer/ValuePanel'
import { AppState } from '../../reducers'
import { Badge, ExpansionPanel, ExpansionPanelDetails, ExpansionPanelSummary, Typography } from '@material-ui/core'
@@ -14,6 +10,8 @@ import { connect } from 'react-redux'
import { settingsActions, sidebarActions } from '../../actions'
import { Theme, withStyles } from '@material-ui/core/styles'
import { TopicViewModel } from '../../model/TopicViewModel'
import TopicPanel from './TopicPanel/TopicPanel'
import Panel from './Panel'
const throttle = require('lodash.throttle')
@@ -27,148 +25,48 @@ interface Props {
connectionId?: string
}
interface State {
compareMessage?: q.Message
valueRenderWidth: number
}
function Sidebar(props: Props) {
const { classes, node } = props
const [lastUpdate, setLastUpdate] = useState(0)
class Sidebar extends React.Component<Props, State> {
private updateNode = throttle(() => {
this.setState(this.state)
}, 300)
const updateNode = useCallback(
throttle(() => {
setLastUpdate(node ? node.lastUpdate : 0)
}, 300),
[node]
)
private detailsStyle = { padding: '0px 16px 8px 8px', display: 'block' }
useEffect(() => {
const updateCallback = updateNode
node && node.onMerge.subscribe(updateCallback)
node && node.onMessage.subscribe(updateCallback)
constructor(props: any) {
super(props)
this.state = { valueRenderWidth: 300 }
}
private registerUpdateListener(node: q.TreeNode<TopicViewModel>) {
node.onMerge.subscribe(this.updateNode)
node.onMessage.subscribe(this.updateNode)
}
private removeUpdateListener(node: q.TreeNode<TopicViewModel>) {
node.onMerge.unsubscribe(this.updateNode)
node.onMessage.unsubscribe(this.updateNode)
}
private renderTopicDeleteButton() {
if (!this.props.node || (!this.props.node.message || !this.props.node.message.value)) {
return null
return function cleanup() {
node && node.onMerge.unsubscribe(updateCallback)
node && node.onMessage.unsubscribe(updateCallback)
}
}, [node])
return (
<CustomIconButton onClick={() => this.deleteTopic(this.props.node)} tooltip="Clear this topic">
<Delete style={{ marginTop: '-3px' }} />
</CustomIconButton>
)
}
private renderRecursiveTopicDeleteButton() {
const deleteLimit = 50
const topicCount = this.props.node ? this.props.node.childTopicCount() : 0
if (!this.props.node || topicCount === 0 || (this.props.node.message && topicCount === 1)) {
return null
}
return (
<Badge
classes={{ badge: this.props.classes.badge }}
badgeContent={<span style={{ whiteSpace: 'nowrap' }}>{topicCount >= deleteLimit ? '50+' : topicCount}</span>}
color="secondary"
>
<CustomIconButton
onClick={() => this.deleteTopic(this.props.node, true, deleteLimit)}
tooltip={`Deletes up to ${deleteLimit} sub-topics with a single click`}
>
<Delete style={{ marginTop: '-3px' }} color="action" />
</CustomIconButton>
</Badge>
)
}
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 summaryStyle = { minHeight: '0' }
return (
return (
<div id="Sidebar" className={props.classes.drawer}>
<div>
<ExpansionPanel key="topic" defaultExpanded={true} disabled={!Boolean(this.props.node)}>
<ExpansionPanelSummary expandIcon={<ExpandMore />} style={summaryStyle}>
<Typography className={classes.heading}>
Topic {copyTopic} {deleteTopic} {deleteRecursiveTopic}
</Typography>
</ExpansionPanelSummary>
<ExpansionPanelDetails style={this.detailsStyle}>
<Topic node={this.props.node} didSelectNode={this.updateNode} />
<TopicPanel node={node} updateNode={updateNode} />
<ValuePanel lastUpdate={node ? node.lastUpdate : 0} />
<Panel>
<span>Publish</span>
<React.Suspense fallback={<div>Loading...</div>}>
<Publish connectionId={props.connectionId} />
</React.Suspense>
</Panel>
<Panel detailsHidden={!node}>
<span>Stats</span>
<ExpansionPanelDetails className={props.classes.details}>
<NodeStats node={node} />
</ExpansionPanelDetails>
</ExpansionPanel>
<ValuePanel lastUpdate={this.props.node ? this.props.node.lastUpdate : 0} />
<ExpansionPanel defaultExpanded={true}>
<ExpansionPanelSummary expandIcon={<ExpandMore />} style={summaryStyle}>
<Typography className={classes.heading}>Publish</Typography>
</ExpansionPanelSummary>
<ExpansionPanelDetails style={this.detailsStyle}>
<React.Suspense fallback={<div>Loading...</div>}>
<Publish connectionId={this.props.connectionId} />
</React.Suspense>
</ExpansionPanelDetails>
</ExpansionPanel>
<ExpansionPanel defaultExpanded={Boolean(this.props.node)}>
<ExpansionPanelSummary expandIcon={<ExpandMore />} style={summaryStyle}>
<Typography className={classes.heading}>Stats</Typography>
</ExpansionPanelSummary>
{this.renderNodeStats()}
</ExpansionPanel>
</Panel>
</div>
)
}
private renderNodeStats() {
if (!this.props.node) {
return null
}
return (
<ExpansionPanelDetails style={this.detailsStyle}>
<NodeStats node={this.props.node} />
</ExpansionPanelDetails>
)
}
public componentWillReceiveProps(nextProps: Props) {
this.props.node && this.removeUpdateListener(this.props.node)
nextProps.node && this.registerUpdateListener(nextProps.node)
if (this.props.node !== nextProps.node) {
this.setState({ compareMessage: undefined })
}
}
public componentWillUnmount() {
this.props.node && this.removeUpdateListener(this.props.node)
}
public render() {
return (
<div id="Sidebar" className={this.props.classes.drawer}>
{this.renderNode()}
</div>
)
}
</div>
)
}
const mapStateToProps = (state: AppState) => {
@@ -188,13 +86,11 @@ const styles = (theme: Theme) => ({
drawer: {
display: 'block' as 'block',
},
badge: {
top: '3px',
right: '3px',
},
valuePaper: {
margin: theme.spacing(1),
},
summary: { minHeight: '0' },
details: { padding: '0px 16px 8px 8px', display: 'block' },
heading: {
fontSize: theme.typography.pxToRem(15),
fontWeight: theme.typography.fontWeightRegular,
@@ -0,0 +1,38 @@
import * as q from '../../../../../backend/src/Model'
import CustomIconButton from '../../helper/CustomIconButton'
import Delete from '@material-ui/icons/Delete'
import React, { useCallback } from 'react'
import { Badge } from '@material-ui/core'
export const RecursiveTopicDeleteButton = (props: {
node?: q.TreeNode<any>
deleteTopicAction: (node: q.TreeNode<any>, a: boolean, limit: number) => void
}) => {
const onClick = useCallback(() => {
if (props.node) {
props.deleteTopicAction(props.node, true, deleteLimit)
}
}, [props.node])
if (!props.node) {
return null
}
const deleteLimit = 50
const topicCount = props.node ? props.node.childTopicCount() : 0
if (topicCount === 0 || (props.node.message && topicCount === 1)) {
return null
}
return (
<Badge
style={{
top: '3px',
right: '3px',
}}
badgeContent={<span style={{ whiteSpace: 'nowrap' }}>{topicCount >= deleteLimit ? '50+' : topicCount}</span>}
color="secondary"
>
<CustomIconButton onClick={onClick} tooltip={`Deletes up to ${deleteLimit} sub-topics with a single click`}>
<Delete style={{ marginTop: '-3px' }} color="action" />
</CustomIconButton>
</Badge>
)
}
@@ -1,11 +1,11 @@
import * as React from 'react'
import * as q from '../../../../backend/src/Model'
import React from 'react'
import * as q from '../../../../../backend/src/Model'
import Button from '@material-ui/core/Button'
import { withStyles, Theme } from '@material-ui/core/styles'
import { treeActions } from '../../actions'
import { treeActions } from '../../../actions'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { TopicViewModel } from '../../model/TopicViewModel'
import { TopicViewModel } from '../../../model/TopicViewModel'
interface Props {
classes: any
@@ -0,0 +1,19 @@
import * as q from '../../../../../backend/src/Model'
import CustomIconButton from '../../helper/CustomIconButton'
import Delete from '@material-ui/icons/Delete'
import React from 'react'
export const TopicDeleteButton = (props: {
node?: q.TreeNode<any>
deleteTopicAction: (node: q.TreeNode<any>) => void
}) => {
const { node } = props
if (!node || !node.message || !node.message.value) {
return null
}
return (
<CustomIconButton onClick={() => props.deleteTopicAction(node)} tooltip="Clear this topic">
<Delete style={{ marginTop: '-3px' }} />
</CustomIconButton>
)
}
@@ -0,0 +1,48 @@
import * as q from '../../../../../backend/src/Model'
import Copy from '../../helper/Copy'
import Panel from '../Panel'
import React, { useMemo } from 'react'
import Topic from './Topic'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { RecursiveTopicDeleteButton } from './RecursiveTopicDeleteButton'
import { sidebarActions } from '../../../actions'
import { TopicDeleteButton } from './TopicDeleteButton'
const TopicPanel = (props: { node?: q.TreeNode<any>; actions: typeof sidebarActions; updateNode: () => void }) => {
const { node, updateNode } = props
const copyTopic = node ? <Copy value={node.path()} /> : null
const deleteTopic = (topic?: q.TreeNode<any>, recursive: boolean = false, maxCount = 50) => {
if (!topic) {
return
}
props.actions.clearTopic(topic, recursive, maxCount)
}
return useMemo(
() => (
<Panel disabled={!Boolean(node)}>
<span>
Topic {copyTopic}
<TopicDeleteButton node={node} deleteTopicAction={deleteTopic} />
<RecursiveTopicDeleteButton node={node} deleteTopicAction={deleteTopic} />
</span>
<Topic node={node} didSelectNode={updateNode} />
</Panel>
),
[node, node && node.childTopicCount()]
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(sidebarActions, dispatch),
}
}
export default connect(
undefined,
mapDispatchToProps
)(TopicPanel)
@@ -79,7 +79,7 @@ class MessageHistory extends React.Component<Props, State> {
const history = node.messageHistory.toArray()
let previousMessage: q.Message | undefined = node.message
const historyElements = history.reverse().map((message, idx) => {
const historyElements = [...history].reverse().map((message, idx) => {
const value = message.value ? Base64Message.toUnicodeString(message.value) : ''
const element = {
value,
@@ -165,7 +165,7 @@ class ValuePanel extends React.Component<Props, State> {
</ExpansionPanelSummary>
<ExpansionPanelDetails style={detailsStyle}>
{this.renderViewOptions()}
<div>
<div style={{ marginBottom: '-8px' }}>
<React.Suspense fallback={<div>Loading...</div>}>{this.renderValue()}</React.Suspense>
</div>
<div>
+29 -8
View File
@@ -5,18 +5,28 @@ import PlotHistory from './Sidebar/PlotHistory'
import { Base64Message } from '../../../backend/src/Model/Base64Message'
import { toPlottableValue } from './Sidebar/CodeDiff/util'
import { PlotCurveTypes } from '../reducers/Charts'
const parseDuration = require('parse-duration')
interface Props {
history: q.MessageHistory
dotPath?: string
timeInterval?: string
interpolation?: PlotCurveTypes
range?: [number?, number?]
color?: string
}
function nodeToHistory(history: q.MessageHistory) {
return history
.toArray()
function filterUsingTimeRange(startTime: number | undefined, data: Array<q.Message>) {
if (startTime) {
const threshold = new Date(Date.now() - startTime)
return data.filter(d => d.received >= threshold)
}
return data
}
function nodeToHistory(startTime: number | undefined, history: q.MessageHistory) {
return filterUsingTimeRange(startTime, history.toArray())
.map((message: q.Message) => {
const value = message.value ? toPlottableValue(Base64Message.toUnicodeString(message.value)) : NaN
return { x: message.received.getTime(), y: toPlottableValue(value) }
@@ -24,9 +34,8 @@ function nodeToHistory(history: q.MessageHistory) {
.filter(data => !isNaN(data.y as any)) as any
}
function nodeDotPathToHistory(history: q.MessageHistory, dotPath: string) {
return history
.toArray()
function nodeDotPathToHistory(startTime: number | undefined, history: q.MessageHistory, dotPath: string) {
return filterUsingTimeRange(startTime, history.toArray())
.map((message: q.Message) => {
let json = {}
try {
@@ -41,8 +50,20 @@ function nodeDotPathToHistory(history: q.MessageHistory, dotPath: string) {
}
function render(props: Props) {
const data = props.dotPath ? nodeDotPathToHistory(props.history, props.dotPath) : nodeToHistory(props.history)
return <PlotHistory color={props.color} range={props.range} interpolation={props.interpolation} data={data} />
const startOffset = props.timeInterval ? parseDuration(props.timeInterval) : undefined
const data = props.dotPath
? nodeDotPathToHistory(startOffset, props.history, props.dotPath)
: nodeToHistory(startOffset, props.history)
return (
<PlotHistory
timeRangeStart={startOffset}
color={props.color}
range={props.range}
interpolation={props.interpolation}
data={data}
/>
)
}
export default render
-255
View File
@@ -1,255 +0,0 @@
import * as q from '../../../../backend/src/Model'
import * as React from 'react'
import TreeNodeSubnodes from './TreeNodeSubnodes'
import TreeNodeTitle from './TreeNodeTitle'
import { Record } from 'immutable'
import { SettingsState } from '../../reducers/Settings'
import { Theme, withStyles } from '@material-ui/core/styles'
import { TopicViewModel } from '../../model/TopicViewModel'
const debounce = require('lodash.debounce')
declare var performance: any
const styles = (theme: Theme) => {
return {
collapsedSubnodes: {
color: theme.palette.text.secondary,
},
displayBlock: {
display: 'block',
},
node: {
display: 'block',
marginLeft: '10px',
},
topicSelect: {
float: 'right' as 'right',
opacity: 0,
cursor: 'pointer',
marginTop: '-1px',
},
subnodes: {
marginLeft: theme.spacing(1.5),
},
selected: {
backgroundColor: theme.palette.type === 'dark' ? 'rgba(170, 170, 170, 0.55)' : 'rgba(170, 170, 170, 0.55)',
},
hover: {
backgroundColor: theme.palette.type === 'dark' ? 'rgba(100, 100, 100, 0.55)' : 'rgba(200, 200, 200, 0.55)',
},
title: {
borderRadius: '4px',
lineHeight: '1em',
display: 'inline-block' as 'inline-block',
whiteSpace: 'nowrap' as 'nowrap',
padding: '1px 4px 0px 4px',
height: '14px',
margin: '1px 0px 2px 0px',
},
}
}
interface Props {
isRoot?: boolean
treeNode: q.TreeNode<TopicViewModel>
name?: string | undefined
collapsed?: boolean | undefined
performanceCallback?: ((ms: number) => void) | undefined
classes: any
className?: string
lastUpdate: number
didSelectTopic: any
theme: Theme
settings: Record<SettingsState>
}
interface State {
collapsedOverride: boolean | undefined
mouseOver: boolean
selected: boolean
}
class TreeNodeComponent extends React.Component<Props, State> {
private animationDirty: boolean = false
private cssAnimationWasSetAt?: number
private willUpdateTime: number = performance.now()
private nodeRef?: React.RefObject<HTMLDivElement> = React.createRef<HTMLDivElement>()
private setHover = debounce((hover: boolean) => {
this.setState({ mouseOver: hover })
}, 45)
constructor(props: Props) {
super(props)
this.state = {
collapsedOverride: props.collapsed,
mouseOver: false,
selected: false,
}
}
private addSubscriber(treeNode: q.TreeNode<TopicViewModel>) {
treeNode.viewModel = new TopicViewModel()
treeNode.viewModel.change.subscribe(this.viewStateHasChanged)
}
private viewStateHasChanged = () => {
this.props.treeNode.viewModel && this.setState({ selected: this.props.treeNode.viewModel.isSelected() })
}
private removeSubscriber(treeNode: q.TreeNode<TopicViewModel>) {
treeNode.viewModel && treeNode.viewModel.change.unsubscribe(this.viewStateHasChanged)
treeNode.viewModel = undefined
}
private stateHasChanged(newState: State) {
return (
this.state.collapsedOverride !== newState.collapsedOverride ||
this.state.mouseOver !== newState.mouseOver ||
this.state.selected !== newState.selected
)
}
private toggle() {
this.setState({ collapsedOverride: !this.collapsed() })
}
private collapsed() {
if (this.state.collapsedOverride !== undefined) {
return this.state.collapsedOverride
}
return this.props.treeNode.edgeCount() > this.props.settings.get('autoExpandLimit')
}
private didSelectTopic = () => {
this.props.didSelectTopic(this.props.treeNode)
}
private didClickTitle = (event: React.MouseEvent) => {
event.preventDefault()
this.props.didSelectTopic(this.props.treeNode)
this.toggle()
}
private mouseOver = (event: React.MouseEvent) => {
event.stopPropagation()
this.setHover(true)
if (
this.props.settings.get('selectTopicWithMouseOver') &&
this.props.treeNode &&
this.props.treeNode.message &&
this.props.treeNode.message.value
) {
this.props.didSelectTopic(this.props.treeNode)
}
}
private mouseOut = (event: React.MouseEvent) => {
event.stopPropagation()
this.setHover(false)
}
private toggleCollapsed = (event: React.MouseEvent) => {
event.stopPropagation()
this.toggle()
}
private renderNodes() {
if (this.collapsed()) {
return null
}
return (
<TreeNodeSubnodes
collapsed={this.collapsed()}
treeNode={this.props.treeNode}
lastUpdate={this.props.treeNode.lastUpdate}
didSelectTopic={this.props.didSelectTopic}
settings={this.props.settings}
/>
)
}
public componentDidMount() {
const { treeNode } = this.props
this.addSubscriber(treeNode)
}
public componentWillUnmount() {
const { treeNode } = this.props
this.removeSubscriber(treeNode)
this.nodeRef = undefined
}
public shouldComponentUpdate(nextProps: Props, nextState: State) {
const shouldRenderToRemoveCssAnimation = this.cssAnimationWasSetAt !== undefined
return (
this.stateHasChanged(nextState) ||
this.props.settings !== nextProps.settings ||
this.props.lastUpdate !== nextProps.lastUpdate ||
this.animationDirty ||
shouldRenderToRemoveCssAnimation
)
}
public componentDidUpdate() {
if (this.props.performanceCallback) {
const renderTime = performance.now() - this.willUpdateTime
this.props.performanceCallback(renderTime)
}
}
public componentWillUpdate() {
if (this.props.performanceCallback) {
this.willUpdateTime = performance.now()
}
}
public render() {
const { classes } = this.props
const shouldStartAnimation =
!this.animationDirty && !this.props.isRoot && this.props.settings.get('highlightTopicUpdates')
const animationName = this.props.theme.palette.type === 'light' ? 'updateLight' : 'updateDark'
const animation = shouldStartAnimation
? { willChange: 'auto', translateZ: 0, animation: `${animationName} 0.5s` }
: {}
this.animationDirty = shouldStartAnimation
const highlightClass = this.state.selected
? this.props.classes.selected
: this.state.mouseOver
? this.props.classes.hover
: ''
return (
<div>
<div
key={this.props.treeNode.hash()}
className={`${classes.node} ${this.props.className} ${highlightClass} ${classes.title}`}
style={animation}
onMouseOver={this.mouseOver}
onMouseOut={this.mouseOut}
onClick={this.didClickTitle}
ref={this.nodeRef}
>
<TreeNodeTitle
toggleCollapsed={this.toggleCollapsed}
didSelectNode={this.didSelectTopic}
collapsed={this.collapsed()}
treeNode={this.props.treeNode}
name={this.props.name}
/>
</div>
{this.renderNodes()}
</div>
)
}
}
export default withStyles(styles, { withTheme: true })(TreeNodeComponent)
@@ -0,0 +1,77 @@
import * as q from '../../../../../backend/src/Model'
import React, { useEffect, useState, useMemo } from 'react'
import TreeNode from '.'
import { SettingsState } from '../../../reducers/Settings'
import { sortedNodes } from '../../../sortedNodes'
import { Theme, withStyles } from '@material-ui/core'
import { TopicViewModel } from '../../../model/TopicViewModel'
import { treeActions } from '../../../actions'
export interface Props {
treeNode: q.TreeNode<TopicViewModel>
filter?: string
classes: any
lastUpdate: number
selectedTopic?: q.TreeNode<TopicViewModel>
selectTopicAction: (treeNode: q.TreeNode<any>) => void
settings: SettingsState
actions: typeof treeActions
theme: Theme
}
function useStagedRendering(treeNode: q.TreeNode<any>) {
const [alreadyAdded, setAlreadyAdded] = useState(10)
const edges = treeNode.edgeArray
useEffect(() => {
let renderMoreAnimationFrame: any
if (alreadyAdded < edges.length) {
renderMoreAnimationFrame = (window as any).requestIdleCallback(
() => {
setAlreadyAdded(alreadyAdded * 1.5)
},
{ timeout: 500 }
)
}
return function cleanup() {
;(window as any).cancelIdleCallback(renderMoreAnimationFrame)
}
}, [alreadyAdded, edges.length])
return alreadyAdded
}
function TreeNodeSubnodes(props: Props) {
const alreadyAdded = useStagedRendering(props.treeNode)
return useMemo(() => {
const nodes = sortedNodes(props.settings, props.treeNode).slice(0, alreadyAdded)
const listItems = nodes.map(node => {
return (
<TreeNode
key={`${node.hash()}-${props.filter}`}
treeNode={node}
className={props.classes.listItem}
lastUpdate={node.lastUpdate}
selectTopicAction={props.selectTopicAction}
settings={props.settings}
actions={props.actions}
/>
)
})
return <span className={props.classes.list}>{listItems}</span>
}, [alreadyAdded, props.treeNode.lastUpdate, props.theme])
}
const styles = (theme: Theme) => ({
list: {
display: 'block' as 'block',
clear: 'both' as 'both',
marginLeft: theme.spacing(1.5),
},
})
export default withStyles(styles, { withTheme: true })(TreeNodeSubnodes)
@@ -1,8 +1,8 @@
import * as React from 'react'
import * as q from '../../../../backend/src/Model'
import * as q from '../../../../../backend/src/Model'
import { withStyles, Theme } from '@material-ui/core'
import { TopicViewModel } from '../../model/TopicViewModel'
import { Base64Message } from '../../../../backend/src/Model/Base64Message'
import { TopicViewModel } from '../../../model/TopicViewModel'
import { Base64Message } from '../../../../../backend/src/Model/Base64Message'
export interface TreeNodeProps extends React.HTMLAttributes<HTMLElement> {
treeNode: q.TreeNode<TopicViewModel>
@@ -0,0 +1,31 @@
import React, { useEffect } from 'react'
export function useAnimationToIndicateTopicUpdate(
ref: React.MutableRefObject<HTMLDivElement | undefined>,
lastUpdate: number,
className: string,
selected: boolean,
shouldAnimate: boolean
) {
useEffect(() => {
if (ref.current && shouldAnimate && Date.now() - lastUpdate < 3000 && !selected) {
let timeout: any
let animationFrame = requestAnimationFrame(() => {
ref.current && ref.current.classList.add(className)
timeout = setTimeout(
() =>
(animationFrame = requestAnimationFrame(() => {
ref.current && ref.current.classList.remove(className)
})),
500
)
})
return function cleanup() {
timeout && clearTimeout(timeout)
animationFrame && cancelAnimationFrame(animationFrame)
}
}
}, [lastUpdate, selected, shouldAnimate, className])
}
@@ -0,0 +1,18 @@
import * as q from '../../../../../../backend/src/Model'
import React, { useCallback } from 'react'
import { KeyCodes } from '../../../../utils/KeyCodes'
import { treeActions } from '../../../../actions'
export function useDeleteKeyCallback(topic: q.TreeNode<any>, actions: typeof treeActions) {
return useCallback(
(event: React.KeyboardEvent) => {
return
if (event.keyCode === KeyCodes.delete || event.keyCode === KeyCodes.backspace) {
event.stopPropagation()
event.preventDefault()
actions.clearTopic(topic, true, 50)
}
},
[topic]
)
}
@@ -0,0 +1,14 @@
import { Props } from '..'
import { useEffect, useState } from 'react'
export function useIsAllowedToAutoExpandState(props: Props): boolean {
const { settings, treeNode, isRoot } = props
const [isAllowedToAutoExpand, setAllowAutoExpand] = useState(false)
useEffect(() => {
const newIsAllowedToAutoExpand = isRoot || treeNode.edgeCount() <= settings.get('autoExpandLimit')
if (newIsAllowedToAutoExpand !== isAllowedToAutoExpand) {
setAllowAutoExpand(newIsAllowedToAutoExpand)
}
}, [treeNode.edgeCount(), settings.get('autoExpandLimit')])
return isAllowedToAutoExpand
}
@@ -0,0 +1,44 @@
import * as q from '../../../../../../backend/src/Model'
import React, { useEffect } from 'react'
import { TopicViewModel } from '../../../../model/TopicViewModel'
export function useViewModelSubscriptions(
treeNode: q.TreeNode<TopicViewModel>,
nodeRef: React.MutableRefObject<HTMLDivElement | undefined>,
setSelected: (value: boolean) => void,
setCollapsedOverride: (value: boolean) => void
) {
const selectionDidChange = () => {
const selected = treeNode.viewModel && treeNode.viewModel.isSelected()
treeNode.viewModel && setSelected(Boolean(selected))
if (selected && nodeRef && nodeRef.current) {
nodeRef.current.focus({ preventScroll: false })
}
}
const expandedDidChange = () => {
treeNode.viewModel && setCollapsedOverride(!treeNode.viewModel.isExpanded())
}
useEffect(() => {
addSubscriber()
return function cleanup() {
removeSubscriber()
}
}, [treeNode])
function addSubscriber() {
treeNode.viewModel = new TopicViewModel()
treeNode.viewModel.selectionChange.subscribe(selectionDidChange)
treeNode.viewModel.expandedChange.subscribe(expandedDidChange)
}
function removeSubscriber() {
if (treeNode.viewModel) {
treeNode.viewModel.selectionChange.unsubscribe(selectionDidChange)
treeNode.viewModel.expandedChange.unsubscribe(expandedDidChange)
treeNode.viewModel = undefined
}
}
}
+142
View File
@@ -0,0 +1,142 @@
import * as q from '../../../../../backend/src/Model'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import TreeNodeSubnodes from './TreeNodeSubnodes'
import TreeNodeTitle from './TreeNodeTitle'
import { SettingsState } from '../../../reducers/Settings'
import { styles } from './styles'
import { Theme, withStyles } from '@material-ui/core/styles'
import { TopicViewModel } from '../../../model/TopicViewModel'
import { treeActions } from '../../../actions'
import { useAnimationToIndicateTopicUpdate } from './effects/useAnimationToIndicateTopicUpdate'
import { useDeleteKeyCallback } from './effects/useDeleteKeyCallback'
import { useIsAllowedToAutoExpandState } from './effects/useIsAllowedToAutoExpandState'
import { useViewModelSubscriptions } from './effects/useViewModelSubscriptions'
export interface Props {
isRoot?: boolean
treeNode: q.TreeNode<TopicViewModel>
name?: string | undefined
collapsed?: boolean | undefined
classes: any
className?: string
lastUpdate: number
actions: typeof treeActions
selectTopicAction: (treeNode: q.TreeNode<any>) => void
theme: Theme
settings: SettingsState
}
function TreeNodeComponent(props: Props) {
const { actions, classes, className, settings, theme, treeNode, lastUpdate, name } = props
const [collapsedOverride, setCollapsedOverride] = useState<boolean | undefined>(undefined)
const [selected, setSelected] = useState(false)
const nodeRef = useRef<HTMLDivElement>()
const isAllowedToAutoExpand = useIsAllowedToAutoExpandState(props)
const deleteTopicCallback = useDeleteKeyCallback(treeNode, actions)
useViewModelSubscriptions(treeNode, nodeRef, setSelected, setCollapsedOverride)
const animationClass =
props.theme.palette.type === 'light' ? props.classes.animationLight : props.classes.animationDark
useAnimationToIndicateTopicUpdate(
nodeRef,
lastUpdate,
animationClass,
selected,
settings.get('highlightTopicUpdates')
)
const isCollapsed =
Boolean(collapsedOverride) === collapsedOverride ? Boolean(collapsedOverride) : !isAllowedToAutoExpand
const toggle = useCallback(() => {
setCollapsedOverride(!isCollapsed)
}, [isCollapsed])
const didSelectTopic = useCallback(
(event?: React.MouseEvent) => {
event && event.stopPropagation()
props.selectTopicAction(treeNode)
},
[treeNode]
)
const didClickTitle = React.useCallback(
(event: React.MouseEvent) => {
event.stopPropagation()
didSelectTopic()
toggle()
},
[toggle, didSelectTopic]
)
const toggleCollapsed = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation()
toggle()
},
[toggle]
)
const didObtainFocus = useCallback(() => {
didSelectTopic()
}, [didSelectTopic])
const mouseOver = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation()
if (settings.get('selectTopicWithMouseOver') && treeNode && treeNode.message && treeNode.message.value) {
didSelectTopic()
}
},
[didSelectTopic]
)
useEffect(() => {
treeNode.viewModel && treeNode.viewModel.setExpanded(!isCollapsed, false)
}, [isCollapsed])
return useMemo(() => {
function renderNodes() {
if (isCollapsed) {
return null
}
return (
<TreeNodeSubnodes
treeNode={treeNode}
lastUpdate={treeNode.lastUpdate}
selectTopicAction={props.selectTopicAction}
settings={settings}
actions={props.actions}
/>
)
}
const highlightClass = selected ? classes.selected : ''
return (
<div>
<div
key={treeNode.hash()}
className={`${classes.node} ${className} ${highlightClass} ${classes.title}`}
onMouseEnter={mouseOver}
onFocus={didObtainFocus}
onClick={didClickTitle}
ref={nodeRef as any}
tabIndex={-1}
onKeyDown={deleteTopicCallback}
>
<TreeNodeTitle
toggleCollapsed={toggleCollapsed}
didSelectNode={didSelectTopic}
collapsed={isCollapsed}
treeNode={treeNode}
name={name}
/>
</div>
{renderNodes()}
</div>
)
}, [treeNode.lastUpdate, treeNode, name, isCollapsed, selected, theme])
}
export default withStyles(styles, { withTheme: true })(TreeNodeComponent)
@@ -0,0 +1,52 @@
import { blueGrey } from '@material-ui/core/colors'
import { Theme } from '@material-ui/core/styles'
export const styles = (theme: Theme) => {
return {
animationLight: {
willChange: 'auto',
translateZ: 0,
animation: `updateLight 0.5s`,
},
animationDark: {
willChange: 'auto',
translateZ: 0,
animation: `updateLight 0.5s`,
},
collapsedSubnodes: {
color: theme.palette.text.secondary,
},
displayBlock: {
display: 'block',
},
node: {
display: 'block',
marginLeft: '10px',
'&:hover': {
backgroundColor: theme.palette.type === 'light' ? blueGrey[100] : theme.palette.primary.light,
},
},
topicSelect: {
float: 'right' as 'right',
opacity: 0,
cursor: 'pointer',
marginTop: '-1px',
},
subnodes: {
marginLeft: theme.spacing(1.5),
},
selected: {
backgroundColor: (theme.palette.type === 'light' ? blueGrey[300] : theme.palette.primary.main) + ' !important',
},
hover: {},
title: {
borderRadius: '4px',
lineHeight: '1em',
display: 'inline-block' as 'inline-block',
whiteSpace: 'nowrap' as 'nowrap',
padding: '1px 4px 0px 4px',
height: '14px',
margin: '1px 0px 2px 0px',
},
}
}
@@ -1,100 +0,0 @@
import * as q from '../../../../backend/src/Model'
import * as React from 'react'
import TreeNode from './TreeNode'
import { Record } from 'immutable'
import { SettingsState, TopicOrder } from '../../reducers/Settings'
import { Theme, withStyles } from '@material-ui/core'
import { TopicViewModel } from '../../model/TopicViewModel'
export interface Props {
treeNode: q.TreeNode<TopicViewModel>
filter?: string
collapsed?: boolean | undefined
classes: any
lastUpdate: number
selectedTopic?: q.TreeNode<TopicViewModel>
didSelectTopic: any
settings: Record<SettingsState>
}
interface State {
alreadyAdded: number
}
class TreeNodeSubnodes extends React.Component<Props, State> {
private renderMoreAnimationFrame?: any
constructor(props: Props) {
super(props)
this.state = { alreadyAdded: 10 }
}
private sortedNodes(): Array<q.TreeNode<TopicViewModel>> {
const { settings, treeNode } = this.props
const topicOrder = settings.get('topicOrder')
let edges = treeNode.edgeArray
if (topicOrder === TopicOrder.abc) {
edges = edges.sort((a, b) => a.name.localeCompare(b.name))
}
let nodes = edges.map(edge => edge.target)
if (topicOrder === TopicOrder.messages) {
nodes = nodes.sort((a, b) => b.leafMessageCount() - a.leafMessageCount())
}
if (topicOrder === TopicOrder.topics) {
nodes = nodes.sort((a, b) => b.childTopicCount() - a.childTopicCount())
}
return nodes
}
private renderMore() {
this.renderMoreAnimationFrame = (window as any).requestIdleCallback(
() => {
this.setState({ ...this.state, alreadyAdded: this.state.alreadyAdded * 1.5 })
},
{ timeout: 500 }
)
}
public componentWillUnmount() {
;(window as any).cancelIdleCallback(this.renderMoreAnimationFrame)
}
public render() {
const edges = this.props.treeNode.edgeArray
if (edges.length === 0 || this.props.collapsed) {
return null
}
if (this.state.alreadyAdded < edges.length) {
this.renderMore()
}
const nodes = this.sortedNodes().slice(0, this.state.alreadyAdded)
const listItems = nodes.map(node => {
return (
<TreeNode
key={`${node.hash()}-${this.props.filter}`}
treeNode={node}
className={this.props.classes.listItem}
lastUpdate={node.lastUpdate}
didSelectTopic={this.props.didSelectTopic}
settings={this.props.settings}
/>
)
})
return <span className={this.props.classes.list}>{listItems}</span>
}
}
const styles = (theme: Theme) => ({
list: {
display: 'block' as 'block',
clear: 'both' as 'both',
marginLeft: theme.spacing(1.5),
},
})
export default withStyles(styles)(TreeNodeSubnodes)
@@ -1,13 +1,13 @@
import * as q from '../../../../backend/src/Model'
import * as React from 'react'
import React, { useCallback } from 'react'
import TreeNode from './TreeNode'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { Record } from 'immutable'
import { SettingsState } from '../../reducers/Settings'
import { TopicViewModel } from '../../model/TopicViewModel'
import { treeActions } from '../../actions'
import { KeyCodes } from '../../utils/KeyCodes'
const MovingAverage = require('moving-average')
@@ -20,16 +20,38 @@ interface Props {
actions: typeof treeActions
connectionId?: string
tree?: q.Tree<TopicViewModel>
filter: string
host?: string
paused: boolean
settings: Record<SettingsState>
settings: SettingsState
}
interface State {
lastUpdate: number
}
function useArrowKeyEventHandler(actions: typeof treeActions) {
return (event: React.KeyboardEvent) => {
switch (event.keyCode) {
case KeyCodes.arrow_down:
actions.moveSelectionUpOrDownwards('next')
event.preventDefault()
break
case KeyCodes.arrow_up:
actions.moveSelectionUpOrDownwards('previous')
event.preventDefault()
break
case KeyCodes.arrow_left:
actions.moveOutward()
event.preventDefault()
break
case KeyCodes.arrow_right:
actions.moveInward()
event.preventDefault()
break
}
}
}
class TreeComponent extends React.PureComponent<Props, State> {
private updateTimer?: any
private perf: number = 0
@@ -40,17 +62,11 @@ class TreeComponent extends React.PureComponent<Props, State> {
this.state = { lastUpdate: 0 }
}
private keyEventHandler = useArrowKeyEventHandler(this.props.actions)
private performanceCallback = (ms: number) => {
average.push(Date.now(), ms)
}
public time(): number {
const time = performance.now() - this.perf
this.perf = performance.now()
return time
}
public componentWillReceiveProps(nextProps: Props) {
if (this.props.tree !== nextProps.tree) {
if (this.props.tree) {
@@ -83,8 +99,8 @@ class TreeComponent extends React.PureComponent<Props, State> {
this.updateTimer = undefined
this.renderTime = performance.now()
if (!this.props.paused) {
this.props.tree && this.props.tree.applyUnmergedChanges()
if (!this.props.paused && this.props.tree) {
this.props.tree.applyUnmergedChanges()
}
window.requestIdleCallback(
() => {
@@ -98,8 +114,16 @@ class TreeComponent extends React.PureComponent<Props, State> {
}, Math.max(0, timeUntilNextUpdate))
}
public componentWillUpdate() {
this.perf = performance.now()
}
public componentDidUpdate() {
this.performanceCallback(performance.now() - this.perf)
}
public render() {
const { tree, filter } = this.props
const { tree } = this.props
if (!tree) {
return null
}
@@ -111,21 +135,22 @@ class TreeComponent extends React.PureComponent<Props, State> {
overflowX: 'hidden',
height: '100%',
width: '100%',
outline: '24px black !important',
paddingBottom: '16px', // avoid conflict with chart panel Resizer
}
return (
<div style={style}>
<div style={style} tabIndex={0} onKeyDown={this.keyEventHandler}>
<TreeNode
key={tree.hash()}
isRoot={true}
treeNode={tree}
name={this.props.host}
collapsed={false}
performanceCallback={this.performanceCallback}
settings={this.props.settings}
lastUpdate={tree.lastUpdate}
didSelectTopic={this.props.actions.selectTopic}
actions={this.props.actions}
selectTopicAction={this.props.actions.selectTopic}
/>
</div>
)
+1 -1
View File
@@ -180,7 +180,7 @@ class UpdateNotifier extends React.Component<Props, State> {
let regex: RegExp
if (os.platform() === 'darwin') {
regex = /\.dmg$/
} else if (os.platform() === 'darwin') {
} else if (os.platform() === 'win32') {
regex = /\.exe$/
} else {
regex = /\.AppImage$/
@@ -0,0 +1,38 @@
import * as q from '../../../../backend/src/Model'
import { useState, useEffect } from 'react'
/**
* If a node is not available when the plot is shown, keep polling until it has been created
*/
export function usePollingToFetchTreeNode(tree: q.Tree<any> | undefined, path: string) {
const [treeNode, setTreeNode] = useState<q.TreeNode<any> | undefined>()
function pollUntilTreeNodeHasBeenFound() {
if (!tree) {
return
}
const initialTreeNode = tree.findNode(path)
if (initialTreeNode) {
setTreeNode(initialTreeNode)
return
}
let intervalTimer: any
if (!treeNode) {
intervalTimer = setInterval(() => {
const node = tree.findNode(path)
if (node) {
setTreeNode(node)
clearInterval(intervalTimer)
}
}, 500)
}
return function cleanup() {
intervalTimer && clearInterval(intervalTimer)
}
}
useEffect(pollUntilTreeNodeHasBeenFound, [tree])
return treeNode
}
@@ -0,0 +1,24 @@
import { useEffect } from 'react'
import { KeyCodes } from '../utils/KeyCodes'
export function useGlobalKeyEventHandler(
key: KeyCodes | undefined,
callback: (event: KeyboardEvent) => void,
dependencies?: Array<any>
) {
useEffect(() => {
function handleKeyEvent(event: KeyboardEvent) {
if (key === undefined) {
callback(event)
} else if (event.keyCode === key) {
callback(event)
event.preventDefault()
}
}
document.addEventListener('keydown', handleKeyEvent, false)
return function cleanup() {
document.removeEventListener('keydown', handleKeyEvent, false)
}
}, dependencies)
}
+4
View File
@@ -20,6 +20,8 @@ export interface ConnectionOptions {
encryption: boolean
certValidation: boolean
selfSignedCertificate?: CertificateParameters
clientCertificate?: CertificateParameters
clientKey?: CertificateParameters
clientId?: string
subscriptions: Array<string>
}
@@ -38,6 +40,8 @@ export function toMqttConnection(options: ConnectionOptions): MqttOptions | unde
certValidation: options.certValidation,
subscriptions: options.subscriptions,
certificateAuthority: options.selfSignedCertificate ? options.selfSignedCertificate.data : undefined,
clientCertificate: options.clientCertificate ? options.clientCertificate.data : undefined,
clientKey: options.clientKey ? options.clientKey.data : undefined,
}
}
+18 -3
View File
@@ -3,22 +3,37 @@ import { EventDispatcher } from '../../../events'
export class TopicViewModel implements Destroyable {
private selected: boolean
public change = new EventDispatcher<void, TopicViewModel>()
private expanded: boolean
public selectionChange = new EventDispatcher<void>()
public expandedChange = new EventDispatcher<void>()
public constructor() {
this.selected = false
this.expanded = false
}
public destroy() {
this.change.removeAllListeners()
this.selectionChange.removeAllListeners()
}
public isSelected() {
return this.selected
}
public isExpanded() {
return this.expanded
}
public setSelected(selected: boolean) {
this.selected = selected
this.change.dispatch()
this.selectionChange.dispatch()
}
public setExpanded(expanded: boolean, fireEvent: boolean) {
const didChange = this.expanded !== expanded
this.expanded = expanded
if (didChange && fireEvent) {
this.expandedChange.dispatch()
}
}
}
+5 -1
View File
@@ -13,6 +13,9 @@ export interface ChartParameters {
from?: number
to?: number
}
timeRange?: {
until: string
}
width?: 'big' | 'medium' | 'small'
color?: string
}
@@ -84,8 +87,9 @@ function moveUp(state: ChartsState, action: MoveUp) {
const previousItem = charts.get(idx - 1)
if (idx === 0 || !item || !previousItem) {
return
return state // do nothing
}
const newlyOrderedCharts = charts.set(idx - 1, item).set(idx, previousItem)
return state.set('charts', newlyOrderedCharts)
}
+19
View File
@@ -6,12 +6,14 @@ export interface ConnectionManagerState {
connections: { [s: string]: ConnectionOptions }
selected?: string
showAdvancedSettings: boolean
showCertificateSettings: boolean
}
const initialState: ConnectionManagerState = {
connections: {},
selected: undefined,
showAdvancedSettings: false,
showCertificateSettings: false,
}
export type Action =
@@ -21,6 +23,7 @@ export type Action =
| AddConnection
| DeleteConnection
| ToggleAdvancedSettings
| ToggleCertificateSettings
| DeleteSubscription
| AddSubscription
@@ -31,6 +34,7 @@ export enum ActionTypes {
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_TOGGLE_CERTIFICATE_SETTINGS = 'CONNECTION_MANAGER_TOGGLE_CERTIFICATE_SETTINGS',
CONNECTION_MANAGER_ADD_SUBSCRIPTION = 'CONNECTION_MANAGER_ADD_SUBSCRIPTION',
CONNECTION_MANAGER_DELETE_SUBSCRIPTION = 'CONNECTION_MANAGER_DELETE_SUBSCRIPTION',
}
@@ -77,6 +81,10 @@ export interface ToggleAdvancedSettings {
type: ActionTypes.CONNECTION_MANAGER_TOGGLE_ADVANCED_SETTINGS
}
export interface ToggleCertificateSettings {
type: ActionTypes.CONNECTION_MANAGER_TOGGLE_CERTIFICATE_SETTINGS
}
export const connectionManagerReducer = createReducer(initialState, {
CONNECTION_MANAGER_SET_CONNECTIONS: setConnections,
CONNECTION_MANAGER_SELECT_CONNECTION: selectConnection,
@@ -84,6 +92,7 @@ export const connectionManagerReducer = createReducer(initialState, {
CONNECTION_MANAGER_ADD_CONNECTION: addConnection,
CONNECTION_MANAGER_DELETE_CONNECTION: deleteConnection,
CONNECTION_MANAGER_TOGGLE_ADVANCED_SETTINGS: toggleAdvancedSettings,
CONNECTION_MANAGER_TOGGLE_CERTIFICATE_SETTINGS: toggleCertificateSettings,
CONNECTION_MANAGER_DELETE_SUBSCRIPTION: deleteSubscription,
CONNECTION_MANAGER_ADD_SUBSCRIPTION: addSubscription,
})
@@ -109,6 +118,16 @@ function toggleAdvancedSettings(state: ConnectionManagerState, action: ToggleAdv
}
}
function toggleCertificateSettings(
state: ConnectionManagerState,
action: ToggleCertificateSettings
): ConnectionManagerState {
return {
...state,
showCertificateSettings: !state.showCertificateSettings,
}
}
function addConnection(state: ConnectionManagerState, action: AddConnection): ConnectionManagerState {
return {
...state,
-1
View File
@@ -44,7 +44,6 @@ export const globalState: Reducer<Record<GlobalStateInterface>, GlobalAction> =
action
): GlobalState => {
trackEvent(action.type)
console.log(action.type)
switch (action.type) {
case ActionTypes.showUpdateNotification:
+15 -14
View File
@@ -9,8 +9,7 @@ export enum TopicOrder {
}
export type ValueRendererDisplayMode = 'diff' | 'raw'
export interface SettingsState {
export interface SettingsStateModel {
autoExpandLimit: number
timeLocale: string
topicOrder: TopicOrder
@@ -21,6 +20,8 @@ export interface SettingsState {
theme: 'light' | 'dark'
}
export type SettingsState = Record<SettingsStateModel>
export type Actions = SetAutoExpandLimitAction &
DidLoadSettingsAction &
SetTopicOrderAction &
@@ -44,7 +45,7 @@ export enum ActionTypes {
SETTINGS_SET_TIME_LOCALE = 'SETTINGS_SET_TIME_LOCALE',
}
const initialState = Record<SettingsState>({
const initialState = Record<SettingsStateModel>({
timeLocale: window.navigator.language,
autoExpandLimit: 0,
topicOrder: TopicOrder.none,
@@ -55,12 +56,12 @@ const initialState = Record<SettingsState>({
topicFilter: undefined,
})
const setTheme = (theme: 'light' | 'dark') => (state: Record<SettingsState>) => {
const setTheme = (theme: 'light' | 'dark') => (state: SettingsState) => {
return state.set('theme', theme)
}
const reducerActions: {
[s: string]: (state: Record<SettingsState>, action: Actions) => Record<SettingsState>
[s: string]: (state: SettingsState, action: Actions) => SettingsState
} = {
SETTINGS_SET_AUTO_EXPAND_LIMIT: setAutoExpandLimit,
SETTINGS_SET_TOPIC_ORDER: setTopicOrder,
@@ -83,10 +84,10 @@ export interface SetTheme {
export interface DidLoadSettingsAction {
type: ActionTypes.SETTINGS_DID_LOAD_SETTINGS
settings: Partial<SettingsState>
settings: Partial<SettingsStateModel>
}
function didLoadSettings(state: Record<SettingsState>, action: DidLoadSettingsAction) {
function didLoadSettings(state: SettingsState, action: DidLoadSettingsAction): SettingsState {
return state.merge(action.settings)
}
@@ -95,7 +96,7 @@ export interface SetSelectTopicWithMouseOverAction {
selectTopicWithMouseOver: boolean
}
export function setSelectTopicWithMouseOver(state: Record<SettingsState>, action: SetSelectTopicWithMouseOverAction) {
export function setSelectTopicWithMouseOver(state: SettingsState, action: SetSelectTopicWithMouseOverAction) {
return state.set('selectTopicWithMouseOver', !state.get('selectTopicWithMouseOver'))
}
@@ -104,7 +105,7 @@ export interface SetTimeLocale {
timeLocale: string
}
export function setTimeLocale(state: Record<SettingsState>, action: SetTimeLocale): Record<SettingsState> {
export function setTimeLocale(state: SettingsState, action: SetTimeLocale): SettingsState {
return state.set('timeLocale', action.timeLocale)
}
@@ -113,7 +114,7 @@ export interface SetValueRendererDisplayModeAction {
valueRendererDisplayMode: ValueRendererDisplayMode
}
export function setValueRendererDisplayMode(state: Record<SettingsState>, action: SetValueRendererDisplayModeAction) {
export function setValueRendererDisplayMode(state: SettingsState, action: SetValueRendererDisplayModeAction) {
return state.set('valueRendererDisplayMode', action.valueRendererDisplayMode)
}
@@ -122,7 +123,7 @@ export interface SetAutoExpandLimitAction {
autoExpandLimit: number
}
function setAutoExpandLimit(state: Record<SettingsState>, action: SetAutoExpandLimitAction) {
function setAutoExpandLimit(state: SettingsState, action: SetAutoExpandLimitAction) {
return state.set('autoExpandLimit', action.autoExpandLimit)
}
@@ -130,7 +131,7 @@ export interface ToggleHighlightTopicUpdatesAction {
type: ActionTypes.SETTINGS_TOGGLE_HIGHLIGHT_ACTIVITY
}
function toggleHighlightTopicUpdates(state: Record<SettingsState>, action: ToggleHighlightTopicUpdatesAction) {
function toggleHighlightTopicUpdates(state: SettingsState, action: ToggleHighlightTopicUpdatesAction) {
return state.set('highlightTopicUpdates', !state.get('highlightTopicUpdates'))
}
@@ -139,7 +140,7 @@ export interface SetTopicOrderAction {
topicOrder: TopicOrder
}
function setTopicOrder(state: Record<SettingsState>, action: SetTopicOrderAction) {
function setTopicOrder(state: SettingsState, action: SetTopicOrderAction) {
return state.set('topicOrder', action.topicOrder)
}
@@ -149,6 +150,6 @@ export interface FilterTopicsAction {
}
// @Todo: move to tree reducer, should not be persisted / is no application setting
function filterTopics(state: Record<SettingsState>, action: FilterTopicsAction) {
function filterTopics(state: SettingsState, action: FilterTopicsAction) {
return state.set('topicFilter', action.topicFilter)
}
+1 -2
View File
@@ -4,7 +4,6 @@ import { connectionManagerReducer, ConnectionManagerState } from './ConnectionMa
import { connectionReducer, ConnectionState } from './Connection'
import { GlobalState, globalState } from './Global'
import { publishReducer, PublishState } from './Publish'
import { Record } from 'immutable'
import { settingsReducer, SettingsState } from './Settings'
import { sidebarReducer, SidebarState } from './Sidebar'
import { treeReducer, TreeState } from './Tree'
@@ -12,7 +11,7 @@ import { treeReducer, TreeState } from './Tree'
export interface AppState {
globalState: GlobalState
tree: TreeState
settings: Record<SettingsState>
settings: SettingsState
publish: PublishState
charts: ChartsState
sidebar: SidebarState
+20
View File
@@ -0,0 +1,20 @@
import * as q from '../../backend/src/Model'
import { SettingsState, TopicOrder } from './reducers/Settings'
import { TopicViewModel } from './model/TopicViewModel'
export function sortedNodes(settings: SettingsState, treeNode: q.TreeNode<any>): Array<q.TreeNode<TopicViewModel>> {
const topicOrder = settings.get('topicOrder')
const edges = [...treeNode.edgeArray]
if (topicOrder === TopicOrder.abc) {
edges.sort((a, b) => a.name.localeCompare(b.name))
}
const nodes = edges.map(edge => edge.target)
if (topicOrder === TopicOrder.messages) {
nodes.sort((a, b) => b.leafMessageCount() - a.leafMessageCount())
}
if (topicOrder === TopicOrder.topics) {
nodes.sort((a, b) => b.childTopicCount() - a.childTopicCount())
}
return nodes
}
+11
View File
@@ -0,0 +1,11 @@
export enum KeyCodes {
backspace = 8,
tab = 9,
enter = 13,
escape = 27,
arrow_left = 37,
arrow_up = 38,
arrow_right = 39,
arrow_down = 40,
delete = 46,
}
+5 -7
View File
@@ -10,14 +10,12 @@
"sourceMap": true,
"module": "esnext",
"target": "es2017",
"jsx": "react"
"jsx": "react",
"types": ["react"],
"allowSyntheticDefaultImports": true
},
"include": [
"./src/**/*"
],
"exclude": [
"**/*.d.ts"
],
"include": ["./src/**/*"],
"exclude": ["**/*.d.ts"],
"awesomeTypescriptLoaderOptions": {
"useCache": true,
"transpileModule": true,
+5
View File
@@ -4252,6 +4252,11 @@ parse-asn1@^5.0.0:
pbkdf2 "^3.0.3"
safe-buffer "^5.1.1"
parse-duration@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/parse-duration/-/parse-duration-0.1.1.tgz#13114ddc9891c1ecd280036244554de43647a226"
integrity sha1-ExFN3JiRwezSgANiRFVN5DZHoiY=
parse-json@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0"
+1 -1
View File
@@ -7,7 +7,7 @@ export interface DataSourceState {
}
export class DataSourceStateMachine {
public onUpdate = new EventDispatcher<DataSourceState, DataSourceStateMachine>()
public onUpdate = new EventDispatcher<DataSourceState>()
private state: DataSourceState = {
error: undefined,
connected: false,
+5 -1
View File
@@ -14,6 +14,8 @@ export interface MqttOptions {
clientId?: string
subscriptions: Array<string>
certificateAuthority?: string
clientCertificate?: string
clientKey?: string
}
export class MqttSource implements DataSource<MqttOptions> {
@@ -44,8 +46,10 @@ export class MqttSource implements DataSource<MqttOptions> {
username: options.username,
password: options.password,
clientId: options.clientId,
servername: options.tls ? url.host : undefined,
servername: options.tls ? url.hostname : undefined,
ca: options.certificateAuthority ? Buffer.from(options.certificateAuthority, 'base64') : undefined,
cert: options.clientCertificate ? Buffer.from(options.clientCertificate, 'base64') : undefined,
key: options.clientKey ? Buffer.from(options.clientKey, 'base64') : undefined,
} as any)
this.client = client
+5
View File
@@ -54,6 +54,11 @@ export class RingBuffer<T extends Lengthwise> {
this.usage -= freedSpace
}
public setCapacity(items: number, bytes: number) {
this.maxItems = items
this.capacity = bytes
}
public clone(): RingBuffer<T> {
return new RingBuffer(this.capacity, this.maxItems, this)
}
+1 -1
View File
@@ -12,7 +12,7 @@ export class Tree<ViewModel extends Destroyable> extends TreeNode<ViewModel> {
public isTree = true
private cachedHash = `${Math.random()}`
private unmergedMessages: ChangeBuffer = new ChangeBuffer()
public didReceive = new EventDispatcher<void, Tree<ViewModel>>()
public didReceive = new EventDispatcher<void>()
constructor() {
super(undefined, undefined)
+19 -8
View File
@@ -13,9 +13,10 @@ export class TreeNode<ViewModel extends Destroyable> {
public collapsed = false
public messages: number = 0
public lastUpdate: number = Date.now()
public onMerge = new EventDispatcher<void, TreeNode<ViewModel>>()
public onEdgesChange = new EventDispatcher<void, TreeNode<ViewModel>>()
public onMessage = new EventDispatcher<Message, TreeNode<ViewModel>>()
public onMerge = new EventDispatcher<void>()
public onEdgesChange = new EventDispatcher<void>()
public onMessage = new EventDispatcher<Message>()
public onDestroy = new EventDispatcher<TreeNode<ViewModel>>()
public isTree = false
private cachedPath?: string
@@ -65,7 +66,11 @@ export class TreeNode<ViewModel extends Destroyable> {
if (!previous || !this.sourceEdge) {
return
}
this.lastUpdate = Date.now()
previous.removeEdge(this.sourceEdge)
if (!this.isTree) {
this.destroy()
}
}
private findChild(edges: Array<string>): TreeNode<ViewModel> | undefined {
@@ -101,6 +106,9 @@ export class TreeNode<ViewModel extends Destroyable> {
}
public destroy() {
this.onDestroy.dispatch(this)
this.onDestroy.removeAllListeners()
for (const edge of this.edgeArray) {
edge.target.destroy()
}
@@ -158,6 +166,8 @@ export class TreeNode<ViewModel extends Destroyable> {
this.edgeArray.push(edge)
edge.source = this
edge.target && edge.target.removeFromTreeIfEmpty()
if (emitUpdate) {
this.onEdgesChange.dispatch()
}
@@ -175,8 +185,12 @@ export class TreeNode<ViewModel extends Destroyable> {
public removeEdge(edge: Edge<any>) {
delete this.edges[edge.name]
this.edgeArray = Object.values(this.edges)
this.onMerge.dispatch()
this.removeFromTreeIfEmpty()
this.onMerge.dispatch()
}
public removeFromTreeIfEmpty() {
if (this.isTopicEmptyLeaf()) {
this.removeFromParent()
}
@@ -189,10 +203,7 @@ export class TreeNode<ViewModel extends Destroyable> {
this.mqttMessage = node.mqttMessage
}
if (this.isTopicEmptyLeaf()) {
this.removeFromParent()
}
this.removeFromTreeIfEmpty()
this.mergeEdges(node)
this.onMerge.dispatch()
}
+2 -4
View File
@@ -1,8 +1,6 @@
import 'mocha'
import { Edge, TreeNode, TreeNodeFactory } from '../'
import { Edge, TreeNodeFactory } from '../'
import { expect } from 'chai'
import 'mocha'
describe('Edge', () => {
it('should contain a name', () => {
@@ -4,7 +4,7 @@ import 'mocha'
describe('EventDispatcher', async () => {
it('should dispatch', async function() {
const dispatcher = new EventDispatcher<string, string>()
const dispatcher = new EventDispatcher<string>()
this.timeout(300)
setTimeout(() => dispatcher.dispatch('hello'), 5)
@@ -18,7 +18,7 @@ describe('EventDispatcher', async () => {
})
it('should unsubscribe', async function() {
const dispatcher = new EventDispatcher<string, string>()
const dispatcher = new EventDispatcher<string>()
this.timeout(300)
let callbackCounter = 0
const callback = (msg: any) => {
-1
View File
@@ -45,7 +45,6 @@ export class ConnectionManager {
if (buffer.length > 20000) {
buffer = buffer.slice(0, 20000)
}
backendEvents.emit(messageEvent, {
topic,
payload: Base64Message.fromBuffer(buffer),
+1 -1
View File
@@ -5,7 +5,7 @@ interface CallbackStore {
callback: any
}
export class EventDispatcher<Message, Dispatcher> {
export class EventDispatcher<Message> {
private emitter = new EventEmitter()
private callbacks: Array<CallbackStore> = []
+9 -4
View File
@@ -1,6 +1,6 @@
{
"name": "MQTT-Explorer",
"version": "0.3.1-alpha-sni",
"version": "0.3.2",
"description": "Explore your message queues",
"main": "dist/src/electron.js",
"scripts": {
@@ -42,6 +42,11 @@
"category": "Development",
"maintainer": "Thomas Nordquist"
},
"snap": {
"environment": {
"DISABLE_WAYLAND": "1"
}
},
"appx": {
"applicationId": "mqttexplorer",
"identityName": "51031thomas.nordquist.MQTT-Explorer",
@@ -74,8 +79,8 @@
"builder-util-runtime": "^8.2.5",
"chai": "^4.2.0",
"cspell": "^4.0.23",
"electron": "5.0.4",
"electron-builder": "20.44.1",
"electron": "5.0.5",
"electron-builder": "21.0.6",
"fs-extra": "^8.0.1",
"mime": "^2.4.0",
"mocha": "^6.1.4",
@@ -85,7 +90,7 @@
"prettier": "1.18.2",
"redux-thunk": "^2.3.0",
"source-map-support": "^0.5.9",
"spectron": "^5.0.0",
"spectron": "^6.0.0",
"ts-node": "^8.2.0",
"tslint": "^5.15.0",
"tslint-config-airbnb": "^5.11.1",
+1 -1
View File
@@ -15,7 +15,7 @@ interface Context {
export default async function(context: Context) {
console.log(context)
const isLinux = context.targets.find(target => target.name === 'appImage' || target.name === 'snap')
const isLinux = context.targets.find(target => target.name === 'appImage')
if (!isLinux) {
return
}
+1 -1
View File
@@ -28,7 +28,7 @@ async function prepareRelease() {
// Clean up
await fs.remove('node_modules')
await exec('yarn', ['install', '--production'])
// await exec('yarn', ['install', '--production']) // Do not clean up, electron version detection will fail otherwise
await fs.remove(path.join('app', 'node_modules'))
chdir(originalDir)
+1
View File
@@ -8,6 +8,7 @@ function finish {
}
trap finish EXIT
set -e
DIMENSIONS="1024x720"
SCR=99
+1 -1
View File
@@ -10,7 +10,7 @@ const applicationMenu: MenuItemConstructorOptions = {
click: () => {
openAboutWindow({
icon_path: path.join(__dirname, '..', '..', 'icon.png'),
license: 'AGPL-3.0',
license: 'CC-BY-ND-4.0',
homepage: 'https://thomasnordquist.github.io/MQTT-Explorer/',
bug_report_url: 'https://github.com/thomasnordquist/MQTT-Explorer/issues',
description: 'Author: Thomas Nordquist',
+3
View File
@@ -9,6 +9,9 @@ import { menuTemplate } from './MenuTemplate'
import buildOptions from './buildOptions'
import { waitForDevServer, isDev, runningUiTestOnCi, loadDevTools } from './development'
import { shouldAutoUpdate, handleAutoUpdate } from './autoUpdater'
import { registerCrashReporter } from './registerCrashReporter'
registerCrashReporter()
if (!isDev() && !runningUiTestOnCi()) {
const electronTelemetry = electronTelemetryFactory('9b0c8ca04a361eb8160d98c5', buildOptions)
+10
View File
@@ -0,0 +1,10 @@
const { crashReporter } = require('electron')
export function registerCrashReporter() {
crashReporter.start({
productName: 'MQTT Explorer',
companyName: 'thomasnordquist',
submitURL: 'http://localhost:3000/app/crash/mqttexplorer',
uploadToServer: true,
})
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { Browser, Element } from 'webdriverio'
import { Browser } from 'webdriverio'
import { clickOn } from '../util'
export async function copyTopicToClipboard(browser: Browser) {
const copyButton = await browser.$('//p[contains(text(), "Topic")]/span')
const copyButton = await browser.$('//span[contains(text(), "Topic")]//button')
await clickOn(copyButton, browser, 1)
}
+1 -1
View File
@@ -2,6 +2,6 @@ import { Browser, Element } from 'webdriverio'
import { clickOn, expandTopic, sleep, writeText } from '../util'
export async function copyValueToClipboard(browser: Browser) {
const copyButton = await browser.$('//p[contains(text(), "Value")]/span')
const copyButton = await browser.$('//p[contains(text(), "Value")]//button')
await clickOn(copyButton, browser, 1)
}
+2 -2
View File
@@ -10,6 +10,7 @@ export async function showNumericPlot(browser: Browser) {
heater = await valuePreviewGuttersShowChartIcon('heater', browser)
await heater.click()
await sleep(1000)
let temperature = await valuePreviewGuttersShowChartIcon('temperature', browser)
await moveToCenterOfElement(temperature, browser)
temperature = await valuePreviewGuttersShowChartIcon('temperature', browser)
@@ -61,8 +62,7 @@ async function chartSettings(name: string, browser: Browser) {
async function clickAway(name: string, browser: Browser) {
const settings = await browser.$(`//*[contains(@data-test-type, "ChartPaper")][contains(@data-test, "${name}")]`)
await moveToCenterOfElement(settings, browser)
const awayClickElement = await browser.$('//div[contains(@role, "presentation")]')
return awayClickElement.click()
await browser.keys(['Escape'])
}
async function removeChart(name: string, browser: Browser) {
+135 -94
View File
@@ -233,7 +233,7 @@ ajv-keywords@^3.4.0:
resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.4.0.tgz#4b831e7b531415a7cc518cd404e73f6193c6349d"
integrity sha512-aUjdRFISbuFOl0EIZc+9e4FfZp0bDZgAdOOf30bJmw8VM9v84SHyVyxDfbWxpGYbdZD/9XoKxfHVNmxPkhwyGw==
ajv@^6.5.5, ajv@^6.9.2:
ajv@^6.10.0, ajv@^6.5.5:
version "6.10.0"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.0.tgz#90d0d54439da587cd7e843bfb7045f50bd22bdf1"
integrity sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg==
@@ -287,28 +287,28 @@ ansi-styles@^3.2.0, ansi-styles@^3.2.1:
dependencies:
color-convert "^1.9.0"
app-builder-bin@2.6.13:
version "2.6.13"
resolved "https://registry.yarnpkg.com/app-builder-bin/-/app-builder-bin-2.6.13.tgz#d3b7eb60b5333b0e5c4daf353260f82f7ba01597"
integrity sha512-P8s4wUuSPp4Vuob/RCmCpsAEAdf28HuP2brGjZ3VLxaAZrBnByuDq2c7Ud7xgEPehOMwEV+EXcEg7wnM7a2H9Q==
app-builder-bin@3.1.4:
version "3.1.4"
resolved "https://registry.yarnpkg.com/app-builder-bin/-/app-builder-bin-3.1.4.tgz#7f13ce5ba677286fe443758c73ff7dfdb0b0f6de"
integrity sha512-W72acDbPD99SvwttRDh2aD7Q02HYleGJewGXER2rmloajLbbLSM8tZ/K5+znwu1bCRPI/at0W35qmNjXjN8Kdg==
app-builder-lib@20.44.1, app-builder-lib@~20.44.0:
version "20.44.1"
resolved "https://registry.yarnpkg.com/app-builder-lib/-/app-builder-lib-20.44.1.tgz#02076f950dcd3a75409356948a0fbb6963ffcd88"
integrity sha512-otmaGKhw9LVPgudeyDe3jOkp1BGad/dWtf/HyjvJH4Ep9UQHw1J4mSNLMuDxNKd1W9BOZVzl8ulw/t5JBmsmHw==
app-builder-lib@21.0.6, app-builder-lib@~21.0.6:
version "21.0.6"
resolved "https://registry.yarnpkg.com/app-builder-lib/-/app-builder-lib-21.0.6.tgz#b1b2e4e437f58348bc8a2db71bdbcaaa15a0a3ca"
integrity sha512-ErALlC3XSFxaJYRPtUAo4raUaKaUigKLKSF16mUJRxYpRFDVtqZJRr+z1Hqgx+Xpzn764PZ/ETPFfZ26oAP9jA==
dependencies:
"7zip-bin" "~4.1.0"
app-builder-bin "2.6.13"
async-exit-hook "^2.0.1"
bluebird-lst "^1.0.9"
builder-util "10.0.4"
builder-util-runtime "8.2.5"
builder-util "10.3.1"
builder-util-runtime "8.3.0"
chromium-pickle-js "^0.2.0"
debug "^4.1.1"
ejs "^2.6.1"
ejs "^2.6.2"
electron-osx-sign "0.4.11"
electron-publish "20.44.0"
fs-extra-p "^8.0.2"
electron-publish "21.0.6"
fs-extra "^8.1.0"
fs-extra-p "^8.1.0"
hosted-git-info "^2.7.1"
is-ci "^2.0.0"
isbinaryfile "^4.0.1"
@@ -316,10 +316,9 @@ app-builder-lib@20.44.1, app-builder-lib@~20.44.0:
lazy-val "^1.0.4"
minimatch "^3.0.4"
normalize-package-data "^2.5.0"
plist "^3.0.1"
read-config-file "3.2.2"
read-config-file "4.0.0"
sanitize-filename "^1.6.1"
semver "^6.1.1"
semver "^6.2.0"
temp-file "^3.3.3"
append-transform@^1.0.0:
@@ -584,7 +583,15 @@ buffer@^5.1.0:
base64-js "^1.0.2"
ieee754 "^1.1.4"
builder-util-runtime@8.2.5, builder-util-runtime@^8.2.5:
builder-util-runtime@8.3.0:
version "8.3.0"
resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-8.3.0.tgz#f5fac9139af6facf42a21fbe4d3aebed88fda33e"
integrity sha512-CSOdsYqf4RXIHh1HANPbrZHlZ9JQJXSuDDloblZPcWQVN62inyYoTQuSmY3KrgefME2Sv3Kn2MxHvbGQHRf8Iw==
dependencies:
debug "^4.1.1"
sax "^1.2.4"
builder-util-runtime@^8.2.5:
version "8.2.5"
resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-8.2.5.tgz#6f19330178345f8ce2c65842b0a9cf6a187d5946"
integrity sha512-YILT+YUlxrE3yNB6mDC1tF+Q24mr1LSYdjP5U861jbBeDZfvy1/VPDzW3boMVrDtzYnDnvkYrzLJnoh6TXA75w==
@@ -604,19 +611,19 @@ builder-util-runtime@~8.1.0:
fs-extra-p "^7.0.0"
sax "^1.2.4"
builder-util@10.0.4, builder-util@~10.0.3:
version "10.0.4"
resolved "https://registry.yarnpkg.com/builder-util/-/builder-util-10.0.4.tgz#06541ffbe7a2f272972accc40124918f148f6cec"
integrity sha512-KEqMNPb6v4sKoHlrIR8KiJQwd9/fqXxpAKwLNz+g7dKYfS2r8B4SVJWRGutevPeOK7nfRrOAukUwPjByBswrtw==
builder-util@10.3.1, builder-util@~10.3.1:
version "10.3.1"
resolved "https://registry.yarnpkg.com/builder-util/-/builder-util-10.3.1.tgz#fe88ca7ac99042c36509ef6bf5c575430657930b"
integrity sha512-52EYK1qMYQXRq507MnijFjcjOicpSRrC49+aOdY5hT7wbhfGa6EJzQV0oCPOdzWN4Q/gjfa1HS9VanjR7ViUyg==
dependencies:
"7zip-bin" "~4.1.0"
"@types/debug" "^4.1.4"
app-builder-bin "2.6.13"
app-builder-bin "3.1.4"
bluebird-lst "^1.0.9"
builder-util-runtime "^8.2.5"
builder-util-runtime "8.3.0"
chalk "^2.4.2"
debug "^4.1.1"
fs-extra-p "^8.0.2"
fs-extra "^8.1.0"
is-ci "^2.0.0"
js-yaml "^3.13.1"
source-map-support "^0.5.12"
@@ -1307,16 +1314,16 @@ diff@^4.0.1:
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.1.tgz#0c667cb467ebbb5cea7f14f135cc2dba7780a8ff"
integrity sha512-s2+XdvhPCOF01LRQBC8hf4vhbVmI2CGS5aZnxLJlT5FtdhPCDFq80q++zK2KlrVorVDdL5BOGZ/VfLrVtYNF+Q==
dmg-builder@6.6.5:
version "6.6.5"
resolved "https://registry.yarnpkg.com/dmg-builder/-/dmg-builder-6.6.5.tgz#0907e573bc4587a1f323dc886d3dc2ab0e272293"
integrity sha512-W9OTcBwKG+EoaQ+W8pbyImogcSISPiRoHS5ZTABaTlok2BP5Kc6ZCeCxVa8gDJcLVaZozpAyTz+QaSrPD3Pmxw==
dmg-builder@6.9.2:
version "6.9.2"
resolved "https://registry.yarnpkg.com/dmg-builder/-/dmg-builder-6.9.2.tgz#ffe7fdbf3ba7232b02f5e96b5b3512e35a57041a"
integrity sha512-jBPvANs1EfxwbWudzqtIfHj0gL/WuGJgop2/yN2owoJv22GeLhaUcv9tYcyB58ke3+bkOylJZvXN/8tRkf+o9Q==
dependencies:
app-builder-lib "~20.44.0"
app-builder-lib "~21.0.6"
bluebird-lst "^1.0.9"
builder-util "~10.0.3"
fs-extra-p "^8.0.2"
iconv-lite "^0.4.24"
builder-util "~10.3.1"
fs-extra "^8.1.0"
iconv-lite "^0.5.0"
js-yaml "^3.13.1"
parse-color "^1.0.0"
sanitize-filename "^1.6.1"
@@ -1343,15 +1350,15 @@ dot-prop@^5.0.0:
dependencies:
is-obj "^1.0.0"
dotenv-expand@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-4.2.0.tgz#def1f1ca5d6059d24a766e587942c21106ce1275"
integrity sha1-3vHxyl1gWdJKdm5YeULCEQbOEnU=
dotenv-expand@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0"
integrity sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==
dotenv@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-6.2.0.tgz#941c0410535d942c8becf28d3f357dbd9d476064"
integrity sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w==
dotenv@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.0.0.tgz#ed310c165b4e8a97bb745b0a9d99c31bda566440"
integrity sha512-30xVGqjLjiUOArT4+M5q9sYdvuR4riM6yK9wMcas9Vbp6zZa+ocC9dp6QoftuhTPhFAiLK/0C5Ni2nou/Bk8lg==
duplexer3@^0.1.4:
version "0.1.4"
@@ -1381,39 +1388,39 @@ ecc-jsbn@~0.1.1:
jsbn "~0.1.0"
safer-buffer "^2.1.0"
ejs@^2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.6.1.tgz#498ec0d495655abc6f23cd61868d926464071aa0"
integrity sha512-0xy4A/twfrRCnkhfk8ErDi5DqdAsAqeGxht4xkCUrsvhhbQNs7E+4jV0CN7+NKIY0aHE72+XvqtBIXzD31ZbXQ==
ejs@^2.6.2:
version "2.6.2"
resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.6.2.tgz#3a32c63d1cd16d11266cd4703b14fec4e74ab4f6"
integrity sha512-PcW2a0tyTuPHz3tWyYqtK6r1fZ3gp+3Sop8Ph+ZYN81Ob5rwmbHEzaqs10N3BEsaGTkh/ooniXK+WwszGlc2+Q==
ejs@~2.5.6:
version "2.5.9"
resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.5.9.tgz#7ba254582a560d267437109a68354112475b0ce5"
integrity sha512-GJCAeDBKfREgkBtgrYSf9hQy9kTb3helv0zGdzqhM7iAkW8FA/ZF97VQDbwFiwIT8MQLLOe5VlPZOEvZAqtUAQ==
electron-builder@20.44.1:
version "20.44.1"
resolved "https://registry.yarnpkg.com/electron-builder/-/electron-builder-20.44.1.tgz#938e590762339b6ecc561a430b8633463ba740a2"
integrity sha512-xA/kj75B9jFm245q+KiYqumg0fpA1LHNTnEUcXDLShwPgJ8JEjlBmPwdjsydxWuoUvxG4Hj3kCnYhYMg4RDtzg==
electron-builder@21.0.6:
version "21.0.6"
resolved "https://registry.yarnpkg.com/electron-builder/-/electron-builder-21.0.6.tgz#a70b0784661cde749577a4ce2f815bfede101437"
integrity sha512-0I+OzvuEitJcLQdwU2da5asmBlPBo8SxDB7lyBUQeG/do18Kw3X24ILwcN00745ZQcekMWY+dun1uy7iX5ndcQ==
dependencies:
app-builder-lib "20.44.1"
app-builder-lib "21.0.6"
bluebird-lst "^1.0.9"
builder-util "10.0.4"
builder-util-runtime "8.2.5"
builder-util "10.3.1"
builder-util-runtime "8.3.0"
chalk "^2.4.2"
dmg-builder "6.6.5"
fs-extra-p "^8.0.2"
dmg-builder "6.9.2"
fs-extra-p "^8.1.0"
is-ci "^2.0.0"
lazy-val "^1.0.4"
read-config-file "3.2.2"
read-config-file "4.0.0"
sanitize-filename "^1.6.1"
update-notifier "^3.0.0"
update-notifier "^3.0.1"
yargs "^13.2.4"
electron-chromedriver@~3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/electron-chromedriver/-/electron-chromedriver-3.0.0.tgz#ec0a17badb6c3529813c660bf91a43fb91407674"
integrity sha512-xWivZRiPTtDFJt+qXv7Ax/Dmhxj0iqESOxoLZ2szu3fv6k1vYDUDJUMHfdfVAke9D2gBRIgChuGb5j3YEt6hxQ==
electron-chromedriver@~4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/electron-chromedriver/-/electron-chromedriver-4.0.0.tgz#064fa2f0d5a97998eeff3258575b90bac8d436da"
integrity sha512-CeeAr4QVtsxkEpDWvFLzhdtgDVLQt32RBWjEJ4WxkIPNj0MX5D8gzli2djEPnnLUOX19vcBgs/CW7WBB/O3JIg==
dependencies:
electron-download "^4.1.0"
extract-zip "^1.6.5"
@@ -1450,22 +1457,22 @@ electron-osx-sign@0.4.11:
minimist "^1.2.0"
plist "^3.0.1"
electron-publish@20.44.0:
version "20.44.0"
resolved "https://registry.yarnpkg.com/electron-publish/-/electron-publish-20.44.0.tgz#4f4c49fabfa085244ebbd9a8d58ec4110a90ec2f"
integrity sha512-lQmNscoql2Wbvon4om7ZiBMJ5NfvoD9W91oKWl1EKaN1PeXWNjgt6+dEso3Rf/0x78WCC/V7eZ1hrARtL8cYNQ==
electron-publish@21.0.6:
version "21.0.6"
resolved "https://registry.yarnpkg.com/electron-publish/-/electron-publish-21.0.6.tgz#ab9bc97536b7d5f03d710259288c8893e15ce182"
integrity sha512-VN5+PgrK9yQxggY22z0nyC9cT+522E5Jdw8bcEbvlSH9LhPrLo44Da3GPNBRjnb1UF0yfEZdbYig2Ut+g1WHJg==
dependencies:
bluebird-lst "^1.0.9"
builder-util "~10.0.3"
builder-util-runtime "^8.2.5"
builder-util "~10.3.1"
builder-util-runtime "8.3.0"
chalk "^2.4.2"
fs-extra-p "^8.0.2"
fs-extra-p "^8.1.0"
lazy-val "^1.0.4"
mime "^2.4.4"
"electron-telemetry@git+https://github.com/thomasnordquist/electron-telemetry.git#dist":
version "1.0.0"
resolved "git+https://github.com/thomasnordquist/electron-telemetry.git#78b140426a31fe6fec75c7fe36daa130445af191"
resolved "git+https://github.com/thomasnordquist/electron-telemetry.git#2d67a18cfe6f5caf2fd0fe35eb35272a4b24439f"
dependencies:
axios "^0.18.0"
pako "^1.0.8"
@@ -1486,10 +1493,10 @@ electron-updater@^4.0.6:
semver "^5.6.0"
source-map-support "^0.5.9"
electron@5.0.4:
version "5.0.4"
resolved "https://registry.yarnpkg.com/electron/-/electron-5.0.4.tgz#2e0d09055363e983f4a73317cde4821c39617b02"
integrity sha512-7QaKorvANvP+azMT7wElx33oLlqw8QxmLs7/outfH7LC5amErk4EUtWDesQ6Zgr+s5pYFbykl8ZtJ4ZGXER05g==
electron@5.0.5:
version "5.0.5"
resolved "https://registry.yarnpkg.com/electron/-/electron-5.0.5.tgz#2d04499064d3f6c89c4642e47cfe9ea151d794eb"
integrity sha512-GzVQhImBX3rSCFPyJ1u1KbxquoidAHzGeCH2FTs3lzAh1H8m4vd7xh6CNC111mT/I8pxFk5D8s3atJlJQLPAeg==
dependencies:
"@types/node" "^10.12.18"
electron-download "^4.1.0"
@@ -1801,7 +1808,7 @@ fs-constants@^1.0.0:
resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
fs-extra-p@^7.0.0, fs-extra-p@^7.0.1:
fs-extra-p@^7.0.0:
version "7.0.1"
resolved "https://registry.yarnpkg.com/fs-extra-p/-/fs-extra-p-7.0.1.tgz#4eec0b6dfa150fa90f6ddd773b4fb1d55cad54e3"
integrity sha512-yhd2OV0HnHt2oitlp+X9hl2ReX4X/7kQeL7/72qzPHTZj5eUPGzAKOvEglU02Fa1OeG2rSy/aKB4WGVaLiF8tw==
@@ -1817,6 +1824,14 @@ fs-extra-p@^8.0.2:
bluebird-lst "^1.0.9"
fs-extra "^8.0.1"
fs-extra-p@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/fs-extra-p/-/fs-extra-p-8.1.0.tgz#781b7105e96cf3c1d3c8a88a83215c8a31c52bae"
integrity sha512-sCLpU5kk5CvrWZvFM9dUlqPgHrE02AEt6XYzF7kDscr5COc7DHfhNfODTXt0bkVNmt5DkvU2uJSYjorxY3bRKA==
dependencies:
bluebird-lst "^1.0.9"
fs-extra "^8.1.0"
fs-extra@^4.0.1:
version "4.0.3"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.3.tgz#0d852122e5bc5beb453fb028e9c0c9bf36340c94"
@@ -1844,6 +1859,15 @@ fs-extra@^8.0.1:
jsonfile "^4.0.0"
universalify "^0.1.0"
fs-extra@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==
dependencies:
graceful-fs "^4.2.0"
jsonfile "^4.0.0"
universalify "^0.1.0"
fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
@@ -2008,6 +2032,11 @@ graceful-fs@^4.1.0, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00"
integrity sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==
graceful-fs@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.0.tgz#8d8fdc73977cb04104721cb53666c1ca64cd328b"
integrity sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg==
grapheme-splitter@^1.0.2, grapheme-splitter@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e"
@@ -2124,6 +2153,13 @@ iconv-lite@^0.4.17, iconv-lite@^0.4.24:
dependencies:
safer-buffer ">= 2.1.2 < 3"
iconv-lite@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.5.0.tgz#59cdde0a2a297cc2aeb0c6445a195ee89f127550"
integrity sha512-NnEhI9hIEKHOzJ4f697DMz9IQEXr/MMJ5w64vN2/4Ai+wRnvV7SBrL0KLoRlwaKVghOc7LQ5YkPLuX146b6Ydw==
dependencies:
safer-buffer ">= 2.1.2 < 3"
ieee754@^1.1.4:
version "1.1.13"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
@@ -2457,7 +2493,7 @@ js-xxhash@^1.0.1:
resolved "https://registry.yarnpkg.com/js-xxhash/-/js-xxhash-1.0.1.tgz#8ef97ff13576abac8539e91512a4033f0e9d69c5"
integrity sha512-ociwKjHkCPjqNMmZtD7uUoL+AVhcSZX3WPmirRwXg+PzKD0v5x9K2yLpvkSglbThvbGN/TVA+3XFjPVolQwDpQ==
js-yaml@3.13.1, js-yaml@^3.12.0, js-yaml@^3.12.1, js-yaml@^3.13.0, js-yaml@^3.13.1:
js-yaml@3.13.1, js-yaml@^3.12.0, js-yaml@^3.13.0, js-yaml@^3.13.1:
version "3.13.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847"
integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==
@@ -3467,18 +3503,18 @@ rc@^1.1.6, rc@^1.2.1, rc@^1.2.8:
minimist "^1.2.0"
strip-json-comments "~2.0.1"
read-config-file@3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/read-config-file/-/read-config-file-3.2.2.tgz#57bbff7dd97caf237d0d625bd541c6d0efb4d067"
integrity sha512-PuFpMgZF01VB0ydH1dfitAxCP/fh+qnfbA9cYNIPoxPbz0SMngsrafCtaHDWfER7MwlDz4fmrNBhPkakxxFpTg==
read-config-file@4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/read-config-file/-/read-config-file-4.0.0.tgz#d57d6edc3a4c8a0999860e57e85664570ef56cd5"
integrity sha512-ZEqYePqjCkOoZu+HFhpjSB0r1eJ7RNNqf8O5/oxPT85b7ZB910au4AGxxxuJ1b81atdmBpiyODonI6vnefVIAA==
dependencies:
ajv "^6.9.2"
ajv "^6.10.0"
ajv-keywords "^3.4.0"
bluebird-lst "^1.0.7"
dotenv "^6.2.0"
dotenv-expand "^4.2.0"
fs-extra-p "^7.0.1"
js-yaml "^3.12.1"
bluebird-lst "^1.0.9"
dotenv "^8.0.0"
dotenv-expand "^5.1.0"
fs-extra "^8.1.0"
js-yaml "^3.13.1"
json5 "^2.1.0"
lazy-val "^1.0.4"
@@ -3758,6 +3794,11 @@ semver@^6.1.1:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.1.1.tgz#53f53da9b30b2103cd4f15eab3a18ecbcb210c9b"
integrity sha512-rWYq2e5iYW+fFe/oPPtYJxYgjBm8sC4rmoGdUOgBB7VnwKt6HrL793l2voH1UlsyYZpJ4g0wfjnTEO1s1NP2eQ==
semver@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-6.2.0.tgz#4d813d9590aaf8a9192693d6c85b9344de5901db"
integrity sha512-jdFC1VdUGT/2Scgbimf7FSx9iJLXoqfglSF+gJeuNWVpiE37OIbc1jywR/GJyFdz3mnkz2/id0L0J/cr0izR5A==
serialize-error@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/serialize-error/-/serialize-error-3.0.0.tgz#80100282b09be33c611536f50033481cb9cc87cf"
@@ -3882,13 +3923,13 @@ spdx-license-ids@^3.0.0:
resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.4.tgz#75ecd1a88de8c184ef015eafb51b5b48bfd11bb1"
integrity sha512-7j8LYJLeY/Yb6ACbQ7F76qy5jHkp0U6jgBfJsk97bwWlVUnUWsAgpyaCvo17h0/RQGnQ036tVDomiwoI4pDkQA==
spectron@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/spectron/-/spectron-5.0.0.tgz#602440da0c892f8d73471652ae68000b98d7769c"
integrity sha512-wJrFe8EZ7xvarYawBPd1pDegmSz81U1jG0rSCx+yXqD1TISUH9ASB21KysLXkPylAnc2vhbpGiWQxrqVFtsiJg==
spectron@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/spectron/-/spectron-6.0.0.tgz#b6ead4735837f36b4be05afa29094460666e61d8"
integrity sha512-YQnQgdlpdPU572YhyX1hcDT40Q+5D0hkH4hM3jYy8HLHU1+6z4WTRQkjYdCBcYHE/3fHIQdPeaI+9DUzupQZtw==
dependencies:
dev-null "^0.1.1"
electron-chromedriver "~3.0.0"
electron-chromedriver "~4.0.0"
request "^2.87.0"
split "^1.0.0"
webdriverio "^4.13.0"
@@ -4431,10 +4472,10 @@ universalify@^0.1.0:
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
update-notifier@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-3.0.0.tgz#e9bbf8f0f5b7a2ce6666ca46334fdb29492e8fab"
integrity sha512-6Xe3oF2bvuoj4YECUc52yxVs94yWrxwqHbzyveDktTS1WhnlTRpNcQMxUshcB7nRVGi1jEXiqL5cW1S5WSyzKg==
update-notifier@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-3.0.1.tgz#78ecb68b915e2fd1be9f767f6e298ce87b736250"
integrity sha512-grrmrB6Zb8DUiyDIaeRTBCkgISYUgETNe7NglEbVsrLWXeESnlCSP50WfRSj/GmzMPl6Uchj24S/p80nP/ZQrQ==
dependencies:
boxen "^3.0.0"
chalk "^2.0.1"