mirror of
https://github.com/thomasnordquist/MQTT-Explorer.git
synced 2026-09-11 09:03:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf78a4dea6 | ||
|
|
22762f9d79 | ||
|
|
b21692d5d2 | ||
|
|
0b5b0a6391 | ||
|
|
2e7acb6461 |
+7
-4
@@ -47,6 +47,7 @@
|
||||
"react-split-pane": "^0.1.85",
|
||||
"react-transition-group": "^4",
|
||||
"react-vis": "^1.11.6",
|
||||
"react-window": "^1.8.10",
|
||||
"redux": "^4.0.1",
|
||||
"redux-batched-actions": "0.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
@@ -65,6 +66,8 @@
|
||||
"@types/react-dom": "^16.0.11",
|
||||
"@types/react-redux": "^7.0.9",
|
||||
"@types/react-resize-detector": "^4.0.1",
|
||||
"@types/react-virtualized": "^9.21.30",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/sha1": "^1.1.1",
|
||||
"@types/socket.io-client": "^1.4.32",
|
||||
"@types/uuid": "^7.0.2",
|
||||
@@ -82,12 +85,12 @@
|
||||
"style-loader": "^1",
|
||||
"ts-loader": "^9.2.6",
|
||||
"typescript": "^4.5.5",
|
||||
"webpack": "^5.69.1",
|
||||
"webpack": "^5.91.0",
|
||||
"webpack-bundle-analyzer": "^4.5.0",
|
||||
"webpack-cli": "^4.9.2",
|
||||
"webpack-dev-server": "^4.7.4"
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^5.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"electron": "^29"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
import React from 'react'
|
||||
import React, { useCallback } 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'
|
||||
import { connectionActions, connectionManagerActions } from '../../../actions'
|
||||
|
||||
export interface Props {
|
||||
connection: ConnectionOptions
|
||||
actions: any
|
||||
actions: {
|
||||
connection: any
|
||||
connectionManager: any
|
||||
}
|
||||
selected: boolean
|
||||
classes: any
|
||||
}
|
||||
|
||||
const ConnectionItem = (props: Props) => {
|
||||
const connect = useCallback(() => {
|
||||
const mqttOptions = toMqttConnection(props.connection)
|
||||
if (mqttOptions) {
|
||||
props.actions.connection.connect(mqttOptions, props.connection.id)
|
||||
}
|
||||
}, [props.connection, 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)}
|
||||
onClick={() => props.actions.connectionManager.selectConnection(props.connection.id)}
|
||||
onDoubleClick={() => {
|
||||
props.actions.connectionManager.selectConnection(props.connection.id)
|
||||
connect()
|
||||
}}
|
||||
>
|
||||
<Typography className={props.classes.name}>{props.connection.name || 'mqtt broker'}</Typography>
|
||||
<Typography className={props.classes.details}>{connection && connection.url}</Typography>
|
||||
@@ -30,10 +44,12 @@ const ConnectionItem = (props: Props) => {
|
||||
|
||||
export const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(connectionManagerActions, dispatch),
|
||||
actions: {
|
||||
connection: bindActionCreators(connectionActions, dispatch),
|
||||
connectionManager: bindActionCreators(connectionManagerActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const connectionItemStyle = (theme: Theme) => ({
|
||||
name: {
|
||||
width: '100%',
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import React, { MutableRefObject, RefObject, useCallback, useMemo, useRef } from 'react'
|
||||
import { FixedSizeList as List, ListOnItemsRenderedProps, ListOnScrollProps } from 'react-window'
|
||||
import { AutoSizer } from 'react-virtualized'
|
||||
import TreeNode from './TreeNode'
|
||||
import { TopicViewModel } from '../../model/TopicViewModel'
|
||||
|
||||
class TreeList {
|
||||
tree: q.TreeNode<TopicViewModel>
|
||||
|
||||
constructor(tree: q.TreeNode<TopicViewModel>) {
|
||||
this.tree = tree
|
||||
}
|
||||
|
||||
getVisibleChildAt(index: number): [q.TreeNode<TopicViewModel>, number] | undefined {
|
||||
return this.tree.viewModel?.visibleChildAt(index, 1)
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.tree.viewModel?.visibleChildren() ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
const InfinitreeComponent: React.FC<{
|
||||
tree: q.TreeNode<TopicViewModel>
|
||||
actions: any
|
||||
selectTopicAction: any
|
||||
settings: any
|
||||
listRef: RefObject<List>
|
||||
lastUpdate: number
|
||||
name: string
|
||||
fixedOnTreeNodeRef: MutableRefObject<q.TreeNode<TopicViewModel> | null>
|
||||
}> = ({ tree, actions, settings, listRef, fixedOnTreeNodeRef, name }) => {
|
||||
const list = useMemo(() => new TreeList(tree), [tree])
|
||||
const lastIndex = useRef<number | undefined>(0)
|
||||
const lastScroll = useRef<number>(Date.now())
|
||||
const getKey = useCallback(
|
||||
(index: number) => {
|
||||
let [treeNode] = list.getVisibleChildAt(index) ?? []
|
||||
|
||||
return treeNode?.hash() ?? index.toString()
|
||||
},
|
||||
[list]
|
||||
)
|
||||
|
||||
const afterRender = useCallback(
|
||||
({ visibleStartIndex }: ListOnItemsRenderedProps) => {
|
||||
if (!visibleStartIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
let [treeNode] = list.getVisibleChildAt(visibleStartIndex) ?? []
|
||||
|
||||
if (treeNode) {
|
||||
fixedOnTreeNodeRef.current = treeNode
|
||||
}
|
||||
},
|
||||
[list]
|
||||
)
|
||||
|
||||
const indexOfItem = fixedOnTreeNodeRef.current?.viewModel?.getIndex()
|
||||
|
||||
if (indexOfItem && lastIndex.current !== indexOfItem && Date.now() - lastScroll.current > 300) {
|
||||
// Kind of dangerous to mutate scroll state directly, useEffect causes glitches
|
||||
indexOfItem && listRef.current?.scrollToItem(indexOfItem, 'start')
|
||||
}
|
||||
lastIndex.current = indexOfItem
|
||||
|
||||
const disableScroll = useCallback((args: ListOnScrollProps) => {
|
||||
if (!args.scrollUpdateWasRequested) {
|
||||
lastScroll.current = Date.now()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<List
|
||||
ref={listRef}
|
||||
width={width}
|
||||
height={height}
|
||||
itemSize={20}
|
||||
itemCount={list.length}
|
||||
itemKey={getKey}
|
||||
onItemsRendered={afterRender}
|
||||
onScroll={disableScroll}
|
||||
overscanCount={3}
|
||||
>
|
||||
{({ index, style }) => {
|
||||
let [treeNode, depth = 0] = list.getVisibleChildAt(index) ?? []
|
||||
if (!treeNode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ ...style, paddingLeft: 12 * (depth - 1) }}>
|
||||
<TreeNode
|
||||
treeNode={treeNode}
|
||||
isRoot={index === 0}
|
||||
doNotRenderSubnodes={true}
|
||||
name={index === 0 ? name : undefined}
|
||||
collapsed={false}
|
||||
settings={settings}
|
||||
lastUpdate={treeNode.lastUpdate}
|
||||
actions={actions}
|
||||
selectTopicAction={actions.selectTopic}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</List>
|
||||
)}
|
||||
</AutoSizer>
|
||||
)
|
||||
}
|
||||
|
||||
export const Infinitree = InfinitreeComponent
|
||||
@@ -4,9 +4,9 @@ import { TopicViewModel } from '../../../../model/TopicViewModel'
|
||||
|
||||
export function useViewModel(treeNode: q.TreeNode<TopicViewModel> | undefined) {
|
||||
useEffect(() => {
|
||||
if (treeNode && !treeNode?.viewModel) {
|
||||
treeNode.viewModel = new TopicViewModel(treeNode)
|
||||
}
|
||||
// if (treeNode && !treeNode?.viewModel) {
|
||||
// treeNode.viewModel = new TopicViewModel(treeNode)
|
||||
// }
|
||||
treeNode?.viewModel?.retain()
|
||||
|
||||
return function cleanup() {
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface Props {
|
||||
treeNode: q.TreeNode<TopicViewModel>
|
||||
name?: string | undefined
|
||||
collapsed?: boolean | undefined
|
||||
doNotRenderSubnodes?: boolean
|
||||
classes: any
|
||||
lastUpdate: number
|
||||
actions: typeof treeActions
|
||||
@@ -27,8 +28,8 @@ export interface Props {
|
||||
}
|
||||
|
||||
function TreeNodeComponent(props: Props) {
|
||||
const { actions, classes, settings, theme, treeNode, lastUpdate, name } = props
|
||||
const [collapsedOverride, setCollapsedOverride] = useState<boolean | undefined>(undefined)
|
||||
const { actions, classes, settings, theme, treeNode, lastUpdate, name, doNotRenderSubnodes } = props
|
||||
const [collapsedOverride, setCollapsedOverride] = useState<boolean | undefined>(!treeNode.viewModel?.isExpanded())
|
||||
const [selected, selectionLastUpdate, setSelected] = useSelectionState(false)
|
||||
const nodeRef = useRef<HTMLDivElement>()
|
||||
const isAllowedToAutoExpand = useIsAllowedToAutoExpandState(props)
|
||||
@@ -94,7 +95,7 @@ function TreeNodeComponent(props: Props) {
|
||||
|
||||
return useMemo(() => {
|
||||
function renderNodes() {
|
||||
if (isCollapsed) {
|
||||
if (isCollapsed || doNotRenderSubnodes) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -134,7 +135,7 @@ function TreeNodeComponent(props: Props) {
|
||||
{renderNodes()}
|
||||
</div>
|
||||
)
|
||||
}, [treeNode.lastUpdate, treeNode, name, isCollapsed, selected, theme, mouseOver, settings])
|
||||
}, [treeNode.lastUpdate, treeNode, name, isCollapsed, selected, theme, mouseOver, settings, doNotRenderSubnodes])
|
||||
}
|
||||
|
||||
export default withStyles(styles, { withTheme: true })(React.memo(TreeNodeComponent))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import React from 'react'
|
||||
import TreeNode from './TreeNode'
|
||||
import React, { useCallback, useMemo, useRef } from 'react'
|
||||
import { Infinitree } from './Infinitree'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
@@ -8,6 +8,8 @@ import { KeyCodes } from '../../utils/KeyCodes'
|
||||
import { SettingsState } from '../../reducers/Settings'
|
||||
import { TopicViewModel } from '../../model/TopicViewModel'
|
||||
import { treeActions } from '../../actions'
|
||||
import { FixedSizeList as List } from 'react-window'
|
||||
import { useSubscription } from '../hooks/useSubscription'
|
||||
const MovingAverage = require('moving-average')
|
||||
|
||||
const averagingTimeInterval = 10 * 1000
|
||||
@@ -24,84 +26,64 @@ interface Props {
|
||||
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
|
||||
}
|
||||
}
|
||||
return useCallback(
|
||||
(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
|
||||
}
|
||||
},
|
||||
[actions]
|
||||
)
|
||||
}
|
||||
|
||||
class TreeComponent extends React.PureComponent<Props, State> {
|
||||
private updateTimer?: any
|
||||
private perf: number = 0
|
||||
private renderTime = 0
|
||||
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = { lastUpdate: 0 }
|
||||
}
|
||||
|
||||
private keyEventHandler = useArrowKeyEventHandler(this.props.actions)
|
||||
private performanceCallback = (ms: number) => {
|
||||
const TreeComponent: React.FC<Props> = props => {
|
||||
const keyEventHandler = useArrowKeyEventHandler(props.actions)
|
||||
const performanceCallback = useCallback((ms: number) => {
|
||||
average.push(Date.now(), ms)
|
||||
}
|
||||
}, [])
|
||||
|
||||
public componentWillReceiveProps(nextProps: Props) {
|
||||
if (this.props.tree !== nextProps.tree) {
|
||||
if (this.props.tree) {
|
||||
this.props.tree.didUpdate.unsubscribe(this.throttledTreeUpdate)
|
||||
}
|
||||
if (nextProps.tree) {
|
||||
nextProps.tree.didUpdate.subscribe(this.throttledTreeUpdate)
|
||||
}
|
||||
this.setState(this.state)
|
||||
}
|
||||
}
|
||||
const updateTimer = useRef<NodeJS.Timeout | number>()
|
||||
const perf = useRef<number>(performance.now())
|
||||
const renderTime = useRef<number>(0)
|
||||
const listRef = useRef<List>(null)
|
||||
const [lastUpdate, triggerUpdate] = React.useState(0)
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.props.tree && this.props.tree.didUpdate.unsubscribe(this.throttledTreeUpdate)
|
||||
}
|
||||
|
||||
public throttledTreeUpdate = () => {
|
||||
if (this.updateTimer) {
|
||||
const throttledTreeUpdate = useCallback(() => {
|
||||
if (updateTimer.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const expectedRenderTime = average.forecast()
|
||||
const updateInterval = Math.max(expectedRenderTime * 7, 300)
|
||||
const timeUntilNextUpdate = updateInterval - (performance.now() - this.renderTime)
|
||||
const updateInterval = Math.max(expectedRenderTime * 7, 500)
|
||||
const timeUntilNextUpdate = updateInterval - (performance.now() - renderTime.current)
|
||||
|
||||
this.updateTimer = setTimeout(
|
||||
updateTimer.current = setTimeout(
|
||||
() => {
|
||||
window.requestIdleCallback(
|
||||
() => {
|
||||
this.updateTimer && clearTimeout(this.updateTimer)
|
||||
this.updateTimer = undefined
|
||||
this.renderTime = performance.now()
|
||||
updateTimer.current && clearTimeout(updateTimer.current)
|
||||
updateTimer.current = undefined
|
||||
renderTime.current = performance.now()
|
||||
|
||||
window.requestIdleCallback(
|
||||
() => {
|
||||
this.setState({ lastUpdate: this.renderTime })
|
||||
triggerUpdate(renderTime.current)
|
||||
},
|
||||
{ timeout: 100 }
|
||||
)
|
||||
@@ -111,49 +93,52 @@ class TreeComponent extends React.PureComponent<Props, State> {
|
||||
},
|
||||
Math.max(0, timeUntilNextUpdate)
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
public componentWillUpdate() {
|
||||
this.perf = performance.now()
|
||||
}
|
||||
perf.current = performance.now()
|
||||
window.requestIdleCallback(() => {
|
||||
performanceCallback(performance.now() - perf.current)
|
||||
})
|
||||
const fixedOnTreeNodeRef = useRef<q.TreeNode<TopicViewModel> | null>(null)
|
||||
|
||||
public componentDidUpdate() {
|
||||
this.performanceCallback(performance.now() - this.perf)
|
||||
}
|
||||
useSubscription(props.tree?.didUpdate, throttledTreeUpdate)
|
||||
|
||||
public render() {
|
||||
const { tree } = this.props
|
||||
if (!tree) {
|
||||
return null
|
||||
}
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
const style: React.CSSProperties = useMemo(
|
||||
() => ({
|
||||
lineHeight: '1.1',
|
||||
cursor: 'default',
|
||||
overflowY: 'scroll',
|
||||
overflowX: 'hidden',
|
||||
// overflowY: 'scroll',
|
||||
// overflowX: 'hidden',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
outline: '24px black !important',
|
||||
paddingBottom: '16px', // avoid conflict with chart panel Resizer
|
||||
}
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={style} tabIndex={0} onKeyDown={this.keyEventHandler}>
|
||||
<TreeNode
|
||||
key={tree.hash()}
|
||||
isRoot={true}
|
||||
treeNode={tree}
|
||||
name={this.props.host}
|
||||
collapsed={false}
|
||||
settings={this.props.settings}
|
||||
lastUpdate={tree.lastUpdate}
|
||||
actions={this.props.actions}
|
||||
selectTopicAction={this.props.actions.selectTopic}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
const { tree } = props
|
||||
if (!tree) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rendered = (
|
||||
<div style={style} tabIndex={0} onKeyDown={keyEventHandler}>
|
||||
<Infinitree
|
||||
lastUpdate={lastUpdate}
|
||||
listRef={listRef}
|
||||
key={tree.hash()}
|
||||
fixedOnTreeNodeRef={fixedOnTreeNodeRef}
|
||||
tree={tree}
|
||||
name={props.host ?? ''}
|
||||
actions={props.actions}
|
||||
selectTopicAction={props.actions.selectTopic}
|
||||
settings={props.settings}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
|
||||
@@ -77,11 +77,11 @@ export function createEmptyConnection(): ConnectionOptions {
|
||||
export function makeDefaultConnections() {
|
||||
return {
|
||||
// remember: there was also iot.eclipse.org once
|
||||
'mqtt.eclipse.org': {
|
||||
'mqtt.eclipseprojects.io': {
|
||||
...createEmptyConnection(),
|
||||
id: 'mqtt.eclipse.org',
|
||||
name: 'mqtt.eclipse.org',
|
||||
host: 'mqtt.eclipse.org',
|
||||
id: 'mqtt.eclipseprojects.io',
|
||||
name: 'mqtt.eclipseprojects.io',
|
||||
host: 'mqtt.eclipseprojects.io',
|
||||
},
|
||||
'test.mosquitto.org': {
|
||||
...createEmptyConnection(),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as q from '../../../backend/src/Model'
|
||||
import { Destroyable } from '../../../backend/src/Model/Destroyable'
|
||||
import { Destroyable, MemoryLifecycle } from '../../../backend/src/Model/Destroyable'
|
||||
import { MessageDecoder, decoders } from '../decoders'
|
||||
import { EventDispatcher } from '../../../events'
|
||||
|
||||
function findDecoder<T extends Destroyable>(node: q.TreeNode<T>): TopicDecoder | undefined {
|
||||
function findDecoder<T extends Destroyable & MemoryLifecycle>(node: q.TreeNode<T>): TopicDecoder | undefined {
|
||||
const decoder = decoders.find(
|
||||
decoder =>
|
||||
decoder.canDecodeTopic?.(node.path()) || (node.message?.payload && decoder.canDecodeData?.(node.message?.payload))
|
||||
@@ -19,7 +19,7 @@ function findDecoder<T extends Destroyable>(node: q.TreeNode<T>): TopicDecoder |
|
||||
|
||||
type TopicDecoder = { decoder: MessageDecoder; format: string | undefined }
|
||||
|
||||
export class TopicViewModel implements Destroyable {
|
||||
export class TopicViewModel implements Destroyable, MemoryLifecycle {
|
||||
private selected: boolean
|
||||
private expanded: boolean
|
||||
private owner: q.TreeNode<TopicViewModel> | undefined
|
||||
@@ -46,10 +46,89 @@ export class TopicViewModel implements Destroyable {
|
||||
this.onDecoderChange.dispatch(override)
|
||||
}
|
||||
|
||||
private clearCache = () => {
|
||||
if (this._cachedChildTopicCount) {
|
||||
this._cachedChildTopicCount = undefined
|
||||
// when child changes, parents are affected as well
|
||||
this.owner?.sourceEdge?.source?.viewModel?.clearCache()
|
||||
}
|
||||
}
|
||||
|
||||
public constructor(treeNode: q.TreeNode<TopicViewModel>) {
|
||||
this.owner = treeNode
|
||||
this.selected = false
|
||||
this.expanded = false
|
||||
this.expanded = true
|
||||
treeNode.onMerge.subscribe(this.clearCache)
|
||||
}
|
||||
|
||||
private _cachedChildTopicCount: number | undefined = undefined
|
||||
|
||||
/**
|
||||
* This function only returns valid values if parents are expanded
|
||||
* @returns
|
||||
*/
|
||||
public getIndex(): number {
|
||||
if (!this.owner) {
|
||||
throw new Error('integrity error')
|
||||
}
|
||||
|
||||
const source = this.owner.sourceEdge?.source
|
||||
|
||||
const parentIndex = source?.viewModel?.getIndex()
|
||||
// If we have a parent, we have its index + 1 (at least)
|
||||
const parentIndexWithDepth = parentIndex !== undefined ? parentIndex + 1 : 0
|
||||
let position = 0
|
||||
const edgeToMatch = this.owner.sourceEdge
|
||||
for (const edge of source?.edgeArray ?? []) {
|
||||
if (edge === edgeToMatch) {
|
||||
break
|
||||
}
|
||||
position += edge.target.viewModel?.visibleChildren() ?? 1
|
||||
}
|
||||
|
||||
return parentIndexWithDepth + position
|
||||
}
|
||||
|
||||
public visibleChildAt(
|
||||
index: number,
|
||||
depth: number = 0,
|
||||
parentOffset: number = 0
|
||||
): [q.TreeNode<TopicViewModel>, number] | undefined {
|
||||
if (!this.owner) {
|
||||
throw new Error('integrity error')
|
||||
}
|
||||
const node = this.owner
|
||||
|
||||
if (parentOffset === index) {
|
||||
return [node, depth]
|
||||
}
|
||||
|
||||
let position = parentOffset + 1
|
||||
for (const edge of node.edgeArray) {
|
||||
let viewModel = edge.target.viewModel
|
||||
const nextPosition = position + (viewModel?.visibleChildren() ?? 0)
|
||||
if (nextPosition > index) {
|
||||
return viewModel?.visibleChildAt(index, depth + 1, position)
|
||||
}
|
||||
position = nextPosition
|
||||
}
|
||||
}
|
||||
|
||||
public visibleChildren(): number {
|
||||
if (!this.owner) {
|
||||
throw new Error('integrity error')
|
||||
}
|
||||
|
||||
if (this._cachedChildTopicCount === undefined) {
|
||||
if (!this.expanded) {
|
||||
return 1
|
||||
}
|
||||
|
||||
this._cachedChildTopicCount =
|
||||
1 + this.owner.edgeArray.map(e => e.target.viewModel?.visibleChildren() ?? 1).reduce((a, b) => a + b, 0)
|
||||
}
|
||||
|
||||
return this._cachedChildTopicCount as number
|
||||
}
|
||||
|
||||
public retain() {
|
||||
@@ -64,7 +143,8 @@ export class TopicViewModel implements Destroyable {
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
console.log('destroy', this.referenceCounter)
|
||||
// console.log('destroy', this.owner?.path(), this.referenceCounter)
|
||||
this.owner?.onMerge.unsubscribe(this.clearCache)
|
||||
if (this.owner) {
|
||||
this.owner.viewModel = undefined
|
||||
this.owner = undefined
|
||||
@@ -90,6 +170,8 @@ export class TopicViewModel implements Destroyable {
|
||||
public setExpanded(expanded: boolean, fireEvent: boolean) {
|
||||
const didChange = this.expanded !== expanded
|
||||
this.expanded = expanded
|
||||
this.clearCache()
|
||||
|
||||
if (didChange && fireEvent) {
|
||||
this.expandedChange.dispatch()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@babel/runtime@^7.0.0":
|
||||
version "7.24.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.5.tgz#230946857c053a36ccc66e1dd03b17dd0c4ed02c"
|
||||
integrity sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/runtime@^7.15.4", "@babel/runtime@^7.17.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
|
||||
version "7.24.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.0.tgz#584c450063ffda59697021430cb47101b085951e"
|
||||
@@ -600,6 +607,21 @@
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-virtualized@^9.21.30":
|
||||
version "9.21.30"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.21.30.tgz#ba39821bcb2487512a8a2cdd9fbdb5e6fc87fedb"
|
||||
integrity sha512-4l2TFLQ8BCjNDQlvH85tU6gctuZoEdgYzENQyZHpgTHU7hoLzYgPSOALMAeA58LOWua8AzC6wBivPj1lfl6JgQ==
|
||||
dependencies:
|
||||
"@types/prop-types" "*"
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-window@^1.8.8":
|
||||
version "1.8.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.8.tgz#c20645414d142364fbe735818e1c1e0a145696e3"
|
||||
integrity sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*":
|
||||
version "18.2.64"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.64.tgz#3700fbb6b2fa60a6868ec1323ae4cbd446a2197d"
|
||||
@@ -3131,6 +3153,11 @@ memfs@^4.6.0:
|
||||
sonic-forest "^1.0.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
"memoize-one@>=3.1.1 <6":
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
|
||||
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
|
||||
|
||||
merge-descriptors@1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
|
||||
@@ -3819,6 +3846,14 @@ react-vis@^1.11.6:
|
||||
prop-types "^15.5.8"
|
||||
react-motion "^0.5.2"
|
||||
|
||||
react-window@^1.8.10:
|
||||
version "1.8.10"
|
||||
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.10.tgz#9e6b08548316814b443f7002b1cf8fd3a1bdde03"
|
||||
integrity sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.0.0"
|
||||
memoize-one ">=3.1.1 <6"
|
||||
|
||||
react@^16.11:
|
||||
version "16.14.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d"
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
export interface Destroyable {
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
export interface MemoryLifecycle {
|
||||
retain(): void
|
||||
release(): void
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Destroyable } from './Destroyable'
|
||||
import { Destroyable, MemoryLifecycle } from './Destroyable'
|
||||
import { Hashable, TreeNode } from './'
|
||||
const sha1 = require('sha1')
|
||||
|
||||
export class Edge<ViewModel extends Destroyable> implements Hashable {
|
||||
export class Edge<ViewModel extends Destroyable & MemoryLifecycle> implements Hashable {
|
||||
public name: string
|
||||
|
||||
public target!: TreeNode<ViewModel>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ChangeBuffer } from './ChangeBuffer'
|
||||
import { Destroyable } from './Destroyable'
|
||||
import { Destroyable, MemoryLifecycle } from './Destroyable'
|
||||
import { EventDispatcher, makeConnectionMessageEvent, MqttMessage, EventBusInterface } from '../../../events'
|
||||
import { TreeNode } from './'
|
||||
import { TreeNodeFactory } from './TreeNodeFactory'
|
||||
|
||||
export class Tree<ViewModel extends Destroyable> extends TreeNode<ViewModel> {
|
||||
export class Tree<ViewModel extends Destroyable & MemoryLifecycle> extends TreeNode<ViewModel> {
|
||||
public connectionId?: string
|
||||
public updateSource?: EventBusInterface
|
||||
public nodeFilter?: (node: TreeNode<ViewModel>) => boolean
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Destroyable } from './Destroyable'
|
||||
import { Destroyable, MemoryLifecycle } from './Destroyable'
|
||||
import { Edge, Message, RingBuffer, MessageHistory } from './'
|
||||
import { EventDispatcher } from '../../../events'
|
||||
import { TopicViewModel } from '../../../app/src/model/TopicViewModel'
|
||||
|
||||
export type TopicDataType = 'string' | 'json' | 'hex'
|
||||
|
||||
export class TreeNode<ViewModel extends Destroyable> {
|
||||
export class TreeNode<ViewModel extends Destroyable & MemoryLifecycle> {
|
||||
public sourceEdge?: Edge<ViewModel>
|
||||
public message?: Message
|
||||
public messageHistory: MessageHistory = new RingBuffer<Message>(20000, 100)
|
||||
@@ -48,6 +49,8 @@ export class TreeNode<ViewModel extends Destroyable> {
|
||||
this.onMessage.subscribe(() => {
|
||||
this.lastUpdate = Date.now()
|
||||
})
|
||||
this.viewModel = new TopicViewModel(this as any) as any
|
||||
this.viewModel?.retain()
|
||||
}
|
||||
|
||||
private previous(): TreeNode<ViewModel> | undefined {
|
||||
@@ -117,7 +120,7 @@ export class TreeNode<ViewModel extends Destroyable> {
|
||||
for (const edge of this.edgeArray) {
|
||||
edge.target.destroy()
|
||||
}
|
||||
this.viewModel && this.viewModel.destroy()
|
||||
this.viewModel?.release()
|
||||
this.viewModel = undefined
|
||||
this.edgeArray = []
|
||||
this.edges = {}
|
||||
@@ -147,7 +150,7 @@ export class TreeNode<ViewModel extends Destroyable> {
|
||||
}
|
||||
|
||||
public hash(): string {
|
||||
return `N${this.sourceEdge ? this.sourceEdge.hash() : ''}`
|
||||
return `N${this.sourceEdge?.hash() ?? ''}`
|
||||
}
|
||||
|
||||
public firstNode(): TreeNode<ViewModel> {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Destroyable } from './Destroyable'
|
||||
import { Destroyable, MemoryLifecycle } from './Destroyable'
|
||||
import { Edge, Tree, TreeNode } from './'
|
||||
import { MqttMessage } from '../../../events'
|
||||
import { Base64Message } from './Base64Message'
|
||||
|
||||
export abstract class TreeNodeFactory {
|
||||
private static messageCounter = 0
|
||||
public static insertNodeAtPosition<ViewModel extends Destroyable>(
|
||||
public static insertNodeAtPosition<ViewModel extends Destroyable & MemoryLifecycle>(
|
||||
edgeNames: Array<string>,
|
||||
node: TreeNode<ViewModel>
|
||||
) {
|
||||
@@ -21,7 +21,7 @@ export abstract class TreeNodeFactory {
|
||||
node.sourceEdge!.target = node
|
||||
}
|
||||
|
||||
public static fromMessage<ViewModel extends Destroyable>(
|
||||
public static fromMessage<ViewModel extends Destroyable & MemoryLifecycle>(
|
||||
mqttMessage: MqttMessage,
|
||||
receiveDate: Date = new Date()
|
||||
): TreeNode<ViewModel> {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'mocha'
|
||||
|
||||
import { expect } from 'chai'
|
||||
import { makeTreeNode } from './makeTreeNode'
|
||||
|
||||
describe('TreeNode', () => {
|
||||
const leaf1 = makeTreeNode('foo/bar', 'foo')
|
||||
const leaf2 = makeTreeNode('foo/bar/baz', 'bar')
|
||||
const leaf3 = makeTreeNode('foo/biz/baz', 'bar')
|
||||
const leaf4 = makeTreeNode('bar/biz', 'bar')
|
||||
const root = leaf1.firstNode()
|
||||
|
||||
root.updateWithNode(leaf2.firstNode())
|
||||
root.updateWithNode(leaf3.firstNode())
|
||||
root.updateWithNode(leaf4.firstNode())
|
||||
|
||||
describe('expanding the root should count the children', () => {
|
||||
it('nothing expanded', () => {
|
||||
expect(root?.viewModel?.visibleChildren()).to.eq(1)
|
||||
})
|
||||
|
||||
it('root expanded', () => {
|
||||
root.viewModel?.setExpanded(true)
|
||||
expect(root?.viewModel?.visibleChildren()).to.eq(3)
|
||||
})
|
||||
|
||||
it('root and "foo" expanded', () => {
|
||||
root.viewModel?.setExpanded(true)
|
||||
root.findNode('foo')!.viewModel!.setExpanded(true)
|
||||
expect(root?.viewModel?.visibleChildren()).to.eq(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('visibleChildAt', () => {
|
||||
it('nothing expanded', () => {
|
||||
expect(root?.viewModel?.visibleChildAt(0)?.[0].path()).to.eq('')
|
||||
})
|
||||
|
||||
it('root expanded', () => {
|
||||
root.viewModel?.setExpanded(true)
|
||||
expect(root?.viewModel?.visibleChildAt(1)?.[0].path()).to.eq('foo')
|
||||
})
|
||||
|
||||
it('root and "foo" expanded', () => {
|
||||
root.viewModel?.setExpanded(true)
|
||||
root.findNode('foo')!.viewModel!.setExpanded(true)
|
||||
expect(root?.viewModel?.visibleChildAt(4)?.[0].path()).to.eq('bar')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getIndex', () => {
|
||||
it('nothing expanded', () => {
|
||||
expect(root?.viewModel?.getIndex()).to.eq(0)
|
||||
})
|
||||
|
||||
it('root expanded', () => {
|
||||
root.viewModel?.setExpanded(true)
|
||||
expect(root?.viewModel?.visibleChildAt(1)?.[0].viewModel?.getIndex()).to.eq(1)
|
||||
})
|
||||
|
||||
it('root and "foo" expanded', () => {
|
||||
root.viewModel?.setExpanded(true)
|
||||
root.findNode('foo')!.viewModel!.setExpanded(true)
|
||||
expect(root?.viewModel?.visibleChildAt(4)?.[0].viewModel?.getIndex()).to.eq(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
const eslint = require('@eslint/js')
|
||||
const hooksPlugin = require('eslint-plugin-react-hooks')
|
||||
|
||||
module.export = [
|
||||
// eslint.configs.recommended,
|
||||
{
|
||||
// files: ['app/src/**/*'],
|
||||
plugins: {
|
||||
'react-hooks': hooksPlugin,
|
||||
},
|
||||
ignores: ['build/**/*', 'dist/**/*', 'node_modules/**/*', 'app/build/**/*'],
|
||||
ignorePatterns: ['build/**/*', 'dist/**/*', 'node_modules/**/*', 'app/build/**/*'],
|
||||
rules: hooksPlugin.configs.recommended.rules,
|
||||
},
|
||||
]
|
||||
@@ -36,6 +36,14 @@ process.on('unhandledRejection', (error: Error | any) => {
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
console.error('Timeout reached')
|
||||
process.exit(1)
|
||||
},
|
||||
60 * 10 * 1000
|
||||
)
|
||||
|
||||
const runningUiTestOnCi = os.platform() === 'darwin' ? [] : ['--runningUiTestOnCi']
|
||||
|
||||
async function doStuff() {
|
||||
@@ -143,10 +151,17 @@ async function doStuff() {
|
||||
await sleep(3000)
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('Forced quit')
|
||||
process.exit(0)
|
||||
}, 10 * 1000)
|
||||
stopMqtt()
|
||||
console.log('Stopped mqtt client')
|
||||
|
||||
cleanUp(scenes, electronApp)
|
||||
|
||||
// Force exit since there appear to be open handles
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
doStuff()
|
||||
|
||||
Reference in New Issue
Block a user