Add observability for LLM topic context inclusion (#1038)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
Co-authored-by: Thomas Nordquist <thomasnordquist@users.noreply.github.com>
This commit is contained in:
Copilot
2026-01-30 20:53:29 +01:00
committed by GitHub
parent 080a773dbd
commit ed8a7f559e
194 changed files with 35234 additions and 4085 deletions

View File

@@ -15,94 +15,93 @@
"author": "",
"license": "CC-BY-SA-4.0",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.6",
"@mui/lab": "^7.0.1-beta.20",
"@mui/material": "^7.3.6",
"@mui/styles": "^6.4.8",
"@react-spring/web": "^9.7.5",
"@types/react-transition-group": "^4.4.11",
"@visx/axis": "^3.10.1",
"@visx/grid": "^3.5.0",
"@visx/tooltip": "^3.3.0",
"@visx/xychart": "^3.10.2",
"ace-builds": "^1.4.11",
"axios": "^1.13.2",
"compare-versions": "^6.1.1",
"copy-text-to-clipboard": "^3.2.0",
"d3": "^7.9.0",
"d3-shape": "^3.2.0",
"diff": "^8.0.3",
"dot-prop": "^5.3.0",
"events": "^3.3.0",
"get-value": "^3.0.1",
"immutable": "^4.3.7",
"in-viewport": "^3.6.0",
"js-base64": "^3.7.8",
"json-to-ast": "^2.1.0",
"lodash.debounce": "^4.0.8",
"lodash.throttle": "^4.1.1",
"moving-average": "^1.0.0",
"number-abbreviate": "^2.0.0",
"os-browserify": "^0.3.0",
"parse-duration": "^0.1.1",
"path-browserify": "^1.0.1",
"prismjs": "^1.29.0",
"react": "^19.2.3",
"react-ace": "^14.0.1",
"react-dom": "^19.2.3",
"react-redux": "^9.2.0",
"react-resize-detector": "^11.0.1",
"react-split-pane": "^0.1.92",
"react-transition-group": "^4.4.5",
"redux": "^5.0.1",
"redux-batched-actions": "^0.5.0",
"redux-thunk": "^3.1.0",
"sha1": "^1.1.1",
"socket.io-client": "^4.8.1",
"url": "^0.11.4",
"uuid": "^11.0.0"
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.1",
"@mui/icons-material": "7.3.6",
"@mui/lab": "7.0.1-beta.20",
"@mui/material": "7.3.6",
"@mui/styles": "6.4.8",
"@react-spring/web": "9.7.5",
"@types/react-transition-group": "4.4.11",
"@visx/axis": "3.10.1",
"@visx/grid": "3.5.0",
"@visx/tooltip": "3.3.0",
"@visx/xychart": "3.10.2",
"ace-builds": "1.4.11",
"axios": "1.13.2",
"compare-versions": "6.1.1",
"copy-text-to-clipboard": "3.2.0",
"d3": "7.9.0",
"d3-shape": "3.2.0",
"diff": "8.0.3",
"dot-prop": "5.3.0",
"events": "3.3.0",
"get-value": "3.0.1",
"immutable": "4.3.7",
"in-viewport": "3.6.0",
"js-base64": "3.7.8",
"json-to-ast": "2.1.0",
"lodash.debounce": "4.0.8",
"lodash.throttle": "4.1.1",
"moving-average": "1.0.0",
"number-abbreviate": "2.0.0",
"os-browserify": "0.3.0",
"parse-duration": "0.1.1",
"path-browserify": "1.0.1",
"prismjs": "1.29.0",
"react": "19.2.3",
"react-ace": "14.0.1",
"react-dom": "19.2.3",
"react-redux": "9.2.0",
"react-resize-detector": "11.0.1",
"react-split-pane": "0.1.92",
"react-transition-group": "4.4.5",
"redux": "5.0.1",
"redux-batched-actions": "0.5.0",
"redux-thunk": "3.1.0",
"sha1": "1.1.1",
"socket.io-client": "4.8.1",
"url": "0.11.4",
"uuid": "11.0.0"
},
"devDependencies": {
"@babel/runtime": "^7.28.4",
"@babel/runtime": "7.28.4",
"@reduxjs/toolkit": "2.5.0",
"@testing-library/dom": "10.4.0",
"@testing-library/react": "16.1.0",
"@testing-library/user-event": "14.5.2",
"@types/d3": "^7.4.3",
"@types/diff": "^7.0.0",
"@types/get-value": "^3.0.5",
"@types/lodash.debounce": "^4.0.9",
"@types/node": "^25.0.3",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-redux": "^7.1.34",
"@types/react-resize-detector": "^4.0.3",
"@types/sha1": "^1.1.1",
"@types/socket.io-client": "^3.0.0",
"@types/uuid": "^11.0.0",
"@types/vis": "^4.21.24",
"chai": "^4.5.0",
"cross-env": "^7.0.3",
"css-loader": "^7.1.2",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.6.3",
"@types/d3": "7.4.3",
"@types/diff": "7.0.0",
"@types/get-value": "3.0.5",
"@types/lodash.debounce": "4.0.9",
"@types/node": "25.0.3",
"@types/prismjs": "1.26.5",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/react-redux": "7.1.34",
"@types/sha1": "1.1.1",
"@types/socket.io-client": "3.0.0",
"@types/uuid": "11.0.0",
"@types/vis": "4.21.24",
"chai": "4.5.0",
"cross-env": "7.0.3",
"css-loader": "7.1.2",
"file-loader": "6.2.0",
"html-webpack-plugin": "5.6.3",
"jsdom": "25.0.1",
"jsdom-global": "3.0.2",
"lodash": "^4.17.23",
"mocha": "^10.8.2",
"moment": "^2.30.1",
"node-loader": "^2.0.0",
"source-map-loader": "^5.0.0",
"style-loader": "^4.0.0",
"ts-loader": "^9.5.1",
"typescript": "^5.9.3",
"webpack": "^5.98.0",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.0"
"lodash": "4.17.23",
"mocha": "10.8.2",
"moment": "2.30.1",
"node-loader": "2.0.0",
"source-map-loader": "5.0.0",
"style-loader": "4.0.0",
"ts-loader": "9.5.1",
"typescript": "5.9.3",
"webpack": "5.98.0",
"webpack-bundle-analyzer": "4.10.2",
"webpack-cli": "6.0.1",
"webpack-dev-server": "5.2.0"
},
"peerDependencies": {
"electron": "^39"

View File

@@ -1,7 +1,7 @@
import { Dispatch } from 'redux'
import { Action, ActionTypes, ChartParameters } from '../reducers/Charts'
import { AppState } from '../reducers'
import { default as persistentStorage, StorageIdentifier } from '../utils/PersistentStorage'
import { Dispatch } from 'redux'
import { showError, showNotification } from './Global'
interface ConnectionViewState {
@@ -16,7 +16,7 @@ const connectionViewStateIdentifier: StorageIdentifier<ConnectionViewStateDictio
}
export const loadCharts = () => async (dispatch: Dispatch<any>, getState: () => AppState) => {
const connectionId = getState().connection.connectionId
const { connectionId } = getState().connection
if (!connectionId) {
return
}
@@ -40,7 +40,7 @@ export const loadCharts = () => async (dispatch: Dispatch<any>, getState: () =>
}
export const saveCharts = () => async (dispatch: Dispatch<any>, getState: () => AppState) => {
const connectionId = getState().connection.connectionId
const { connectionId } = getState().connection
if (!connectionId) {
return
}
@@ -110,9 +110,7 @@ export const moveChartUp =
dispatch(saveCharts())
}
export const setCharts = (charts: Array<ChartParameters>): Action => {
return {
charts,
type: ActionTypes.CHARTS_SET,
}
}
export const setCharts = (charts: Array<ChartParameters>): Action => ({
charts,
type: ActionTypes.CHARTS_SET,
})

View File

@@ -1,10 +1,10 @@
import * as q from '../../../backend/src/Model'
import * as url from 'url'
import { DataSourceState, MqttOptions } from 'mqtt-explorer-backend/src/DataSource/DataSource'
import { Dispatch } from 'redux'
import * as q from '../../../backend/src/Model'
import { Action, ActionTypes } from '../reducers/Connection'
import { ActionTypes as SettingsActionTypes } from '../reducers/Settings'
import { AppState } from '../reducers'
import { DataSourceState, MqttOptions } from '../../../backend/src/DataSource'
import { Dispatch } from 'redux'
import { globalActions } from '.'
import { resetStore as resetTreeStore, showTree } from './Tree'
import { showError } from './Global'

View File

@@ -1,3 +1,7 @@
import { Dispatch } from 'redux'
import * as path from 'path'
import { Subscription } from 'mqtt-explorer-backend/src/DataSource/MqttSource'
import { makeOpenDialogRpc } from '../../../events/OpenDialogRequest'
import { AppState } from '../reducers'
import { clearLegacyConnectionOptions, loadLegacyConnectionOptions } from '../model/LegacyConnectionSettings'
import {
@@ -7,14 +11,10 @@ import {
CertificateParameters,
} from '../model/ConnectionOptions'
import { default as persistentStorage, StorageIdentifier } from '../utils/PersistentStorage'
import { Dispatch } from 'redux'
import { showError } from './Global'
import * as path from 'path'
import { ActionTypes, Action } from '../reducers/ConnectionManager'
import { Subscription } from '../../../backend/src/DataSource/MqttSource'
import { connectionsMigrator } from './migrations/Connection'
import { rendererRpc, readFromFile } from '../eventBus'
import { makeOpenDialogRpc } from '../../../events/OpenDialogRequest'
export interface ConnectionDictionary {
[s: string]: ConnectionOptions

View File

@@ -1,5 +1,5 @@
import { ActionTypes, ConfirmationRequest } from '../reducers/Global'
import { Dispatch } from 'redux'
import { ActionTypes, ConfirmationRequest } from '../reducers/Global'
export const showError = (error?: string | unknown) => ({
error,
@@ -27,8 +27,8 @@ export const toggleAboutDialogVisibility = () => (dispatch: Dispatch<any>) => {
})
}
export const requestConfirmation = (title: string, inquiry: string) => (dispatch: Dispatch<any>) => {
return new Promise(resolve => {
export const requestConfirmation = (title: string, inquiry: string) => (dispatch: Dispatch<any>) =>
new Promise(resolve => {
const confirmationRequest = {
title,
inquiry,
@@ -43,13 +43,11 @@ export const requestConfirmation = (title: string, inquiry: string) => (dispatch
type: ActionTypes.requestConfirmation,
})
})
}
export const removeConfirmationRequest = (confirmationRequest: ConfirmationRequest) => (dispatch: Dispatch<any>) => {
return new Promise((resolve, reject) => {
export const removeConfirmationRequest = (confirmationRequest: ConfirmationRequest) => (dispatch: Dispatch<any>) =>
new Promise((resolve, reject) => {
dispatch({
confirmationRequest,
type: ActionTypes.removeConfirmationRequest,
})
})
}

View File

@@ -1,18 +1,16 @@
import { Dispatch } from 'redux'
import { makeOpenDialogRpc } from '../../../events/OpenDialogRequest'
import { Base64 } from 'js-base64'
import { Base64Message } from '../../../backend/src/Model/Base64Message'
import { Action, ActionTypes } from '../reducers/Publish'
import { AppState } from '../reducers'
import { Base64Message } from '../../../backend/src/Model/Base64Message'
import { Dispatch } from 'redux'
import { MqttMessage, makePublishEvent, rendererEvents, rendererRpc, readFromFile } from '../eventBus'
import { makeOpenDialogRpc } from '../../../events/OpenDialogRequest'
import { showError } from './Global'
import { Base64 } from 'js-base64'
export const setTopic = (topic?: string): Action => {
return {
topic,
type: ActionTypes.PUBLISH_SET_TOPIC,
}
}
export const setTopic = (topic?: string): Action => ({
topic,
type: ActionTypes.PUBLISH_SET_TOPIC,
})
export const openFile =
(encoding: BufferEncoding = 'utf8') =>
@@ -58,26 +56,20 @@ async function getFileContent(encoding: BufferEncoding): Promise<FileParameters
}
}
export const setPayload = (payload?: string): Action => {
return {
payload,
type: ActionTypes.PUBLISH_SET_PAYLOAD,
}
}
export const setPayload = (payload?: string): Action => ({
payload,
type: ActionTypes.PUBLISH_SET_PAYLOAD,
})
export const setQoS = (qos: 0 | 1 | 2): Action => {
return {
qos,
type: ActionTypes.PUBLISH_SET_QOS,
}
}
export const setQoS = (qos: 0 | 1 | 2): Action => ({
qos,
type: ActionTypes.PUBLISH_SET_QOS,
})
export const setEditorMode = (editorMode: string): Action => {
return {
editorMode,
type: ActionTypes.PUBLISH_SET_EDITOR_MODE,
}
}
export const setEditorMode = (editorMode: string): Action => ({
editorMode,
type: ActionTypes.PUBLISH_SET_EDITOR_MODE,
})
export const publish = (connectionId: string) => (dispatch: Dispatch<Action>, getState: () => AppState) => {
const state = getState()
@@ -97,8 +89,6 @@ export const publish = (connectionId: string) => (dispatch: Dispatch<Action>, ge
rendererEvents.emit(publishEvent, mqttMessage)
}
export const toggleRetain = (): Action => {
return {
type: ActionTypes.PUBLISH_TOGGLE_RETAIN,
}
}
export const toggleRetain = (): Action => ({
type: ActionTypes.PUBLISH_TOGGLE_RETAIN,
})

View File

@@ -1,12 +1,12 @@
import { batchActions } from 'redux-batched-actions'
import { Dispatch } from 'redux'
import * as q from '../../../backend/src/Model'
import { Base64Message } from '../../../backend/src/Model/Base64Message'
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'
import { batchActions } from 'redux-batched-actions'
import { default as persistentStorage, StorageIdentifier } from '../utils/PersistentStorage'
import { Dispatch } from 'redux'
import { globalActions } from './'
import { globalActions } from '.'
import { showError } from './Global'
import { showTree } from './Tree'
import { TopicViewModel } from '../model/TopicViewModel'

View File

@@ -1,7 +1,7 @@
import { Dispatch } from 'redux'
import * as q from '../../../backend/src/Model'
import { ActionTypes } from '../reducers/Sidebar'
import { AppState } from '../reducers'
import { Dispatch } from 'redux'
import { clearTopic } from './clearTopic'
export { clearTopic } from './clearTopic'

View File

@@ -1,13 +1,14 @@
import { AnyAction, Dispatch } from 'redux'
import { batchActions } from 'redux-batched-actions'
import debounce from 'lodash.debounce'
import * as q from '../../../backend/src/Model'
import { ActionTypes } from '../reducers/Tree'
import { ActionTypes as SidebarActionTypes } from '../reducers/Sidebar'
import { AnyAction, Dispatch } from 'redux'
import { AppState } from '../reducers'
import { batchActions } from 'redux-batched-actions'
import { globalActions } from './'
import { globalActions } from '.'
import { setTopic } from './Publish'
import { TopicViewModel } from '../model/TopicViewModel'
import debounce from 'lodash.debounce'
export { clearTopic } from './clearTopic'
export { moveSelectionUpOrDownwards, moveInward, moveOutward } from './visibleTreeTraversal'

View File

@@ -1,15 +1,11 @@
import { ActionTypes, GlobalAction } from '../reducers/Global'
export const showUpdateNotification = (show: boolean): GlobalAction => {
return {
type: ActionTypes.showUpdateNotification,
showUpdateNotification: show,
}
}
export const showUpdateNotification = (show: boolean): GlobalAction => ({
type: ActionTypes.showUpdateNotification,
showUpdateNotification: show,
})
export const showUpdateDetails = (show: boolean): GlobalAction => {
return {
type: ActionTypes.showUpdateDetails,
showUpdateDetails: show,
}
}
export const showUpdateDetails = (show: boolean): GlobalAction => ({
type: ActionTypes.showUpdateDetails,
showUpdateDetails: show,
})

View File

@@ -1,6 +1,6 @@
import { Dispatch } from 'redux'
import * as q from '../../../backend/src/Model'
import { AppState } from '../reducers'
import { Dispatch } from 'redux'
import { makePublishEvent, rendererEvents } from '../eventBus'
import { moveSelectionUpOrDownwards } from './visibleTreeTraversal'
import { globalActions } from '.'
@@ -45,7 +45,7 @@ export const clearTopic =
topic: path,
payload: null,
retain: true,
qos: 0 as 0,
qos: 0 as const,
messageId: undefined,
}
// Rate limit deletion

View File

@@ -21,7 +21,7 @@ export interface ConnectionOptionsV0 {
subscriptions: Array<string>
}
let migrations: Migration[] = [
const migrations: Migration[] = [
// iot.eclipse.org ha moved to mqtt.eclipse.org
{
from: undefined,
@@ -60,13 +60,11 @@ let migrations: Migration[] = [
// Added QoS level to subscription options
{
from: undefined,
apply: (connection: ConnectionOptionsV0): ConnectionOptions => {
return {
...connection,
configVersion: 1,
subscriptions: connection.subscriptions.map(topic => ({ topic, qos: 0 })),
}
},
apply: (connection: ConnectionOptionsV0): ConnectionOptions => ({
...connection,
configVersion: 1,
subscriptions: connection.subscriptions.map(topic => ({ topic, qos: 0 })),
}),
},
]
@@ -79,9 +77,9 @@ function isMigrationNecessary(connections: ConnectionDictionary): boolean {
}
function applyMigrations(connections: ConnectionDictionary): ConnectionDictionary {
let newConnectionDictionary: ConnectionDictionary = {}
const newConnectionDictionary: ConnectionDictionary = {}
Object.keys(connections).forEach(key => {
let newConnection = connectionMigrator.applyMigrations(connections[key]) as any
const newConnection = connectionMigrator.applyMigrations(connections[key]) as any
newConnectionDictionary[newConnection.id] = newConnection
})

View File

@@ -1,6 +1,6 @@
import { Dispatch } from 'redux'
import * as q from '../../../backend/src/Model'
import { AppState } from '../reducers'
import { Dispatch } from 'redux'
import { selectTopic } from './Tree'
import { SettingsState } from '../reducers/Settings'
import { sortedNodes } from '../sortedNodes'
@@ -69,9 +69,8 @@ function nextVisibleElementInTree(
): q.TreeNode<TopicViewModel> | undefined {
if (direction === 'next') {
return findNextNodeDownward(settings, node)
} else {
return findNextNodeUpward(settings, node)
}
return findNextNodeUpward(settings, node)
}
/** Not very efficient but easy to implement, complexity should not be an issue here */
@@ -92,9 +91,8 @@ function findNextNodeUpward(
const upwardNeighbor = neighborNodes[nodeIdx - 1]
if (upwardNeighbor) {
return lastVisibleChild(settings, upwardNeighbor)
} else {
return findNextNodeUpward(settings, parent)
}
return findNextNodeUpward(settings, parent)
}
function lastVisibleChild(settings: SettingsState, treeNode: q.TreeNode<TopicViewModel>): q.TreeNode<TopicViewModel> {
@@ -132,7 +130,6 @@ function findNextNodeDownwardNeighbor(
const downwardNeighbor = neighborNodes[nodeIdx + 1]
if (downwardNeighbor) {
return downwardNeighbor
} else {
return findNextNodeDownwardNeighbor(settings, parent)
}
return findNextNodeDownwardNeighbor(settings, parent)
}

View File

@@ -1,31 +1,31 @@
// Auto-connect handler for browser mode
// This file is loaded early in the app initialization to handle server-initiated auto-connect
import { store } from './store'
import * as q from '../../backend/src/Model'
import { DataSourceState } from 'mqtt-explorer-backend/src/DataSource/DataSource'
import { store } from './store'
import { TopicViewModel } from './model/TopicViewModel'
import { showTree } from './actions/Tree'
import { connecting, connected } from './actions/Connection'
import { makeConnectionStateEvent, rendererEvents } from './eventBus'
import { DataSourceState } from '../../backend/src/DataSource'
// Listen for auto-connect-initiated event from server
if (typeof window !== 'undefined') {
window.addEventListener('mqtt-auto-connect-initiated', ((event: CustomEvent) => {
const { connectionId } = event.detail
console.log('Auto-connect initiated from server, connectionId:', connectionId)
// Dispatch connecting action
store.dispatch(connecting(connectionId) as any)
console.log('Dispatched connecting action')
// Subscribe to connection state events
const stateEvent = makeConnectionStateEvent(connectionId)
console.log('Subscribing to connection state event:', stateEvent)
rendererEvents.subscribe(stateEvent, (dataSourceState: DataSourceState) => {
console.log('Auto-connect state update:', JSON.stringify(dataSourceState, null, 2))
if (dataSourceState.connected) {
console.log('Auto-connect: connection established!')
const state = store.getState()

View File

@@ -23,76 +23,105 @@ const socket: Socket = io({
})
// Handle connection errors
socket.on('connect_error', (error) => {
socket.on('connect_error', error => {
console.error('Socket connection error:', error.message)
// Check if it's an authentication error
if (error.message.includes('Invalid credentials') ||
error.message.includes('Authentication required') ||
error.message.includes('Too many')) {
if (
error.message.includes('Invalid credentials') ||
error.message.includes('Authentication required') ||
error.message.includes('Too many')
) {
// Clear invalid credentials from sessionStorage
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem('mqtt-explorer-username')
sessionStorage.removeItem('mqtt-explorer-password')
}
// Dispatch custom event that BrowserAuthWrapper can listen to
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('mqtt-auth-error', {
detail: { message: error.message }
}))
window.dispatchEvent(
new CustomEvent('mqtt-auth-error', {
detail: { message: error.message },
})
)
}
}
})
socket.on('disconnect', (reason) => {
socket.on('disconnect', reason => {
console.log('Socket disconnected:', reason)
})
socket.on('connect', () => {
console.log('Socket connected successfully')
// Dispatch custom event that BrowserAuthWrapper can listen to
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('mqtt-auth-success', {
detail: { message: 'Authentication successful' }
}))
window.dispatchEvent(
new CustomEvent('mqtt-auth-success', {
detail: { message: 'Authentication successful' },
})
)
}
})
// Listen for auth-status from server (sent on connection)
socket.on('auth-status', (data: { authDisabled: boolean }) => {
console.log('Auth status received from server:', data)
// Dispatch custom event with auth status
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('mqtt-auth-status', {
detail: { authDisabled: data.authDisabled }
}))
window.dispatchEvent(
new CustomEvent('mqtt-auth-status', {
detail: { authDisabled: data.authDisabled },
})
)
}
})
// Listen for auto-connect configuration from server
socket.on('auto-connect-config', (config: any) => {
console.log('Auto-connect configuration received from server')
// Dispatch custom event with auto-connect config
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('mqtt-auto-connect-config', {
detail: config
}))
window.dispatchEvent(
new CustomEvent('mqtt-auto-connect-config', {
detail: config,
})
)
}
})
// Listen for auto-connect-initiated event from server
socket.on('auto-connect-initiated', (data: { connectionId: string }) => {
console.log('Auto-connect initiated by server, connectionId:', data.connectionId)
// Dispatch custom event to trigger connection flow
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('mqtt-auto-connect-initiated', {
detail: data
}))
window.dispatchEvent(
new CustomEvent('mqtt-auto-connect-initiated', {
detail: data,
})
)
}
})
// Listen for LLM availability from server (new architecture)
socket.on('llm-available', (data: { available: boolean }) => {
console.log('LLM availability received from server:', data.available)
// Store availability flag in window object
if (typeof window !== 'undefined') {
window.__llmAvailable = data.available
// Dispatch custom event for components to react
window.dispatchEvent(
new CustomEvent('llm-availability-changed', {
detail: { available: data.available },
})
)
}
})
@@ -104,19 +133,19 @@ socket.on('auto-connect-initiated', (data: { connectionId: string }) => {
export function updateSocketAuth(newUsername: string, newPassword: string) {
username = newUsername
password = newPassword
// Update socket auth
socket.auth = {
username: newUsername,
password: newPassword,
}
// Store in sessionStorage
if (typeof sessionStorage !== 'undefined') {
sessionStorage.setItem('mqtt-explorer-username', newUsername)
sessionStorage.setItem('mqtt-explorer-password', newPassword)
}
// Disconnect if connected, then reconnect with new credentials
if (socket.connected) {
socket.disconnect()

View File

@@ -5,10 +5,10 @@ import * as path from 'path'
/**
* AboutDialog License Compliance Tests
*
*
* These tests verify that the About dialog properly displays required
* attribution information as mandated by the CC-BY-ND-4.0 license.
*
*
* CC-BY-ND-4.0 (Creative Commons Attribution-NoDerivatives 4.0 International):
* - BY (Attribution): Must credit the original author (Thomas Nordquist)
* - ND (NoDerivatives): Cannot create derivative works without permission
@@ -48,45 +48,45 @@ describe('AboutDialog License Compliance', () => {
// CC-BY-ND-4.0 Attribution (BY) requirement:
// Must credit the original author "Thomas Nordquist"
const hasAuthor = aboutDialogContent.includes('Thomas Nordquist')
if (!hasAuthor) {
throw new Error(
'LICENSE VIOLATION: Author attribution "Thomas Nordquist" is missing. ' +
'This violates the CC-BY-ND-4.0 Attribution (BY) requirement. ' +
'The author must be properly credited in the About dialog.'
'This violates the CC-BY-ND-4.0 Attribution (BY) requirement. ' +
'The author must be properly credited in the About dialog.'
)
}
expect(hasAuthor).to.be.true
})
it('removing license notice violates CC-BY-ND-4.0 license', () => {
// CC-BY-ND-4.0 requires the license identifier to be displayed
const hasLicense = aboutDialogContent.includes('CC-BY-ND-4.0')
if (!hasLicense) {
throw new Error(
'LICENSE VIOLATION: License notice "CC-BY-ND-4.0" is missing. ' +
'This violates CC-BY-ND-4.0 license notice requirements. ' +
'The license identifier must be displayed in the About dialog.'
'This violates CC-BY-ND-4.0 license notice requirements. ' +
'The license identifier must be displayed in the About dialog.'
)
}
expect(hasLicense).to.be.true
})
it('removing LICENSE NOTICE comment violates CC-BY-ND-4.0 license', () => {
// CC-BY-ND-4.0 requires attribution notice in source code
const hasLicenseNotice = aboutDialogContent.includes('LICENSE NOTICE')
if (!hasLicenseNotice) {
throw new Error(
'LICENSE VIOLATION: LICENSE NOTICE comment is missing from source code. ' +
'This violates CC-BY-ND-4.0 source code attribution requirements. ' +
'The LICENSE NOTICE comment must be retained in the component source.'
'This violates CC-BY-ND-4.0 source code attribution requirements. ' +
'The LICENSE NOTICE comment must be retained in the component source.'
)
}
expect(hasLicenseNotice).to.be.true
})
})
@@ -94,39 +94,39 @@ describe('AboutDialog License Compliance', () => {
/**
* AboutDialog Functionality Tests
*
*
* These tests verify that the About dialog is accessible and functional.
*/
describe('AboutDialog Accessibility', () => {
const detailsTabPath = path.join(__dirname, 'Sidebar', 'DetailsTab.tsx')
const appPath = path.join(__dirname, 'App.tsx')
it('should be accessible from the DetailsTab component', () => {
const detailsTabContent = fs.readFileSync(detailsTabPath, 'utf-8')
// Verify the About button exists in DetailsTab
expect(detailsTabContent).to.include('About')
// Verify it triggers the toggle action
expect(detailsTabContent).to.include('toggleAboutDialogVisibility')
})
it('should be integrated in the App component', () => {
const appContent = fs.readFileSync(appPath, 'utf-8')
// Verify AboutDialog is imported
expect(appContent).to.include('AboutDialog')
// Verify it's rendered with state
expect(appContent).to.include('aboutDialogVisible')
})
it('should have About button with Info icon in DetailsTab', () => {
const detailsTabContent = fs.readFileSync(detailsTabPath, 'utf-8')
// Verify the button text
expect(detailsTabContent).to.include('About MQTT Explorer')
// Verify Info icon is used
expect(detailsTabContent).to.match(/import.*Info.*from.*@mui\/icons-material/)
})

View File

@@ -11,8 +11,8 @@ import {
Box,
Divider,
} from '@mui/material'
import { rendererRpc, getAppVersion } from '../../../events'
import FavoriteIcon from '@mui/icons-material/Favorite'
import { rendererRpc, getAppVersion } from '../eventBus'
// Fallback version if RPC call fails (e.g., in browser mode during initialization)
const FALLBACK_VERSION = '0.4.0-beta.5'
@@ -24,20 +24,20 @@ interface AboutDialogProps {
/**
* About Dialog Component
*
*
* This component displays application information including version, author, and license.
*
*
* LICENSE NOTICE (CC-BY-ND-4.0):
* This component is licensed under Creative Commons Attribution-NoDerivatives 4.0 International.
*
*
* REQUIRED ATTRIBUTION:
* - Author: Thomas Nordquist
* - License: CC-BY-ND-4.0
*
*
* RESTRICTIONS:
* - BY (Attribution): You must give appropriate credit to the author
* - ND (NoDerivatives): You may not create derivative works without permission
*
*
* Removing or modifying this attribution violates the license terms.
* For full license text: https://creativecommons.org/licenses/by-nd/4.0/legalcode
*/
@@ -68,15 +68,19 @@ export function AboutDialog(props: AboutDialogProps) {
<Typography variant="body1" gutterBottom>
<strong>Description:</strong> Explore your message queues
</Typography>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }} data-testid="about-author">
<Avatar
src="https://github.com/thomasnordquist.png"
alt="Thomas Nordquist"
sx={{ width: 56, height: 56 }}
/>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
mb: 2,
}}
data-testid="about-author"
>
<Avatar src="https://github.com/thomasnordquist.png" alt="Thomas Nordquist" sx={{ width: 56, height: 56 }} />
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 500 }}>
Thomas Nordquist

View File

@@ -1,19 +1,19 @@
import CssBaseline from '@mui/material/CssBaseline'
import React from 'react'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import ConfirmationDialog from './ConfirmationDialog'
import ConnectionSetup from './ConnectionSetup/ConnectionSetup'
import CssBaseline from '@mui/material/CssBaseline'
import ErrorBoundary from './ErrorBoundary'
import Notification from './Layout/Notification'
import React from 'react'
import TitleBar from './Layout/TitleBar'
import UpdateNotifier from './UpdateNotifier'
import { AboutDialog } from './AboutDialog'
import { AppState } from '../reducers'
import { bindActionCreators } from 'redux'
import { ConfirmationRequest } from '../reducers/Global'
import { connect } from 'react-redux'
import { globalActions, settingsActions } from '../actions'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
;(window as any).global = window
const Settings = React.lazy(() => import('./SettingsDrawer/Settings'))
@@ -82,7 +82,7 @@ class App extends React.PureComponent<Props, {}> {
onClose={() => this.props.actions.toggleAboutDialogVisibility()}
/>
{this.renderNotification()}
<React.Suspense fallback={<div></div>}>
<React.Suspense fallback={<div />}>
<Settings {...anyProps} />
</React.Suspense>
<div className={centerContent}>
@@ -90,7 +90,7 @@ class App extends React.PureComponent<Props, {}> {
<TitleBar />
</div>
<div className={settingsVisible ? contentShift : content}>
<React.Suspense fallback={<div></div>}>
<React.Suspense fallback={<div />}>
<ContentView
heightProperty={heightProperty}
connectionId={this.props.connectionId}
@@ -121,12 +121,12 @@ const styles = (theme: Theme) => {
paneDefaults: {
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
display: 'block' as 'block',
display: 'block' as const,
height: 'calc(100vh - 64px)',
},
centerContent: {
width: '100vw',
overflow: 'hidden' as 'hidden',
overflow: 'hidden' as const,
},
content: {
...contentBaseStyle,
@@ -148,24 +148,20 @@ const styles = (theme: Theme) => {
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(globalActions, dispatch),
settingsActions: bindActionCreators(settingsActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(globalActions, dispatch),
settingsActions: bindActionCreators(settingsActions, dispatch),
})
const mapStateToProps = (state: AppState) => {
return {
settingsVisible: state.globalState.get('settingsVisible'),
connectionId: state.connection.connectionId,
error: state.globalState.get('error'),
notification: state.globalState.get('notification'),
highlightTopicUpdates: state.settings.get('highlightTopicUpdates'),
launching: state.globalState.get('launching'),
confirmationRequests: state.globalState.get('confirmationRequests'),
aboutDialogVisible: state.globalState.get('aboutDialogVisible'),
}
}
const mapStateToProps = (state: AppState) => ({
settingsVisible: state.globalState.get('settingsVisible'),
connectionId: state.connection.connectionId,
error: state.globalState.get('error'),
notification: state.globalState.get('notification'),
highlightTopicUpdates: state.settings.get('highlightTopicUpdates'),
launching: state.globalState.get('launching'),
confirmationRequests: state.globalState.get('confirmationRequests'),
aboutDialogVisible: state.globalState.get('aboutDialogVisible'),
})
export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(App))

View File

@@ -29,7 +29,7 @@ export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) {
const handleAuthStatus = (event: CustomEvent) => {
const { authDisabled } = event.detail
setAuthDisabled(authDisabled)
if (authDisabled) {
// Authentication is disabled on server
console.log('Authentication is disabled on server, skipping login')
@@ -39,7 +39,7 @@ export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) {
} else {
// Authentication is enabled, check if we have credentials
setAuthCheckComplete(true)
const username = sessionStorage.getItem('mqtt-explorer-username')
const password = sessionStorage.getItem('mqtt-explorer-password')
@@ -67,15 +67,15 @@ export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) {
const handleAuthError = (event: CustomEvent) => {
const errorMessage = event.detail?.message || 'Authentication failed'
console.error('Authentication error:', errorMessage)
// Mark auth check as complete - we now know auth is required
setAuthCheckComplete(true)
// Clear authentication state
setIsAuthenticated(false)
setShowLogin(true)
setIsConnecting(false)
// Extract wait time from error message (e.g., "Please wait 30 seconds")
const waitTimeMatch = errorMessage.match(/(\d+)\s+seconds?/)
if (waitTimeMatch) {
@@ -85,7 +85,7 @@ export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) {
} else {
setWaitTimeSeconds(undefined)
}
// Set user-friendly error message based on error type
// Error messages from server already include wait times
if (errorMessage.includes('Too many failed authentication attempts')) {
@@ -121,7 +121,7 @@ export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) {
setLoginError(undefined)
setWaitTimeSeconds(undefined)
setIsConnecting(true)
// Update socket auth and reconnect (no page reload needed)
updateSocketAuth(username, password)
} catch (error) {

View File

@@ -11,11 +11,11 @@ describe('Chart X-Axis Domain Investigation', () => {
{ x: now - 3000, y: 21 },
{ x: now - 2000, y: 22 },
{ x: now - 1000, y: 23 },
{ x: now, y: 24 }
{ x: now, y: 24 },
]
const { container } = renderWithProviders(<Chart data={data} />)
// Find all circle elements (data points)
const circles = container.querySelectorAll('svg circle')
expect(circles).to.have.length(5)
@@ -33,26 +33,28 @@ describe('Chart X-Axis Domain Investigation', () => {
console.log('\n========== X-AXIS DOMAIN INVESTIGATION ==========')
console.log('Data X values (timestamps):')
data.forEach((d, i) => console.log(` Point ${i}: ${d.x} (${new Date(d.x).toISOString()})`))
console.log(`\nData X range: ${data[data.length - 1].x - data[0].x}ms (${(data[data.length - 1].x - data[0].x) / 1000}s)`)
console.log(
`\nData X range: ${data[data.length - 1].x - data[0].x}ms (${(data[data.length - 1].x - data[0].x) / 1000}s)`
)
console.log('\nRendered circle CX positions:')
cxValues.forEach((cx, i) => console.log(` Circle ${i}: cx=${cx.toFixed(2)}px`))
const minCx = Math.min(...cxValues)
const maxCx = Math.max(...cxValues)
const cxRange = maxCx - minCx
console.log(`\nCX position range: ${cxRange.toFixed(2)}px (from ${minCx.toFixed(2)} to ${maxCx.toFixed(2)})`)
console.log(`Points per pixel: ${(cxValues.length / cxRange).toFixed(4)}`)
// Calculate spacing between consecutive points
const spacings: number[] = []
for (let i = 1; i < cxValues.length; i++) {
spacings.push(cxValues[i] - cxValues[i - 1])
}
console.log('\nSpacing between consecutive points:')
spacings.forEach((s, i) => console.log(` ${i} to ${i+1}: ${s.toFixed(2)}px`))
spacings.forEach((s, i) => console.log(` ${i} to ${i + 1}: ${s.toFixed(2)}px`))
const avgSpacing = spacings.reduce((a, b) => a + b, 0) / spacings.length
console.log(`Average spacing: ${avgSpacing.toFixed(2)}px`)
console.log('=================================================\n')
@@ -60,18 +62,19 @@ describe('Chart X-Axis Domain Investigation', () => {
// Assertions:
// 1. Points should be spread out (CX range should be significant, not bunched)
expect(cxRange).to.be.greaterThan(50, 'Points should be spread across at least 50px')
// 2. Points should be in ascending order (left to right)
for (let i = 1; i < cxValues.length; i++) {
expect(cxValues[i]).to.be.greaterThan(cxValues[i - 1],
`Point ${i} (cx=${cxValues[i]}) should be to the right of point ${i-1} (cx=${cxValues[i-1]})`)
expect(cxValues[i]).to.be.greaterThan(
cxValues[i - 1],
`Point ${i} (cx=${cxValues[i]}) should be to the right of point ${i - 1} (cx=${cxValues[i - 1]})`
)
}
// 3. Spacing should be relatively uniform (since data points are equally spaced in time)
const spacingVariance = spacings.map(s => Math.abs(s - avgSpacing))
const maxVariance = Math.max(...spacingVariance)
expect(maxVariance).to.be.lessThan(avgSpacing * 0.5,
'Spacing between points should be relatively uniform')
expect(maxVariance).to.be.lessThan(avgSpacing * 0.5, 'Spacing between points should be relatively uniform')
})
it('should handle points bunched at far right correctly', () => {
@@ -82,11 +85,11 @@ describe('Chart X-Axis Domain Investigation', () => {
{ x: largeTimestamp + 1000, y: 21 },
{ x: largeTimestamp + 2000, y: 22 },
{ x: largeTimestamp + 3000, y: 23 },
{ x: largeTimestamp + 4000, y: 24 }
{ x: largeTimestamp + 4000, y: 24 },
]
const { container } = renderWithProviders(<Chart data={data} />)
const circles = container.querySelectorAll('svg circle')
const cxValues: number[] = []
circles.forEach(circle => {
@@ -97,16 +100,21 @@ describe('Chart X-Axis Domain Investigation', () => {
})
console.log('\n========== LARGE TIMESTAMP TEST ==========')
console.log('Data X values:', data.map(d => d.x))
console.log('Rendered CX values:', cxValues.map(v => v.toFixed(2)))
console.log(
'Data X values:',
data.map(d => d.x)
)
console.log(
'Rendered CX values:',
cxValues.map(v => v.toFixed(2))
)
const minCx = Math.min(...cxValues)
const maxCx = Math.max(...cxValues)
console.log(`CX range: ${(maxCx - minCx).toFixed(2)}px`)
console.log('==========================================\n')
// Points should still be spread out even with large timestamps
expect(maxCx - minCx).to.be.greaterThan(50,
'Points with large timestamps should still be spread across the chart')
expect(maxCx - minCx).to.be.greaterThan(50, 'Points with large timestamps should still be spread across the chart')
})
})

View File

@@ -1,6 +1,6 @@
/**
* Chart Component Tests
*
*
* These tests verify the Chart component functionality including:
* - Rendering with various data configurations
* - Theme integration
@@ -22,14 +22,14 @@ describe('Chart Component', () => {
it('should render without crashing with valid data', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container).to.exist
expect(container.querySelector('svg')).to.exist
})
it('should render NoData component when data is empty', () => {
const { container } = renderWithProviders(<Chart data={[]} />, { withTheme: true })
expect(container).to.exist
// NoData component should be rendered
const noDataElement = container.querySelector('div')
@@ -39,7 +39,7 @@ describe('Chart Component', () => {
it('should render chart with correct height', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
const chartContainer = container.querySelector('[style*="height"]') as HTMLElement
expect(chartContainer).to.exist
expect(chartContainer.style.height).to.equal('150px')
@@ -48,11 +48,11 @@ describe('Chart Component', () => {
it('should render SVG chart elements', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
// Check for SVG element
const svg = container.querySelector('svg')
expect(svg).to.exist
// Check for chart elements (paths for line series)
const paths = container.querySelectorAll('path')
expect(paths.length).to.be.greaterThan(0)
@@ -63,7 +63,7 @@ describe('Chart Component', () => {
it('should render data points as glyphs', () => {
const data = createMockChartData(3)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
// Check for circles (glyphs representing data points)
const circles = container.querySelectorAll('circle')
expect(circles.length).to.be.greaterThan(0)
@@ -73,11 +73,11 @@ describe('Chart Component', () => {
const dataLength = 5
const data = createMockChartData(dataLength)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
// Each data point should render as a circle
const circles = container.querySelectorAll('circle')
expect(circles.length).to.equal(dataLength, `Expected ${dataLength} circles for ${dataLength} data points`)
// Verify each circle has proper attributes
circles.forEach((circle, index) => {
expect(circle.getAttribute('cx')).to.exist
@@ -90,18 +90,18 @@ describe('Chart Component', () => {
it('should position data points with valid coordinates', () => {
const data = createMockChartData(3)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
const circles = container.querySelectorAll('circle')
circles.forEach((circle) => {
circles.forEach(circle => {
const cx = parseFloat(circle.getAttribute('cx') || '0')
const cy = parseFloat(circle.getAttribute('cy') || '0')
// Coordinates should be valid numbers
expect(cx).to.be.a('number')
expect(cy).to.be.a('number')
expect(isNaN(cx)).to.be.false
expect(isNaN(cy)).to.be.false
// Coordinates should be within chart bounds (positive values)
expect(cx).to.be.greaterThan(0)
expect(cy).to.be.greaterThan(0)
@@ -111,7 +111,7 @@ describe('Chart Component', () => {
it('should render line connecting data points', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
// Line series should create a path element
const paths = container.querySelectorAll('path')
expect(paths.length).to.be.greaterThan(0)
@@ -120,7 +120,7 @@ describe('Chart Component', () => {
it('should handle single data point', () => {
const data = [{ x: Date.now(), y: 50 }]
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
const circles = container.querySelectorAll('circle')
expect(circles.length).to.equal(1, 'Single data point should render as one circle')
@@ -129,7 +129,7 @@ describe('Chart Component', () => {
it('should handle large datasets', () => {
const data = createMockChartData(100)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
const circles = container.querySelectorAll('circle')
expect(circles.length).to.equal(100, '100 data points should render as 100 circles')
@@ -139,14 +139,13 @@ describe('Chart Component', () => {
describe('Curve Interpolation', () => {
const curveTypes: PlotCurveTypes[] = ['curve', 'linear', 'cubic_basis_spline', 'step_after', 'step_before']
curveTypes.forEach((interpolation) => {
curveTypes.forEach(interpolation => {
it(`should render with ${interpolation} interpolation`, () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(
<Chart data={data} interpolation={interpolation} />,
{ withTheme: true }
)
const { container } = renderWithProviders(<Chart data={data} interpolation={interpolation} />, {
withTheme: true,
})
expect(container.querySelector('svg')).to.exist
const paths = container.querySelectorAll('path')
expect(paths.length).to.be.greaterThan(0)
@@ -158,11 +157,8 @@ describe('Chart Component', () => {
it('should apply custom color', () => {
const data = createMockChartData(5)
const customColor = '#ff0000'
const { container } = renderWithProviders(
<Chart data={data} color={customColor} />,
{ withTheme: true }
)
const { container } = renderWithProviders(<Chart data={data} color={customColor} />, { withTheme: true })
// Check if custom color is applied to line or glyphs
const svg = container.querySelector('svg')
expect(svg).to.exist
@@ -171,7 +167,7 @@ describe('Chart Component', () => {
it('should use theme colors when no custom color provided', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
})
@@ -180,44 +176,34 @@ describe('Chart Component', () => {
it('should render with custom Y range', () => {
const data = createMockChartData(5)
const range: [number, number] = [0, 100]
const { container } = renderWithProviders(
<Chart data={data} range={range} />,
{ withTheme: true }
)
const { container } = renderWithProviders(<Chart data={data} range={range} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
it('should render with custom time range', () => {
const data = createMockChartData(5)
const timeRangeStart = 60000 // 1 minute
const { container } = renderWithProviders(
<Chart data={data} timeRangeStart={timeRangeStart} />,
{ withTheme: true }
)
const { container } = renderWithProviders(<Chart data={data} timeRangeStart={timeRangeStart} />, {
withTheme: true,
})
expect(container.querySelector('svg')).to.exist
})
it('should render with partial Y range (only min)', () => {
const data = createMockChartData(5)
const range: [number?, number?] = [0, undefined]
const { container } = renderWithProviders(
<Chart data={data} range={range} />,
{ withTheme: true }
)
const { container } = renderWithProviders(<Chart data={data} range={range} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
it('should render with partial Y range (only max)', () => {
const data = createMockChartData(5)
const range: [number?, number?] = [undefined, 100]
const { container } = renderWithProviders(
<Chart data={data} range={range} />,
{ withTheme: true }
)
const { container } = renderWithProviders(<Chart data={data} range={range} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
})
@@ -226,11 +212,11 @@ describe('Chart Component', () => {
it('should render Y-axis', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
// Y-axis should be present (look for axis group or tick marks)
const svg = container.querySelector('svg')
expect(svg).to.exist
// Axis typically contains text elements for labels
const texts = container.querySelectorAll('text')
expect(texts.length).to.be.greaterThan(0)
@@ -239,18 +225,18 @@ describe('Chart Component', () => {
it('should render X-axis with time labels', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
// X-axis should be present with text labels
const svg = container.querySelector('svg')
expect(svg).to.exist
// X-axis has text labels for timestamps
const texts = container.querySelectorAll('text')
expect(texts.length).to.be.greaterThan(0, 'X-axis and Y-axis should have text labels')
// At least one text element should contain time format (e.g., contains ":")
let hasTimeFormat = false
texts.forEach((text) => {
texts.forEach(text => {
if (text.textContent && text.textContent.includes(':')) {
hasTimeFormat = true
}
@@ -261,14 +247,14 @@ describe('Chart Component', () => {
it('should render both X and Y axes', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
const svg = container.querySelector('svg')
expect(svg).to.exist
// Both axes should render tick marks (lines)
const lines = container.querySelectorAll('line')
expect(lines.length).to.be.greaterThan(0, 'Axes should render tick marks')
// Both axes should have labels (text)
const texts = container.querySelectorAll('text')
expect(texts.length).to.be.greaterThan(2, 'Both axes should have multiple labels')
@@ -277,7 +263,7 @@ describe('Chart Component', () => {
it('should render grid lines', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
// Grid lines are rendered as line elements
const svg = container.querySelector('svg')
expect(svg).to.exist
@@ -286,10 +272,10 @@ describe('Chart Component', () => {
it('should have proper chart margins', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
const svg = container.querySelector('svg')
expect(svg).to.exist
// SVG should have proper dimensions
expect(svg?.getAttribute('width')).to.exist
expect(svg?.getAttribute('height')).to.exist
@@ -304,7 +290,7 @@ describe('Chart Component', () => {
{ x: Date.now(), y: -75 },
]
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
@@ -315,7 +301,7 @@ describe('Chart Component', () => {
{ x: Date.now(), y: 0 },
]
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
@@ -326,7 +312,7 @@ describe('Chart Component', () => {
{ x: Date.now(), y: 3000000 },
]
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
// Y-axis should abbreviate large numbers
const texts = container.querySelectorAll('text')
@@ -340,7 +326,7 @@ describe('Chart Component', () => {
{ x: Date.now(), y: 50 },
]
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
})
@@ -355,7 +341,7 @@ describe('Chart Component', () => {
timeRangeStart: 60000,
color: '#00ff00',
}
const { container } = renderWithProviders(<Chart {...props} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
@@ -363,7 +349,7 @@ describe('Chart Component', () => {
it('should work with minimal props', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
})
@@ -372,7 +358,7 @@ describe('Chart Component', () => {
it('should render in light theme', () => {
const data = createMockChartData(5)
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
expect(container.querySelector('svg')).to.exist
})
@@ -384,7 +370,7 @@ describe('Chart Component', () => {
it('should memoize component with same props', () => {
const data = createMockChartData(5)
const { rerender } = renderWithProviders(<Chart data={data} />, { withTheme: true })
// Component should not re-render with same props due to React.memo
expect(() => {
rerender(<Chart data={data} />)
@@ -392,16 +378,13 @@ describe('Chart Component', () => {
})
it('should handle rapid data updates', () => {
const { rerender, container } = renderWithProviders(
<Chart data={createMockChartData(5)} />,
{ withTheme: true }
)
const { rerender, container } = renderWithProviders(<Chart data={createMockChartData(5)} />, { withTheme: true })
// Simulate rapid updates
for (let i = 0; i < 10; i++) {
rerender(<Chart data={createMockChartData(5)} />)
}
expect(container.querySelector('svg')).to.exist
})
})
@@ -410,43 +393,40 @@ describe('Chart Component', () => {
it('should dynamically update when data points are added', () => {
// Start with 3 data points
const initialData = createMockChartData(3)
const { rerender, container } = renderWithProviders(
<Chart data={initialData} />,
{ withTheme: true }
)
const { rerender, container } = renderWithProviders(<Chart data={initialData} />, { withTheme: true })
// Verify initial state: should have 3 data points
const initialCircles = container.querySelectorAll('circle')
expect(initialCircles.length).to.equal(3, 'Should initially render 3 data points')
// Verify each initial circle has valid attributes
initialCircles.forEach((circle, index) => {
const cx = circle.getAttribute('cx')
const cy = circle.getAttribute('cy')
const r = circle.getAttribute('r')
expect(cx).to.exist
expect(cy).to.exist
expect(r).to.equal('3')
expect(parseFloat(cx!)).to.be.a('number').and.not.NaN
expect(parseFloat(cy!)).to.be.a('number').and.not.NaN
})
// Update state: add 2 more data points (total 5)
const updatedData = createMockChartData(5)
rerender(<Chart data={updatedData} />)
// Verify updated state: should now have 5 data points
const updatedCircles = container.querySelectorAll('circle')
expect(updatedCircles.length).to.equal(5, 'Should render 5 data points after update')
// Verify each updated circle has valid attributes
updatedCircles.forEach((circle, index) => {
const cx = circle.getAttribute('cx')
const cy = circle.getAttribute('cy')
const r = circle.getAttribute('r')
const fill = circle.getAttribute('fill')
expect(cx).to.exist
expect(cy).to.exist
expect(r).to.equal('3')
@@ -455,12 +435,12 @@ describe('Chart Component', () => {
expect(parseFloat(cy!)).to.be.a('number').and.not.NaN
expect(parseFloat(cy!)).to.be.greaterThan(0, 'Y coordinate should be positive')
})
// Verify the line path is updated to connect all 5 points
const linePath = container.querySelector('path[stroke]')
expect(linePath).to.exist
expect(linePath!.getAttribute('d')).to.exist
// The path should start with MoveTo (M) command and contain curve/line commands
const pathData = linePath!.getAttribute('d')
expect(pathData).to.include('M') // MoveTo command for first point
@@ -471,19 +451,16 @@ describe('Chart Component', () => {
it('should handle data point removal', () => {
// Start with 5 data points
const initialData = createMockChartData(5)
const { rerender, container } = renderWithProviders(
<Chart data={initialData} />,
{ withTheme: true }
)
const { rerender, container } = renderWithProviders(<Chart data={initialData} />, { withTheme: true })
// Verify initial state
let circles = container.querySelectorAll('circle')
expect(circles.length).to.equal(5, 'Should initially render 5 data points')
// Remove 2 data points (now 3)
const reducedData = createMockChartData(3)
rerender(<Chart data={reducedData} />)
// Verify reduced state
circles = container.querySelectorAll('circle')
expect(circles.length).to.equal(3, 'Should render 3 data points after removal')
@@ -491,20 +468,17 @@ describe('Chart Component', () => {
it('should maintain chart structure during data updates', () => {
const initialData = createMockChartData(3)
const { rerender, container } = renderWithProviders(
<Chart data={initialData} />,
{ withTheme: true }
)
const { rerender, container } = renderWithProviders(<Chart data={initialData} />, { withTheme: true })
// Verify chart structure exists initially
expect(container.querySelector('svg')).to.exist
expect(container.querySelectorAll('line').length).to.be.greaterThan(0, 'Should have axis/grid lines')
expect(container.querySelectorAll('text').length).to.be.greaterThan(0, 'Should have axis labels')
// Update data
const updatedData = createMockChartData(5)
rerender(<Chart data={updatedData} />)
// Verify chart structure is maintained after update
expect(container.querySelector('svg')).to.exist
expect(container.querySelectorAll('line').length).to.be.greaterThan(0, 'Should still have axis/grid lines')

View File

@@ -1,16 +1,17 @@
import React, { memo, useCallback, useMemo } from 'react'
import { useResizeDetector } from 'react-resize-detector'
import { emphasize, useTheme } from '@mui/material/styles'
import { XYChart, Axis, Grid, LineSeries, GlyphSeries } from '@visx/xychart'
import DateFormatter from '../helper/DateFormatter'
import NoData from './NoData'
import NumberFormatter from '../helper/NumberFormatter'
import React, { memo, useCallback, useMemo } from 'react'
import TooltipComponent from './TooltipComponent'
import { useResizeDetector } from 'react-resize-detector'
import { emphasize, useTheme } from '@mui/material/styles'
import { mapCurveType } from './mapCurveType'
import { PlotCurveTypes } from '../../reducers/Charts'
import { Point, Tooltip } from './Model'
import { useCustomXDomain } from './effects/useCustomXDomain'
import { useCustomYDomain } from './effects/useCustomYDomain'
import { XYChart, Axis, Grid, LineSeries, GlyphSeries } from '@visx/xychart'
const abbreviate = require('number-abbreviate')
export interface Props {
@@ -32,7 +33,7 @@ export default memo((props: Props) => {
const hintFormatter = React.useCallback(
(point: any) => [
{ title: <b>Time</b>, value: <DateFormatter timeFirst={true} date={new Date(point.x)} /> },
{ title: <b>Time</b>, value: <DateFormatter timeFirst date={new Date(point.x)} /> },
{ title: <b>Value</b>, value: <NumberFormatter value={point.y} /> },
{ title: <b>Raw</b>, value: <span>{point.y}</span> },
],
@@ -55,8 +56,7 @@ export default memo((props: Props) => {
[hintFormatter]
)
const paletteColor =
theme.palette.mode === 'light' ? theme.palette.secondary.dark : theme.palette.primary.light
const paletteColor = theme.palette.mode === 'light' ? theme.palette.secondary.dark : theme.palette.primary.light
const color = props.color ? props.color : paletteColor
const highlightSelectedPoint = useCallback(
@@ -68,7 +68,7 @@ export default memo((props: Props) => {
)
const formatYAxis = useCallback((num: number) => abbreviate(num), [])
const formatXAxis = useCallback((timestamp: number) => {
const date = new Date(timestamp)
const hours = date.getHours().toString().padStart(2, '0')
@@ -80,7 +80,7 @@ export default memo((props: Props) => {
const xDomain = useCustomXDomain(props)
const yDomain = useCustomYDomain(props)
const data = props.data
const { data } = props
const hasData = data.length > 0
const dummyDomain: [number, number] = [-1, 1]
const dummyData = [{ x: -2, y: -2 }]
@@ -101,25 +101,30 @@ export default memo((props: Props) => {
<XYChart
width={width || 300}
height={CHART_HEIGHT}
margin={{ top: 10, right: 10, bottom: 30, left: 50 }}
margin={{
top: 10,
right: 10,
bottom: 30,
left: 50,
}}
xScale={{ type: 'time', domain: xDomain || dummyDomain }}
yScale={{ type: 'linear', domain: hasData ? yDomain : dummyDomain }}
onPointerOut={onMouseLeave}
>
<Grid rows={true} columns={false} stroke={theme.palette.divider} strokeOpacity={0.3} />
<Axis
orientation="left"
<Grid rows columns={false} stroke={theme.palette.divider} strokeOpacity={0.3} />
<Axis
orientation="left"
numTicks={5}
tickFormat={formatYAxis}
stroke={theme.palette.text.secondary}
tickFormat={formatYAxis}
stroke={theme.palette.text.secondary}
tickStroke={theme.palette.text.secondary}
tickLabelProps={() => ({ fontSize: 11, fill: theme.palette.text.secondary })}
/>
<Axis
orientation="bottom"
<Axis
orientation="bottom"
numTicks={4}
tickFormat={formatXAxis}
stroke={theme.palette.text.secondary}
tickFormat={formatXAxis}
stroke={theme.palette.text.secondary}
tickStroke={theme.palette.text.secondary}
tickLabelProps={() => ({ fontSize: 10, fill: theme.palette.text.secondary, textAnchor: 'middle' })}
/>
@@ -131,7 +136,7 @@ export default memo((props: Props) => {
stroke={color}
strokeWidth={2}
curve={mapCurveType(props.interpolation)}
onPointerMove={(datum) => {
onPointerMove={datum => {
if (datum && datum.datum) {
const point = datum.datum as Point
showTooltip(point)
@@ -143,17 +148,10 @@ export default memo((props: Props) => {
data={hasData ? data : dummyData}
xAccessor={accessors.xAccessor}
yAccessor={accessors.yAccessor}
renderGlyph={(glyphProps) => {
renderGlyph={glyphProps => {
const point = glyphProps.datum as Point
const pointColor = highlightSelectedPoint(point)
return (
<circle
cx={glyphProps.x}
cy={glyphProps.y}
r={3}
fill={pointColor}
/>
)
return <circle cx={glyphProps.x} cy={glyphProps.y} r={3} fill={pointColor} />
}}
/>
</XYChart>

View File

@@ -8,9 +8,9 @@ function TooltipComponent(props: { tooltip?: Tooltip }) {
const { tooltip } = props
return (
<Popper
style={Boolean(tooltip) ? { transition: 'all 0.1s ease-out' } : undefined}
style={tooltip ? { transition: 'all 0.1s ease-out' } : undefined}
open={Boolean(tooltip)}
transition={true}
transition
placement="top"
anchorEl={tooltip && tooltip.element}
>
@@ -27,9 +27,7 @@ function TooltipComponent(props: { tooltip?: Tooltip }) {
padding: '4px',
marginTop: '-12px',
backgroundColor: fade(
theme.palette.mode === 'light'
? theme.palette.background.paper
: theme.palette.background.default,
theme.palette.mode === 'light' ? theme.palette.background.paper : theme.palette.background.default,
0.7
),
}}

View File

@@ -9,16 +9,15 @@ export function useCustomXDomain(props: Props): [number, number] | undefined {
const lastDataPoint = [...props.data].sort((a, b) => b.x - a.x)[0]
const lastDataDate = lastDataPoint ? lastDataPoint.x : Date.now()
if (props.timeRangeStart) {
// Custom time range mode
return [Date.now() - props.timeRangeStart, lastDataDate]
} else {
// Auto-calculate from data (like react-vis did)
const xValues = props.data.map(d => d.x)
const minX = Math.min(...xValues)
const maxX = Math.max(...xValues)
return [minX, maxX]
}
// Auto-calculate from data (like react-vis did)
const xValues = props.data.map(d => d.x)
const minX = Math.min(...xValues)
const maxX = Math.max(...xValues)
return [minX, maxX]
}, [props.data, props.timeRangeStart])
}

View File

@@ -1,5 +1,5 @@
import { Props } from '../Chart'
import { useMemo } from 'react'
import { Props } from '../Chart'
import { Point } from '../Model'
function defaultFor(a: number | undefined, b: number) {
@@ -8,7 +8,7 @@ function defaultFor(a: number | undefined, b: number) {
export function useCustomYDomain(props: Props) {
return useMemo(() => {
const data = props.data
const { data } = props
const calculatedDomain = domainForData(data)
const yDomain: [number, number] = props.range
? [defaultFor(props.range[0], calculatedDomain[0]), defaultFor(props.range[1], calculatedDomain[1])]

View File

@@ -1,5 +1,5 @@
import { PlotCurveTypes } from '../../reducers/Charts'
import * as d3Shape from 'd3-shape'
import { PlotCurveTypes } from '../../reducers/Charts'
export function mapCurveType(type: PlotCurveTypes | undefined) {
switch (type) {

View File

@@ -1,9 +1,9 @@
import React, { memo } from 'react'
import { bindActionCreators } from 'redux'
import { chartActions } from '../../../actions'
import { ChartParameters } from '../../../reducers/Charts'
import { connect } from 'react-redux'
import { Menu, MenuItem } from '@mui/material'
import { chartActions } from '../../../actions'
import { ChartParameters } from '../../../reducers/Charts'
import { colors as createColors } from './colors'
function chartParametersForColor(chart: ChartParameters, color?: string) {
@@ -30,17 +30,24 @@ function ColorSettings(props: {
[props.chart]
)
const menuItems = React.useMemo(() => {
return colors.map(color => (
<MenuItem
style={{ minWidth: '8em', minHeight: '36px', backgroundColor: color, textAlign: 'center' }}
key={color}
onClick={() => setColor(color)}
>
{props.chart.color === color ? 'X' : ''}
</MenuItem>
))
}, [colors, props.chart])
const menuItems = React.useMemo(
() =>
colors.map(color => (
<MenuItem
style={{
minWidth: '8em',
minHeight: '36px',
backgroundColor: color,
textAlign: 'center',
}}
key={color}
onClick={() => setColor(color)}
>
{props.chart.color === color ? 'X' : ''}
</MenuItem>
)),
[colors, props.chart]
)
return (
<Menu anchorEl={props.anchorEl} open={props.open} onClose={props.close}>
@@ -57,12 +64,10 @@ function ColorSettings(props: {
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
})
export default connect(undefined, mapDispatchToProps)(memo(ColorSettings))

View File

@@ -1,10 +1,10 @@
import * as React from 'react'
import { AppState } from '../../../reducers'
import { bindActionCreators } from 'redux'
import { chartActions } from '../../../actions'
import { ChartParameters, PlotCurveTypes } from '../../../reducers/Charts'
import { connect } from 'react-redux'
import { Menu, MenuItem, Typography } from '@mui/material'
import { AppState } from '../../../reducers'
import { chartActions } from '../../../actions'
import { ChartParameters, PlotCurveTypes } from '../../../reducers/Charts'
function chartParametersForAction(chart: ChartParameters, action: string) {
return {
@@ -37,18 +37,20 @@ function InterpolationSettings(props: {
return callbacks
}, [curves])
const menuItems = React.useMemo(() => {
return curves.map(curve => (
<MenuItem
key={curve}
onClick={callbacks[curve]}
selected={props.chart.interpolation === curve}
data-menu-item={curve.replace(/_/g, ' ')}
>
<Typography variant="inherit">{curve.replace(/_/g, ' ')}</Typography>
</MenuItem>
))
}, [curves, props.chart])
const menuItems = React.useMemo(
() =>
curves.map(curve => (
<MenuItem
key={curve}
onClick={callbacks[curve]}
selected={props.chart.interpolation === curve}
data-menu-item={curve.replace(/_/g, ' ')}
>
<Typography variant="inherit">{curve.replace(/_/g, ' ')}</Typography>
</MenuItem>
)),
[curves, props.chart]
)
return (
<Menu anchorEl={props.anchorEl} open={props.open} onClose={props.close}>
@@ -57,12 +59,10 @@ function InterpolationSettings(props: {
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
})
export default connect(undefined, mapDispatchToProps)(InterpolationSettings)

View File

@@ -1,10 +1,10 @@
import * as React from 'react'
import ArrowUpward from '@mui/icons-material/ArrowUpward'
import { bindActionCreators } from 'redux'
import { chartActions } from '../../../actions'
import { ChartParameters } from '../../../reducers/Charts'
import { connect } from 'react-redux'
import { MenuItem, Typography, ListItemIcon } from '@mui/material'
import { chartActions } from '../../../actions'
import { ChartParameters } from '../../../reducers/Charts'
function MoveUp(props: { actions: { chart: typeof chartActions }; chart: ChartParameters; close: () => void }) {
const moveUp = React.useCallback(() => {
@@ -25,12 +25,10 @@ function MoveUp(props: { actions: { chart: typeof chartActions }; chart: ChartPa
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
})
export default connect(undefined, mapDispatchToProps)(MoveUp)

View File

@@ -1,8 +1,8 @@
import React, { useCallback, useState, ChangeEvent, MouseEvent, useRef, useEffect, useMemo } from 'react'
import { ChartParameters } from '../../../reducers/Charts'
import { Menu, TextField, Typography } from '@mui/material'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import { ChartParameters } from '../../../reducers/Charts'
import { chartActions } from '../../../actions'
import { KeyCodes } from '../../../utils/KeyCodes'
@@ -50,7 +50,7 @@ function RangeSettings(props: Props) {
() => (
<Menu
style={{ textAlign: 'center' }}
keepMounted={true}
keepMounted
anchorEl={props.anchorEl}
open={props.open}
onClose={props.onClose}
@@ -62,7 +62,7 @@ function RangeSettings(props: Props) {
inputProps={{
ref: rangeFromRef,
}}
autoFocus={true}
autoFocus
style={{ marginTop: '0' }}
label="from"
value={rangeFrom}
@@ -87,13 +87,11 @@ function RangeSettings(props: Props) {
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
})
export default connect(undefined, mapDispatchToProps)(RangeSettings)

View File

@@ -1,7 +1,7 @@
import * as React from 'react'
import MoreVertIcon from '@mui/icons-material/Settings'
import ChartSettings from '.'
import CustomIconButton from '../../helper/CustomIconButton'
import MoreVertIcon from '@mui/icons-material/Settings'
import { ChartParameters } from '../../../reducers/Charts'
export function SettingsButton(props: {

View File

@@ -1,8 +1,8 @@
import React, { memo } from 'react'
import { ChartParameters } from '../../../reducers/Charts'
import { Menu, MenuItem, TextField, Typography } from '@mui/material'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import { ChartParameters } from '../../../reducers/Charts'
import { chartActions } from '../../../actions'
function Size(props: {
@@ -39,12 +39,10 @@ function Size(props: {
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
})
export default connect(undefined, mapDispatchToProps)(memo(Size))

View File

@@ -1,9 +1,10 @@
import React, { ChangeEvent, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { bindActionCreators } from 'redux'
import { Button, Menu, TextField, Typography } from '@mui/material'
import { connect } from 'react-redux'
import { chartActions } from '../../../actions'
import { ChartParameters } from '../../../reducers/Charts'
import { connect } from 'react-redux'
const parseDuration = require('parse-duration')
interface Props {
@@ -51,25 +52,23 @@ function TimeRangeSettings(props: Props) {
return (
<Menu
style={{ textAlign: 'center' }}
keepMounted={true}
keepMounted
anchorEl={props.anchorEl}
open={props.open}
onClose={props.onClose}
>
<Typography>Chart data within a time interval</Typography>
<div style={{ padding: '0 16px', width: '275px', textAlign: 'center' }}>
{ranges.map(r => {
return (
<Button
style={{ margin: '4px', textTransform: 'none' }}
variant="contained"
key={r}
onClick={createRangeHandler(r)}
>
{r}
</Button>
)
})}
{ranges.map(r => (
<Button
style={{ margin: '4px', textTransform: 'none' }}
variant="contained"
key={r}
onClick={createRangeHandler(r)}
>
{r}
</Button>
))}
</div>
<Typography style={{ fontSize: '0.75em' }}>
<i>Limited to 500 data points</i>
@@ -88,12 +87,10 @@ function TimeRangeSettings(props: Props) {
}, [value, props.open])
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
})
export default connect(undefined, mapDispatchToProps)(TimeRangeSettings)

View File

@@ -22,7 +22,7 @@ export function colors() {
function colorCompare(colorA: string, colorB: string) {
const a = colorToInt(colorA)
const b = colorToInt(colorB)
return Math.sqrt(Math.pow(a[0] - b[0], 2) + Math.pow(a[1] - b[1], 2) + Math.pow(a[2] - b[2], 2))
return Math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2)
}
const colors: Array<string> = [
brown,

View File

@@ -1,17 +1,17 @@
import BarChart from '@mui/icons-material/BarChart'
import Clear from '@mui/icons-material/Refresh'
import ColorLens from '@mui/icons-material/ColorLens'
import MultilineChart from '@mui/icons-material/MultilineChart'
import React, { memo } from 'react'
import Sort from '@mui/icons-material/Sort'
import { Menu, MenuItem, ListItemIcon, Typography } from '@mui/material'
import ColorSettings from './ColorSettings'
import InterpolationSettings from './InterpolationSettings'
import MoveUp from './MoveUp'
import MultilineChart from '@mui/icons-material/MultilineChart'
import RangeSettings from './RangeSettings'
import React, { memo } from 'react'
import Size from './Size'
import Sort from '@mui/icons-material/Sort'
import TimeRangeSettings from './TimeRangeSettings'
import { ChartParameters } from '../../../reducers/Charts'
import { Menu, MenuItem, ListItemIcon, Typography } from '@mui/material'
function ChartSettings(props: {
open: boolean
@@ -25,7 +25,7 @@ function ChartSettings(props: {
const [interpolationVisible, setInterpolationVisible] = React.useState(false)
const [sizeVisible, setSizeVisible] = React.useState(false)
const [colorVisible, setColorVisible] = React.useState(false)
const open = props.open
const { open } = props
const toggleRange = React.useCallback(() => {
if (open) {

View File

@@ -1,8 +1,8 @@
import * as React from 'react'
import { ChartParameters } from '../../reducers/Charts'
import { Typography } from '@mui/material'
import { withStyles } from '@mui/styles'
import { Theme } from '@mui/material/styles'
import { ChartParameters } from '../../reducers/Charts'
function ChartTitle(props: { parameters: ChartParameters; classes: any }) {
const { classes, parameters } = props
@@ -13,7 +13,7 @@ function ChartTitle(props: { parameters: ChartParameters; classes: any }) {
</Typography>
<br />
<Typography variant="caption" className={classes.topic}>
{parameters.dotPath ? parameters.topic : <span dangerouslySetInnerHTML={{ __html: '&nbsp;' }}></span>}
{parameters.dotPath ? parameters.topic : <span dangerouslySetInnerHTML={{ __html: '&nbsp;' }} />}
</Typography>
</div>
)
@@ -21,10 +21,10 @@ function ChartTitle(props: { parameters: ChartParameters; classes: any }) {
const styles = (theme: Theme) => ({
topic: {
wordBreak: 'break-all' as 'break-all',
whiteSpace: 'nowrap' as 'nowrap',
overflow: 'hidden' as 'hidden',
textOverflow: 'ellipsis' as 'ellipsis',
wordBreak: 'break-all' as const,
whiteSpace: 'nowrap' as const,
overflow: 'hidden' as const,
textOverflow: 'ellipsis' as const,
},
})

View File

@@ -1,5 +1,5 @@
import * as q from '../../../../backend/src/Model'
import React from 'react'
import * as q from '../../../../backend/src/Model'
import TopicChart from './TopicChart'
import { ChartParameters } from '../../reducers/Charts'
import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode'

View File

@@ -1,13 +1,14 @@
import React, { useState, useCallback, memo, useRef } from 'react'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { Paper } from '@mui/material'
import * as q from '../../../../backend/src/Model'
import ChartTitle from './ChartTitle'
import React, { useState, useCallback, memo, useRef } from 'react'
import TopicPlot from '../TopicPlot'
import { bindActionCreators } from 'redux'
import { ChartActions } from './ChartActions'
import { chartActions } from '../../actions'
import { ChartParameters } from '../../reducers/Charts'
import { connect } from 'react-redux'
import { Paper } from '@mui/material'
const throttle = require('lodash.throttle')
class ClearableMessageBuffer extends q.RingBuffer<q.Message> {
@@ -126,12 +127,10 @@ function TopicChart(props: Props) {
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
})
export default connect(undefined, mapDispatchToProps)(memo(TopicChart))

View File

@@ -1,16 +1,17 @@
import * as q from '../../../../backend/src/Model'
import * as React from 'react'
import ShowChart from '@mui/icons-material/ShowChart'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { chartActions } from '../../actions'
import { ChartParameters } from '../../reducers/Charts'
import { ChartWithTreeNode } from './ChartWithTreeNode'
import { connect } from 'react-redux'
import { Grid, Typography } from '@mui/material'
import { withStyles } from '@mui/styles'
import { Theme } from '@mui/material/styles'
import { List } from 'immutable'
import * as q from '../../../../backend/src/Model'
import { AppState } from '../../reducers'
import { ChartWithTreeNode } from './ChartWithTreeNode'
import { ChartParameters } from '../../reducers/Charts'
import { chartActions } from '../../actions'
const { TransitionGroup, CSSTransition } = require('react-transition-group/esm')
interface Props {
@@ -26,11 +27,11 @@ interface Props {
function spacingForChartCount(count: number): 4 | 6 | 12 {
if (count >= 5) {
return 4
} else if (count >= 2) {
return 6
} else {
return 12
}
if (count >= 2) {
return 6
}
return 12
}
function mapWidth(width: 'big' | 'medium' | 'small' | undefined, calculatedSpacing: 4 | 6 | 12): 4 | 6 | 12 {
@@ -46,7 +47,6 @@ function mapWidth(width: 'big' | 'medium' | 'small' | undefined, calculatedSpaci
}
}
// Helper function to generate unique keys for charts
const getChartKey = (chart: ChartParameters) => `${chart.topic}-${chart.dotPath || ''}`
@@ -74,32 +74,27 @@ function ChartPanel(props: Props) {
React.useEffect(() => {
const currentKeys = new Set(props.charts.map(getChartKey).toArray())
const refsToDelete: string[] = []
nodeRefsMap.current.forEach((_, key) => {
if (!currentKeys.has(key)) {
refsToDelete.push(key)
}
})
refsToDelete.forEach(key => nodeRefsMap.current.delete(key))
}, [props.charts])
const charts = props.charts.map(chartParameters => {
const key = getChartKey(chartParameters)
// Get or create a ref for this specific chart
if (!nodeRefsMap.current.has(key)) {
nodeRefsMap.current.set(key, React.createRef<HTMLDivElement>())
}
const nodeRef = nodeRefsMap.current.get(key)!
return (
<CSSTransition
key={key}
timeout={{ enter: 500, exit: 500 }}
classNames="example"
nodeRef={nodeRef}
>
<CSSTransition key={key} timeout={{ enter: 500, exit: 500 }} classNames="example" nodeRef={nodeRef}>
<Grid item xs={mapWidth(chartParameters.width, spacing)} ref={nodeRef}>
<ChartWithTreeNode tree={props.tree} parameters={chartParameters} />
</Grid>
@@ -131,21 +126,17 @@ function NoCharts() {
)
}
const mapStateToProps = (state: AppState) => {
return {
charts: state.charts.get('charts'),
connectionId: state.connection.connectionId,
tree: state.connection.tree,
}
}
const mapStateToProps = (state: AppState) => ({
charts: state.charts.get('charts'),
connectionId: state.connection.connectionId,
tree: state.connection.tree,
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
})
const styles = (theme: Theme) => ({
container: {

View File

@@ -1,6 +1,6 @@
import React, { useRef, useCallback, memo } from 'react'
import { ConfirmationRequest } from '../reducers/Global'
import { Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button } from '@mui/material'
import { ConfirmationRequest } from '../reducers/Global'
import { KeyCodes } from '../utils/KeyCodes'
function ConfirmationDialog(props: { confirmationRequests: Array<ConfirmationRequest> }) {
@@ -34,7 +34,7 @@ function ConfirmationDialog(props: { confirmationRequests: Array<ConfirmationReq
return (
<Dialog
open={true}
open
onClose={reject}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"

View File

@@ -5,14 +5,15 @@ import Lock from '@mui/icons-material/Lock'
import Undo from '@mui/icons-material/Undo'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { ConnectionOptions } from '../../model/ConnectionOptions'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { Button, Grid, TextField, Tooltip } from '@mui/material'
import { QoS } from 'mqtt-explorer-backend/src/DataSource/MqttSource'
import { connectionManagerActions } from '../../actions'
import { QosSelect } from '../QosSelect'
import { QoS } from '../../../../backend/src/DataSource/MqttSource'
import { ConnectionOptions } from '../../model/ConnectionOptions'
import Subscriptions from './Subscriptions'
const SubscriptionsAny = Subscriptions as any
interface Props {
@@ -21,7 +22,7 @@ interface Props {
managerActions: typeof connectionManagerActions
}
const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
const ConnectionSettings = memo((props: Props) => {
const [qos, setQos] = useState<QoS>(0)
const [topic, setTopic] = useState('')
const { classes } = props
@@ -42,9 +43,9 @@ const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
return (
<div>
<form className={classes.container} noValidate={true} autoComplete="off">
<Grid container={true} spacing={3}>
<Grid item={true} xs={8} className={classes.gridPadding}>
<form className={classes.container} noValidate autoComplete="off">
<Grid container spacing={3}>
<Grid item xs={8} className={classes.gridPadding}>
<TextField
className={`${classes.fullWidth} advanced-connection-settings-topic-input`}
label="Topic"
@@ -54,12 +55,12 @@ const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
onChange={updateSubscription}
/>
</Grid>
<Grid item={true} xs={2} className={classes.gridPadding}>
<Grid item xs={2} className={classes.gridPadding}>
<div className={classes.qos}>
<QosSelect label="QoS" selected={qos} onChange={setQos} />
</div>
</Grid>
<Grid item={true} xs={2} className={classes.gridPadding}>
<Grid item xs={2} className={classes.gridPadding}>
<Button
className={classes.button}
color="secondary"
@@ -70,10 +71,10 @@ const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
<Add /> Add
</Button>
</Grid>
<Grid item={true} xs={12} style={{ padding: 0 }}>
<Grid item xs={12} style={{ padding: 0 }}>
<SubscriptionsAny connection={props.connection} />
</Grid>
<Grid item={true} xs={7} className={classes.gridPadding}>
<Grid item xs={7} className={classes.gridPadding}>
<TextField
className={classes.fullWidth}
label="MQTT Client ID"
@@ -82,7 +83,7 @@ const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
onChange={handleChange('clientId')}
/>
</Grid>
<Grid item={true} xs={3} className={classes.gridPadding}>
<Grid item xs={3} className={classes.gridPadding}>
<div>
<Tooltip title="Manage tls connection certificates" placement="top">
<Button
@@ -95,7 +96,7 @@ const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
</Tooltip>
</div>
</Grid>
<Grid item={true} xs={2} className={classes.gridPadding}>
<Grid item xs={2} className={classes.gridPadding}>
<Button
variant="contained"
className={classes.button}
@@ -111,11 +112,9 @@ const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
)
})
const mapDispatchToProps = (dispatch: any) => {
return {
managerActions: bindActionCreators(connectionManagerActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
managerActions: bindActionCreators(connectionManagerActions, dispatch),
})
const styles = (theme: Theme) => ({
fullWidth: {
@@ -126,7 +125,7 @@ const styles = (theme: Theme) => ({
},
button: {
marginTop: theme.spacing(3),
float: 'right' as 'right',
float: 'right' as const,
},
qos: {
marginTop: theme.spacing(1),

View File

@@ -1,15 +1,15 @@
import * as React from 'react'
import ClearAdornment from '../helper/ClearAdornment'
import Lock from '@mui/icons-material/Lock'
import { bindActionCreators } from 'redux'
import { Button, Theme, Tooltip, Typography } from '@mui/material'
import { connect } from 'react-redux'
import { withStyles } from '@mui/styles'
import { RpcEvents } from '../../../../events/EventsV2'
import { CertificateParameters, ConnectionOptions } from '../../model/ConnectionOptions'
import { CertificateTypes } from '../../actions/ConnectionManager'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { withStyles } from '@mui/styles'
import ClearAdornment from '../helper/ClearAdornment'
import { rendererRpc } from '../../eventBus'
import { RpcEvents } from '../../../../events/EventsV2'
function BrowserCertificateFileSelection(props: {
certificateType: CertificateTypes
@@ -114,21 +114,19 @@ function ClearCertificate(props: { classes: any; certificate?: CertificateParame
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
connectionManager: bindActionCreators(connectionManagerActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
connectionManager: bindActionCreators(connectionManagerActions, dispatch),
},
})
const styles = (theme: Theme) => ({
certificateName: {
width: '100%',
height: 'calc(1em + 4px)',
overflow: 'hidden' as 'hidden',
whiteSpace: 'nowrap' as 'nowrap',
textOverflow: 'ellipsis' as 'ellipsis',
overflow: 'hidden' as const,
whiteSpace: 'nowrap' as const,
textOverflow: 'ellipsis' as const,
color: theme.palette.text.secondary,
},
button: {

View File

@@ -1,13 +1,13 @@
import * as React from 'react'
import ClearAdornment from '../helper/ClearAdornment'
import Lock from '@mui/icons-material/Lock'
import { bindActionCreators } from 'redux'
import { Button, Theme, Tooltip, Typography } from '@mui/material'
import { connect } from 'react-redux'
import { withStyles } from '@mui/styles'
import { CertificateParameters, ConnectionOptions } from '../../model/ConnectionOptions'
import { CertificateTypes } from '../../actions/ConnectionManager'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { withStyles } from '@mui/styles'
import ClearAdornment from '../helper/ClearAdornment'
function CertificateFileSelection(props: {
certificateType: CertificateTypes
@@ -56,21 +56,19 @@ function ClearCertificate(props: { classes: any; certificate?: CertificateParame
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
connectionManager: bindActionCreators(connectionManagerActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
connectionManager: bindActionCreators(connectionManagerActions, dispatch),
},
})
const styles = (theme: Theme) => ({
certificateName: {
width: '100%',
height: 'calc(1em + 4px)',
overflow: 'hidden' as 'hidden',
whiteSpace: 'nowrap' as 'nowrap',
textOverflow: 'ellipsis' as 'ellipsis',
overflow: 'hidden' as const,
whiteSpace: 'nowrap' as const,
textOverflow: 'ellipsis' as const,
color: theme.palette.text.secondary,
},
button: {

View File

@@ -1,14 +1,14 @@
import * as React from 'react'
import CertificateFileSelection from './CertificateFileSelection'
import BrowserCertificateFileSelection from './BrowserCertificateFileSelection'
import Undo from '@mui/icons-material/Undo'
import { bindActionCreators } from 'redux'
import { Button, Grid } from '@mui/material'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { ConnectionOptions } from '../../model/ConnectionOptions'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { connectionManagerActions } from '../../actions'
import { ConnectionOptions } from '../../model/ConnectionOptions'
import BrowserCertificateFileSelection from './BrowserCertificateFileSelection'
import CertificateFileSelection from './CertificateFileSelection'
import { isBrowserMode } from '../../utils/browserMode'
// Use browser or desktop file selection based on mode
@@ -48,9 +48,9 @@ class Certificates extends React.PureComponent<Props, State> {
const { classes } = this.props
return (
<div>
<form noValidate={true} autoComplete="off">
<Grid container={true} spacing={3}>
<Grid item={true} xs={12} className={classes.gridPadding}>
<form noValidate autoComplete="off">
<Grid container spacing={3}>
<Grid item xs={12} className={classes.gridPadding}>
<CertSelector
connection={this.props.connection}
certificate={this.props.connection.selfSignedCertificate}
@@ -58,7 +58,7 @@ class Certificates extends React.PureComponent<Props, State> {
certificateType="selfSignedCertificate"
/>
</Grid>
<Grid item={true} xs={12} className={classes.gridPadding}>
<Grid item xs={12} className={classes.gridPadding}>
<CertSelector
connection={this.props.connection}
certificate={this.props.connection.clientCertificate}
@@ -66,7 +66,7 @@ class Certificates extends React.PureComponent<Props, State> {
certificateType="clientCertificate"
/>
</Grid>
<Grid item={true} xs={12} className={classes.gridPadding}>
<Grid item xs={12} className={classes.gridPadding}>
<CertSelector
connection={this.props.connection}
certificate={this.props.connection.clientKey}
@@ -74,7 +74,7 @@ class Certificates extends React.PureComponent<Props, State> {
certificateType="clientKey"
/>
</Grid>
<Grid item={true} xs={2} className={classes.gridPadding}>
<Grid item xs={2} className={classes.gridPadding}>
<br />
<Button
variant="contained"
@@ -91,11 +91,9 @@ class Certificates extends React.PureComponent<Props, State> {
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
managerActions: bindActionCreators(connectionManagerActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
managerActions: bindActionCreators(connectionManagerActions, dispatch),
})
const styles = (theme: Theme) => ({
fullWidth: {

View File

@@ -1,18 +1,18 @@
import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator'
import PowerSettingsNew from '@mui/icons-material/PowerSettingsNew'
import React from 'react'
import { Button } from '@mui/material'
import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator'
function ConnectButton(props: { connecting: boolean; classes: any; toggle: () => void }) {
const { classes, toggle, connecting } = props
if (connecting) {
return (
<Button
variant="contained"
color="primary"
className={classes.button}
onClick={toggle}
<Button
variant="contained"
color="primary"
className={classes.button}
onClick={toggle}
data-testid="abort-button"
aria-label="Cancel connection attempt"
>
@@ -23,11 +23,11 @@ function ConnectButton(props: { connecting: boolean; classes: any; toggle: () =>
}
return (
<Button
variant="contained"
color="primary"
className={classes.button}
onClick={toggle}
<Button
variant="contained"
color="primary"
className={classes.button}
onClick={toggle}
data-testid="connect-button"
aria-label="Connect to MQTT broker"
>

View File

@@ -1,29 +1,21 @@
import ConnectButton from './ConnectButton'
import React, { useCallback, useState } from 'react'
import Save from '@mui/icons-material/Save'
import Delete from '@mui/icons-material/Delete'
import Settings from '@mui/icons-material/Settings'
import Visibility from '@mui/icons-material/Visibility'
import VisibilityOff from '@mui/icons-material/VisibilityOff'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { Button, Grid, IconButton, InputAdornment, MenuItem, TextField, Tooltip } from '@mui/material'
import { AppState } from '../../reducers'
import { connectionActions, connectionManagerActions, globalActions } from '../../actions'
import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions'
import { KeyCodes } from '../../utils/KeyCodes'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { ToggleSwitch } from './ToggleSwitch'
import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler'
import {
Button,
Grid,
IconButton,
InputAdornment,
MenuItem,
TextField,
Tooltip,
} from '@mui/material'
import ConnectButton from './ConnectButton'
interface Props {
connection: ConnectionOptions
@@ -45,7 +37,7 @@ function ConnectionSettings(props: Props) {
'Delete Connection',
`Are you sure you want to delete the connection "${props.connection.name}"?\n\nThis action cannot be undone.`
)
if (confirmed) {
props.managerActions.deleteConnection(props.connection.id)
}
@@ -80,7 +72,7 @@ function ConnectionSettings(props: Props) {
function renderBasePathInput() {
return (
<Grid item={true} xs={4}>
<Grid item xs={4}>
<TextField
label="Basepath"
className={props.classes.textField}
@@ -111,21 +103,23 @@ function ConnectionSettings(props: Props) {
const protocolItems = protocols.map((value: string) => (
<MenuItem key={value} value={value}>
{value}:// {value === 'mqtt' ? '(Standard)' : '(WebSocket)'}
{value}
://
{value === 'mqtt' ? '(Standard)' : '(WebSocket)'}
</MenuItem>
))
return (
<Tooltip title="Use 'mqtt' for standard connections or 'ws' for WebSocket connections" arrow>
<TextField
select={true}
select
label="Protocol"
className={classes.textField}
value={connection.protocol}
onChange={updateProtocol}
margin="dense"
inputProps={{
'aria-label': 'MQTT protocol'
inputProps={{
'aria-label': 'MQTT protocol',
}}
>
{protocolItems}
@@ -135,7 +129,7 @@ function ConnectionSettings(props: Props) {
}
const updateProtocol = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
const { value } = event.target
updateConnection('protocol', value)
if (event.target.value === 'mqtt') {
updateConnection('basePath', undefined)
@@ -159,9 +153,9 @@ function ConnectionSettings(props: Props) {
function PasswordVisibilityButton(props: { showPassword: boolean; toggle: () => void }) {
return (
<InputAdornment position="end">
<Tooltip title={props.showPassword ? "Hide password" : "Show password"} arrow>
<IconButton
aria-label={props.showPassword ? "Hide password" : "Show password"}
<Tooltip title={props.showPassword ? 'Hide password' : 'Show password'} arrow>
<IconButton
aria-label={props.showPassword ? 'Hide password' : 'Show password'}
onClick={props.toggle}
edge="end"
>
@@ -176,23 +170,23 @@ function ConnectionSettings(props: Props) {
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<form className={classes.container} noValidate={true} autoComplete="off" style={{ flex: 1, overflow: 'auto' }}>
<Grid container={true} spacing={2}>
<Grid item={true} xs={5}>
<form className={classes.container} noValidate autoComplete="off" style={{ flex: 1, overflow: 'auto' }}>
<Grid container spacing={2}>
<Grid item xs={5}>
<TextField
autoFocus={true}
autoFocus
label="Name"
className={classes.textField}
value={connection.name}
onChange={handleChange('name')}
margin="dense"
placeholder="My MQTT Connection"
inputProps={{
'aria-label': 'Connection name'
inputProps={{
'aria-label': 'Connection name',
}}
/>
</Grid>
<Grid item={true} xs={4}>
<Grid item xs={4}>
<ToggleSwitch
label="Validate certificate"
classes={classes}
@@ -200,13 +194,13 @@ function ConnectionSettings(props: Props) {
toggle={toggleCertValidation}
/>
</Grid>
<Grid item={true} xs={3}>
<Grid item xs={3}>
<ToggleSwitch label="Encryption (tls)" classes={classes} value={connection.encryption} toggle={toggleTls} />
</Grid>
<Grid item={true} xs={2}>
<Grid item xs={2}>
{renderProtocols()}
</Grid>
<Grid item={true} xs={7}>
<Grid item xs={7}>
<TextField
label="Host"
className={classes.textField}
@@ -214,13 +208,13 @@ function ConnectionSettings(props: Props) {
onChange={handleChange('host')}
margin="dense"
placeholder="broker.example.com"
inputProps={{
inputProps={{
'data-testid': 'host-input',
'aria-label': 'MQTT broker host'
'aria-label': 'MQTT broker host',
}}
/>
</Grid>
<Grid item={true} xs={3}>
<Grid item xs={3}>
<TextField
label="Port"
className={classes.textField}
@@ -229,15 +223,15 @@ function ConnectionSettings(props: Props) {
margin="dense"
type="number"
placeholder="1883"
inputProps={{
inputProps={{
'aria-label': 'MQTT broker port',
min: 1,
max: 65535
max: 65535,
}}
/>
</Grid>
{requiresBasePath() ? renderBasePathInput() : null}
<Grid item={true} xs={requiresBasePath() ? 4 : 6}>
<Grid item xs={requiresBasePath() ? 4 : 6}>
<TextField
label="Username"
className={classes.textField}
@@ -245,13 +239,13 @@ function ConnectionSettings(props: Props) {
onChange={handleChange('username')}
margin="dense"
placeholder="Optional"
inputProps={{
inputProps={{
'aria-label': 'MQTT username',
'autoComplete': 'username'
autoComplete: 'username',
}}
/>
</Grid>
<Grid item={true} xs={requiresBasePath() ? 4 : 6}>
<Grid item xs={requiresBasePath() ? 4 : 6}>
<TextField
label="Password"
className={classes.textField}
@@ -261,17 +255,25 @@ function ConnectionSettings(props: Props) {
margin="dense"
placeholder="Optional"
InputProps={{
endAdornment: <PasswordVisibilityButton showPassword={showPassword} toggle={handleClickShowPassword} />
endAdornment: <PasswordVisibilityButton showPassword={showPassword} toggle={handleClickShowPassword} />,
}}
inputProps={{
inputProps={{
'aria-label': 'MQTT password',
'autoComplete': 'current-password'
autoComplete: 'current-password',
}}
/>
</Grid>
</Grid>
</form>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingTop: '16px', borderTop: '1px solid rgba(0, 0, 0, 0.12)' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: '16px',
borderTop: '1px solid rgba(0, 0, 0, 0.12)',
}}
>
<div>
<Tooltip title="Delete this connection permanently" arrow>
<Button
@@ -315,20 +317,16 @@ function ConnectionSettings(props: Props) {
)
}
const mapStateToProps = (state: AppState) => {
return {
connected: state.connection.connected,
connecting: state.connection.connecting,
}
}
const mapStateToProps = (state: AppState) => ({
connected: state.connection.connected,
connecting: state.connection.connecting,
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(connectionActions, dispatch),
managerActions: bindActionCreators(connectionManagerActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(connectionActions, dispatch),
managerActions: bindActionCreators(connectionManagerActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
})
const styles = (theme: Theme) => ({
textField: {

View File

@@ -1,19 +1,20 @@
import * as React from 'react'
import ConnectionSettings from './ConnectionSettings'
const ConnectionSettingsAny = ConnectionSettings as any
import ProfileList from './ProfileList'
import MobileConnectionSelector from './MobileConnectionSelector'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { Modal, Paper, Toolbar, Typography, Collapse } from '@mui/material'
import ConnectionSettings from './ConnectionSettings'
import ProfileList from './ProfileList'
import MobileConnectionSelector from './MobileConnectionSelector'
import { AppState } from '../../reducers'
import { connectionManagerActions } from '../../actions'
import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions'
import AdvancedConnectionSettings from './AdvancedConnectionSettings'
const AdvancedConnectionSettingsAny = AdvancedConnectionSettings as any
import Certificates from './Certificates'
const ConnectionSettingsAny = ConnectionSettings as any
const AdvancedConnectionSettingsAny = AdvancedConnectionSettings as any
const CertificatesAny = Certificates as any
interface Props {
@@ -60,7 +61,7 @@ class ConnectionSetup extends React.PureComponent<Props, {}> {
const mqttConnection = connection && toMqttConnection(connection)
return (
<div>
<Modal open={visible} disableAutoFocus={true}>
<Modal open={visible} disableAutoFocus>
<Paper className={classes.root}>
<div className={classes.left}>
<ProfileList />
@@ -90,7 +91,7 @@ const connectionHeight = '440px'
const styles = (theme: Theme) => ({
title: {
color: theme.palette.text.primary,
whiteSpace: 'nowrap' as 'nowrap',
whiteSpace: 'nowrap' as const,
},
toolbarContent: {
width: '100%',
@@ -103,7 +104,7 @@ const styles = (theme: Theme) => ({
flex: 1,
// Hide on mobile - connection selector will take its place
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
display: 'none' as const,
},
},
root: {
@@ -111,29 +112,29 @@ const styles = (theme: Theme) => ({
minWidth: '800px',
maxWidth: '850px',
height: connectionHeight,
outline: 'none' as 'none',
display: 'flex' as 'flex',
outline: 'none' as const,
display: 'flex' as const,
// Mobile responsive adjustments
[theme.breakpoints.down('md')]: {
minWidth: '95vw',
maxWidth: '95vw',
height: '85vh',
margin: '7.5vh auto 0 auto',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
},
},
left: {
borderRightStyle: 'dotted' as 'dotted',
borderRightStyle: 'dotted' as const,
borderRadius: `${theme.shape.borderRadius}px 0 0 ${theme.shape.borderRadius}px`,
paddingTop: theme.spacing(2),
flex: 3,
overflow: 'hidden' as 'hidden',
overflow: 'hidden' as const,
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
overflowY: 'auto' as 'auto',
overflowY: 'auto' as const,
// Mobile: hide profile list to save space
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
display: 'none' as const,
},
},
right: {
@@ -144,35 +145,31 @@ const styles = (theme: Theme) => ({
// Mobile: enable scrolling
[theme.breakpoints.down('md')]: {
borderRadius: `${theme.shape.borderRadius}px`,
overflowY: 'auto' as 'auto',
overflowY: 'auto' as const,
},
},
connectionUri: {
width: '27em',
textOverflow: 'ellipsis' as 'ellipsis',
whiteSpace: 'nowrap' as 'nowrap',
overflow: 'hidden' as 'hidden',
textOverflow: 'ellipsis' as const,
whiteSpace: 'nowrap' as const,
overflow: 'hidden' as const,
color: theme.palette.text.secondary,
fontSize: '0.9em',
marginLeft: theme.spacing(4),
},
})
const mapStateToProps = (state: AppState) => {
return {
visible: !state.connection.connected,
showAdvancedSettings: state.connectionManager.showAdvancedSettings,
showCertificateSettings: state.connectionManager.showCertificateSettings,
connection: state.connectionManager.selected
? state.connectionManager.connections[state.connectionManager.selected]
: undefined,
}
}
const mapStateToProps = (state: AppState) => ({
visible: !state.connection.connected,
showAdvancedSettings: state.connectionManager.showAdvancedSettings,
showCertificateSettings: state.connectionManager.showCertificateSettings,
connection: state.connectionManager.selected
? state.connectionManager.connections[state.connectionManager.selected]
: undefined,
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(connectionManagerActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(connectionManagerActions, dispatch),
})
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ConnectionSetup) as any)

View File

@@ -1,12 +1,12 @@
import * as React from 'react'
import Add from '@mui/icons-material/Add'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { IconButton, MenuItem, Select, SelectChangeEvent } from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { connectionManagerActions } from '../../actions'
import { AppState } from '../../reducers'
const styles = (theme: Theme) => ({
container: {
@@ -51,9 +51,8 @@ class MobileConnectionSelector extends React.PureComponent<Props, {}> {
this.props.actions.createConnection()
}
private getConnectionDisplayName = (connection: { name?: string; host?: string }) => {
return connection.name || connection.host || 'Unnamed Connection'
}
private getConnectionDisplayName = (connection: { name?: string; host?: string }) =>
connection.name || connection.host || 'Unnamed Connection'
public render() {
const { classes, connections, currentConnectionId, isConnected, currentActiveConnectionId } = this.props
@@ -110,7 +109,7 @@ class MobileConnectionSelector extends React.PureComponent<Props, {}> {
}
const mapStateToProps = (state: AppState) => {
const connectionManager = state.connectionManager
const { connectionManager } = state
const connections =
connectionManager && connectionManager.connections
? Object.values(connectionManager.connections).map(conn => ({
@@ -128,11 +127,9 @@ const mapStateToProps = (state: AppState) => {
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(connectionManagerActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(connectionManagerActions, dispatch),
})
// Using 'as any' here is consistent with other Material-UI + Redux connected components
// in this codebase (see ConnectionSettings.tsx, ProfileList/index.tsx, ChartPanel/index.tsx)

View File

@@ -15,12 +15,10 @@ const styles = (theme: Theme) => ({
},
})
export const AddButton = withStyles(styles)((props: { classes: any; action: any }) => {
return (
<span id="addProfileButton" style={{ marginRight: '12px' }}>
<Fab size="small" color="secondary" aria-label="Add" className={props.classes.addButton} onClick={props.action}>
<Add className={props.classes.addIcon} />
</Fab>
</span>
)
})
export const AddButton = withStyles(styles)((props: { classes: any; action: any }) => (
<span id="addProfileButton" style={{ marginRight: '12px' }}>
<Fab size="small" color="secondary" aria-label="Add" className={props.classes.addButton} onClick={props.action}>
<Add className={props.classes.addIcon} />
</Fab>
</span>
))

View File

@@ -1,10 +1,10 @@
import React, { useCallback } from 'react'
import { connect } from 'react-redux'
import { ListItem, Typography } from '@mui/material'
import { toMqttConnection, ConnectionOptions } from '../../../model/ConnectionOptions'
import { withStyles } from '@mui/styles'
import { Theme } from '@mui/material/styles'
import { bindActionCreators } from 'redux'
import { toMqttConnection, ConnectionOptions } from '../../../model/ConnectionOptions'
import { connectionActions, connectionManagerActions } from '../../../actions'
export interface Props {
@@ -17,7 +17,7 @@ export interface Props {
classes: any
}
const ConnectionItem = (props: Props) => {
function ConnectionItem(props: Props) {
const connect = useCallback(() => {
const mqttOptions = toMqttConnection(props.connection)
if (mqttOptions) {
@@ -28,7 +28,7 @@ const ConnectionItem = (props: Props) => {
const connection = props.connection.host && toMqttConnection(props.connection)
return (
<ListItem
button={true}
button
selected={props.selected}
style={{ display: 'block' }}
onClick={() => props.actions.connectionManager.selectConnection(props.connection.id)}
@@ -43,26 +43,24 @@ const ConnectionItem = (props: Props) => {
)
}
export const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
connection: bindActionCreators(connectionActions, dispatch),
connectionManager: bindActionCreators(connectionManagerActions, dispatch),
},
}
}
export const mapDispatchToProps = (dispatch: any) => ({
actions: {
connection: bindActionCreators(connectionActions, dispatch),
connectionManager: bindActionCreators(connectionManagerActions, dispatch),
},
})
export const connectionItemStyle = (theme: Theme) => ({
name: {
width: '100%',
textOverflow: 'ellipsis' as 'ellipsis',
whiteSpace: 'nowrap' as 'nowrap',
overflow: 'hidden' as 'hidden',
textOverflow: 'ellipsis' as const,
whiteSpace: 'nowrap' as const,
overflow: 'hidden' as const,
},
details: {
width: '100%',
textOverflow: 'ellipsis' as 'ellipsis',
whiteSpace: 'nowrap' as 'nowrap',
overflow: 'hidden' as 'hidden',
textOverflow: 'ellipsis' as const,
whiteSpace: 'nowrap' as const,
overflow: 'hidden' as const,
color: theme.palette.text.secondary,
fontSize: '0.7em',
},

View File

@@ -1,18 +1,19 @@
import ConnectionItem from './ConnectionItem'
const ConnectionItemAny = ConnectionItem as any
import React from 'react'
import { AddButton } from './AddButton'
import { AppState } from '../../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../../actions'
import { ConnectionOptions } from '../../../model/ConnectionOptions'
import { KeyCodes } from '../../../utils/KeyCodes'
import { List } from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import ConnectionItem from './ConnectionItem'
import { AddButton } from './AddButton'
import { AppState } from '../../../reducers'
import { connectionManagerActions } from '../../../actions'
import { ConnectionOptions } from '../../../model/ConnectionOptions'
import { KeyCodes } from '../../../utils/KeyCodes'
import { useGlobalKeyEventHandler } from '../../../effects/useGlobalKeyEventHandler'
const ConnectionItemAny = ConnectionItem as any
interface Props {
classes: any
selected?: string
@@ -62,21 +63,17 @@ const styles = (theme: Theme) => ({
list: {
marginTop: theme.spacing(1),
height: `calc(100% - ${theme.spacing(6)})`,
overflowY: 'auto' as 'auto',
overflowY: 'auto' as const,
},
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(connectionManagerActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(connectionManagerActions, dispatch),
})
const mapStateToProps = (state: AppState) => {
return {
connections: state.connectionManager.connections,
selected: state.connectionManager.selected,
}
}
const mapStateToProps = (state: AppState) => ({
connections: state.connectionManager.connections,
selected: state.connectionManager.selected,
})
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ProfileList) as any)

View File

@@ -1,7 +1,5 @@
import React, { useCallback, useState } from 'react'
import Delete from '@mui/icons-material/Delete'
import { connectionManagerActions } from '../../actions'
import { ConnectionOptions } from '../../model/ConnectionOptions'
import {
IconButton,
TableContainer,
@@ -16,6 +14,8 @@ import {
import { bindActionCreators } from 'redux'
import { withStyles } from '@mui/styles'
import { connect } from 'react-redux'
import { ConnectionOptions } from '../../model/ConnectionOptions'
import { connectionManagerActions } from '../../actions'
function Subscriptions(props: {
classes: any
@@ -29,7 +29,7 @@ function Subscriptions(props: {
<Table size="small">
<TableHead>
<TableRow>
<TableCell align="left" padding="checkbox" className={classes.tableTitleCell}></TableCell>
<TableCell align="left" padding="checkbox" className={classes.tableTitleCell} />
<TableCell className={classes.tableTitleCell}>Topic</TableCell>
<TableCell align="right" className={classes.tableTitleCell}>
QoS
@@ -38,7 +38,7 @@ function Subscriptions(props: {
</TableHead>
<TableBody>
{connection.subscriptions.map(subscription => (
<TableRow key={subscription.topic + '_qos_' + subscription.qos}>
<TableRow key={`${subscription.topic}_qos_${subscription.qos}`}>
<TableCell align="right" className={classes.tableCell}>
<IconButton
onClick={() => managerActions.deleteSubscription(subscription, connection.id)}
@@ -62,11 +62,9 @@ function Subscriptions(props: {
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
managerActions: bindActionCreators(connectionManagerActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
managerActions: bindActionCreators(connectionManagerActions, dispatch),
})
const styles = (theme: Theme) => ({
tableCell: {
@@ -80,7 +78,7 @@ const styles = (theme: Theme) => ({
},
topicList: {
height: '196px',
overflowY: 'scroll' as 'scroll',
overflowY: 'scroll' as const,
margin: `${theme.spacing(1)}px ${theme.spacing(1)}px 0 ${theme.spacing(1)}px`,
backgroundColor: theme.palette.background.default,
width: 'auto',

View File

@@ -4,24 +4,20 @@ import { FormControlLabel, Switch } from '@mui/material'
export function ToggleSwitch(props: { value: boolean; classes: any; toggle: () => void; label: string }) {
const { classes, value, toggle, label } = props
const toggleSwitch = (
<Switch
checked={value}
onChange={toggle}
<Switch
checked={value}
onChange={toggle}
color="primary"
role="switch"
aria-checked={value}
inputProps={{
'aria-label': label
inputProps={{
'aria-label': label,
}}
/>
)
return (
<div className={classes.switch}>
<FormControlLabel
control={toggleSwitch}
label={`${label} (${value ? 'On' : 'Off'})`}
labelPlacement="bottom"
/>
<FormControlLabel control={toggleSwitch} label={`${label} (${value ? 'On' : 'Off'})`} labelPlacement="bottom" />
</div>
)
}

View File

@@ -24,20 +24,20 @@ class Key extends React.Component<Props, {}> {
const style = (theme: Theme) => ({
keyStyle: {
display: 'inline-block' as 'inline-block',
display: 'inline-block' as const,
width: '1em',
height: '1em',
backgroundColor: '#bbb',
borderRadius: '10%',
verticalAlign: 'middle' as 'middle',
textAlign: 'center' as 'center',
verticalAlign: 'middle' as const,
textAlign: 'center' as const,
textShadow: '1px 1px rgba(255,255,255,0.45)',
boxShadow: '0.08em 0.15em 0.01em 0px rgba(100,100,100,0.75)',
},
keyTextStyle: {
marginTop: '0.65em',
fontSize: '0.4em',
fontWeight: 'bold' as 'bold',
fontWeight: 'bold' as const,
},
})

View File

@@ -1,6 +1,7 @@
import * as React from 'react'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
const cursor = require('./cursor.png')
interface State {
@@ -13,11 +14,18 @@ interface State {
class Demo extends React.Component<{ classes: any }, State> {
private timer: any
private frameInterval = 20
constructor(props: any) {
super(props)
this.state = { enabled: false, target: { x: 0, y: 0 }, position: { x: 0, y: 0 }, stepSizeX: 1, stepSizeY: 1 }
this.state = {
enabled: false,
target: { x: 0, y: 0 },
position: { x: 0, y: 0 },
stepSizeX: 1,
stepSizeY: 1,
}
}
private moveCloser(steps: number = 0) {
@@ -50,7 +58,12 @@ class Demo extends React.Component<{ classes: any }, State> {
;(window as any).demo.moveMouse = (x: number, y: number, animationTime: number) => {
const stepSizeX = Math.abs(this.state.position.x - x) / (animationTime / this.frameInterval)
const stepSizeY = Math.abs(this.state.position.y - y) / (animationTime / this.frameInterval)
this.setState({ stepSizeX, stepSizeY, enabled: true, target: { x, y } })
this.setState({
stepSizeX,
stepSizeY,
enabled: true,
target: { x, y },
})
this.moveCloser()
}
}
@@ -73,10 +86,10 @@ const style = (theme: Theme) => ({
cursor: {
width: '32px',
height: '32px',
position: 'fixed' as 'fixed',
position: 'fixed' as const,
zIndex: 1000000,
filter: theme.palette.mode === 'light' ? undefined : 'invert(100%)',
pointerEvents: 'none' as 'none',
pointerEvents: 'none' as const,
},
})

View File

@@ -11,6 +11,7 @@ interface State {
class Demo extends React.Component<{ classes: any }, State> {
private timer: any
constructor(props: any) {
super(props)
this.state = { location: 'bottom', keys: [] }
@@ -44,7 +45,7 @@ class Demo extends React.Component<{ classes: any }, State> {
middle: -32,
}
const style = {
position: 'fixed' as 'fixed',
position: 'fixed' as const,
left: '5vw',
zIndex: 1000000,
margin: '30vw auto 50vw',
@@ -52,7 +53,7 @@ class Demo extends React.Component<{ classes: any }, State> {
bottom: `${positions[this.state.location]}vh`,
}
const style2 = {
textAlign: 'center' as 'center',
textAlign: 'center' as const,
fontSize: '4em',
color: 'white',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
@@ -67,9 +68,7 @@ class Demo extends React.Component<{ classes: any }, State> {
if (this.state.keys.length > 0) {
keys = this.state.keys
.map(key => [<Key key={key} keyboardKey={key} />])
.reduce((prev, current) => {
return [prev, '+' as any, current]
})
.reduce((prev, current) => [prev, '+' as any, current])
}
return (
@@ -86,7 +85,7 @@ class Demo extends React.Component<{ classes: any }, State> {
const style = (theme: Theme) => ({
keysStyle: {
fontSize: '1em',
display: 'inline-block' as 'inline-block',
display: 'inline-block' as const,
transform: 'translateY(0.3em) translateX(0.8em)',
},
})

View File

@@ -1,11 +1,12 @@
import * as React from 'react'
import ShowText from './ShowText'
import Mouse from './Mouse'
let heapdump: any
function writeHeapdump(path?: string) {
if (!heapdump) {
//<heapdump = require('heapdump')
// <heapdump = require('heapdump')
}
heapdump.writeSnapshot(path || `${Date.now()}.heapsnapshot`)

View File

@@ -1,10 +1,10 @@
import * as React from 'react'
import PersistentStorage from '../utils/PersistentStorage'
import SentimentDissatisfied from '@mui/icons-material/SentimentDissatisfied'
import Warning from '@mui/icons-material/Warning'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { Button, Modal, Paper, Toolbar, Typography } from '@mui/material'
import PersistentStorage from '../utils/PersistentStorage'
interface State {
error?: Error
@@ -19,6 +19,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
public static getDerivedStateFromError(error: Error) {
return { error }
}
constructor(props: Props) {
super(props)
this.state = {}
@@ -45,7 +46,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
const { classes } = this.props
return (
<Modal open={true} disableAutoFocus={true}>
<Modal open disableAutoFocus>
<Paper className={classes.root}>
<Toolbar style={{ padding: '0' }}>
<Typography className={classes.title} variant="h6" color="inherit">
@@ -101,17 +102,17 @@ const styles = (theme: Theme) => ({
title: {
color: theme.palette.text.primary,
margin: '0',
textAlign: 'center' as 'center',
textAlign: 'center' as const,
},
textColor: {
color: theme.palette.text.primary,
userSelect: 'all' as 'all',
userSelect: 'all' as const,
},
centered: {
textAlign: 'center' as 'center',
textAlign: 'center' as const,
},
buttonPositioning: {
textAlign: 'center' as 'center',
textAlign: 'center' as const,
marginTop: theme.spacing(2),
},
})

View File

@@ -1,13 +1,13 @@
import * as React from 'react'
import ChartPanel from '../ChartPanel'
import ReactSplitPaneImport from 'react-split-pane'
import { connect } from 'react-redux'
import { List } from 'immutable'
import { useResizeDetector } from 'react-resize-detector'
import ChartPanel from '../ChartPanel'
import Tree from '../Tree'
import { AppState } from '../../reducers'
import { ChartParameters } from '../../reducers/Charts'
import { connect } from 'react-redux'
import { List } from 'immutable'
import { Sidebar } from '../Sidebar'
import { useResizeDetector } from 'react-resize-detector'
import MobileTabs from './MobileTabs'
import PublishTab from '../Sidebar/PublishTab'
@@ -30,31 +30,31 @@ function ContentView(props: Props) {
const [sidebarWidth, setSidebarWidth] = React.useState<string | number>(isMobile ? '100%' : '40%')
const [detectedHeight, setDetectedHeight] = React.useState(0)
const [detectedSidebarWidth, setDetectedSidebarWidth] = React.useState(0)
// Update mobile state on resize
React.useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth <= 768)
}
// Set initial state
handleResize()
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
const { height: resizeHeight, ref: heightRef } = useResizeDetector()
const { width: resizeWidth, ref: widthRef } = useResizeDetector()
React.useEffect(() => {
if (resizeHeight) setDetectedHeight(resizeHeight)
}, [resizeHeight])
React.useEffect(() => {
if (resizeWidth) setDetectedSidebarWidth(resizeWidth)
}, [resizeWidth])
const detectSize = React.useCallback((width: any, newHeight: any) => {
setDetectedHeight(newHeight)
}, [])
@@ -92,8 +92,8 @@ function ContentView(props: Props) {
// Expose tab switching functions for other components to call
React.useEffect(() => {
if (typeof window !== 'undefined') {
(window as any).switchToDetailsTab = () => setMobileTab(1)
(window as any).switchToTopicsTab = () => setMobileTab(0)
;(window as any).switchToDetailsTab = () =>
(setMobileTab(1)(window as any).switchToTopicsTab = () => setMobileTab(0))
;(window as any).switchToPublishTab = () => setMobileTab(2)
;(window as any).switchToChartsTab = () => setMobileTab(3)
}
@@ -107,6 +107,19 @@ function ContentView(props: Props) {
}
}, [])
// Scroll to selected topic when returning to tree tab
React.useEffect(() => {
if (mobileTab === 0) {
// Delay to ensure DOM is rendered
setTimeout(() => {
const selectedNode = document.querySelector('.tree .selected')
if (selectedNode) {
selectedNode.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}, 100)
}
}, [mobileTab])
const mobileContainerStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
@@ -150,30 +163,22 @@ function ContentView(props: Props) {
<div style={mobileContainerStyle}>
<MobileTabs value={mobileTab} onChange={setMobileTab} />
<div style={tabContentStyle}>
{/* Topics tab */}
{mobileTab === 0 && (
<div style={treeContainerStyle}>
<Tree />
</div>
)}
{/* Details tab */}
{mobileTab === 1 && (
<div style={sidebarContainerStyle}>
<Sidebar connectionId={props.connectionId} />
</div>
)}
{/* Publish tab */}
{mobileTab === 2 && (
<div style={sidebarContainerStyle}>
<PublishTab connectionId={props.connectionId} />
</div>
)}
{/* Charts tab */}
{mobileTab === 3 && (
<div style={sidebarContainerStyle}>
<ChartPanel />
</div>
)}
{/* Topics tab - keep mounted, toggle visibility */}
<div style={{ ...treeContainerStyle, display: mobileTab === 0 ? 'block' : 'none' }}>
<Tree />
</div>
{/* Details tab - keep mounted, toggle visibility */}
<div style={{ ...sidebarContainerStyle, display: mobileTab === 1 ? 'block' : 'none' }}>
<Sidebar connectionId={props.connectionId} />
</div>
{/* Publish tab - keep mounted, toggle visibility */}
<div style={{ ...sidebarContainerStyle, display: mobileTab === 2 ? 'block' : 'none' }}>
<PublishTab connectionId={props.connectionId} />
</div>
{/* Charts tab - keep mounted, toggle visibility */}
<div style={{ ...sidebarContainerStyle, display: mobileTab === 3 ? 'block' : 'none' }}>
<ChartPanel />
</div>
</div>
</div>
)
@@ -192,7 +197,7 @@ function ContentView(props: Props) {
size={sidebarWidth}
onChange={(size: number) => setSidebarWidth(size)}
onDragFinished={closeSidebarCompletelyIfItSitsOnTheEdge}
allowResize={true}
allowResize
style={{ height: '100%' }}
pane1Style={{ overflowX: 'hidden' }}
resizerStyle={{ height: '100%' }}
@@ -203,7 +208,7 @@ function ContentView(props: Props) {
split="horizontal"
minSize={0}
size={height}
allowResize={true}
allowResize
style={{ height: 'calc(100vh - 64px)' }}
pane1Style={{ maxHeight: '100%' }}
pane2Style={{ borderTop: '1px solid #999', display: 'flex' }}
@@ -212,7 +217,15 @@ function ContentView(props: Props) {
>
<Tree />
{/** Passing height constraints via flex options down */}
<div ref={heightRef} style={{ flex: 1, display: 'flex', height: '100%', width: '100%' }}>
<div
ref={heightRef}
style={{
flex: 1,
display: 'flex',
height: '100%',
width: '100%',
}}
>
{/** Resize detector must not be in the scroll zone, it needs to detect actual available size */}
<ChartPanel />
</div>
@@ -221,11 +234,11 @@ function ContentView(props: Props) {
<div ref={widthRef} style={{ height: '100%' }}>
<div
className={props.paneDefaults}
style={{
minWidth: '250px',
height: '100%',
overflowY: 'auto',
overflowX: 'hidden'
style={{
minWidth: '250px',
height: '100%',
overflowY: 'auto',
overflowX: 'hidden',
}}
>
<Sidebar connectionId={props.connectionId} />
@@ -237,10 +250,8 @@ function ContentView(props: Props) {
)
}
const mapStateToProps = (state: AppState) => {
return {
chartPanelItems: state.charts.get('charts'),
}
}
const mapStateToProps = (state: AppState) => ({
chartPanelItems: state.charts.get('charts'),
})
export default connect(mapStateToProps)(ContentView)

View File

@@ -20,41 +20,41 @@ function MobileTabs(props: Props) {
return (
<Box className={props.classes.root} role="navigation" aria-label="Mobile navigation tabs">
<Tabs
value={props.value}
<Tabs
value={props.value}
onChange={handleChange}
variant="fullWidth"
indicatorColor="primary"
textColor="primary"
aria-label="Topics, Details, Publish and Charts tabs"
>
<Tab
<Tab
icon={<AccountTreeIcon />}
label="Topics"
label="Topics"
data-testid="mobile-tab-topics"
aria-label="View topics tree"
id="mobile-tab-0"
aria-controls="mobile-tabpanel-0"
/>
<Tab
<Tab
icon={<InfoIcon />}
label="Details"
label="Details"
data-testid="mobile-tab-details"
aria-label="View topic details"
id="mobile-tab-1"
aria-controls="mobile-tabpanel-1"
/>
<Tab
<Tab
icon={<SendIcon />}
label="Publish"
label="Publish"
data-testid="mobile-tab-publish"
aria-label="Publish messages"
id="mobile-tab-2"
aria-controls="mobile-tabpanel-2"
/>
<Tab
<Tab
icon={<ShowChartIcon />}
label="Charts"
label="Charts"
data-testid="mobile-tab-charts"
aria-label="View charts"
id="mobile-tab-3"
@@ -69,7 +69,7 @@ const styles = (theme: Theme) => ({
root: {
borderBottom: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.paper,
position: 'relative' as 'relative',
position: 'relative' as const,
zIndex: 1,
minHeight: '56px', // Touch-friendly tab height
'& .MuiTab-root': {
@@ -77,7 +77,7 @@ const styles = (theme: Theme) => ({
fontSize: '16px', // Prevent iOS zoom
fontWeight: 500,
padding: theme.spacing(1.5, 2),
textTransform: 'none' as 'none', // Better readability
textTransform: 'none' as const, // Better readability
'&:active': {
opacity: 0.7, // Touch feedback
},

View File

@@ -30,8 +30,8 @@ class Notification extends React.PureComponent<Props, {}> {
public render() {
const snackbarAnchor = {
vertical: 'bottom' as 'bottom',
horizontal: 'left' as 'left',
vertical: 'bottom' as const,
horizontal: 'left' as const,
}
return (

View File

@@ -1,19 +1,19 @@
import * as React from 'react'
import * as q from '../../../../backend/src/Model'
import CustomIconButton from '../helper/CustomIconButton'
import Pause from '@mui/icons-material/PauseCircleFilled'
import Resume from '@mui/icons-material/PlayArrow'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { treeActions } from '../../actions'
import { withStyles } from '@mui/styles'
import { Theme } from '@mui/material/styles'
import * as q from '../../../../backend/src/Model'
import CustomIconButton from '../helper/CustomIconButton'
import { treeActions } from '../../actions'
import { AppState } from '../../reducers'
const styles = (theme: Theme) => ({
icon: {
color: theme.palette.primary.contrastText,
verticalAlign: 'middle' as 'middle',
verticalAlign: 'middle' as const,
},
bufferStats: {
minWidth: '8em',
@@ -31,6 +31,7 @@ interface Props {
class PauseButton extends React.PureComponent<Props, { changes: number }> {
private timer?: any
constructor(props: Props) {
super(props)
this.state = { changes: 0 }
@@ -88,19 +89,15 @@ class PauseButton extends React.PureComponent<Props, { changes: number }> {
}
}
const mapStateToProps = (state: AppState) => {
return {
paused: state.tree.get('paused'),
tree: state.tree.get('tree'),
}
}
const mapStateToProps = (state: AppState) => ({
paused: state.tree.get('paused'),
tree: state.tree.get('tree'),
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
tree: bindActionCreators(treeActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
tree: bindActionCreators(treeActions, dispatch),
},
})
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(PauseButton) as any)

View File

@@ -1,13 +1,13 @@
import React, { useCallback, useState, useRef } from 'react'
import ClearAdornment from '../helper/ClearAdornment'
import Search from '@mui/icons-material/Search'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { InputBase } from '@mui/material'
import { settingsActions } from '../../actions'
import { alpha as fade, Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { settingsActions } from '../../actions'
import { AppState } from '../../reducers'
import ClearAdornment from '../helper/ClearAdornment'
import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler'
import { KeyCodes } from '../../utils/KeyCodes'
@@ -28,7 +28,7 @@ function SearchBar(props: {
// On mobile, switch to Topics tab when search is focused
if (typeof window !== 'undefined' && window.innerWidth <= 768) {
if ((window as any).switchToTopicsTab) {
(window as any).switchToTopicsTab()
;(window as any).switchToTopicsTab()
}
}
}, [])
@@ -90,24 +90,20 @@ function SearchBar(props: {
)
}
const mapStateToProps = (state: AppState) => {
return {
topicFilter: state.settings.get('topicFilter'),
hasConnection: Boolean(state.connection.connectionId),
}
}
const mapStateToProps = (state: AppState) => ({
topicFilter: state.settings.get('topicFilter'),
hasConnection: Boolean(state.connection.connectionId),
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
settings: bindActionCreators(settingsActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
settings: bindActionCreators(settingsActions, dispatch),
},
})
const styles = (theme: Theme) => ({
search: {
position: 'relative' as 'relative',
position: 'relative' as const,
borderRadius: theme.shape.borderRadius,
backgroundColor: fade(theme.palette.common.white, 0.15),
'&:hover': {
@@ -122,21 +118,21 @@ const styles = (theme: Theme) => ({
maxWidth: '30%',
marginLeft: theme.spacing(4),
width: 'auto' as 'auto',
width: 'auto' as const,
},
[theme.breakpoints.up(750)]: {
marginLeft: theme.spacing(4),
width: 'auto' as 'auto',
width: 'auto' as const,
},
},
searchIcon: {
width: theme.spacing(6),
height: '100%',
position: 'absolute' as 'absolute',
pointerEvents: 'none' as 'none',
display: 'flex' as 'flex',
alignItems: 'center' as 'center',
justifyContent: 'center' as 'center',
position: 'absolute' as const,
pointerEvents: 'none' as const,
display: 'flex' as const,
alignItems: 'center' as const,
justifyContent: 'center' as const,
},
inputRoot: {
color: `${theme.palette.common.white} !important`, // Ensure white text color with high specificity

View File

@@ -1,35 +1,36 @@
import * as React from 'react'
import CloudOff from '@mui/icons-material/CloudOff'
import Logout from '@mui/icons-material/Logout'
import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator'
const ConnectionHealthIndicatorAny = ConnectionHealthIndicator as any
import Menu from '@mui/icons-material/Menu'
import PauseButton from './PauseButton'
import SearchBar from './SearchBar'
import { AppBar, Button, IconButton, Toolbar, Typography } from '@mui/material'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionActions, globalActions, settingsActions } from '../../actions'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { connectionActions, globalActions, settingsActions } from '../../actions'
import { AppState } from '../../reducers'
import SearchBar from './SearchBar'
import PauseButton from './PauseButton'
import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator'
import { isBrowserMode } from '../../utils/browserMode'
import { useAuth } from '../../contexts/AuthContext'
const ConnectionHealthIndicatorAny = ConnectionHealthIndicator as any
const styles = (theme: Theme) => ({
title: {
display: 'none' as 'none',
display: 'none' as const,
[theme.breakpoints.up(750)]: {
display: 'block' as 'block',
display: 'block' as const,
},
[theme.breakpoints.up('md')]: {
display: 'block' as 'block',
display: 'block' as const,
},
whiteSpace: 'nowrap' as 'nowrap',
whiteSpace: 'nowrap' as const,
},
disconnectIcon: {
[theme.breakpoints.down('xs')]: {
display: 'none' as 'none',
display: 'none' as const,
},
marginRight: '8px',
paddingLeft: '8px',
@@ -42,14 +43,14 @@ const styles = (theme: Theme) => ({
margin: 'auto 8px auto auto',
// Hide on mobile (<=768px)
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
display: 'none' as const,
},
},
logout: {
margin: 'auto 0 auto 8px',
// Hide on mobile (<=768px)
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
display: 'none' as const,
},
},
disconnectLabel: {
@@ -76,13 +77,13 @@ class TitleBar extends React.PureComponent<Props, {}> {
private handleLogout = async () => {
// Disconnect first
this.props.actions.connection.disconnect()
// Clear credentials from sessionStorage
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem('mqtt-explorer-username')
sessionStorage.removeItem('mqtt-explorer-password')
}
// Reload page to reset all state and show login dialog
if (typeof window !== 'undefined') {
window.location.reload()
@@ -117,7 +118,7 @@ class TitleBar extends React.PureComponent<Props, {}> {
Disconnect <CloudOff className={classes.disconnectIcon} />
</Button>
<LogoutButton classes={classes} onLogout={this.handleLogout} />
<ConnectionHealthIndicatorAny withBackground={true} />
<ConnectionHealthIndicatorAny withBackground />
</Toolbar>
</AppBar>
)
@@ -127,36 +128,28 @@ class TitleBar extends React.PureComponent<Props, {}> {
// Separate component to use hooks
function LogoutButton({ classes, onLogout }: { classes: any; onLogout: () => void }) {
const { authDisabled } = useAuth()
if (!isBrowserMode || authDisabled) {
return null
}
return (
<Button
className={classes.logout}
sx={{ color: 'primary.contrastText' }}
onClick={onLogout}
>
<Button className={classes.logout} sx={{ color: 'primary.contrastText' }} onClick={onLogout}>
Logout <Logout className={classes.disconnectIcon} />
</Button>
)
}
const mapStateToProps = (state: AppState) => {
return {
topicFilter: state.settings.get('topicFilter'),
}
}
const mapStateToProps = (state: AppState) => ({
topicFilter: state.settings.get('topicFilter'),
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
settings: bindActionCreators(settingsActions, dispatch),
global: bindActionCreators(globalActions, dispatch),
connection: bindActionCreators(connectionActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
settings: bindActionCreators(settingsActions, dispatch),
global: bindActionCreators(globalActions, dispatch),
connection: bindActionCreators(connectionActions, dispatch),
},
})
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(TitleBar))

View File

@@ -1,6 +1,6 @@
/**
* LoginDialog Security Tests
*
*
* Security-focused tests for the Login Page:
* - Error message visibility to users
* - Rate limiting enforcement (anti-brute force)
@@ -26,12 +26,9 @@ describe('LoginDialog Security Tests', () => {
it('should display "Invalid credentials" error message to user', () => {
const mockLogin = () => {}
const errorMessage = 'Invalid credentials'
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} error={errorMessage} />, { withTheme: true })
// Verify error is visible to user
const errorElement = getByText(errorMessage)
expect(errorElement).to.exist
@@ -40,12 +37,9 @@ describe('LoginDialog Security Tests', () => {
it('should display rate limiting error message to user', () => {
const mockLogin = () => {}
const errorMessage = 'Too many failed authentication attempts. Please wait 30 seconds before trying again.'
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} error={errorMessage} />, { withTheme: true })
// Verify rate limiting error is visible to user
expect(getByText('Too many failed authentication attempts')).to.exist
})
@@ -53,12 +47,9 @@ describe('LoginDialog Security Tests', () => {
it('should display "Authentication required" error message to user', () => {
const mockLogin = () => {}
const errorMessage = 'Please enter your username and password.'
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} error={errorMessage} />, { withTheme: true })
// Verify auth required message is visible to user
expect(getByText('Please enter your username and password.')).to.exist
})
@@ -66,12 +57,9 @@ describe('LoginDialog Security Tests', () => {
it('should display generic authentication failure message to user', () => {
const mockLogin = () => {}
const errorMessage = 'Authentication failed. Please try again.'
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} error={errorMessage} />, { withTheme: true })
// Verify generic error is visible to user
expect(getByText('Authentication failed. Please try again.')).to.exist
})
@@ -81,12 +69,11 @@ describe('LoginDialog Security Tests', () => {
it('should disable login button during rate limit countdown', () => {
const mockLogin = () => {}
const waitTime = 30
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} waitTimeSeconds={waitTime} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} waitTimeSeconds={waitTime} />, {
withTheme: true,
})
// Verify button is disabled to prevent further attempts
const buttons = Array.from(document.querySelectorAll('button'))
const loginButton = buttons.find(b => b.textContent?.match(/Wait \d+s/))
@@ -97,16 +84,15 @@ describe('LoginDialog Security Tests', () => {
it('should disable input fields during rate limit countdown', () => {
const mockLogin = () => {}
const waitTime = 30
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} waitTimeSeconds={waitTime} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} waitTimeSeconds={waitTime} />, {
withTheme: true,
})
// Verify inputs are disabled to prevent modification during lockout
const usernameInput = getByTestId('username-input')?.querySelector('input')
const passwordInput = getByTestId('password-input')?.querySelector('input')
expect(usernameInput?.hasAttribute('disabled')).to.be.true
expect(passwordInput?.hasAttribute('disabled')).to.be.true
})
@@ -114,12 +100,11 @@ describe('LoginDialog Security Tests', () => {
it('should display countdown timer to user during rate limiting', () => {
const mockLogin = () => {}
const waitTime = 30
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} waitTimeSeconds={waitTime} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} waitTimeSeconds={waitTime} />, {
withTheme: true,
})
// Verify countdown is visible to inform user of lockout duration
const countdownElement = getByText('Please wait')
expect(countdownElement).to.exist
@@ -130,12 +115,11 @@ describe('LoginDialog Security Tests', () => {
const mockLogin = () => {}
const errorMessage = 'Too many failed authentication attempts. Please wait 30 seconds before trying again.'
const waitTime = 30
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} waitTimeSeconds={waitTime} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} error={errorMessage} waitTimeSeconds={waitTime} />, {
withTheme: true,
})
// Verify both error and countdown are visible
expect(getByText(errorMessage)).to.exist
expect(getByText('Please wait 30 seconds before trying again')).to.exist
@@ -145,28 +129,22 @@ describe('LoginDialog Security Tests', () => {
describe('Credential Requirement Validation (Prevent Unauthorized Access)', () => {
it('should require both username and password fields to be present', () => {
const mockLogin = () => {}
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} />, { withTheme: true })
// Verify both credential fields exist and are required
const usernameInput = getByTestId('username-input')
const passwordInput = getByTestId('password-input')
expect(usernameInput).to.exist
expect(passwordInput).to.exist
})
it('should require password field to be masked', () => {
const mockLogin = () => {}
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} />, { withTheme: true })
// Verify password is masked (type="password") for security
const passwordInput = getByTestId('password-input')?.querySelector('input')
expect(passwordInput?.getAttribute('type')).to.equal('password')
@@ -178,12 +156,9 @@ describe('LoginDialog Security Tests', () => {
const mockLogin = () => {}
// Error doesn't distinguish between invalid username vs invalid password
const errorMessage = 'Invalid credentials'
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} error={errorMessage} />, { withTheme: true })
// Verify error doesn't leak whether username or password was wrong
const errorElement = getByText(errorMessage)
expect(errorElement).to.exist
@@ -194,12 +169,9 @@ describe('LoginDialog Security Tests', () => {
it('should not display sensitive information in error messages', () => {
const mockLogin = () => {}
const errorMessage = 'Invalid credentials'
renderWithProviders(
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
{ withTheme: true }
)
renderWithProviders(<LoginDialog open onLogin={mockLogin} error={errorMessage} />, { withTheme: true })
// Verify error doesn't contain sensitive data
const errorElement = getByText(errorMessage)
expect(errorElement?.textContent).to.not.include('database')

View File

@@ -57,7 +57,15 @@ export function LoginDialog(props: LoginDialogProps) {
const isDisabled = countdown !== undefined && countdown > 0
return (
<Dialog open={props.open} disableEscapeKeyDown onClose={(event, reason) => { if (reason !== 'backdropClick') { /* Allow closing only via escape if needed */ } }}>
<Dialog
open={props.open}
disableEscapeKeyDown
onClose={(event, reason) => {
if (reason !== 'backdropClick') {
/* Allow closing only via escape if needed */
}
}}
>
<DialogTitle>Login to MQTT Explorer</DialogTitle>
<DialogContent>
{props.error && (

View File

@@ -1,9 +1,9 @@
import * as React from 'react'
import { TextField, MenuItem, Tooltip } from '@mui/material'
import { QoS } from '../../../backend/src/DataSource/MqttSource'
import { QoS } from 'mqtt-explorer-backend/src/DataSource/MqttSource'
export function QosSelect(props: { selected: QoS; onChange: (value: QoS) => void; label?: string }) {
const tooltipStyle = { textAlign: 'center' as 'center', width: '100%' }
const tooltipStyle = { textAlign: 'center' as const, width: '100%' }
const itemStyle = { padding: '0' }
const onChangeQos = React.useCallback(
@@ -19,7 +19,7 @@ export function QosSelect(props: { selected: QoS; onChange: (value: QoS) => void
return (
<TextField
select={true}
select
label={props.label}
value={props.selected}
margin="normal"

View File

@@ -1,9 +1,17 @@
import * as React from 'react'
import { InputLabel, Switch, Theme, Tooltip } from '@mui/material'
import { withStyles } from '@mui/styles'
const sha1 = require('sha1')
function BooleanSwitch(props: { title: string; value: boolean; tooltip: string; action: () => void; classes: any; 'data-testid'?: string }) {
function BooleanSwitch(props: {
title: string
value: boolean
tooltip: string
action: () => void
classes: any
'data-testid'?: string
}) {
const { tooltip, value, action, title, classes } = props
const clickHandler = (e: React.MouseEvent) => {
@@ -20,10 +28,10 @@ function BooleanSwitch(props: { title: string; value: boolean; tooltip: string;
</InputLabel>
</Tooltip>
<Tooltip title={tooltip}>
<Switch
name={`toggle-${sha1(title)}`}
checked={value}
onChange={action}
<Switch
name={`toggle-${sha1(title)}`}
checked={value}
onChange={action}
color="primary"
data-testid={props['data-testid']}
/>

View File

@@ -1,14 +1,15 @@
import * as q from '../../../../backend/src/Model'
import React, { useMemo } from 'react'
import { AppState } from '../../reducers'
import { Base64Message } from '../../../../backend/src/Model/Base64Message'
import { connect } from 'react-redux'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { TopicViewModel } from '../../model/TopicViewModel'
import { Typography } from '@mui/material'
import * as q from '../../../../backend/src/Model'
import { Base64Message } from '../../../../backend/src/Model/Base64Message'
import { TopicViewModel } from '../../model/TopicViewModel'
import { AppState } from '../../reducers'
import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode'
import { useUpdateComponentWhenNodeUpdates } from '../helper/useUpdateComponentWhenNodeUpdates'
const abbreviate = require('number-abbreviate')
interface Stats {
@@ -37,7 +38,7 @@ function BrokerStatistics(props: Props) {
useUpdateComponentWhenNodeUpdates(sysTopic)
return useMemo(() => {
if (!Boolean(sysTopic)) {
if (!sysTopic) {
return null
}
@@ -96,11 +97,9 @@ function BrokerStatistics(props: Props) {
}, [sysTopic && sysTopic.lastUpdate, props.classes])
}
const mapStateToProps = (state: AppState) => {
return {
tree: state.connection.tree,
}
}
const mapStateToProps = (state: AppState) => ({
tree: state.connection.tree,
})
export default withStyles(styles)(connect(mapStateToProps)(BrokerStatistics))

View File

@@ -1,21 +1,12 @@
import * as React from 'react'
import BooleanSwitch from './BooleanSwitch'
import BrokerStatistics from './BrokerStatistics'
import ChevronRight from '@mui/icons-material/ChevronRight'
import CloudOff from '@mui/icons-material/CloudOff'
import Logout from '@mui/icons-material/Logout'
import TimeLocale from './TimeLocale'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { globalActions, settingsActions, connectionActions } from '../../actions'
import { shell } from 'electron'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { TopicOrder } from '../../reducers/Settings'
import { isBrowserMode } from '../../utils/browserMode'
import { useAuth } from '../../contexts/AuthContext'
import {
Button,
Divider,
@@ -29,6 +20,15 @@ import {
Typography,
Tooltip,
} from '@mui/material'
import TimeLocale from './TimeLocale'
import { AppState } from '../../reducers'
import { globalActions, settingsActions, connectionActions } from '../../actions'
import { TopicOrder } from '../../reducers/Settings'
import { isBrowserMode } from '../../utils/browserMode'
import { useAuth } from '../../contexts/AuthContext'
import BrokerStatistics from './BrokerStatistics'
import BooleanSwitch from './BooleanSwitch'
export const autoExpandLimitSet = [
{
@@ -61,7 +61,7 @@ const styles = (theme: Theme) => ({
drawer: {
backgroundColor: theme.palette.background.default,
flexShrink: 0,
userSelect: 'none' as 'none',
userSelect: 'none' as const,
},
paper: {
width: '300px',
@@ -78,16 +78,16 @@ const styles = (theme: Theme) => ({
author: {
margin: 'auto 8px 8px auto',
color: theme.palette.text.secondary,
cursor: 'pointer' as 'pointer',
cursor: 'pointer' as const,
},
mobileButtons: {
padding: theme.spacing(1),
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
gap: theme.spacing(1),
// Only show on mobile
[theme.breakpoints.up('md')]: {
display: 'none' as 'none',
display: 'none' as const,
},
},
mobileButton: {
@@ -205,7 +205,7 @@ class Settings extends React.PureComponent<Props, {}> {
value={topicOrder}
onChange={this.onChangeSorting}
input={<Input name="node-order" id="node-order-label-placeholder" />}
displayEmpty={true}
displayEmpty
name="node-order"
className={classes.input}
style={{ flex: '1' }}
@@ -265,13 +265,13 @@ function MobileActionButtons({ classes, actions }: { classes: any; actions: any
const handleLogout = async () => {
// Disconnect first
actions.connection.disconnect()
// Clear credentials from sessionStorage
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem('mqtt-explorer-username')
sessionStorage.removeItem('mqtt-explorer-password')
}
// Reload page to reset all state and show login dialog
if (typeof window !== 'undefined') {
window.location.reload()
@@ -304,25 +304,21 @@ function MobileActionButtons({ classes, actions }: { classes: any; actions: any
)
}
const mapStateToProps = (state: AppState) => {
return {
autoExpandLimit: state.settings.get('autoExpandLimit'),
topicOrder: state.settings.get('topicOrder'),
visible: state.globalState.get('settingsVisible'),
highlightTopicUpdates: state.settings.get('highlightTopicUpdates'),
selectTopicWithMouseOver: state.settings.get('selectTopicWithMouseOver'),
theme: state.settings.get('theme'),
}
}
const mapStateToProps = (state: AppState) => ({
autoExpandLimit: state.settings.get('autoExpandLimit'),
topicOrder: state.settings.get('topicOrder'),
visible: state.globalState.get('settingsVisible'),
highlightTopicUpdates: state.settings.get('highlightTopicUpdates'),
selectTopicWithMouseOver: state.settings.get('selectTopicWithMouseOver'),
theme: state.settings.get('theme'),
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
settings: bindActionCreators(settingsActions, dispatch),
global: bindActionCreators(globalActions, dispatch),
connection: bindActionCreators(connectionActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
settings: bindActionCreators(settingsActions, dispatch),
global: bindActionCreators(globalActions, dispatch),
connection: bindActionCreators(connectionActions, dispatch),
},
})
export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(Settings))

View File

@@ -1,11 +1,11 @@
import * as React from 'react'
import DateFormatter from '../helper/DateFormatter'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { Input, InputLabel, MenuItem, Select, Theme } from '@mui/material'
import { settingsActions } from '../../actions'
import { withStyles } from '@mui/styles'
import { settingsActions } from '../../actions'
import { AppState } from '../../reducers'
import DateFormatter from '../helper/DateFormatter'
function importAll(r: any) {
r.keys().forEach(r)
@@ -63,19 +63,15 @@ function TimeLocaleSettings(props: Props) {
)
}
const mapStateToProps = (state: AppState) => {
return {
timeLocale: state.settings.get('timeLocale'),
}
}
const mapStateToProps = (state: AppState) => ({
timeLocale: state.settings.get('timeLocale'),
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
settings: bindActionCreators(settingsActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
settings: bindActionCreators(settingsActions, dispatch),
},
})
const styles = (theme: Theme) => ({
input: {

View File

@@ -15,14 +15,9 @@ import {
Collapse,
Alert,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Select,
MenuItem,
FormControl,
InputLabel,
Card,
CardContent,
CardActions,
} from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
@@ -30,12 +25,16 @@ import SendIcon from '@mui/icons-material/Send'
import SmartToyIcon from '@mui/icons-material/SmartToy'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
import SettingsIcon from '@mui/icons-material/Settings'
import ClearIcon from '@mui/icons-material/Clear'
import { getLLMService, LLMMessage, LLMProvider } from '../../services/llmService'
import PublishIcon from '@mui/icons-material/Publish'
import BugReportIcon from '@mui/icons-material/BugReport'
import { Base64Message } from '../../../../backend/src/Model/Base64Message'
import { getLLMService, LLMMessage, MessageProposal, QuestionProposal } from '../../services/llmService'
import { makePublishEvent, rendererEvents } from '../../eventBus'
interface Props {
node?: any
connectionId?: string
classes: any
}
@@ -43,25 +42,24 @@ interface ChatMessage {
role: 'user' | 'assistant' | 'system'
content: string
timestamp: Date
proposals?: MessageProposal[]
questionProposals?: QuestionProposal[]
debugInfo?: any // API debug information
}
function AIAssistant(props: Props) {
const { node, classes } = props
const { node, connectionId, classes } = props
const [expanded, setExpanded] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [inputValue, setInputValue] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [configDialogOpen, setConfigDialogOpen] = useState(false)
const [apiKey, setApiKey] = useState('')
const [provider, setProvider] = useState<LLMProvider>('openai')
const [suggestedQuestions, setSuggestedQuestions] = useState<string[]>([])
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
const [showDebug, setShowDebug] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const llmService = getLLMService()
useEffect(() => {
// Initialize provider from service
setProvider(llmService.getProvider())
}, [])
const previousNodePathRef = useRef<string>('')
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -71,15 +69,40 @@ function AIAssistant(props: Props) {
scrollToBottom()
}, [messages])
// Auto-generate questions when node changes or chat is expanded
// Clear chat when node changes
useEffect(() => {
const nodePath = node?.path?.()
if (expanded && node && nodePath && nodePath !== previousNodePathRef.current && llmService.hasApiKey()) {
previousNodePathRef.current = nodePath
// Clear chat messages and error when switching to a new topic
setMessages([])
setError(null)
setLoadingSuggestions(true)
llmService
.generateSuggestedQuestions(node)
.then(questions => {
setSuggestedQuestions(questions)
})
.catch(err => {
console.error('Failed to generate suggested questions:', err)
})
.finally(() => {
setLoadingSuggestions(false)
})
}
}, [expanded, node, llmService])
const handleSendMessage = useCallback(
async (messageText?: string) => {
const text = messageText || inputValue.trim()
if (!text) return
// Check if API key is configured
// Check if backend LLM service is available
if (!llmService.hasApiKey()) {
setError(`Please configure your ${provider === 'gemini' ? 'Gemini' : 'OpenAI'} API key first`)
setConfigDialogOpen(true)
setError('LLM service not configured on server. Please contact your administrator.')
return
}
@@ -93,25 +116,33 @@ function AIAssistant(props: Props) {
content: text,
timestamp: new Date(),
}
setMessages((prev) => [...prev, userMessage])
setMessages(prev => [...prev, userMessage])
try {
// Generate topic context if available
const topicContext = node ? llmService.generateTopicContext(node) : undefined
// Send to LLM
const response = await llmService.sendMessage(text, topicContext)
// Send to LLM - now returns { response, debugInfo }
const llmResponse = await llmService.sendMessage(text, topicContext)
// Add assistant response to UI
// Parse response for proposals and questions
const parsed = llmService.parseResponse(llmResponse.response)
// Add assistant response to UI with proposals, questions, and debug info
const assistantMessage: ChatMessage = {
role: 'assistant',
content: response,
content: parsed.text,
timestamp: new Date(),
proposals: parsed.proposals,
questionProposals: parsed.questions,
debugInfo: llmResponse.debugInfo, // Store debug info
}
setMessages((prev) => [...prev, assistantMessage])
setMessages(prev => [...prev, assistantMessage])
} catch (err: unknown) {
const error = err as { message?: string }
setError(error.message || 'Failed to get response')
console.error('AI Assistant error:', err)
console.error('Error details:', error)
setError(error.message || 'Failed to get response from AI assistant')
} finally {
setLoading(false)
}
@@ -136,24 +167,47 @@ function AIAssistant(props: Props) {
setError(null)
}
const handleSaveApiKey = () => {
if (apiKey.trim()) {
llmService.saveApiKey(apiKey.trim())
llmService.saveProvider(provider)
setConfigDialogOpen(false)
setApiKey('')
setError(null)
// Reset the service to use new config
window.location.reload()
}
}
const handlePublishProposal = useCallback(
(proposal: MessageProposal) => {
if (!connectionId) {
setError('No active connection to publish message')
return
}
try {
const publishEvent = makePublishEvent(connectionId)
const mqttMessage = {
topic: proposal.topic,
payload: Base64Message.fromString(proposal.payload),
retain: false,
qos: proposal.qos,
}
rendererEvents.emit(publishEvent, mqttMessage)
// Show success feedback
setMessages(prev => [
...prev,
{
role: 'assistant',
content: `✓ Published to ${proposal.topic}`,
timestamp: new Date(),
},
])
} catch (err) {
setError(`Failed to publish message: ${err}`)
}
},
[connectionId]
)
const suggestions = node ? llmService.getQuickSuggestions(node) : []
// Check if API key is available (from localStorage or environment)
const allSuggestions = [...suggestions, ...suggestedQuestions]
// Check if backend LLM service is available
const hasApiKey = llmService.hasApiKey()
// Don't render the component at all if no API key is available
// Hide component completely if backend doesn't have LLM configured (new requirement)
if (!hasApiKey) {
return null
}
@@ -161,8 +215,8 @@ function AIAssistant(props: Props) {
return (
<Box className={classes.root}>
{/* Header */}
<Box className={classes.header} onClick={() => setExpanded(!expanded)}>
<Box className={classes.headerLeft}>
<Box className={classes.header}>
<Box className={classes.headerLeft} onClick={() => setExpanded(!expanded)} style={{ flex: 1, cursor: 'pointer' }}>
<SmartToyIcon className={classes.icon} />
<Typography variant="subtitle2" className={classes.title}>
AI Assistant
@@ -170,17 +224,22 @@ function AIAssistant(props: Props) {
<Chip label="Beta" size="small" color="primary" className={classes.betaChip} />
</Box>
<Box className={classes.headerRight}>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation()
setConfigDialogOpen(true)
}}
className={classes.iconButton}
>
<SettingsIcon fontSize="small" />
</IconButton>
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
{expanded && messages.length > 0 && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation()
setShowDebug(!showDebug)
}}
title="Toggle debug view"
className={classes.iconButton}
>
<BugReportIcon fontSize="small" style={{ color: showDebug ? '#f50057' : 'inherit' }} />
</IconButton>
)}
<Box onClick={() => setExpanded(!expanded)} style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</Box>
</Box>
</Box>
@@ -194,20 +253,21 @@ function AIAssistant(props: Props) {
</Alert>
)}
{/* Quick Suggestions */}
{messages.length === 0 && suggestions.length > 0 && (
{/* Quick Suggestions - Always shown when available */}
{hasApiKey && allSuggestions.length > 0 && (
<Box className={classes.suggestions}>
<Typography variant="caption" color="textSecondary" className={classes.suggestionsTitle}>
Quick questions:
{loadingSuggestions ? 'Generating questions...' : 'Suggested questions:'}
</Typography>
<Box className={classes.suggestionChips}>
{suggestions.slice(0, 4).map((suggestion, idx) => (
{allSuggestions.slice(0, 6).map((suggestion, idx) => (
<Chip
key={idx}
label={suggestion}
size="small"
onClick={() => handleSuggestionClick(suggestion)}
className={classes.suggestionChip}
disabled={loadingSuggestions}
/>
))}
</Box>
@@ -226,16 +286,81 @@ function AIAssistant(props: Props) {
)}
{messages.map((msg, idx) => (
<Box
key={idx}
className={msg.role === 'user' ? classes.userMessage : classes.assistantMessage}
>
<Typography variant="body2" className={classes.messageText}>
{msg.content}
</Typography>
<Typography variant="caption" color="textSecondary" className={classes.messageTime}>
{msg.timestamp.toLocaleTimeString()}
</Typography>
<Box key={idx}>
<Box className={msg.role === 'user' ? classes.userMessage : classes.assistantMessage}>
<Typography variant="body2" className={classes.messageText}>
{msg.content}
</Typography>
<Typography variant="caption" color="textSecondary" className={classes.messageTime}>
{msg.timestamp.toLocaleTimeString()}
</Typography>
</Box>
{/* Render proposals if any */}
{msg.proposals && msg.proposals.length > 0 && (
<Box className={classes.proposalsContainer}>
{msg.proposals.map((proposal, pIdx) => (
<Card key={pIdx} className={classes.proposalCard} variant="outlined">
<CardContent className={classes.proposalContent}>
<Typography variant="caption" color="primary" fontWeight="bold">
Proposed Action
</Typography>
<Typography variant="body2" gutterBottom>
{proposal.description}
</Typography>
<Box className={classes.proposalDetails}>
<Typography variant="caption" color="textSecondary">
Topic: <code>{proposal.topic}</code>
</Typography>
<Typography variant="caption" color="textSecondary">
Payload: <code>{proposal.payload}</code>
</Typography>
</Box>
</CardContent>
<CardActions className={classes.proposalActions}>
<Button
size="small"
variant="contained"
color="primary"
startIcon={<PublishIcon />}
onClick={() => handlePublishProposal(proposal)}
disabled={!connectionId}
>
Send Message
</Button>
</CardActions>
</Card>
))}
</Box>
)}
{/* Render question proposals if any */}
{msg.questionProposals && msg.questionProposals.length > 0 && (
<Box className={classes.questionProposalsContainer}>
<Typography variant="caption" color="textSecondary" gutterBottom>
Follow-up questions:
</Typography>
<Box className={classes.suggestionChips}>
{msg.questionProposals.map((qProposal, qIdx) => (
<Chip
key={qIdx}
label={qProposal.question}
size="small"
color="secondary"
onClick={() => handleSuggestionClick(qProposal.question)}
className={classes.questionProposalChip}
icon={
qProposal.category ? (
<Typography variant="caption" component="span">
{qProposal.category}:
</Typography>
) : undefined
}
/>
))}
</Box>
</Box>
)}
</Box>
))}
@@ -251,6 +376,68 @@ function AIAssistant(props: Props) {
<div ref={messagesEndRef} />
</Box>
{/* Debug View - Enhanced with API Traffic */}
{showDebug && messages.length > 0 && (
<Paper className={classes.debugContainer} variant="outlined">
<Typography variant="caption" fontWeight="bold" color="textSecondary" gutterBottom>
Debug: Complete API Traffic
</Typography>
<Box className={classes.debugContent}>
<pre className={classes.debugPre}>
{JSON.stringify(
{
systemMessage: {
role: 'system',
content: llmService.getSystemMessage(),
note: 'This is the system prompt that provides context to the LLM',
},
messages: messages.map((msg, idx) => ({
index: idx,
role: msg.role,
content: msg.content.substring(0, 200) + (msg.content.length > 200 ? '...' : ''),
fullContent: msg.content,
hasTopicContext: msg.role === 'user' && msg.content.startsWith('Context:'),
contentLength: msg.content.length,
timestamp: msg.timestamp.toISOString(),
proposals: msg.proposals?.length || 0,
questionProposals: msg.questionProposals?.length || 0,
...(msg.debugInfo && {
apiDebug: {
provider: msg.debugInfo.provider,
model: msg.debugInfo.model,
timing: msg.debugInfo.timing,
request: {
url: msg.debugInfo.request?.url,
body: msg.debugInfo.request?.body,
},
response: {
id: msg.debugInfo.response?.id,
model: msg.debugInfo.response?.model,
created: msg.debugInfo.response?.created,
choices: msg.debugInfo.response?.choices,
usage: msg.debugInfo.response?.usage,
system_fingerprint: msg.debugInfo.response?.system_fingerprint,
},
},
}),
})),
summary: {
totalMessages: messages.length,
messagesWithDebugInfo: messages.filter(m => m.debugInfo).length,
lastApiCall: messages
.filter(m => m.debugInfo)
.pop()
?.debugInfo?.timing?.timestamp,
},
},
null,
2
)}
</pre>
</Box>
</Paper>
)}
{/* Input */}
<Box className={classes.inputContainer}>
{messages.length > 0 && (
@@ -263,7 +450,7 @@ function AIAssistant(props: Props) {
size="small"
placeholder="Ask about this topic..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onChange={e => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
disabled={loading}
className={classes.input}
@@ -281,60 +468,6 @@ function AIAssistant(props: Props) {
</Box>
</Box>
</Collapse>
{/* Configuration Dialog */}
<Dialog open={configDialogOpen} onClose={() => setConfigDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>AI Assistant Configuration</DialogTitle>
<DialogContent>
<FormControl fullWidth margin="normal">
<InputLabel>AI Provider</InputLabel>
<Select
value={provider}
label="AI Provider"
onChange={(e) => setProvider(e.target.value as LLMProvider)}
>
<MenuItem value="openai">OpenAI (GPT-3.5 Turbo)</MenuItem>
<MenuItem value="gemini">Google Gemini (Flash)</MenuItem>
</Select>
</FormControl>
<Typography variant="body2" color="textSecondary" paragraph sx={{ mt: 2 }}>
{provider === 'openai' ? (
<>
Get your OpenAI API key from{' '}
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer">
OpenAI's platform
</a>
.
</>
) : (
<>
Get your Gemini API key from{' '}
<a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener noreferrer">
Google AI Studio
</a>
.
</>
)}
</Typography>
<TextField
fullWidth
label={`${provider === 'openai' ? 'OpenAI' : 'Gemini'} API Key`}
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={provider === 'openai' ? 'sk-...' : 'AIza...'}
margin="normal"
helperText="Your API key is stored locally and never sent to our servers"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfigDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSaveApiKey} variant="contained" disabled={!apiKey.trim()}>
Save
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
@@ -383,7 +516,7 @@ const styles = (theme: Theme) => ({
content: {
padding: theme.spacing(2),
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
gap: theme.spacing(1.5),
},
alert: {
@@ -398,7 +531,7 @@ const styles = (theme: Theme) => ({
},
suggestionChips: {
display: 'flex',
flexWrap: 'wrap' as 'wrap',
flexWrap: 'wrap' as const,
gap: theme.spacing(0.5),
},
suggestionChip: {
@@ -410,14 +543,14 @@ const styles = (theme: Theme) => ({
},
messages: {
maxHeight: '300px',
overflowY: 'auto' as 'auto',
overflowY: 'auto' as const,
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
gap: theme.spacing(1),
},
emptyState: {
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(4),
@@ -445,8 +578,8 @@ const styles = (theme: Theme) => ({
borderBottomLeftRadius: theme.spacing(0.5),
},
messageText: {
whiteSpace: 'pre-wrap' as 'pre-wrap',
wordBreak: 'break-word' as 'break-word',
whiteSpace: 'pre-wrap' as const,
wordBreak: 'break-word' as const,
},
messageTime: {
display: 'block',
@@ -472,6 +605,70 @@ const styles = (theme: Theme) => ({
sendButton: {
padding: theme.spacing(1),
},
proposalsContainer: {
marginTop: theme.spacing(1),
marginLeft: theme.spacing(6),
display: 'flex',
flexDirection: 'column' as const,
gap: theme.spacing(1),
},
proposalCard: {
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.08)' : 'rgba(25, 118, 210, 0.04)',
borderColor: theme.palette.primary.main,
},
proposalContent: {
paddingBottom: theme.spacing(1),
'&:last-child': {
paddingBottom: theme.spacing(1),
},
},
proposalDetails: {
marginTop: theme.spacing(1),
display: 'flex',
flexDirection: 'column' as const,
gap: theme.spacing(0.5),
'& code': {
backgroundColor: theme.palette.action.hover,
padding: theme.spacing(0.25, 0.5),
borderRadius: theme.shape.borderRadius,
fontSize: '0.85em',
},
},
proposalActions: {
padding: theme.spacing(1, 2),
paddingTop: 0,
},
questionProposalsContainer: {
marginTop: theme.spacing(1),
marginLeft: theme.spacing(6),
display: 'flex',
flexDirection: 'column' as const,
gap: theme.spacing(0.5),
},
questionProposalChip: {
cursor: 'pointer',
'&:hover': {
backgroundColor: theme.palette.secondary.dark,
},
},
debugContainer: {
marginTop: theme.spacing(1),
padding: theme.spacing(1.5),
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(0, 0, 0, 0.2)' : 'rgba(0, 0, 0, 0.03)',
maxHeight: '200px',
overflow: 'auto',
},
debugContent: {
marginTop: theme.spacing(0.5),
},
debugPre: {
margin: 0,
fontSize: '0.75rem',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap' as const,
wordBreak: 'break-word' as const,
color: theme.palette.text.secondary,
},
})
export default withStyles(styles)(AIAssistant)

View File

@@ -1,12 +1,12 @@
import * as q from '../../../../../backend/src/Model'
import * as React from 'react'
import ShowChart from '@mui/icons-material/ShowChart'
import TopicPlot from '../../TopicPlot'
import { bindActionCreators } from 'redux'
import { chartActions } from '../../../actions'
import { connect } from 'react-redux'
import { Fade, Paper, Popper, Tooltip } from '@mui/material'
import { JsonPropertyLocation } from '../../../../../backend/src/JsonAstParser'
import * as q from '../../../../../backend/src/Model'
import { chartActions } from '../../../actions'
import TopicPlot from '../../TopicPlot'
interface Props {
treeNode: q.TreeNode<any>
@@ -79,12 +79,10 @@ function ChartPreview(props: Props) {
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
})
export default connect(undefined, mapDispatchToProps)(ChartPreview)

View File

@@ -22,11 +22,13 @@ function changeAmount(props: Props) {
<span>
Comparing with <b>{props.nameOfCompareMessage}</b> message:&nbsp;
<span className={props.classes.additions}>
+ {additions} line{additions === 1 ? '' : 's'}
+ {additions} line
{additions === 1 ? '' : 's'}
</span>
,{' '}
<span className={props.classes.deletions}>
- {deletions} line{deletions === 1 ? '' : 's'}
- {deletions} line
{deletions === 1 ? '' : 's'}
</span>
</span>
</span>

View File

@@ -1,13 +1,13 @@
import * as diff from 'diff'
import * as q from '../../../../../backend/src/Model'
import * as React from 'react'
import Add from '@mui/icons-material/Add'
import ChartPreview from './ChartPreview'
import Remove from '@mui/icons-material/Remove'
import { JsonPropertyLocation } from '../../../../../backend/src/JsonAstParser'
import { lineChangeStyle, trimNewlineRight } from './util'
import { Theme } from '@mui/material'
import { withStyles } from '@mui/styles'
import * as q from '../../../../../backend/src/Model'
import { lineChangeStyle, trimNewlineRight } from './util'
import ChartPreview from './ChartPreview'
interface Props {
changes: Array<diff.Change>
@@ -40,7 +40,7 @@ const style = (theme: Theme) => {
},
},
gutterLine: {
textAlign: 'right' as 'right',
textAlign: 'right' as const,
paddingRight: theme.spacing(0.5),
height: '16px',
width: '100%',
@@ -52,7 +52,7 @@ function tokensForLine(change: diff.Change, line: number, props: Props) {
const { classes, literalPositions } = props
const literal = literalPositions[line]
const chartPreview = Boolean(literal) ? (
const chartPreview = literal ? (
<ChartPreview
key="chartPreview"
treeNode={props.treeNode}
@@ -63,25 +63,25 @@ function tokensForLine(change: diff.Change, line: number, props: Props) {
if (change.added) {
return [chartPreview, <Add key="add" className={classes.icon} />]
} else if (change.removed) {
return [<Remove key="remove" className={classes.icon} />]
} else {
return [
chartPreview,
<div
key="placeholder"
style={{ width: '12px', display: 'inline-block' }}
dangerouslySetInnerHTML={{ __html: '&nbsp;' }}
/>,
]
}
if (change.removed) {
return [<Remove key="remove" className={classes.icon} />]
}
return [
chartPreview,
<div
key="placeholder"
style={{ width: '12px', display: 'inline-block' }}
dangerouslySetInnerHTML={{ __html: '&nbsp;' }}
/>,
]
}
function Gutters(props: Props) {
let currentLine = -1
const gutters = props.changes
.map((change, key) => {
return trimNewlineRight(change.value)
.map((change, key) =>
trimNewlineRight(change.value)
.split('\n')
.map((_, idx) => {
currentLine = !change.removed ? currentLine + 1 : currentLine
@@ -91,7 +91,7 @@ function Gutters(props: Props) {
</div>
)
})
})
)
.reduce((a, b) => a.concat(b), [])
return (

View File

@@ -1,15 +1,15 @@
import * as diff from 'diff'
import * as Prism from 'prismjs'
import * as q from '../../../../../backend/src/Model'
import * as React from 'react'
import { JsonPropertyLocation, literalsMappedByLines } from '../../../../../backend/src/JsonAstParser'
import { Typography } from '@mui/material'
import { withStyles } from '@mui/styles'
import * as q from '../../../../../backend/src/Model'
import DiffCount from './DiffCount'
import Gutters from './Gutters'
import { isPlottable, lineChangeStyle, trimNewlineRight } from './util'
import { JsonPropertyLocation, literalsMappedByLines } from '../../../../../backend/src/JsonAstParser'
import { selectTextWithCtrlA } from '../../../utils/handleTextSelectWithCtrlA'
import { style } from './style'
import { Typography } from '@mui/material'
import { withStyles } from '@mui/styles'
import 'prismjs/components/prism-json'
interface Props {
@@ -59,13 +59,11 @@ class CodeDiff extends React.PureComponent<Props, State> {
const changedLines = change.count || 0
if (hasStyledCode && this.props.language === 'json') {
const currentLines = styledLines.slice(lineNumber, lineNumber + changedLines)
const lines = currentLines.map((html: string, idx: number) => {
return (
<div key={`${key}-${idx}`} style={lineChangeStyle(change)} className={this.props.classes.line}>
<span dangerouslySetInnerHTML={{ __html: html }} />
</div>
)
})
const lines = currentLines.map((html: string, idx: number) => (
<div key={`${key}-${idx}`} style={lineChangeStyle(change)} className={this.props.classes.line}>
<span dangerouslySetInnerHTML={{ __html: html }} />
</div>
))
lineNumber += changedLines
return [<div key={key}>{lines}</div>]
@@ -73,13 +71,11 @@ class CodeDiff extends React.PureComponent<Props, State> {
return trimNewlineRight(change.value)
.split('\n')
.map((line, idx) => {
return (
<div key={`${key}-${idx}`} style={lineChangeStyle(change)} className={this.props.classes.line}>
<span>{line}</span>
</div>
)
})
.map((line, idx) => (
<div key={`${key}-${idx}`} style={lineChangeStyle(change)} className={this.props.classes.line}>
<span>{line}</span>
</div>
))
})
.reduce((a, b) => a.concat(b), [])
}

View File

@@ -1,11 +1,11 @@
import { CodeBlockColors, CodeBlockColorsBraceMonokai } from '../CodeBlockColors'
import { Theme } from '@mui/material'
import { CodeBlockColors, CodeBlockColorsBraceMonokai } from '../CodeBlockColors'
export const style = (theme: Theme) => {
const codeBlockColors = theme.palette.mode === 'light' ? CodeBlockColors : CodeBlockColorsBraceMonokai
const codeBaseStyle = {
font: "12px/normal 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace",
display: 'inline-grid' as 'inline-grid',
display: 'inline-grid' as const,
margin: '0',
padding: '1px 0 0 0',
}
@@ -17,7 +17,7 @@ export const style = (theme: Theme) => {
backgroundColor: codeBlockColors.gutters,
},
line: {
lineHeight: 'normal' as 'normal',
lineHeight: 'normal' as const,
paddingLeft: '4px',
width: '100%',
height: '16px',
@@ -32,7 +32,7 @@ export const style = (theme: Theme) => {
...codeBaseStyle,
width: '33px',
backgroundColor: codeBlockColors.gutters,
userSelect: 'none' as 'none',
userSelect: 'none' as const,
},
codeBlock: {
...codeBaseStyle,

View File

@@ -56,7 +56,7 @@ export function toPlottableValue(value: any): number | undefined {
return value.toLowerCase() === 'on' ? 1 : 0
}
if (/^[0-9]*,[0-9]+$/.test(value)) {
let parsedFloat = parseFloat(value.replace(',', '.'))
const parsedFloat = parseFloat(value.replace(',', '.'))
if (!isNaN(parsedFloat)) {
return parsedFloat
}

View File

@@ -1,11 +1,14 @@
import * as q from '../../../../backend/src/Model'
import React, { useCallback } from 'react'
import { Box, Typography, IconButton, Chip, Tooltip, Button } from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { AppState } from '../../reducers'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import DeleteIcon from '@mui/icons-material/Delete'
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'
import Info from '@mui/icons-material/Info'
import * as q from '../../../../backend/src/Model'
import { AppState } from '../../reducers'
import { sidebarActions, globalActions } from '../../actions'
import Copy from '../helper/Copy'
import Save from '../helper/Save'
@@ -15,9 +18,6 @@ import MessageHistory from './ValueRenderer/MessageHistory'
import ActionButtons from './ValueRenderer/ActionButtons'
import DeleteSelectedTopicButton from './ValueRenderer/DeleteSelectedTopicButton'
import { useDecoder } from '../hooks/useDecoder'
import DeleteIcon from '@mui/icons-material/Delete'
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'
import Info from '@mui/icons-material/Info'
import SimpleBreadcrumb from './SimpleBreadcrumb'
import AIAssistant from './AIAssistant'
@@ -25,6 +25,7 @@ interface Props {
node?: q.TreeNode<any>
classes: any
compareMessage?: q.Message
connectionId?: string
sidebarActions: typeof sidebarActions
globalActions: typeof globalActions
}
@@ -33,9 +34,10 @@ function DetailsTab(props: Props) {
const { node, compareMessage, classes } = props
const decodeMessage = useDecoder(node)
const getDecodedValue = useCallback(() => {
return node?.message && decodeMessage(node.message)?.message?.toUnicodeString()
}, [node, decodeMessage])
const getDecodedValue = useCallback(
() => node?.message && decodeMessage(node.message)?.message?.toUnicodeString(),
[node, decodeMessage]
)
const getData = () => {
if (node?.message && node.message.payload) {
@@ -70,7 +72,7 @@ function DetailsTab(props: Props) {
<Typography variant="body2" color="textSecondary" align="center">
Select a topic to view details
</Typography>
{/* About Button - always show even when no topic selected */}
<Box className={classes.aboutSection}>
<Button
@@ -129,12 +131,7 @@ function DetailsTab(props: Props) {
{node.message?.retain && (
<Chip label="Retained" size="small" variant="outlined" color="primary" className={classes.chip} />
)}
<Chip
label={`QoS ${node.message?.qos ?? 0}`}
size="small"
variant="outlined"
className={classes.chip}
/>
<Chip label={`QoS ${node.message?.qos ?? 0}`} size="small" variant="outlined" className={classes.chip} />
</Box>
</Box>
@@ -198,7 +195,7 @@ function DetailsTab(props: Props) {
)}
{/* AI Assistant - Always available when a node is selected */}
{node && <AIAssistant node={node} />}
{node && <AIAssistant node={node} connectionId={props.connectionId} />}
{/* About Section - always visible at bottom */}
<Box className={classes.aboutSection}>
@@ -219,7 +216,7 @@ function DetailsTab(props: Props) {
const styles = (theme: Theme) => ({
root: {
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
gap: theme.spacing(3),
[theme.breakpoints.down('sm')]: {
gap: theme.spacing(2),
@@ -227,7 +224,7 @@ const styles = (theme: Theme) => ({
},
emptyState: {
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
minHeight: '200px',
@@ -276,7 +273,7 @@ const styles = (theme: Theme) => ({
},
statItem: {
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
alignItems: 'center',
padding: theme.spacing(1.5, 1),
backgroundColor: theme.palette.action.hover,
@@ -286,7 +283,7 @@ const styles = (theme: Theme) => ({
statLabel: {
fontSize: '0.75rem',
fontWeight: 500,
textTransform: 'uppercase' as 'uppercase',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px',
},
statValue: {
@@ -297,7 +294,7 @@ const styles = (theme: Theme) => ({
// Value section
valueSection: {
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
gap: theme.spacing(2),
},
metadataBar: {
@@ -305,7 +302,7 @@ const styles = (theme: Theme) => ({
justifyContent: 'space-between',
alignItems: 'center',
gap: theme.spacing(1),
flexWrap: 'wrap' as 'wrap',
flexWrap: 'wrap' as const,
padding: theme.spacing(1),
backgroundColor: theme.palette.action.hover,
borderRadius: theme.shape.borderRadius,
@@ -314,7 +311,7 @@ const styles = (theme: Theme) => ({
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
flexWrap: 'wrap' as 'wrap',
flexWrap: 'wrap' as const,
},
metadataRight: {
display: 'flex',
@@ -328,13 +325,13 @@ const styles = (theme: Theme) => ({
justifyContent: 'space-between',
alignItems: 'center',
gap: theme.spacing(1),
flexWrap: 'wrap' as 'wrap',
flexWrap: 'wrap' as const,
},
valueTitle: {
fontWeight: 600,
color: theme.palette.text.primary,
fontSize: '0.875rem',
textTransform: 'uppercase' as 'uppercase',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px',
flexShrink: 0,
},
@@ -356,17 +353,13 @@ const styles = (theme: Theme) => ({
},
})
const mapStateToProps = (state: AppState) => {
return {
compareMessage: state.sidebar.get('compareMessage'),
}
}
const mapStateToProps = (state: AppState) => ({
compareMessage: state.sidebar.get('compareMessage'),
})
const mapDispatchToProps = (dispatch: any) => {
return {
sidebarActions: bindActionCreators(sidebarActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
sidebarActions: bindActionCreators(sidebarActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
})
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(DetailsTab))

View File

@@ -1,8 +1,8 @@
import React, { useCallback, useState, useEffect, memo } from 'react'
import { Badge, Typography } from '@mui/material'
import { selectTextWithCtrlA } from '../../utils/handleTextSelectWithCtrlA'
import { Theme, emphasize } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { selectTextWithCtrlA } from '../../utils/handleTextSelectWithCtrlA'
interface HistoryItem {
key: string
@@ -81,7 +81,7 @@ function HistoryDrawer(props: Props) {
borderBottom: expanded ? `1px solid ${emphasize(props.theme.palette.background.default, 0.2)}` : undefined,
}}
>
<Typography component={'span'} onClick={toggle} style={{ cursor: 'pointer', display: 'flex' }}>
<Typography component="span" onClick={toggle} style={{ cursor: 'pointer', display: 'flex' }}>
<span style={{ flexGrow: 1 }}>
<Badge
classes={{ badge: props.classes.badge }}

View File

@@ -1,8 +1,8 @@
import React, { memo } from 'react'
import { Message } from '../../../../backend/src/Model'
import { Tooltip } from '@mui/material'
import { Message } from '../../../../backend/src/Model'
export const MessageId = memo(function MessageId(props: { message: Message; addComma?: boolean }) {
export const MessageId = memo((props: { message: Message; addComma?: boolean }) => {
const { message, addComma } = props
if (!message.messageId) {

View File

@@ -1,7 +1,7 @@
import * as q from '../../../../backend/src/Model'
import * as React from 'react'
import { TopicViewModel } from '../../model/TopicViewModel'
import { Typography } from '@mui/material'
import * as q from '../../../../backend/src/Model'
import { TopicViewModel } from '../../model/TopicViewModel'
interface Props {
node?: q.TreeNode<TopicViewModel>
@@ -21,7 +21,10 @@ class NodeStats extends React.Component<Props, {}> {
return (
<div>
<Typography>Messages: #{node.messages}</Typography>
<Typography>Subtopics: {node.childTopicCount()}</Typography>
<Typography>
Subtopics:
{node.childTopicCount()}
</Typography>
<Typography>Messages Subtopics: #{node.leafMessageCount()}</Typography>
</div>
)

View File

@@ -12,14 +12,14 @@ const styles = (theme: Theme) => ({
},
})
const Panel = (props: {
function Panel(props: {
classes: any
children: [React.ReactElement, React.ReactElement]
disabled?: boolean
detailsHidden?: boolean
}) => {
}) {
return (
<Accordion defaultExpanded={true} disabled={props.disabled}>
<Accordion defaultExpanded disabled={props.disabled}>
<AccordionSummary expandIcon={<ExpandMore />} className={props.classes.summary}>
<Typography className={props.classes.heading}>{props.children[0]}</Typography>
</AccordionSummary>

View File

@@ -9,7 +9,6 @@ import 'ace-builds/src-noconflict/snippets/json'
import 'ace-builds/src-noconflict/snippets/xml'
import 'ace-builds/src-noconflict/mode-text'
import 'ace-builds/src-noconflict/theme-monokai'
import 'react-ace'
function Editor(props: {
editorMode: string
@@ -32,11 +31,11 @@ function Editor(props: {
name="UNIQUE_ID_OF_DIV"
width="100%"
height="200px"
enableSnippets={true}
enableBasicAutocompletion={true}
enableLiveAutocompletion={true}
enableSnippets
enableBasicAutocompletion
enableLiveAutocompletion
showPrintMargin={false}
showGutter={true}
showGutter
value={props.value}
onChange={props.onChange}
setOptions={editorOptions}

View File

@@ -14,7 +14,7 @@ export function EditorModeSelect(props: Props) {
value={props.value}
onFocus={props.focusEditor}
onChange={props.onChange}
row={true}
row
>
<FormControlLabel
value="text"

View File

@@ -1,19 +1,19 @@
import Editor from './Editor'
import { AttachFileOutlined, FormatAlignLeft } from '@mui/icons-material'
import Message from './Model/Message'
import Navigation from '@mui/icons-material/Navigation'
import PublishHistory from './PublishHistory'
import React, { useCallback, useMemo, useState, useRef, memo } from 'react'
import RetainSwitch from './RetainSwitch'
import TopicInput from './TopicInput'
import { AppState } from '../../../reducers'
import { bindActionCreators } from 'redux'
import { Button, Fab, Tooltip } from '@mui/material'
import { connect } from 'react-redux'
import { default as AceEditor } from 'react-ace'
import Editor from './Editor'
import Message from './Model/Message'
import PublishHistory from './PublishHistory'
import RetainSwitch from './RetainSwitch'
import TopicInput from './TopicInput'
import { AppState } from '../../../reducers'
import { EditorModeSelect } from './EditorModeSelect'
import { globalActions, publishActions } from '../../../actions'
import { KeyCodes } from '../../../utils/KeyCodes'
import { default as AceEditor } from 'react-ace'
interface Props {
connectionId?: string
@@ -56,7 +56,7 @@ function Publish(props: Props) {
props.actions.publish(props.connectionId)
const topic = props.topic || ''
const payload = props.payload
const { payload } = props
if (props.connectionId && topic) {
amendToHistory(topic, payload)
}
@@ -101,87 +101,85 @@ function Publish(props: Props) {
)
}
const EditorMode = memo(function EditorMode(props: {
payload?: string
editorMode: string
focusEditor: () => void
actions: typeof publishActions
globalActions: typeof globalActions
publish: () => void
}) {
const updatePayload = props.actions.setPayload
const EditorMode = memo(
(props: {
payload?: string
editorMode: string
focusEditor: () => void
actions: typeof publishActions
globalActions: typeof globalActions
publish: () => void
}) => {
const updatePayload = props.actions.setPayload
const updateMode = useCallback((e: React.ChangeEvent<{}>, value: string) => {
props.actions.setEditorMode(value)
}, [])
const updateMode = useCallback((e: React.ChangeEvent<{}>, value: string) => {
props.actions.setEditorMode(value)
}, [])
const openFile = useCallback(() => {
props.actions.openFile()
}, [])
const openFile = useCallback(() => {
props.actions.openFile()
}, [])
const formatJson = useCallback(() => {
if (props.payload) {
try {
const str = JSON.stringify(JSON.parse(props.payload), undefined, ' ')
updatePayload(str)
} catch (error) {
props.globalActions.showError(`Format error: ${(error as Error)?.message}`)
const formatJson = useCallback(() => {
if (props.payload) {
try {
const str = JSON.stringify(JSON.parse(props.payload), undefined, ' ')
updatePayload(str)
} catch (error) {
props.globalActions.showError(`Format error: ${(error as Error)?.message}`)
}
}
}
}, [props.payload])
}, [props.payload])
return (
<div style={{ marginTop: '16px' }}>
<div style={{ width: '100%', lineHeight: '64px', textAlign: 'center' }}>
<EditorModeSelect value={props.editorMode} onChange={updateMode} focusEditor={props.focusEditor} />
<FormatJsonButton editorMode={props.editorMode} focusEditor={props.focusEditor} formatJson={formatJson} />
<OpenFileButton editorMode={props.editorMode} openFile={openFile} />
<div style={{ float: 'right' }}>
<PublishButton publish={props.publish} focusEditor={props.focusEditor} />
return (
<div style={{ marginTop: '16px' }}>
<div style={{ width: '100%', lineHeight: '64px', textAlign: 'center' }}>
<EditorModeSelect value={props.editorMode} onChange={updateMode} focusEditor={props.focusEditor} />
<FormatJsonButton editorMode={props.editorMode} focusEditor={props.focusEditor} formatJson={formatJson} />
<OpenFileButton editorMode={props.editorMode} openFile={openFile} />
<div style={{ float: 'right' }}>
<PublishButton publish={props.publish} focusEditor={props.focusEditor} />
</div>
</div>
</div>
</div>
)
})
const FormatJsonButton = React.memo(function FormatJsonButton(props: {
editorMode: string
focusEditor: () => void
formatJson: () => void
}) {
if (props.editorMode !== 'json') {
return null
)
}
)
return (
<Tooltip title="Format JSON">
<Fab
style={{ width: '36px', height: '36px', margin: '0 8px' }}
onClick={props.formatJson}
onFocus={props.focusEditor}
id="sidebar-publish-format-json"
>
<FormatAlignLeft style={{ fontSize: '20px' }} />
</Fab>
</Tooltip>
)
})
const FormatJsonButton = React.memo(
(props: { editorMode: string; focusEditor: () => void; formatJson: () => void }) => {
if (props.editorMode !== 'json') {
return null
}
const OpenFileButton = React.memo(function OpenFileButton(props: { editorMode: string; openFile: () => void }) {
return (
<Tooltip title="Open file">
<Fab
style={{ width: '36px', height: '36px', margin: '0 8px' }}
onClick={props.openFile}
id="sidebar-publish-open-file"
>
<AttachFileOutlined style={{ fontSize: '20px' }} />
</Fab>
</Tooltip>
)
})
return (
<Tooltip title="Format JSON">
<Fab
style={{ width: '36px', height: '36px', margin: '0 8px' }}
onClick={props.formatJson}
onFocus={props.focusEditor}
id="sidebar-publish-format-json"
>
<FormatAlignLeft style={{ fontSize: '20px' }} />
</Fab>
</Tooltip>
)
}
)
const PublishButton = memo(function PublishButton(props: { publish: () => void; focusEditor: () => void }) {
const OpenFileButton = React.memo((props: { editorMode: string; openFile: () => void }) => (
<Tooltip title="Open file">
<Fab
style={{ width: '36px', height: '36px', margin: '0 8px' }}
onClick={props.openFile}
id="sidebar-publish-open-file"
>
<AttachFileOutlined style={{ fontSize: '20px' }} />
</Fab>
</Tooltip>
))
const PublishButton = memo((props: { publish: () => void; focusEditor: () => void }) => {
const handleClickPublish = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@@ -204,20 +202,16 @@ const PublishButton = memo(function PublishButton(props: { publish: () => void;
)
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(publishActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(publishActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
})
const mapStateToProps = (state: AppState) => {
return {
topic: state.publish.manualTopic,
payload: state.publish.payload,
editorMode: state.publish.editorMode,
retain: state.publish.retain,
}
}
const mapStateToProps = (state: AppState) => ({
topic: state.publish.manualTopic,
payload: state.publish.payload,
editorMode: state.publish.editorMode,
retain: state.publish.retain,
})
export default connect(mapStateToProps, mapDispatchToProps)(Publish)

View File

@@ -1,9 +1,10 @@
import History from '../HistoryDrawer'
import Message from './Model/Message'
import React, { useCallback, useMemo } from 'react'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import Message from './Model/Message'
import History from '../HistoryDrawer'
import { publishActions } from '../../../actions'
const sha1 = require('sha1')
function PublishHistory(props: { history: Array<Message>; actions: typeof publishActions }) {
@@ -24,14 +25,12 @@ function PublishHistory(props: { history: Array<Message>; actions: typeof publis
value: message.payload || '',
}))
return <History autoOpen={true} items={items} onClick={didSelectHistoryEntry} />
return <History autoOpen items={items} onClick={didSelectHistoryEntry} />
}, [props.history])
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(publishActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(publishActions, dispatch),
})
export default connect(undefined, mapDispatchToProps)(PublishHistory)

View File

@@ -1,10 +1,10 @@
import * as React from 'react'
import { connect } from 'react-redux'
import { AppState } from '../../../reducers'
import { bindActionCreators } from 'redux'
import { QoS } from 'mqtt-explorer-backend/src/DataSource/MqttSource'
import { AppState } from '../../../reducers'
import { publishActions } from '../../../actions'
import { QosSelect } from '../../QosSelect'
import { QoS } from '../../../../../backend/src/DataSource/MqttSource'
interface Props {
qos: QoS
@@ -17,18 +17,14 @@ function QosPublishOption(props: Props) {
return <QosSelect onChange={props.actions.publish.setQoS} selected={props.qos} />
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
publish: bindActionCreators(publishActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
publish: bindActionCreators(publishActions, dispatch),
},
})
const mapStateToProps = (state: AppState) => {
return {
qos: state.publish.qos,
}
}
const mapStateToProps = (state: AppState) => ({
qos: state.publish.qos,
})
export default connect(mapStateToProps, mapDispatchToProps)(QosPublishOption)

View File

@@ -1,10 +1,10 @@
import QosSelect from './QosPublishOption'
import React from 'react'
import { Checkbox, FormControlLabel, Tooltip } from '@mui/material'
import { publishActions } from '../../../actions'
import { bindActionCreators } from 'redux'
import { AppState } from '../../../reducers'
import { connect } from 'react-redux'
import { publishActions } from '../../../actions'
import { AppState } from '../../../reducers'
import QosSelect from './QosPublishOption'
export function RetainSwitch(props: { retain: boolean; actions: typeof publishActions }) {
const labelStyle = { margin: '0 8px 0 8px' }
@@ -29,16 +29,12 @@ export function RetainSwitch(props: { retain: boolean; actions: typeof publishAc
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(publishActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(publishActions, dispatch),
})
const mapStateToProps = (state: AppState) => {
return {
retain: state.publish.retain,
}
}
const mapStateToProps = (state: AppState) => ({
retain: state.publish.retain,
})
export default connect(mapStateToProps, mapDispatchToProps)(RetainSwitch)

View File

@@ -1,10 +1,10 @@
import ClearAdornment from '../../helper/ClearAdornment'
import React, { useCallback, useMemo, useRef } from 'react'
import { FormControl, Input, InputLabel } from '@mui/material'
import { publishActions } from '../../../actions'
import { bindActionCreators } from 'redux'
import { AppState } from '../../../reducers'
import { connect } from 'react-redux'
import { publishActions } from '../../../actions'
import { AppState } from '../../../reducers'
import ClearAdornment from '../../helper/ClearAdornment'
function TopicInput(props: { actions: typeof publishActions; manualTopic?: string; selectedTopic?: string }) {
const inputElement = useRef<HTMLInputElement>(null)
@@ -49,17 +49,13 @@ function TopicInput(props: { actions: typeof publishActions; manualTopic?: strin
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(publishActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(publishActions, dispatch),
})
const mapStateToProps = (state: AppState) => {
return {
manualTopic: state.publish.manualTopic,
selectedTopic: state.tree.get('selectedTopic')?.path(),
}
}
const mapStateToProps = (state: AppState) => ({
manualTopic: state.publish.manualTopic,
selectedTopic: state.tree.get('selectedTopic')?.path(),
})
export default connect(mapStateToProps, mapDispatchToProps)(TopicInput)

View File

@@ -23,7 +23,7 @@ function PublishTab(props: Props) {
Send messages to MQTT topics
</Typography>
</Box>
<React.Suspense fallback={<div>Loading...</div>}>
<Publish connectionId={props.connectionId} />
</React.Suspense>
@@ -34,7 +34,7 @@ function PublishTab(props: Props) {
const styles = (theme: Theme) => ({
root: {
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
gap: theme.spacing(2),
},
header: {
@@ -44,7 +44,7 @@ const styles = (theme: Theme) => ({
fontWeight: 600,
color: theme.palette.text.primary,
fontSize: '0.875rem',
textTransform: 'uppercase' as 'uppercase',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px',
marginBottom: theme.spacing(0.5),
},

View File

@@ -1,14 +1,14 @@
import * as q from '../../../../backend/src/Model'
import React, { useState, useEffect, useCallback } from 'react'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { globalActions, settingsActions, sidebarActions } from '../../actions'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { Tabs, Tab, Box, useMediaQuery, useTheme } from '@mui/material'
import * as q from '../../../../backend/src/Model'
import { globalActions, settingsActions, sidebarActions } from '../../actions'
import { TopicViewModel } from '../../model/TopicViewModel'
import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode'
import { Tabs, Tab, Box, useMediaQuery, useTheme } from '@mui/material'
import { AppState } from '../../reducers'
import DetailsTab from './DetailsTab'
import PublishTab from './PublishTab'
@@ -63,7 +63,7 @@ function SidebarNew(props: Props) {
return (
<div id="Sidebar" className={classes.root}>
<Box className={classes.mobileContent}>
<DetailsTab node={node} />
<DetailsTab node={node} connectionId={props.connectionId} />
</Box>
</div>
)
@@ -85,10 +85,10 @@ function SidebarNew(props: Props) {
<Tab label="Publish" className={classes.tab} />
</Tabs>
</Box>
<Box className={classes.tabContent}>
<Box sx={{ display: tabValue === 0 ? 'block' : 'none' }}>
<DetailsTab node={node} />
<DetailsTab node={node} connectionId={props.connectionId} />
</Box>
<Box sx={{ display: tabValue === 1 ? 'block' : 'none' }}>
<PublishTab connectionId={props.connectionId} />
@@ -106,18 +106,16 @@ const mapStateToProps = (state: AppState) => {
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(sidebarActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
settingsActions: bindActionCreators(settingsActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(sidebarActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
settingsActions: bindActionCreators(settingsActions, dispatch),
})
const styles = (theme: Theme) => ({
root: {
display: 'flex',
flexDirection: 'column' as 'column',
flexDirection: 'column' as const,
height: '100%',
width: '100%',
},
@@ -132,19 +130,19 @@ const styles = (theme: Theme) => ({
minHeight: '48px',
fontSize: '14px',
fontWeight: 500,
textTransform: 'none' as 'none',
textTransform: 'none' as const,
padding: theme.spacing(1.5, 2),
},
tabContent: {
flex: 1,
overflowY: 'auto' as 'auto',
overflowX: 'hidden' as 'hidden',
overflowY: 'auto' as const,
overflowX: 'hidden' as const,
padding: theme.spacing(2),
},
mobileContent: {
flex: 1,
overflowY: 'auto' as 'auto',
overflowX: 'hidden' as 'hidden',
overflowY: 'auto' as const,
overflowX: 'hidden' as const,
padding: theme.spacing(2),
},
})

View File

@@ -1,11 +1,11 @@
import React from 'react'
import * as q from '../../../../backend/src/Model'
import { Link } from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { treeActions } from '../../actions'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import * as q from '../../../../backend/src/Model'
import { treeActions } from '../../actions'
interface Props {
node?: q.TreeNode<any>
@@ -15,7 +15,7 @@ interface Props {
function SimpleBreadcrumb(props: Props) {
const { node, classes, actions } = props
if (!node) {
return null
}
@@ -54,7 +54,7 @@ function SimpleBreadcrumb(props: Props) {
const styles = (theme: Theme) => ({
breadcrumbContainer: {
display: 'flex',
flexWrap: 'wrap' as 'wrap',
flexWrap: 'wrap' as const,
alignItems: 'center',
gap: 0,
},
@@ -63,7 +63,7 @@ const styles = (theme: Theme) => ({
fontWeight: 500,
color: theme.palette.text.primary,
cursor: 'pointer',
textAlign: 'left' as 'left',
textAlign: 'left' as const,
border: 'none',
background: 'none',
padding: 0,
@@ -74,14 +74,12 @@ const styles = (theme: Theme) => ({
},
separator: {
color: theme.palette.text.secondary,
userSelect: 'none' as 'none',
userSelect: 'none' as const,
},
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(treeActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(treeActions, dispatch),
})
export default connect(null, mapDispatchToProps)(withStyles(styles)(SimpleBreadcrumb))

View File

@@ -1,13 +1,13 @@
import * as q from '../../../../../backend/src/Model'
import CustomIconButton from '../../helper/CustomIconButton'
import Delete from '@mui/icons-material/Delete'
import React, { useCallback } from 'react'
import { Badge } from '@mui/material'
import * as q from '../../../../../backend/src/Model'
import CustomIconButton from '../../helper/CustomIconButton'
export const RecursiveTopicDeleteButton = (props: {
export function RecursiveTopicDeleteButton(props: {
node?: q.TreeNode<any>
deleteTopicAction: (node: q.TreeNode<any>, a: boolean, limit: number) => void
}) => {
}) {
const onClick = useCallback(
(event: React.MouseEvent) => {
if (props.node) {

View File

@@ -1,11 +1,11 @@
import React from 'react'
import * as q from '../../../../../backend/src/Model'
import Button from '@mui/material/Button'
import { withStyles } from '@mui/styles'
import { Theme } from '@mui/material/styles'
import { treeActions } from '../../../actions'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import * as q from '../../../../../backend/src/Model'
import { treeActions } from '../../../actions'
import { TopicViewModel } from '../../../model/TopicViewModel'
interface Props {
@@ -18,7 +18,7 @@ interface Props {
const styles = (theme: Theme) => ({
button: {
textTransform: 'none' as 'none',
textTransform: 'none' as const,
padding: '3px 5px 3px 5px',
minWidth: '30px',
},
@@ -61,10 +61,8 @@ class Topic extends React.PureComponent<Props, {}> {
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(treeActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(treeActions, dispatch),
})
export default connect(null, mapDispatchToProps)(withStyles(styles, { withTheme: true })(Topic) as any)

View File

@@ -1,12 +1,12 @@
import * as q from '../../../../../backend/src/Model'
import CustomIconButton from '../../helper/CustomIconButton'
import Delete from '@mui/icons-material/Delete'
import React from 'react'
import * as q from '../../../../../backend/src/Model'
import CustomIconButton from '../../helper/CustomIconButton'
export const TopicDeleteButton = (props: {
export function TopicDeleteButton(props: {
node?: q.TreeNode<any>
deleteTopicAction: (node: q.TreeNode<any>) => void
}) => {
}) {
const { node } = props
if (!node || !node.message || !node.message.payload) {
return null

View File

@@ -1,16 +1,17 @@
import React, { useMemo, useCallback } from 'react'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import * as q from '../../../../../backend/src/Model'
import Copy from '../../helper/Copy'
import Panel from '../Panel'
import React, { useMemo, useCallback } from 'react'
import Topic from './Topic'
const TopicAny = Topic as any
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { RecursiveTopicDeleteButton } from './RecursiveTopicDeleteButton'
import { TopicDeleteButton } from './TopicDeleteButton'
import { TopicTypeButton } from './TopicTypeButton'
import { sidebarActions } from '../../../actions'
const TopicAny = Topic as any
const TopicPanel = (props: { node?: q.TreeNode<any>; actions: typeof sidebarActions }) => {
const { node } = props
@@ -25,7 +26,7 @@ const TopicPanel = (props: { node?: q.TreeNode<any>; actions: typeof sidebarActi
return useMemo(
() => (
<Panel disabled={!Boolean(node)}>
<Panel disabled={!node}>
<span>
Topic {copyTopic}
<TopicDeleteButton node={node} deleteTopicAction={deleteTopic} />
@@ -39,10 +40,8 @@ const TopicPanel = (props: { node?: q.TreeNode<any>; actions: typeof sidebarActi
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(sidebarActions, dispatch),
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: bindActionCreators(sidebarActions, dispatch),
})
export default connect(undefined, mapDispatchToProps)(TopicPanel)

View File

@@ -1,5 +1,4 @@
import React, { useCallback, useMemo } from 'react'
import * as q from '../../../../../backend/src/Model'
import ClickAwayListener from '@mui/material/ClickAwayListener'
import Grow from '@mui/material/Grow'
import Button from '@mui/material/Button'
@@ -8,10 +7,11 @@ import Popper from '@mui/material/Popper'
import MenuItem from '@mui/material/MenuItem'
import MenuList from '@mui/material/MenuList'
import WarningRounded from '@mui/icons-material/WarningRounded'
import { MessageDecoder, decoders } from '../../../decoders'
import { Tooltip } from '@mui/material'
import * as q from '../../../../../backend/src/Model'
import { MessageDecoder, decoders } from '../../../decoders'
export const TopicTypeButton = (props: { node?: q.TreeNode<any> }) => {
export function TopicTypeButton(props: { node?: q.TreeNode<any> }) {
const { node } = props
if (!node || !node.message || !node.message.payload) {
return null
@@ -87,9 +87,10 @@ export const TopicTypeButton = (props: { node?: q.TreeNode<any> }) => {
}
function DecoderStatus({ node, decoder, format }: { node: q.TreeNode<any>; decoder: MessageDecoder; format: string }) {
const decoded = useMemo(() => {
return node.message?.payload && decoder.decode(node.message?.payload, format)
}, [node.message, decoder, format])
const decoded = useMemo(
() => node.message?.payload && decoder.decode(node.message?.payload, format),
[node.message, decoder, format]
)
return decoded?.error ? (
<Tooltip title={decoded.error}>

View File

@@ -3,13 +3,13 @@ import Code from '@mui/icons-material/Code'
import Reorder from '@mui/icons-material/Reorder'
import ToggleButton from '@mui/material/ToggleButton'
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
import { settingsActions } from '../../../actions'
import { Tooltip } from '@mui/material'
import { withStyles } from '@mui/styles'
import { Theme } from '@mui/material/styles'
import { bindActionCreators } from 'redux'
import { AppState } from '../../../reducers'
import { connect } from 'react-redux'
import { AppState } from '../../../reducers'
import { settingsActions } from '../../../actions'
import { ValueRendererDisplayMode } from '../../../reducers/Settings'
function ActionButtons(props: {
@@ -31,7 +31,7 @@ function ActionButtons(props: {
<ToggleButtonGroup
id="valueRendererDisplayMode"
value={props.valueRendererDisplayMode}
exclusive={true}
exclusive
onChange={handleValue}
>
<ToggleButton className={props.classes.toggleButton} value="diff" id="valueRendererDisplayMode-diff">
@@ -70,22 +70,18 @@ const styles = (theme: Theme) => ({
},
buttonText: {
fontSize: '0.875rem',
textTransform: 'none' as 'none',
textTransform: 'none' as const,
},
})
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
settings: bindActionCreators(settingsActions, dispatch),
},
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: {
settings: bindActionCreators(settingsActions, dispatch),
},
})
const mapStateToProps = (state: AppState) => {
return {
valueRendererDisplayMode: state.settings.get('valueRendererDisplayMode'),
}
}
const mapStateToProps = (state: AppState) => ({
valueRendererDisplayMode: state.settings.get('valueRendererDisplayMode'),
})
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ActionButtons) as any)

View File

@@ -27,10 +27,8 @@ const DeleteSelectedTopicButton = (props: {
[props.actions.sidebar.clearRetainedTopic]
)
const mapDispatchToProps = (dispatch: any) => {
return {
actions: { sidebar: bindActionCreators(sidebarActions, dispatch) },
}
}
const mapDispatchToProps = (dispatch: any) => ({
actions: { sidebar: bindActionCreators(sidebarActions, dispatch) },
})
export default connect(undefined, mapDispatchToProps)(DeleteSelectedTopicButton)

Some files were not shown because too many files have changed in this diff Show More