Compare commits

...
17 changed files with 476 additions and 132 deletions
+7 -4
View File
@@ -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%',
+117
View File
@@ -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() {
+5 -4
View File
@@ -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))
+80 -95
View File
@@ -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) => {
+4 -4
View File
@@ -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(),
+87 -5
View File
@@ -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()
}
+35
View File
@@ -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"
+5
View File
@@ -1,3 +1,8 @@
export interface Destroyable {
destroy(): void
}
export interface MemoryLifecycle {
retain(): void
release(): void
}
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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
+7 -4
View File
@@ -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> {
+3 -3
View File
@@ -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)
})
})
})
+15
View File
@@ -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,
},
]
+15
View File
@@ -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()