Added support for binary data types

- Select data type (string, json, hex, uint, int, float) for each topic individually
- Default data type is 'string'
- Show milliseconds in message received timestamp
This commit is contained in:
Max Horsche
2021-01-11 10:11:34 +01:00
parent 9cdfa2de7b
commit 567f6d2d50
15 changed files with 303 additions and 91 deletions

View File

@@ -1,5 +1,5 @@
import * as q from '../../../backend/src/Model'
import { ActionTypes, SettingsStateModel, TopicOrder } from '../reducers/Settings'
import { ActionTypes, SettingsStateModel, TopicOrder, ValueRendererDisplayMode } from '../reducers/Settings'
import { AppState } from '../reducers'
import { autoExpandLimitSet } from '../components/SettingsDrawer/Settings'
import { Base64Message } from '../../../backend/src/Model/Base64Message'
@@ -66,7 +66,7 @@ export const selectTopicWithMouseOver = (doSelect: boolean) => (dispatch: Dispat
dispatch(storeSettings())
}
export const setValueDisplayMode = (valueRendererDisplayMode: 'diff' | 'raw') => (dispatch: Dispatch<any>) => {
export const setValueDisplayMode = (valueRendererDisplayMode: ValueRendererDisplayMode) => (dispatch: Dispatch<any>) => {
dispatch({
valueRendererDisplayMode,
type: ActionTypes.SETTINGS_SET_VALUE_RENDERER_DISPLAY_MODE,

View File

@@ -114,6 +114,7 @@ function TopicChart(props: Props) {
</div>
</div>
<TopicPlot
node={props.treeNode ? props.treeNode : undefined}
color={props.parameters.color}
interpolation={props.parameters.interpolation}
timeInterval={props.parameters.timeRange ? props.parameters.timeRange.until : undefined}

View File

@@ -52,16 +52,16 @@ function ChartPreview(props: Props) {
/>
</Tooltip>
) : (
<Tooltip title="Add to chart panel, not enough data for preview">
<ShowChart
onClick={onClick}
className={props.classes.icon}
style={{ color: '#aaa' }}
data-test-type="ShowChart"
data-test={props.literal.path}
/>
</Tooltip>
)
<Tooltip title="Add to chart panel, not enough data for preview">
<ShowChart
onClick={onClick}
className={props.classes.icon}
style={{ color: '#aaa' }}
data-test-type="ShowChart"
data-test={props.literal.path}
/>
</Tooltip>
)
return (
<span>
@@ -69,7 +69,7 @@ function ChartPreview(props: Props) {
<Popper open={open} anchorEl={chartIconRef.current} placement="left-end">
<Fade in={open} timeout={300}>
<Paper style={{ width: '300px' }}>
{open ? <TopicPlot history={props.treeNode.messageHistory} dotPath={props.literal.path} /> : <span />}
{open ? <TopicPlot node={props.treeNode} history={props.treeNode.messageHistory} dotPath={props.literal.path} /> : <span />}
</Paper>
</Fade>
</Popper>

View File

@@ -6,22 +6,30 @@ 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'
import { TopicTypeButton } from './TopicTypeButton'
import { sidebarActions } from '../../../actions'
const TopicPanel = (props: { node?: q.TreeNode<any>; actions: typeof sidebarActions }) => {
const { node } = props
console.log(node && node.path())
const copyTopic = node ? <Copy value={node.path()} /> : null
const deleteTopic = useCallback((topic?: q.TreeNode<any>, recursive: boolean = false) => {
if (!topic) {
return
}
props.actions.clearTopic(topic, recursive)
}, [])
const setTopicType = useCallback((node?: q.TreeNode<any>, type: q.TopicDataType = 'string') => {
if (!node) {
return
}
node.type = type
}, [])
return useMemo(
() => (
<Panel disabled={!Boolean(node)}>
@@ -29,6 +37,7 @@ const TopicPanel = (props: { node?: q.TreeNode<any>; actions: typeof sidebarActi
Topic {copyTopic}
<TopicDeleteButton node={node} deleteTopicAction={deleteTopic} />
<RecursiveTopicDeleteButton node={node} deleteTopicAction={deleteTopic} />
<TopicTypeButton node={node} setTopicType={setTopicType} />
</span>
<Topic node={node} />
</Panel>

View File

@@ -0,0 +1,83 @@
import React, { useCallback } from 'react'
import * as q from '../../../../../backend/src/Model'
import CustomIconButton from '../../helper/CustomIconButton'
import Code from '@material-ui/icons/Code'
import ClickAwayListener from '@material-ui/core/ClickAwayListener'
import Grow from '@material-ui/core/Grow'
import Paper from '@material-ui/core/Paper'
import Popper from '@material-ui/core/Popper'
import MenuItem from '@material-ui/core/MenuItem'
import MenuList from '@material-ui/core/MenuList'
const options: q.TopicDataType[] = ['string', 'json', 'hex', 'integer', 'unsigned int', 'floating point'];
export const TopicTypeButton = (props: {
node?: q.TreeNode<any>
setTopicType: (node: q.TreeNode<any>, type: q.TopicDataType) => void
}) => {
const { node } = props
if (!node || !node.message || !node.message.payload) {
return null
}
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const [open, setOpen] = React.useState(false)
const handleMenuItemClick = useCallback(
(mouseEvent: React.MouseEvent, element: q.TreeNode<any>, type: q.TopicDataType) => {
if (!element || !type) {
return
}
props.setTopicType(element, type as q.TopicDataType)
setOpen(false)
},
[props.setTopicType]
)
const handleToggle = (event: React.MouseEvent<HTMLElement>) => {
if (open === true) {
return
}
setAnchorEl(event.currentTarget)
setOpen((prevOpen) => !prevOpen)
}
const handleClose = (event: React.MouseEvent<Document, MouseEvent>) => {
if (anchorEl && anchorEl.contains(event.target as HTMLElement)) {
return
}
setOpen(false)
}
return (
<CustomIconButton tooltip="" onClick={handleToggle}>
<Code />
<Popper open={open} anchorEl={anchorEl} role={undefined} transition>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
style={{
transformOrigin: placement === 'bottom' ? 'center top' : 'center bottom',
}}
>
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MenuList id="topicTypeMode">
{options.map((option, index) => (
<MenuItem
key={option}
selected={node && option === node.type}
onClick={(event) => handleMenuItemClick(event, node, option)}
>
{option}
</MenuItem>
))}
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</CustomIconButton>
)
}

View File

@@ -82,7 +82,7 @@ class MessageHistory extends React.PureComponent<Props, State> {
const history = node.messageHistory.toArray()
let previousMessage: q.Message | undefined = node.message
const historyElements = [...history].reverse().map((message, idx) => {
const value = message.payload ? Base64Message.toUnicodeString(message.payload) : ''
const [value, ignore] = Base64Message.format(message.payload, node.type)
const element = {
value,
key: `${message.messageNumber}-${message.received}`,
@@ -102,7 +102,7 @@ class MessageHistory extends React.PureComponent<Props, State> {
<MessageId message={message} />
</span>
<div style={{ float: 'right' }}>
<Copy value={value} />
<Copy value={value ? value : ''} />
</div>
</span>
),
@@ -112,8 +112,8 @@ class MessageHistory extends React.PureComponent<Props, State> {
return element
})
const isMessagePlottable =
node.message && node.message.payload && isPlottable(Base64Message.toUnicodeString(node.message.payload))
const [value, ignore] = node.message && node.message.payload ? Base64Message.format(node.message.payload, node.type) : [null, undefined]
const isMessagePlottable = isPlottable(value)
return (
<div>
<History
@@ -131,7 +131,7 @@ class MessageHistory extends React.PureComponent<Props, State> {
}
onClick={this.displayMessage}
>
{isMessagePlottable ? <TopicPlot history={node.messageHistory} /> : null}
{isMessagePlottable ? <TopicPlot node={node} history={node.messageHistory} /> : null}
</History>
</div>
)

View File

@@ -85,10 +85,10 @@ function ValuePanel(props: Props) {
[compareMessage]
)
const copyValue =
node && node.message && node.message.payload ? (
<Copy value={Base64Message.toUnicodeString(node.message.payload)} />
) : null
const [value, ignore] = node && node.message && node.message.payload ? Base64Message.format(node.message.payload, node.type) : [null, undefined]
const copyValue = value ? (
<Copy value={value} />
) : null
return (
<Panel>

View File

@@ -4,9 +4,8 @@ import CodeDiff from '../CodeDiff'
import { AppState } from '../../../reducers'
import { Base64Message } from '../../../../../backend/src/Model/Base64Message'
import { connect } from 'react-redux'
import { default as ReactResizeDetector } from 'react-resize-detector'
import { ValueRendererDisplayMode } from '../../../reducers/Settings'
import { Typography, Fade, Grow } from '@material-ui/core'
import { Fade } from '@material-ui/core'
interface Props {
message: q.Message
@@ -38,44 +37,42 @@ class ValueRenderer extends React.Component<Props, State> {
)
}
private convertMessage(msg?: Base64Message): [string | undefined, 'json' | undefined] {
if (!msg) {
return [undefined, undefined]
}
const str = Base64Message.toUnicodeString(msg)
try {
JSON.parse(str)
} catch (error) {
return [str, undefined]
}
return [this.messageToPrettyJson(str), 'json']
}
private messageToPrettyJson(str: string): string | undefined {
try {
const json = JSON.parse(str)
return JSON.stringify(json, undefined, ' ')
} catch {
return undefined
}
}
private renderRawMode(message: q.Message, compare?: q.Message) {
private renderDiffMode(message: q.Message, treeNode: q.TreeNode<any>, compare?: q.Message) {
if (!message.payload) {
return
}
const [value, valueLanguage] = this.convertMessage(message.payload)
const [compareStr, compareStrLanguage] =
compare && compare.payload ? this.convertMessage(compare.payload) : [undefined, undefined]
const previousMessages = treeNode.messageHistory.toArray()
const previousMessage = previousMessages[previousMessages.length - 2]
const compareMessage = compare || previousMessage || message
const compareValue = compareMessage.payload || message.payload
const [currentStr, currentType] = Base64Message.format(message.payload, treeNode.type)
const [compareStr, compareType] = Base64Message.format(compareValue, treeNode.type)
const language = currentType === compareType && compareType === 'json' ? 'json' : undefined
return (
<div>
{this.renderDiff(value, value, undefined, valueLanguage)}
{this.renderDiff(currentStr, compareStr, undefined, language)}
</div>
)
}
private renderRawMode(message: q.Message, treeNode: q.TreeNode<any>, compare?: q.Message) {
if (!message.payload) {
return
}
const [currentStr, currentType] = Base64Message.format(message.payload, treeNode.type)
const [compareStr, compareType] = compare && compare.payload ? Base64Message.format(compare.payload, treeNode.type) : [undefined, undefined]
return (
<div>
{this.renderDiff(currentStr, currentStr, undefined, currentType)}
<Fade in={Boolean(compareStr)} timeout={400}>
<div>
{Boolean(compareStr) ? this.renderDiff(compareStr, compareStr, 'selected', compareStrLanguage) : null}
{Boolean(compareStr) ? this.renderDiff(compareStr, compareStr, 'selected', compareType) : null}
</div>
</Fade>
</div>
@@ -88,24 +85,16 @@ class ValueRenderer extends React.Component<Props, State> {
public renderValue() {
const { message, treeNode, compareWith, renderMode } = this.props
const previousMessages = treeNode.messageHistory.toArray()
const previousMessage = previousMessages[previousMessages.length - 2]
const compareMessage = compareWith || previousMessage || message
if (renderMode === 'raw') {
return this.renderRawMode(message, compareWith)
}
if (!message.payload) {
return null
}
const compareValue = compareMessage.payload || message.payload
const [current, currentLanguage] = this.convertMessage(message.payload)
const [compare, compareLanguage] = this.convertMessage(compareValue)
const language = currentLanguage === compareLanguage && compareLanguage === 'json' ? 'json' : undefined
return this.renderDiff(current, compare, undefined, language)
switch (renderMode) {
case 'diff':
return this.renderDiffMode(message, treeNode, compareWith)
default:
return this.renderRawMode(message, treeNode, compareWith)
}
}
}

View File

@@ -8,6 +8,7 @@ import { PlotCurveTypes } from '../reducers/Charts'
const parseDuration = require('parse-duration')
interface Props {
node?: q.TreeNode<any>
history: q.MessageHistory
dotPath?: string
timeInterval?: string
@@ -25,22 +26,23 @@ function filterUsingTimeRange(startTime: number | undefined, data: Array<q.Messa
return data
}
function nodeToHistory(startTime: number | undefined, history: q.MessageHistory) {
function nodeToHistory(startTime: number | undefined, history: q.MessageHistory, type: q.TopicDataType) {
return filterUsingTimeRange(startTime, history.toArray())
.map((message: q.Message) => {
const value = message.payload ? toPlottableValue(Base64Message.toUnicodeString(message.payload)) : NaN
const [value, ignore] = message.payload ? Base64Message.format(message.payload, type) : [NaN, undefined]
// const value = message.payload ? toPlottableValue(Base64Message.toUnicodeString(message.payload)) : NaN
return { x: message.received.getTime(), y: toPlottableValue(value) }
})
.filter(data => !isNaN(data.y as any)) as any
}
function nodeDotPathToHistory(startTime: number | undefined, history: q.MessageHistory, dotPath: string) {
function nodeDotPathToHistory(startTime: number | undefined, history: q.MessageHistory, dotPath: string, type: q.TopicDataType) {
return filterUsingTimeRange(startTime, history.toArray())
.map((message: q.Message) => {
let json = {}
try {
json = message.payload ? JSON.parse(Base64Message.toUnicodeString(message.payload)) : {}
} catch (ignore) {}
} catch (ignore) { }
const value = dotProp.get(json, dotPath)
@@ -54,8 +56,8 @@ function TopicPlot(props: Props) {
const data = React.useMemo(
() =>
props.dotPath
? nodeDotPathToHistory(startOffset, props.history, props.dotPath)
: nodeToHistory(startOffset, props.history),
? nodeDotPathToHistory(startOffset, props.history, props.dotPath, props.node ? props.node.type : 'string')
: nodeToHistory(startOffset, props.history, props.node ? props.node.type : 'string'),
[props.history.last(), startOffset, props.dotPath]
)

View File

@@ -31,19 +31,19 @@ class TreeNodeTitle extends React.PureComponent<TreeNodeProps, {}> {
return ''
}
const str = Base64Message.toUnicodeString(this.props.treeNode.message.payload)
return str.length > limit ? `${str.slice(0, limit)}` : str
const [value, ignore] = Base64Message.format(this.props.treeNode.message.payload, this.props.treeNode.type)
return value.length > limit ? `${value.slice(0, limit)}` : value
}
private renderValue() {
return this.props.treeNode.message &&
this.props.treeNode.message.payload &&
this.props.treeNode.message.length > 0 ? (
<span key="value" className={this.props.classes.value}>
{' '}
<span key="value" className={this.props.classes.value}>
{' '}
= {this.truncatedMessage()}
</span>
) : null
</span>
) : null
}
private renderExpander() {
@@ -66,9 +66,8 @@ class TreeNodeTitle extends React.PureComponent<TreeNodeProps, {}> {
const messages = this.props.treeNode.leafMessageCount()
const topicCount = this.props.treeNode.childTopicCount()
return (
<span key="metadata" className={this.props.classes.collapsedSubnodes}>{` (${topicCount} ${
topicCount === 1 ? 'topic' : 'topics'
}, ${messages} ${messages === 1 ? 'message' : 'messages'})`}</span>
<span key="metadata" className={this.props.classes.collapsedSubnodes}>{` (${topicCount} ${topicCount === 1 ? 'topic' : 'topics'
}, ${messages} ${messages === 1 ? 'message' : 'messages'})`}</span>
)
}

View File

@@ -12,6 +12,7 @@ interface Props {
}
const unitMapping = {
ms: 'milliseconds',
s: 'seconds',
m: 'minutes',
h: 'hours',
@@ -21,7 +22,7 @@ class DateFormatter extends React.PureComponent<Props, {}> {
private intervalSince(intervalSince: Date) {
const interval = intervalSince.getTime() - this.props.date.getTime()
const unit = this.unitForInterval(interval)
return `${Math.round(moment.duration(interval).as(unit) * 100) / 100} ${unitMapping[unit]}`
return `${moment.duration(interval).as(unit).toFixed(3)} ${unitMapping[unit]}`
}
private legacyDate() {
@@ -31,10 +32,11 @@ class DateFormatter extends React.PureComponent<Props, {}> {
private localizedDate(locale: string) {
return moment(this.props.date)
.locale(locale)
.format(this.props.timeFirst ? 'LTS L' : 'L LTS')
.format(this.props.timeFirst ? 'LTS.SSS L' : 'L LTS.SSS')
}
private unitForInterval(milliseconds: number) {
const oneSecond = 1000 * 1
const oneMinute = 1000 * 60
const oneHour = oneMinute * 60
@@ -46,7 +48,11 @@ class DateFormatter extends React.PureComponent<Props, {}> {
return 'm'
}
return 's'
if (milliseconds > oneSecond * 0.5) {
return 's'
}
return 'ms'
}
public render() {

View File

@@ -1,3 +1,5 @@
import { TopicDataType } from "./TreeNode"
const { Base64 } = require('js-base64')
export class Base64Message {
@@ -24,6 +26,124 @@ export class Base64Message {
return new Base64Message(Base64.encode(str))
}
/* Raw message conversions (hex, uint, int, float) */
public static format(message: Base64Message | null, type: TopicDataType = 'string'): [string, 'json' | undefined] {
if (!message) {
return ['', undefined]
}
try {
switch (type) {
case 'json':
{
const json = JSON.parse(Base64Message.toUnicodeString(message))
return [JSON.stringify(json, undefined, ' '), 'json']
}
case 'hex':
{
const hex = Base64Message.toHex(message)
return [hex, undefined]
}
case 'integer':
{
const int = Base64Message.toInt(message)
return [int ? int : '', undefined]
}
case 'unsigned int':
{
const uint = Base64Message.toUInt(message)
return [uint ? uint : '', undefined]
}
case 'floating point':
{
const float = Base64Message.toFloat(message)
return [float ? float : '', undefined]
}
default:
{
const str = Base64Message.toUnicodeString(message)
return [str, undefined]
}
}
} catch (error) {
const str = Base64Message.toUnicodeString(message)
return [str, undefined]
}
}
public static toHex(message: Base64Message) {
const buf = Buffer.from(message.base64Message, 'base64')
let str: string = '';
buf.forEach(element => {
str += `0x${element.toString(16)} `
})
return str.trimRight()
}
public static toUInt(message: Base64Message) {
const buf = Buffer.from(message.base64Message, 'base64')
let num: Number = 0;
switch (buf.length) {
case 1:
num = buf.readUInt8(0)
break
case 2:
num = buf.readUInt16LE(0)
break
case 4:
num = buf.readUInt32LE(0)
break
case 8:
num = Number(buf.readBigUInt64LE(0))
break
default:
return undefined
}
return num.toString()
}
public static toInt(message: Base64Message) {
const buf = Buffer.from(message.base64Message, 'base64')
let num: Number = 0;
switch (buf.length) {
case 1:
num = buf.readInt8(0)
break
case 2:
num = buf.readInt16LE(0)
break
case 4:
num = buf.readInt32LE(0)
break
case 8:
num = Number(buf.readBigInt64LE(0))
break
default:
return undefined
}
return num.toString()
}
public static toFloat(message: Base64Message) {
const buf = Buffer.from(message.base64Message, 'base64')
let num: Number = 0;
switch (buf.length) {
case 4:
num = buf.readFloatLE(0)
break
case 8:
num = buf.readDoubleLE(0)
break
default:
return undefined
}
return num.toString()
}
public static toDataUri(message: Base64Message, mimeType: string) {
return `data:${mimeType};base64,${message.base64Message}`
}

View File

@@ -2,6 +2,8 @@ import { Destroyable } from './Destroyable'
import { Edge, Message, RingBuffer, MessageHistory } from './'
import { EventDispatcher } from '../../../events'
export type TopicDataType = 'string' | 'json' | 'hex' | 'integer' | 'unsigned int' | 'floating point'
export class TreeNode<ViewModel extends Destroyable> {
public sourceEdge?: Edge<ViewModel>
public message?: Message
@@ -17,6 +19,7 @@ export class TreeNode<ViewModel extends Destroyable> {
public onMessage = new EventDispatcher<Message>()
public onDestroy = new EventDispatcher<TreeNode<ViewModel>>()
public isTree = false
public type: TopicDataType = 'string'
private cachedPath?: string
private cachedChildTopics?: Array<TreeNode<ViewModel>>

View File

@@ -1,5 +1,5 @@
export { Edge } from './Edge'
export { TreeNode } from './TreeNode'
export { TreeNode, TopicDataType } from './TreeNode'
export { Message } from './Message'
export { TreeNodeFactory } from './TreeNodeFactory'
export { Tree } from './Tree'

View File

@@ -1,6 +1,6 @@
{
"name": "MQTT-Explorer",
"version": "0.4.0-beta1",
"version": "0.4.0-beta4",
"description": "Explore your message queues",
"main": "dist/src/electron.js",
"scripts": {