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:
159
app/package.json
159
app/package.json
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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])]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: ' ' }}></span>}
|
||||
{parameters.dotPath ? parameters.topic : <span dangerouslySetInnerHTML={{ __html: ' ' }} />}
|
||||
</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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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)',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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']}
|
||||
/>
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -22,11 +22,13 @@ function changeAmount(props: Props) {
|
||||
<span>
|
||||
Comparing with <b>{props.nameOfCompareMessage}</b> message:
|
||||
<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>
|
||||
|
||||
@@ -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: ' ' }}
|
||||
/>,
|
||||
]
|
||||
}
|
||||
if (change.removed) {
|
||||
return [<Remove key="remove" className={classes.icon} />]
|
||||
}
|
||||
return [
|
||||
chartPreview,
|
||||
<div
|
||||
key="placeholder"
|
||||
style={{ width: '12px', display: 'inline-block' }}
|
||||
dangerouslySetInnerHTML={{ __html: ' ' }}
|
||||
/>,
|
||||
]
|
||||
}
|
||||
|
||||
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 (
|
||||
|
||||
@@ -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), [])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -14,7 +14,7 @@ export function EditorModeSelect(props: Props) {
|
||||
value={props.value}
|
||||
onFocus={props.focusEditor}
|
||||
onChange={props.onChange}
|
||||
row={true}
|
||||
row
|
||||
>
|
||||
<FormControlLabel
|
||||
value="text"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user