diff --git a/app/src/actions/Settings.ts b/app/src/actions/Settings.ts index 257e5e5..d222afc 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' @@ -68,7 +68,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..3fcb439 --- /dev/null +++ b/app/src/components/Sidebar/TopicPanel/TopicTypeButton.tsx @@ -0,0 +1,84 @@ +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[] = ['json', 'string', 'hex', 'integer', 'unsigned int', 'floating point'] +const options: q.TopicDataType[] = ['json', 'string', 'hex', 'uint8', 'uint16', 'uint32', 'uint64', 'int8', 'int16', 'int32', 'int64', 'float', 'double'] + +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 61f2df9..de3bb7b 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 8b4449f..4e5d784 100644 --- a/app/src/components/Sidebar/ValueRenderer/ValueRenderer.tsx +++ b/app/src/components/Sidebar/ValueRenderer/ValueRenderer.tsx @@ -38,45 +38,38 @@ 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(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(value, value, undefined, valueLanguage)} + {this.renderDiff(currentStr, currentStr, undefined, currentType)} -
- {Boolean(compareStr) ? this.renderDiff(compareStr, compareStr, 'selected', compareStrLanguage) : null} -
+
{Boolean(compareStr) ? this.renderDiff(compareStr, compareStr, 'selected', compareType) : null}
) @@ -93,24 +86,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 ac635be..0a3ce79 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: any = {} 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 f44a27d..db3a8f2 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 4bd005b..dd10d66 100644 --- a/backend/src/Model/Base64Message.ts +++ b/backend/src/Model/Base64Message.ts @@ -1,5 +1,6 @@ import { Base64 } from 'js-base64' import { Decoder } from './Decoder' +import { TopicDataType } from './TreeNode' export class Base64Message { private base64Message: string @@ -26,6 +27,167 @@ export class Base64Message { return new Base64Message(Base64.encode(str)) } + /* Raw message conversions ('uint8' | 'uint16' | 'uint32' | 'uint64' | 'int8' | 'int16' | 'int32' | 'int64' | 'float' | 'double') */ + 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 'uint8': { + const uint = Base64Message.toUInt(message, 1) + return [uint ? uint : '', undefined] + } + case 'uint16': { + const uint = Base64Message.toUInt(message, 2) + return [uint ? uint : '', undefined] + } + case 'uint32': { + const uint = Base64Message.toUInt(message, 4) + return [uint ? uint : '', undefined] + } + case 'uint64': { + const uint = Base64Message.toUInt(message, 8) + return [uint ? uint : '', undefined] + } + case 'int8': { + const int = Base64Message.toInt(message, 1) + return [int ? int : '', undefined] + } + case 'int16': { + const int = Base64Message.toInt(message, 2) + return [int ? int : '', undefined] + } + case 'int32': { + const int = Base64Message.toInt(message, 4) + return [int ? int : '', undefined] + } + case 'int64': { + const int = Base64Message.toInt(message, 8) + return [int ? int : '', undefined] + } + case 'float': { + const float = Base64Message.toFloat(message, 4) + return [float ? float : '', undefined] + } + case 'double': { + const float = Base64Message.toFloat(message, 8) + 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 => { + let hex = element.toString(16).toUpperCase() + str += `0x${hex.length < 2 ? '0' + hex : hex} ` + }) + return str.trimRight() + } + + public static toUInt(message: Base64Message, bytes: number) { + const buf = Buffer.from(message.base64Message, 'base64') + + let str: String[] = [] + switch (bytes) { + case 1: + for (let index = 0; index < buf.length; index += bytes) { + str.push(buf.readUInt8(index).toString()) + } + break + case 2: + for (let index = 0; index < buf.length; index += bytes) { + str.push(buf.readUInt16LE(index).toString()) + } + break + case 4: + for (let index = 0; index < buf.length; index += bytes) { + str.push(buf.readUInt32LE(index).toString()) + } + break + case 8: + for (let index = 0; index < buf.length; index += bytes) { + str.push(buf.readBigUInt64LE(index).toString()) + } + break + default: + return undefined + } + return str.join(', ') + } + + public static toInt(message: Base64Message, bytes: number) { + const buf = Buffer.from(message.base64Message, 'base64') + + let str: String[] = [] + switch (bytes) { + case 1: + for (let index = 0; index < buf.length; index += bytes) { + str.push(buf.readInt8(index).toString()) + } + break + case 2: + for (let index = 0; index < buf.length; index += bytes) { + str.push(buf.readInt16LE(index).toString()) + } + break + case 4: + for (let index = 0; index < buf.length; index += bytes) { + str.push(buf.readInt32LE(index).toString()) + } + break + case 8: + for (let index = 0; index < buf.length; index += bytes) { + str.push(buf.readBigInt64LE(index).toString()) + } + break + default: + return undefined + } + return str.join(', ') + } + + public static toFloat(message: Base64Message, bytes: number) { + const buf = Buffer.from(message.base64Message, 'base64') + + let str: String[] = [] + switch (bytes) { + case 4: + for (let index = 0; index < buf.length; index += bytes) { + str.push(buf.readFloatLE(index).toString()) + } + break + case 8: + for (let index = 0; index < buf.length; index += bytes) { + str.push(buf.readDoubleLE(index).toString()) + } + break + default: + return undefined + } + return str.join(', ') + } + 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..7adcd98 100644 --- a/backend/src/Model/TreeNode.ts +++ b/backend/src/Model/TreeNode.ts @@ -2,6 +2,9 @@ import { Destroyable } from './Destroyable' import { Edge, Message, RingBuffer, MessageHistory } from './' import { EventDispatcher } from '../../../events' +// export type TopicDataType = 'json' | 'string' | 'hex' | 'integer' | 'unsigned int' | 'floating point' +export type TopicDataType = 'json' | 'string' | 'hex' | 'uint8' | 'uint16' | 'uint32' | 'uint64' | 'int8' | 'int16' | 'int32' | 'int64' | 'float' | 'double' + export class TreeNode { public sourceEdge?: Edge public message?: Message @@ -17,6 +20,7 @@ export class TreeNode { public onMessage = new EventDispatcher() public onDestroy = new EventDispatcher>() public isTree = false + public type: TopicDataType = 'json' 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'