Add topic filter

This commit is contained in:
Thomas Nordquist
2019-01-21 15:07:53 +01:00
parent 4d21c63da9
commit 4c438bd00b
16 changed files with 286 additions and 53 deletions

View File

@@ -42,6 +42,10 @@ class UpdateNotifier extends React.Component<Props, {}> {
rendererEvents.subscribe(updateAvailable, this.handleUpdate) rendererEvents.subscribe(updateAvailable, this.handleUpdate)
} }
public componentWillUnmount() {
rendererEvents.unsubscribeAll(updateAvailable)
}
private fixUrl(url: string, version: string) { private fixUrl(url: string, version: string) {
if (!/^http/.test(url)) { if (!/^http/.test(url)) {
return `https://github.com/thomasnordquist/MQTT-Explorer/releases/download/v${version}/${url}` return `https://github.com/thomasnordquist/MQTT-Explorer/releases/download/v${version}/${url}`
@@ -49,9 +53,6 @@ class UpdateNotifier extends React.Component<Props, {}> {
return url return url
} }
public componentWillUnmount() {
rendererEvents.unsubscribeAll(updateAvailable)
}
private handleUpdate = (updateInfo: UpdateInfo) => { private handleUpdate = (updateInfo: UpdateInfo) => {
this.updateInfo = updateInfo this.updateInfo = updateInfo

View File

@@ -4,6 +4,7 @@ import { Dispatch } from 'redux'
import { rendererEvents, addMqttConnectionEvent, makeConnectionStateEvent, removeConnection } from '../../../events' import { rendererEvents, addMqttConnectionEvent, makeConnectionStateEvent, removeConnection } from '../../../events'
import { AppState } from '../reducers' import { AppState } from '../reducers'
import * as q from '../../../backend/src/Model' import * as q from '../../../backend/src/Model'
import { showTree } from './Tree'
export const connect = (options: MqttOptions, connectionId: string) => (dispatch: Dispatch<any>, getState: () => AppState) => { export const connect = (options: MqttOptions, connectionId: string) => (dispatch: Dispatch<any>, getState: () => AppState) => {
dispatch(connecting(connectionId)) dispatch(connecting(connectionId))
@@ -14,6 +15,7 @@ export const connect = (options: MqttOptions, connectionId: string) => (dispatch
const tree = new q.Tree() const tree = new q.Tree()
tree.updateWithConnection(rendererEvents, connectionId) tree.updateWithConnection(rendererEvents, connectionId)
dispatch(connected(tree)) dispatch(connected(tree))
dispatch(showTree(tree))
} else if (dataSourceState.error) { } else if (dataSourceState.error) {
dispatch(showError(dataSourceState.error)) dispatch(showError(dataSourceState.error))
dispatch(disconnect()) dispatch(disconnect())
@@ -36,11 +38,12 @@ export const showError = (error?: string) => ({
type: ActionTypes.CONNECTION_SET_SHOW_ERROR, type: ActionTypes.CONNECTION_SET_SHOW_ERROR,
}) })
export const disconnect = () => (dispatch: Dispatch<Action>, getState: () => AppState) => { export const disconnect = () => (dispatch: Dispatch<any>, getState: () => AppState) => {
const { connectionId, tree } = getState().connection const { connectionId, tree } = getState().connection
rendererEvents.emit(removeConnection, connectionId) rendererEvents.emit(removeConnection, connectionId)
tree && tree.stopUpdating() tree && tree.stopUpdating()
dispatch(showTree(undefined))
dispatch({ dispatch({
type: ActionTypes.CONNECTION_SET_DISCONNECTED, type: ActionTypes.CONNECTION_SET_DISCONNECTED,
}) })

View File

@@ -1,4 +1,8 @@
import { Action, ActionTypes, TopicOrder } from '../reducers/Settings' import { Action, ActionTypes, TopicOrder } from '../reducers/Settings'
import { ActionTypes as TreeActionTypes, Action as TreeAction } from '../reducers/Tree'
import { Dispatch } from 'redux'
import { AppState } from '../reducers'
import * as q from '../../../backend/src/Model'
export const setAutoExpandLimit = (autoExpandLimit: number = 0): Action => { export const setAutoExpandLimit = (autoExpandLimit: number = 0): Action => {
return { return {
@@ -20,9 +24,44 @@ export const setTopicOrder = (topicOrder: TopicOrder = TopicOrder.none): Action
} }
} }
export const filterTopics = (topicFilter: string): Action => { export const filterTopics = (filterStr: string) => (dispatch: Dispatch<any>, getState: () => AppState) => {
return { const topicFilter = filterStr.toLowerCase()
dispatch({
topicFilter, topicFilter,
type: ActionTypes.SETTINGS_FILTER_TOPICS, type: ActionTypes.SETTINGS_FILTER_TOPICS,
})
const { tree } = getState().connection
if (!tree) {
return
} }
if (!topicFilter) {
dispatch({
tree,
filter: '',
type: TreeActionTypes.TREE_SHOW_TREE,
})
return
}
const resultTree = tree.leafes()
.filter(leaf => leaf.path().toLowerCase().indexOf(topicFilter) !== -1)
.map((node) => {
const clone = node.unconnectedClone()
q.TreeNodeFactory.insertNodeAtPosition(node.path().split('/'), clone)
return clone.firstNode()
})
.reduce((a: q.TreeNode, b: q.TreeNode) => {
a.updateWithNode(b)
return a
}, new q.Tree())
dispatch({
tree: resultTree,
filter: topicFilter,
type: TreeActionTypes.TREE_SHOW_TREE,
})
} }

View File

@@ -3,7 +3,7 @@ import { AppState } from '../reducers'
import { makePublishEvent, rendererEvents } from '../../../events' import { makePublishEvent, rendererEvents } from '../../../events'
export const clearRetainedTopic = () => (dispatch: Dispatch<Action>, getState: () => AppState) => { export const clearRetainedTopic = () => (dispatch: Dispatch<Action>, getState: () => AppState) => {
const { selectedTopic } = getState().tooBigReducer const { selectedTopic } = getState().tree
const { connectionId } = getState().connection const { connectionId } = getState().connection
if (!selectedTopic || !connectionId) { if (!selectedTopic || !connectionId) {

View File

@@ -1,10 +1,11 @@
import { ActionTypes, CustomAction, AppState } from '../reducers' import { AppState } from '../reducers'
import { ActionTypes } from '../reducers/Tree'
import * as q from '../../../backend/src/Model' import * as q from '../../../backend/src/Model'
import { Dispatch } from 'redux' import { Dispatch } from 'redux'
import { setTopic } from './Publish' import { setTopic } from './Publish'
export const selectTopic = (topic: q.TreeNode) => (dispatch: Dispatch<any>, getState: () => AppState) => { export const selectTopic = (topic: q.TreeNode) => (dispatch: Dispatch<any>, getState: () => AppState) => {
const { selectedTopic } = getState().tooBigReducer const { selectedTopic } = getState().tree
// Update publish topic // Update publish topic
if (selectedTopic && (selectedTopic.path() === getState().publish.topic || !getState().publish.topic)) { if (selectedTopic && (selectedTopic.path() === getState().publish.topic || !getState().publish.topic)) {
@@ -13,6 +14,13 @@ export const selectTopic = (topic: q.TreeNode) => (dispatch: Dispatch<any>, getS
dispatch({ dispatch({
selectedTopic: topic, selectedTopic: topic,
type: ActionTypes.selectTopic, type: ActionTypes.TREE_SELECT_TOPIC,
}) })
} }
export const showTree = (tree?: q.Tree) => {
return {
tree,
type: ActionTypes.TREE_SHOW_TREE,
}
}

View File

@@ -1,5 +1,4 @@
import * as React from 'react' import * as React from 'react'
import * as q from '../../../backend/src/Model'
import { AppState } from '../reducers' import { AppState } from '../reducers'
import { import {
@@ -19,6 +18,7 @@ import { bindActionCreators } from 'redux'
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { settingsActions } from '../actions' import { settingsActions } from '../actions'
import { TopicOrder } from '../reducers/Settings' import { TopicOrder } from '../reducers/Settings'
import Topic from './Sidebar/Topic';
const styles: StyleRulesCallback = theme => ({ const styles: StyleRulesCallback = theme => ({
drawer: { drawer: {
@@ -40,7 +40,7 @@ const styles: StyleRulesCallback = theme => ({
}) })
interface Props { interface Props {
actions?: any actions: typeof settingsActions
autoExpandLimit: number autoExpandLimit: number
visible: boolean visible: boolean
store?: any store?: any
@@ -107,7 +107,7 @@ class Settings extends React.Component<Props, {}> {
} }
private onChangeAutoExpand = (e: React.ChangeEvent<HTMLSelectElement>) => { private onChangeAutoExpand = (e: React.ChangeEvent<HTMLSelectElement>) => {
this.props.actions.setAutoExpandLimit(e.target.value) this.props.actions.setAutoExpandLimit(parseInt(e.target.value, 10))
} }
private renderNodeOrder() { private renderNodeOrder() {
@@ -133,7 +133,7 @@ class Settings extends React.Component<Props, {}> {
} }
private onChangeSorting = (e: React.ChangeEvent<HTMLSelectElement>) => { private onChangeSorting = (e: React.ChangeEvent<HTMLSelectElement>) => {
this.props.actions.setNodeOrder(e.target.value) this.props.actions.setTopicOrder(e.target.value as TopicOrder)
} }
} }

View File

@@ -84,6 +84,10 @@ class Sidebar extends React.Component<Props, State> {
} }
} }
public componentWillUnmount() {
this.props.node && this.removeUpdateListener(this.props.node)
}
private registerUpdateListener(node: q.TreeNode) { private registerUpdateListener(node: q.TreeNode) {
node.onMerge.subscribe(this.updateNode) node.onMerge.subscribe(this.updateNode)
node.onMessage.subscribe(this.updateNode) node.onMessage.subscribe(this.updateNode)
@@ -215,7 +219,7 @@ class Sidebar extends React.Component<Props, State> {
const mapStateToProps = (state: AppState) => { const mapStateToProps = (state: AppState) => {
return { return {
node: state.tooBigReducer.selectedTopic, node: state.tree.selectedTopic,
} }
} }

View File

@@ -19,6 +19,7 @@ interface Props {
didSelectNode?: (node: q.TreeNode) => void didSelectNode?: (node: q.TreeNode) => void
connectionId?: string connectionId?: string
tree?: q.Tree tree?: q.Tree
filter: string
} }
class Tree extends React.Component<Props, {}> { class Tree extends React.Component<Props, {}> {
@@ -46,9 +47,14 @@ class Tree extends React.Component<Props, {}> {
if (nextProps.tree) { if (nextProps.tree) {
nextProps.tree.onMerge.subscribe(this.throttledTreeUpdate) nextProps.tree.onMerge.subscribe(this.throttledTreeUpdate)
} }
this.setState(this.state)
} }
} }
public componentWillUnmount() {
this.props.tree && this.props.tree.onMerge.unsubscribe(this.throttledTreeUpdate)
}
public throttledTreeUpdate = () => { public throttledTreeUpdate = () => {
if (this.updateTimer) { if (this.updateTimer) {
return return
@@ -68,15 +74,9 @@ class Tree extends React.Component<Props, {}> {
}, Math.max(0, timeUntilNextUpdate)) }, Math.max(0, timeUntilNextUpdate))
} }
public componentWillUnmount() {
if (this.props.connectionId) {
const event = makeConnectionMessageEvent(this.props.connectionId)
rendererEvents.unsubscribeAll(event)
}
}
public render() { public render() {
if (!this.props.tree) { const { tree, filter } = this.props
if (!tree) {
return null return null
} }
@@ -84,17 +84,17 @@ class Tree extends React.Component<Props, {}> {
lineHeight: '1.1', lineHeight: '1.1',
cursor: 'default', cursor: 'default',
} }
const key = `rootNode-${filter}`
return ( return (
<div style={style}> <div style={style}>
<TreeNode <TreeNode
key={key}
animateChages={true} animateChages={true}
isRoot={true} isRoot={true}
treeNode={this.props.tree} treeNode={tree}
name="/" name="/"
lastUpdate={tree.lastUpdate}
collapsed={false} collapsed={false}
key="rootNode"
lastUpdate={this.props.tree.lastUpdate}
performanceCallback={this.performanceCallback} performanceCallback={this.performanceCallback}
/> />
</div> </div>
@@ -109,7 +109,8 @@ class Tree extends React.Component<Props, {}> {
const mapStateToProps = (state: AppState) => { const mapStateToProps = (state: AppState) => {
return { return {
autoExpandLimit: state.settings.autoExpandLimit, autoExpandLimit: state.settings.autoExpandLimit,
tree: state.connection.tree, tree: state.tree.tree,
filter: state.tree.filter,
} }
} }

View File

@@ -19,7 +19,17 @@ export interface Props {
theme: Theme theme: Theme
} }
class TreeNodeSubnodes extends React.Component<Props, {}> { interface State {
alreadyAdded: number
}
class TreeNodeSubnodes extends React.Component<Props, State> {
private renderMoreAnimationFrame?: any
constructor(props: Props) {
super(props)
this.state = { alreadyAdded: 10 }
}
private sortedNodes(): q.TreeNode[] { private sortedNodes(): q.TreeNode[] {
const { topicOrder, treeNode } = this.props const { topicOrder, treeNode } = this.props
@@ -39,17 +49,32 @@ class TreeNodeSubnodes extends React.Component<Props, {}> {
return nodes return nodes
} }
private renderMore() {
this.renderMoreAnimationFrame = window.requestAnimationFrame(() => {
this.setState({ ...this.state, alreadyAdded: this.state.alreadyAdded * 1.5 })
})
}
public componentWillUnmount() {
window.cancelAnimationFrame(this.renderMoreAnimationFrame)
}
public render() { public render() {
const edges = Object.values(this.props.treeNode.edges) const edges = Object.values(this.props.treeNode.edges)
if (edges.length === 0 || this.props.collapsed) { if (edges.length === 0 || this.props.collapsed) {
return null return null
} }
if (this.state.alreadyAdded < edges.length) {
const delta = Math.min(this.state.alreadyAdded, edges.length - this.state.alreadyAdded)
this.renderMore()
}
const listItemStyle = { const listItemStyle = {
padding: '3px 0px 0px 8px', padding: '3px 0px 0px 8px',
} }
const nodes = this.sortedNodes() const nodes = this.sortedNodes().slice(0, this.state.alreadyAdded)
const listItems = nodes.map(node => ( const listItems = nodes.map(node => (
<div key={node.hash()}> <div key={node.hash()}>
<TreeNode <TreeNode

View File

@@ -11,7 +11,7 @@ import App from './App'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
import { createStore, applyMiddleware, compose } from 'redux' import { createStore, applyMiddleware, compose } from 'redux'
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose const composeEnhancers = /*(window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || */ compose
const store = createStore( const store = createStore(
reducers, reducers,
composeEnhancers( composeEnhancers(

View File

@@ -0,0 +1,84 @@
import { Action } from 'redux'
import { createReducer } from './lib'
export enum TopicOrder {
none = 'none',
messages = '#messages',
abc = 'abc',
topics = '#topics',
}
export interface SettingsState {
autoExpandLimit: number
visible: boolean
topicOrder: TopicOrder
topicFilter?: string
}
export type Action = SetAutoExpandLimit | ToggleVisibility | SetTopicOrder | FilterTopics
export enum ActionTypes {
SETTINGS_SET_AUTO_EXPAND_LIMIT = 'SETTINGS_SET_AUTO_EXPAND_LIMIT',
SETTINGS_TOGGLE_VISIBILITY = 'SETTINGS_TOGGLE_VISIBILITY',
SETTINGS_SET_TOPIC_ORDER = 'SETTINGS_SET_TOPIC_ORDER',
SETTINGS_FILTER_TOPICS = 'SETTINGS_FILTER_TOPICS',
}
const initialState: SettingsState = {
autoExpandLimit: 0,
topicOrder: TopicOrder.none,
visible: false,
}
export const settingsReducer = createReducer(initialState, {
SETTINGS_SET_AUTO_EXPAND_LIMIT: setAutoExpandLimit,
SETTINGS_TOGGLE_VISIBILITY: toggleVisibility,
SETTINGS_SET_TOPIC_ORDER: setTopicOrder,
SETTINGS_FILTER_TOPICS: filterTopics,
})
function setAutoExpandLimit(state: SettingsState, action: SetAutoExpandLimit) {
return {
...state,
autoExpandLimit: action.autoExpandLimit,
}
}
export interface SetAutoExpandLimit {
type: ActionTypes.SETTINGS_SET_AUTO_EXPAND_LIMIT
autoExpandLimit: number
}
function toggleVisibility(state: SettingsState, action: ToggleVisibility) {
return {
...state,
visible: !state.visible,
}
}
export interface ToggleVisibility {
type: ActionTypes.SETTINGS_TOGGLE_VISIBILITY
}
function setTopicOrder(state: SettingsState, action: SetTopicOrder) {
return {
...state,
topicOrder: action.topicOrder,
}
}
export interface SetTopicOrder {
type: ActionTypes.SETTINGS_SET_TOPIC_ORDER
topicOrder: string
}
function filterTopics(state: SettingsState, action: FilterTopics) {
return {
...state,
topicFilter: action.topicFilter,
}
}
export interface FilterTopics {
type: ActionTypes.SETTINGS_FILTER_TOPICS
topicFilter: string
}

49
app/src/reducers/Tree.ts Normal file
View File

@@ -0,0 +1,49 @@
import * as q from '../../../backend/src/Model'
import { Action } from 'redux'
import { createReducer } from './lib'
export interface TreeState {
tree?: q.Tree
selectedTopic?: q.TreeNode
filter?: string
}
export type Action = ShowTree | SelectTopic
export enum ActionTypes {
TREE_SHOW_TREE = 'TREE_SHOW_TREE',
TREE_SELECT_TOPIC = 'TREE_SELECT_TOPIC',
}
export interface ShowTree {
type: ActionTypes.TREE_SHOW_TREE
tree?: q.Tree
filter?: string
}
export interface SelectTopic {
type: ActionTypes.TREE_SELECT_TOPIC
selectedTopic?: q.TreeNode
}
const initialState: TreeState = { }
export const treeReducer = createReducer(initialState, {
TREE_SHOW_TREE: showTree,
TREE_SELECT_TOPIC: selectTopic,
})
function showTree(state: TreeState, action: ShowTree) {
return {
...state,
tree: action.tree,
filter: action.filter,
}
}
function selectTopic(state: TreeState, action: SelectTopic) {
return {
...state,
selectedTopic: action.selectedTopic,
}
}

View File

@@ -6,35 +6,33 @@ import { trackEvent } from '../tracking'
import { PublishState, publishReducer } from './Publish' import { PublishState, publishReducer } from './Publish'
import { ConnectionState, connectionReducer } from './Connection' import { ConnectionState, connectionReducer } from './Connection'
import { SettingsState, settingsReducer } from './Settings' import { SettingsState, settingsReducer } from './Settings'
import { TreeState, treeReducer } from './Tree'
export enum ActionTypes { export enum ActionTypes {
selectTopic = 'SELECT_TOPIC',
showUpdateNotification = 'SHOW_UPDATE_NOTIFICATION', showUpdateNotification = 'SHOW_UPDATE_NOTIFICATION',
showUpdateDetails = 'SHOW_UPDATE_DETAILS', showUpdateDetails = 'SHOW_UPDATE_DETAILS',
} }
export interface CustomAction extends Action { export interface CustomAction extends Action {
type: ActionTypes, type: ActionTypes,
selectedTopic?: q.TreeNode
showUpdateNotification?: boolean showUpdateNotification?: boolean
showUpdateDetails?: boolean showUpdateDetails?: boolean
} }
export interface AppState { export interface AppState {
tooBigReducer: TooBigOfState tooBigReducer: TooBigOfState
tree: TreeState
settings: SettingsState, settings: SettingsState,
publish: PublishState publish: PublishState
connection: ConnectionState connection: ConnectionState
} }
export interface TooBigOfState { export interface TooBigOfState {
selectedTopic?: q.TreeNode
showUpdateNotification?: boolean showUpdateNotification?: boolean
showUpdateDetails: boolean showUpdateDetails: boolean
} }
const initialBigState: TooBigOfState = { const initialBigState: TooBigOfState = {
selectedTopic: undefined,
showUpdateDetails: false, showUpdateDetails: false,
} }
@@ -43,17 +41,8 @@ const tooBigReducer: Reducer<TooBigOfState | undefined, CustomAction> = (state =
throw Error('No initial state') throw Error('No initial state')
} }
trackEvent(action.type) trackEvent(action.type)
console.log(action, state)
switch (action.type) {
case ActionTypes.selectTopic:
if (!action.selectedTopic) {
return state
}
return {
...state,
selectedTopic: action.selectedTopic,
}
switch (action.type) {
case ActionTypes.showUpdateNotification: case ActionTypes.showUpdateNotification:
return { return {
...state, ...state,
@@ -79,6 +68,7 @@ const reducer = combineReducers({
publish: publishReducer, publish: publishReducer,
connection: connectionReducer, connection: connectionReducer,
settings: settingsReducer, settings: settingsReducer,
tree: treeReducer,
}) })
export default reducer export default reducer

View File

@@ -10,9 +10,19 @@ export class RingBuffer<T extends Lengthwise> {
private start: number = 0 private start: number = 0
private end: number = 0 private end: number = 0
constructor(capacity: number, maxItems = Infinity) { constructor(capacity: number, maxItems = Infinity, ringBuffer?: RingBuffer<T>) {
this.capacity = capacity this.capacity = capacity
this.maxItems = maxItems this.maxItems = maxItems
if (ringBuffer) {
this.items = ringBuffer.toArray()
this.end = this.items.length
this.usage = this.items.length
}
}
public clone(): RingBuffer<T> {
return new RingBuffer(this.capacity, this.maxItems, this)
} }
public toArray() { public toArray() {

View File

@@ -16,6 +16,17 @@ export class TreeNode {
private cachedLeafes?: TreeNode[] private cachedLeafes?: TreeNode[]
private cachedLeafMessageCount?: number private cachedLeafMessageCount?: number
public unconnectedClone() {
const node = new TreeNode()
node.message = this.message
node.mqttMessage = this.mqttMessage
node.messageHistory = this.messageHistory.clone()
node.messages = this.messages
node.lastUpdate = this.lastUpdate
return node
}
constructor(sourceEdge?: Edge, message?: Message) { constructor(sourceEdge?: Edge, message?: Message) {
if (sourceEdge) { if (sourceEdge) {
this.sourceEdge = sourceEdge this.sourceEdge = sourceEdge

View File

@@ -5,21 +5,29 @@ interface HasLength {
} }
export abstract class TreeNodeFactory { export abstract class TreeNodeFactory {
public static fromEdgesAndValue<T extends HasLength>(edgeNames: string[], value?: T): TreeNode { public static insertNodeAtPosition(edgeNames: string[], node: TreeNode) {
let currentNode: TreeNode = new Tree() let currentNode: TreeNode = new Tree()
let edge
for (const edgeName of edgeNames) { for (const edgeName of edgeNames) {
const edge = new Edge(edgeName) edge = new Edge(edgeName)
const newNode = new TreeNode(edge)
edge.target = newNode
currentNode.addEdge(edge) currentNode.addEdge(edge)
currentNode = newNode currentNode = new TreeNode(edge)
edge.target = currentNode
} }
node.sourceEdge = edge
node.sourceEdge!.target = node
}
currentNode.setMessage({ public static fromEdgesAndValue<T extends HasLength>(edgeNames: string[], value?: T): TreeNode {
const node = new TreeNode()
node.setMessage({
value, value,
length: value ? value.length : 0, length: value ? value.length : 0,
received: new Date(), received: new Date(),
}) })
return currentNode
this.insertNodeAtPosition(edgeNames, node)
return node
} }
} }