diff --git a/app/src/actions/Settings.ts b/app/src/actions/Settings.ts index 0202aff..f275297 100644 --- a/app/src/actions/Settings.ts +++ b/app/src/actions/Settings.ts @@ -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) => { +export const setValueDisplayMode = (valueRendererDisplayMode: ValueRendererDisplayMode) => (dispatch: Dispatch) => { dispatch({ valueRendererDisplayMode, type: ActionTypes.SETTINGS_SET_VALUE_RENDERER_DISPLAY_MODE, diff --git a/app/src/components/ChartPanel/TopicChart.tsx b/app/src/components/ChartPanel/TopicChart.tsx index 3500860..33a943a 100644 --- a/app/src/components/ChartPanel/TopicChart.tsx +++ b/app/src/components/ChartPanel/TopicChart.tsx @@ -114,6 +114,7 @@ function TopicChart(props: Props) { ) : ( - - - - ) + + + + ) return ( @@ -69,7 +69,7 @@ function ChartPreview(props: Props) { - {open ? : } + {open ? : } diff --git a/app/src/components/Sidebar/TopicPanel/TopicPanel.tsx b/app/src/components/Sidebar/TopicPanel/TopicPanel.tsx index 24ad257..129b24e 100644 --- a/app/src/components/Sidebar/TopicPanel/TopicPanel.tsx +++ b/app/src/components/Sidebar/TopicPanel/TopicPanel.tsx @@ -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; actions: typeof sidebarActions }) => { const { node } = props console.log(node && node.path()) + const copyTopic = node ? : null const deleteTopic = useCallback((topic?: q.TreeNode, recursive: boolean = false) => { if (!topic) { return } - props.actions.clearTopic(topic, recursive) }, []) + const setTopicType = useCallback((node?: q.TreeNode, type: q.TopicDataType = 'string') => { + if (!node) { + return + } + node.type = type + }, []) + return useMemo( () => ( @@ -29,6 +37,7 @@ const TopicPanel = (props: { node?: q.TreeNode; actions: typeof sidebarActi Topic {copyTopic} + diff --git a/app/src/components/Sidebar/TopicPanel/TopicTypeButton.tsx b/app/src/components/Sidebar/TopicPanel/TopicTypeButton.tsx new file mode 100644 index 0000000..d5e8095 --- /dev/null +++ b/app/src/components/Sidebar/TopicPanel/TopicTypeButton.tsx @@ -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 + setTopicType: (node: q.TreeNode, type: q.TopicDataType) => void +}) => { + const { node } = props + if (!node || !node.message || !node.message.payload) { + return null + } + + const [anchorEl, setAnchorEl] = React.useState(null); + const [open, setOpen] = React.useState(false) + + const handleMenuItemClick = useCallback( + (mouseEvent: React.MouseEvent, element: q.TreeNode, type: q.TopicDataType) => { + if (!element || !type) { + return + } + props.setTopicType(element, type as q.TopicDataType) + setOpen(false) + }, + [props.setTopicType] + ) + + const handleToggle = (event: React.MouseEvent) => { + if (open === true) { + return + } + setAnchorEl(event.currentTarget) + setOpen((prevOpen) => !prevOpen) + } + + const handleClose = (event: React.MouseEvent) => { + if (anchorEl && anchorEl.contains(event.target as HTMLElement)) { + return + } + setOpen(false) + } + + return ( + + + + {({ TransitionProps, placement }) => ( + + + + + {options.map((option, index) => ( + handleMenuItemClick(event, node, option)} + > + {option} + + ))} + + + + + )} + + + ) +} \ No newline at end of file diff --git a/app/src/components/Sidebar/ValueRenderer/MessageHistory.tsx b/app/src/components/Sidebar/ValueRenderer/MessageHistory.tsx index e3d9dbe..c9590e4 100644 --- a/app/src/components/Sidebar/ValueRenderer/MessageHistory.tsx +++ b/app/src/components/Sidebar/ValueRenderer/MessageHistory.tsx @@ -82,7 +82,7 @@ class MessageHistory extends React.PureComponent { 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 {
- +
), @@ -112,8 +112,8 @@ class MessageHistory extends React.PureComponent { 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 (
{ } onClick={this.displayMessage} > - {isMessagePlottable ? : null} + {isMessagePlottable ? : null}
) diff --git a/app/src/components/Sidebar/ValueRenderer/ValuePanel.tsx b/app/src/components/Sidebar/ValueRenderer/ValuePanel.tsx index 459f351..8642a4b 100644 --- a/app/src/components/Sidebar/ValueRenderer/ValuePanel.tsx +++ b/app/src/components/Sidebar/ValueRenderer/ValuePanel.tsx @@ -85,10 +85,10 @@ function ValuePanel(props: Props) { [compareMessage] ) - const copyValue = - node && node.message && 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 ? ( + + ) : null return ( diff --git a/app/src/components/Sidebar/ValueRenderer/ValueRenderer.tsx b/app/src/components/Sidebar/ValueRenderer/ValueRenderer.tsx index 3a4f9ff..9d561c3 100644 --- a/app/src/components/Sidebar/ValueRenderer/ValueRenderer.tsx +++ b/app/src/components/Sidebar/ValueRenderer/ValueRenderer.tsx @@ -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 { ) } - 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, 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 (
- {this.renderDiff(value, value, undefined, valueLanguage)} + {this.renderDiff(currentStr, compareStr, undefined, language)} +
+ ) + } + + private renderRawMode(message: q.Message, treeNode: q.TreeNode, 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 ( +
+ {this.renderDiff(currentStr, currentStr, undefined, currentType)}
- {Boolean(compareStr) ? this.renderDiff(compareStr, compareStr, 'selected', compareStrLanguage) : null} + {Boolean(compareStr) ? this.renderDiff(compareStr, compareStr, 'selected', compareType) : null}
@@ -88,24 +85,16 @@ class ValueRenderer extends React.Component { 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) + } } } diff --git a/app/src/components/TopicPlot.tsx b/app/src/components/TopicPlot.tsx index 56c69d1..81b64fe 100644 --- a/app/src/components/TopicPlot.tsx +++ b/app/src/components/TopicPlot.tsx @@ -8,6 +8,7 @@ import { PlotCurveTypes } from '../reducers/Charts' const parseDuration = require('parse-duration') interface Props { + node?: q.TreeNode history: q.MessageHistory dotPath?: string timeInterval?: string @@ -25,22 +26,23 @@ function filterUsingTimeRange(startTime: number | undefined, data: Array { - 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] ) diff --git a/app/src/components/Tree/TreeNode/TreeNodeTitle.tsx b/app/src/components/Tree/TreeNode/TreeNodeTitle.tsx index 07d60a5..0663f9d 100644 --- a/app/src/components/Tree/TreeNode/TreeNodeTitle.tsx +++ b/app/src/components/Tree/TreeNode/TreeNodeTitle.tsx @@ -31,19 +31,19 @@ class TreeNodeTitle extends React.PureComponent { 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 ? ( - - {' '} + + {' '} = {this.truncatedMessage()} - - ) : null + + ) : null } private renderExpander() { @@ -66,9 +66,8 @@ class TreeNodeTitle extends React.PureComponent { const messages = this.props.treeNode.leafMessageCount() const topicCount = this.props.treeNode.childTopicCount() return ( - {` (${topicCount} ${ - topicCount === 1 ? 'topic' : 'topics' - }, ${messages} ${messages === 1 ? 'message' : 'messages'})`} + {` (${topicCount} ${topicCount === 1 ? 'topic' : 'topics' + }, ${messages} ${messages === 1 ? 'message' : 'messages'})`} ) } diff --git a/app/src/components/helper/DateFormatter.tsx b/app/src/components/helper/DateFormatter.tsx index 9741f5e..4e7fd6b 100644 --- a/app/src/components/helper/DateFormatter.tsx +++ b/app/src/components/helper/DateFormatter.tsx @@ -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 { 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 { 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 { return 'm' } - return 's' + if (milliseconds > oneSecond * 0.5) { + return 's' + } + + return 'ms' } public render() { diff --git a/backend/src/Model/Base64Message.ts b/backend/src/Model/Base64Message.ts index b3763fe..82ae52c 100644 --- a/backend/src/Model/Base64Message.ts +++ b/backend/src/Model/Base64Message.ts @@ -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}` } diff --git a/backend/src/Model/TreeNode.ts b/backend/src/Model/TreeNode.ts index 1576b92..d9ca465 100644 --- a/backend/src/Model/TreeNode.ts +++ b/backend/src/Model/TreeNode.ts @@ -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 { public sourceEdge?: Edge public message?: Message @@ -17,6 +19,7 @@ export class TreeNode { public onMessage = new EventDispatcher() public onDestroy = new EventDispatcher>() public isTree = false + public type: TopicDataType = 'string' private cachedPath?: string private cachedChildTopics?: Array> diff --git a/backend/src/Model/index.ts b/backend/src/Model/index.ts index 047e633..b0ac673 100644 --- a/backend/src/Model/index.ts +++ b/backend/src/Model/index.ts @@ -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' diff --git a/package.json b/package.json index 7153f80..f6da39f 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -119,4 +119,4 @@ "yarn-run-all": "^3.1.1" }, "optionalDependencies": {} -} +} \ No newline at end of file