mirror of
https://github.com/thomasnordquist/MQTT-Explorer.git
synced 2026-09-12 01:23:31 +00:00
Compare commits
139
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7829a27c69 | ||
|
|
285001d184 | ||
|
|
d5a4d1e4d3 | ||
|
|
c59c64b65f | ||
|
|
5badd19015 | ||
|
|
a7eacfac46 | ||
|
|
20dae2ea2b | ||
|
|
dc50fd40a3 | ||
|
|
3df4d18a71 | ||
|
|
3aeee31193 | ||
|
|
af4cfec451 | ||
|
|
ae915a158b | ||
|
|
eac522fe63 | ||
|
|
a2bdf71422 | ||
|
|
f2d632959b | ||
|
|
b33412504d | ||
|
|
c458f6c64d | ||
|
|
d6489563b2 | ||
|
|
d651fc7a6c | ||
|
|
c86659f61c | ||
|
|
8959bbdee7 | ||
|
|
9a8474e3d8 | ||
|
|
c5e0e652f3 | ||
|
|
f047f97e44 | ||
|
|
cda8e928ef | ||
|
|
ccbea4acc5 | ||
|
|
8ae1528064 | ||
|
|
1c52aced63 | ||
|
|
72020b02b8 | ||
|
|
aa340b2158 | ||
|
|
faf909654b | ||
|
|
33f3458e38 | ||
|
|
3737410563 | ||
|
|
28afc439ba | ||
|
|
04ba067775 | ||
|
|
0a5d010827 | ||
|
|
1cef529ac7 | ||
|
|
094831f037 | ||
|
|
5148e92651 | ||
|
|
13be7a6697 | ||
|
|
1bef73f356 | ||
|
|
88cc57b8b1 | ||
|
|
b5d9ff3067 | ||
|
|
7c63e871f0 | ||
|
|
fd2c78eea0 | ||
|
|
e8e8926757 | ||
|
|
0b6a1e42d6 | ||
|
|
ff148a8e3c | ||
|
|
ecdecd8626 | ||
|
|
843f2377f0 | ||
|
|
a9a5fdf9dd | ||
|
|
a9742ebf81 | ||
|
|
df3e0fc047 | ||
|
|
05867dab48 | ||
|
|
4f3150d0f3 | ||
|
|
175aeea468 | ||
|
|
167f0b39e0 | ||
|
|
7a9b7d7bf6 | ||
|
|
1a1596ad3d | ||
|
|
8e49d19fbe | ||
|
|
df42d75651 | ||
|
|
c1e2a4c625 | ||
|
|
901acf2bed | ||
|
|
4ec7c8ca75 | ||
|
|
75c3619898 | ||
|
|
38f8d2e6ee | ||
|
|
e3584add7c | ||
|
|
77dcbccd5c | ||
|
|
0ff6359a41 | ||
|
|
66bfcab256 | ||
|
|
195dcf37d4 | ||
|
|
5830d99d45 | ||
|
|
d50cef7bb3 | ||
|
|
b79725bdf0 | ||
|
|
6bead5b5a6 | ||
|
|
183ea9d8c0 | ||
|
|
282736d2f6 | ||
|
|
45b30e5997 | ||
|
|
3d45e8ce6e | ||
|
|
393cf76839 | ||
|
|
410bc6a39b | ||
|
|
a56c00aeb3 | ||
|
|
c3d7d653fb | ||
|
|
9d72ef0122 | ||
|
|
b5aa22a6d8 | ||
|
|
aa05c16651 | ||
|
|
c543d0fb85 | ||
|
|
de7fa2dd9c | ||
|
|
f9829c2d5c | ||
|
|
e02091f645 | ||
|
|
8d15018986 | ||
|
|
76c63d38cd | ||
|
|
188b5c6c16 | ||
|
|
fc5a5d2035 | ||
|
|
838d106b62 | ||
|
|
d59a1af5d4 | ||
|
|
1638080e85 | ||
|
|
f4051b4cdf | ||
|
|
d054e64568 | ||
|
|
759176c4fc | ||
|
|
c2a9b7b1c6 | ||
|
|
2536b9fff7 | ||
|
|
b714547928 | ||
|
|
005fba5ca3 | ||
|
|
b27df0c0d8 | ||
|
|
341ab44f9e | ||
|
|
e0d35548c5 | ||
|
|
ba91730f43 | ||
|
|
f691a7a0ae | ||
|
|
5a43ecd8d2 | ||
|
|
73f33c31bb | ||
|
|
ffdce6f825 | ||
|
|
38b16daf51 | ||
|
|
bed5c74150 | ||
|
|
261454986e | ||
|
|
03784601ea | ||
|
|
b060861657 | ||
|
|
34a117b74c | ||
|
|
9e341afdcc | ||
|
|
0d585eef8f | ||
|
|
7e99c18410 | ||
|
|
68118248c9 | ||
|
|
f1a2ae16c1 | ||
|
|
7904cb22cf | ||
|
|
0e0fbae8bb | ||
|
|
3b72048b67 | ||
|
|
caa54809b4 | ||
|
|
23f5846122 | ||
|
|
de7b461b4e | ||
|
|
d92ea0bad7 | ||
|
|
d7bb4bb55f | ||
|
|
9ed8a3b91e | ||
|
|
dd78b43058 | ||
|
|
cd68e966ab | ||
|
|
6baeafce39 | ||
|
|
1d8900d6eb | ||
|
|
e82c8c4eb0 | ||
|
|
449d046ce3 | ||
|
|
06ab52bb6b |
+46
-6
@@ -1,8 +1,48 @@
|
||||
{
|
||||
"language": "en",
|
||||
"words": [
|
||||
"subheader",
|
||||
"basepath",
|
||||
"webdriverio"
|
||||
]
|
||||
"language": "en",
|
||||
"words": [
|
||||
"thomasnordquist",
|
||||
"nowrap",
|
||||
"subheader",
|
||||
"basepath",
|
||||
"webdriverio",
|
||||
"repo",
|
||||
"hexagonalize",
|
||||
"pixelize",
|
||||
"Transistions",
|
||||
"squashfs",
|
||||
"squashfs",
|
||||
"provisionprofile",
|
||||
"Nsis",
|
||||
"Appx",
|
||||
"Hashable",
|
||||
"clickaway",
|
||||
"Resizer",
|
||||
"Subnodes",
|
||||
"Unmount",
|
||||
"Monokai",
|
||||
"plottable",
|
||||
"snackbar",
|
||||
"webdriverio",
|
||||
"prismjs",
|
||||
"Nordquist",
|
||||
"debounced",
|
||||
"mosquitto",
|
||||
"snapcraft",
|
||||
"unsquashfs",
|
||||
"armv",
|
||||
"livingroom",
|
||||
"selectall",
|
||||
"toggledevtools",
|
||||
"Octo",
|
||||
"zigbee",
|
||||
"memoization",
|
||||
"submenu",
|
||||
"AGPL",
|
||||
"Oooooops",
|
||||
"DEVTOOLS",
|
||||
"mixins",
|
||||
"Explorerdmg",
|
||||
"heapsnapshot"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
patreon: thomasnordquist
|
||||
custom: https://paypal.me/ThomasNordquist
|
||||
|
||||
@@ -7,3 +7,5 @@ build
|
||||
test.dot
|
||||
test.png
|
||||
.awcache
|
||||
.scannerwork
|
||||
screen*.png
|
||||
|
||||
+3
-1
@@ -17,6 +17,8 @@ os:
|
||||
- linux
|
||||
- osx
|
||||
|
||||
osx_image: xcode10.2
|
||||
|
||||
dist: xenial
|
||||
|
||||
services:
|
||||
@@ -35,4 +37,4 @@ script:
|
||||
- if [[ "$TRAVIS_TAG" != "" ]]; then yarn run prepare-release; fi
|
||||
- if [[ "$TRAVIS_OS_NAME" == "linux" ]] && [[ "$TRAVIS_TAG" != "" ]]; then yarn run package linux; fi
|
||||
- if [[ "$TRAVIS_OS_NAME" == "osx" ]] && [[ "$TRAVIS_TAG" != "" ]]; then yarn run package -- mac; fi
|
||||
- if [[ "$TRAVIS_OS_NAME" == "osx" ]] && [[ "$TRAVIS_TAG" != "" ]]; then yarn run package -- win; fi
|
||||
- if [[ "$TRAVIS_OS_NAME" == "osx" ]] && [[ "$TRAVIS_TAG" != "" ]]; then unset CSC_LINK; yarn run package -- win; fi
|
||||
|
||||
+11
-12
@@ -22,10 +22,10 @@
|
||||
background-color: none;
|
||||
}
|
||||
25% {
|
||||
background-color: #3f51b5;
|
||||
background-color: #595585;
|
||||
}
|
||||
50% {
|
||||
background-color: #3f51b5;
|
||||
background-color: #595585;
|
||||
}
|
||||
100% {
|
||||
background-color: none;
|
||||
@@ -38,12 +38,10 @@
|
||||
color: inherit;
|
||||
}
|
||||
25% {
|
||||
background-color: #bfc9c8;
|
||||
color: #000;
|
||||
background-color: #c0c8c0;
|
||||
}
|
||||
50% {
|
||||
background-color: #bfc9c8;
|
||||
color: #000;
|
||||
background-color: #c0c8c0;
|
||||
}
|
||||
100% {
|
||||
background-color: none;
|
||||
@@ -114,15 +112,16 @@
|
||||
.Resizer.vertical {
|
||||
width: 2px;
|
||||
margin: 0px -8px 0px 0px;
|
||||
border-left: 4px solid rgba(128, 128, 128, 0);
|
||||
border-right: 4px solid rgba(128, 128, 128, 0);
|
||||
border-left: 0px solid rgba(128, 128, 128, 0);
|
||||
border-right: 8px solid rgba(128, 128, 128, 0);
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.Resizer.vertical::before {
|
||||
content: '•••';
|
||||
margin-left: -6px;
|
||||
height: 100%;
|
||||
margin-left: -11px;
|
||||
height: 3em;
|
||||
margin-top: calc(50vh - 32px);
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
@@ -133,8 +132,8 @@
|
||||
}
|
||||
|
||||
.Resizer.vertical:hover {
|
||||
border-left: 4px solid rgba(130, 130, 130, 0.3);
|
||||
border-right: 4px solid rgba(140, 140, 140, 0.3);
|
||||
border-left: 0px solid rgba(130, 130, 130, 0.3);
|
||||
border-right: 8px solid rgba(140, 140, 140, 0.3);
|
||||
}
|
||||
|
||||
.Resizer.disabled {
|
||||
|
||||
+4
-2
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "yarn rebuild && webpack --mode production",
|
||||
"dev": "node_modules/.bin/webpack-dev-server --mode development --progress",
|
||||
"rebuild": "cd node_modules/heapdump && node-gyp rebuild --target=5.0.4 --arch=x64 --dist-url=https://atom.io/download/electron || echo Could not build heapdump; cd -"
|
||||
"rebuild": "cd node_modules/heapdump && node-gyp rebuild --target=5.0.7 --arch=x64 --dist-url=https://atom.io/download/electron || echo Could not build heapdump; cd -"
|
||||
},
|
||||
"author": "",
|
||||
"license": "CC-BY-ND-4.0",
|
||||
@@ -28,6 +28,7 @@
|
||||
"file-loader": "^4.0.0",
|
||||
"get-value": "^3.0.1",
|
||||
"immutable": "^4.0.0-rc.12",
|
||||
"in-viewport": "^3.6.0",
|
||||
"js-base64": "^2.5.1",
|
||||
"json-to-ast": "^2.1.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
@@ -35,6 +36,7 @@
|
||||
"moment": "^2.24.0",
|
||||
"moving-average": "^1.0.0",
|
||||
"number-abbreviate": "^2.0.0",
|
||||
"parse-duration": "^0.1.1",
|
||||
"prismjs": "^1.15.0",
|
||||
"react": "16.8",
|
||||
"react-ace": "^7.0.1",
|
||||
@@ -80,6 +82,6 @@
|
||||
"webpack-dev-server": "^3.1.14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"electron": "^5.0.4"
|
||||
"electron": "^5.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ export const loadConnectionSettings = () => async (dispatch: Dispatch<any>, getS
|
||||
}
|
||||
}
|
||||
|
||||
export const selectCertificate = (connectionId: string) => async (
|
||||
export type CertificateTypes = 'selfSignedCertificate' | 'clientCertificate' | 'clientKey'
|
||||
export const selectCertificate = (type: CertificateTypes, connectionId: string) => async (
|
||||
dispatch: Dispatch<any>,
|
||||
getState: () => AppState
|
||||
) => {
|
||||
@@ -50,7 +51,7 @@ export const selectCertificate = (connectionId: string) => async (
|
||||
const certificate = await openCertificate()
|
||||
dispatch(
|
||||
updateConnection(connectionId, {
|
||||
selfSignedCertificate: certificate,
|
||||
[type]: certificate,
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
@@ -147,6 +148,10 @@ export const toggleAdvancedSettings = (): Action => ({
|
||||
type: ActionTypes.CONNECTION_MANAGER_TOGGLE_ADVANCED_SETTINGS,
|
||||
})
|
||||
|
||||
export const toggleCertificateSettings = (): Action => ({
|
||||
type: ActionTypes.CONNECTION_MANAGER_TOGGLE_CERTIFICATE_SETTINGS,
|
||||
})
|
||||
|
||||
export const deleteConnection = (connectionId: string) => (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
const connectionIds = Object.keys(getState().connectionManager.connections)
|
||||
const connectionIdLocation = connectionIds.indexOf(connectionId)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ActionTypes } from '../reducers/Global'
|
||||
import { ActionTypes, ConfirmationRequest } from '../reducers/Global'
|
||||
import { Dispatch } from 'redux'
|
||||
|
||||
export const showError = (error?: string) => ({
|
||||
@@ -20,3 +20,30 @@ export const toggleSettingsVisibility = () => (dispatch: Dispatch<any>) => {
|
||||
type: ActionTypes.toggleSettingsVisibility,
|
||||
})
|
||||
}
|
||||
|
||||
export const requestConfirmation = (title: string, inquiry: string) => (dispatch: Dispatch<any>) => {
|
||||
return new Promise(resolve => {
|
||||
const confirmationRequest = {
|
||||
title,
|
||||
inquiry,
|
||||
callback: (confirmed: boolean) => {
|
||||
resolve(confirmed)
|
||||
dispatch(removeConfirmationRequest(confirmationRequest))
|
||||
},
|
||||
}
|
||||
|
||||
dispatch({
|
||||
confirmationRequest,
|
||||
type: ActionTypes.requestConfirmation,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const removeConfirmationRequest = (confirmationRequest: ConfirmationRequest) => (dispatch: Dispatch<any>) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
dispatch({
|
||||
confirmationRequest,
|
||||
type: ActionTypes.removeConfirmationRequest,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as q from '../../../backend/src/Model'
|
||||
import { ActionTypes, SettingsState, TopicOrder } from '../reducers/Settings'
|
||||
import { ActionTypes, SettingsStateModel, TopicOrder } from '../reducers/Settings'
|
||||
import { AppState } from '../reducers'
|
||||
import { autoExpandLimitSet } from '../components/SettingsDrawer/Settings'
|
||||
import { Base64Message } from '../../../backend/src/Model/Base64Message'
|
||||
@@ -11,7 +11,7 @@ import { showError } from './Global'
|
||||
import { showTree } from './Tree'
|
||||
import { TopicViewModel } from '../model/TopicViewModel'
|
||||
|
||||
const settingsIdentifier: StorageIdentifier<Partial<SettingsState>> = {
|
||||
const settingsIdentifier: StorageIdentifier<Partial<SettingsStateModel>> = {
|
||||
id: 'Settings',
|
||||
}
|
||||
|
||||
@@ -149,9 +149,10 @@ function autoExpandLimitForTree(tree: q.Tree<TopicViewModel>) {
|
||||
if (!tree) {
|
||||
return 0
|
||||
}
|
||||
|
||||
function closestExistingLimit(i: number): number {
|
||||
const sorted = autoExpandLimitSet.sort((a, b) => Math.abs(a.limit - i) - Math.abs(b.limit - i))
|
||||
return sorted[0]!.limit
|
||||
const sorted = [...autoExpandLimitSet].sort((a, b) => Math.abs(a.limit - i) - Math.abs(b.limit - i))
|
||||
return sorted[0].limit
|
||||
}
|
||||
|
||||
const count = tree.childTopicCount()
|
||||
|
||||
@@ -2,7 +2,9 @@ import * as q from '../../../backend/src/Model'
|
||||
import { ActionTypes } from '../reducers/Sidebar'
|
||||
import { AppState } from '../reducers'
|
||||
import { Dispatch } from 'redux'
|
||||
import { makePublishEvent, rendererEvents } from '../../../events'
|
||||
import { clearTopic } from './clearTopic'
|
||||
|
||||
export { clearTopic } from './clearTopic'
|
||||
|
||||
export const clearRetainedTopic = () => (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
const selectedTopic = getState().tree.get('selectedTopic')
|
||||
@@ -19,38 +21,3 @@ export const setCompareMessage = (message?: q.Message) => (dispatch: Dispatch<an
|
||||
type: ActionTypes.SIDEBAR_SET_COMPARE_MESSAGE,
|
||||
})
|
||||
}
|
||||
|
||||
export const clearTopic = (topic: q.TreeNode<any>, recursive: boolean, subtopicClearLimit = 50) => (
|
||||
dispatch: Dispatch<any>,
|
||||
getState: () => AppState
|
||||
) => {
|
||||
const { connectionId } = getState().connection
|
||||
if (!connectionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const publishEvent = makePublishEvent(connectionId)
|
||||
const mqttMessage = {
|
||||
topic: topic.path(),
|
||||
payload: null,
|
||||
retain: true,
|
||||
qos: 0 as 0,
|
||||
}
|
||||
rendererEvents.emit(publishEvent, mqttMessage)
|
||||
|
||||
if (recursive) {
|
||||
topic
|
||||
.childTopics()
|
||||
.filter(topic => Boolean(topic.message && topic.message.value))
|
||||
.slice(0, subtopicClearLimit)
|
||||
.forEach(topic => {
|
||||
const mqttMessage = {
|
||||
topic: topic.path(),
|
||||
payload: null,
|
||||
retain: true,
|
||||
qos: 0 as 0,
|
||||
}
|
||||
rendererEvents.emit(publishEvent, mqttMessage)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ 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 { setTopic } from './Publish'
|
||||
import { TopicViewModel } from '../model/TopicViewModel'
|
||||
import { globalActions } from '.'
|
||||
const debounce = require('lodash.debounce')
|
||||
export { clearTopic } from './clearTopic'
|
||||
|
||||
export { moveSelectionUpOrDownwards, moveInward, moveOutward } from './visibleTreeTraversal'
|
||||
|
||||
export const selectTopic = (topic: q.TreeNode<TopicViewModel>) => (
|
||||
dispatch: Dispatch<any>,
|
||||
@@ -96,15 +99,18 @@ export const togglePause = (tree?: q.Tree<TopicViewModel>) => (dispatch: Dispatc
|
||||
const tree = getState().tree.get('tree')
|
||||
const changes = tree ? tree.unmergedChanges().length : 0
|
||||
|
||||
if (tree) {
|
||||
paused ? tree.resume() : tree.pause()
|
||||
}
|
||||
|
||||
if (paused && changes > 0) {
|
||||
dispatch(globalActions.showNotification('Applying recorded changes.'))
|
||||
}
|
||||
|
||||
// Allow for notification to be displayed
|
||||
setTimeout(() => {
|
||||
tree && tree.applyUnmergedChanges()
|
||||
if (paused && changes > 0) {
|
||||
dispatch(globalActions.showNotification(`Sucessfully applied ${changes} changes.`))
|
||||
dispatch(globalActions.showNotification(`Successfully applied ${changes} changes.`))
|
||||
}
|
||||
}, 50)
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as q from '../../../backend/src/Model'
|
||||
import { AppState } from '../reducers'
|
||||
import { Dispatch } from 'redux'
|
||||
import { makePublishEvent, rendererEvents } from '../../../events'
|
||||
import { moveSelectionUpOrDownwards } from './visibleTreeTraversal'
|
||||
import { globalActions } from '.'
|
||||
|
||||
export const clearTopic = (topic: q.TreeNode<any>, recursive: boolean, subtopicClearLimit = 50) => async (
|
||||
dispatch: Dispatch<any>,
|
||||
getState: () => AppState
|
||||
) => {
|
||||
if (recursive) {
|
||||
const topicCount = topic.childTopicCount()
|
||||
const deleteLimitMessage =
|
||||
topicCount > subtopicClearLimit ? ` You can only delete ${subtopicClearLimit} child topics at once.` : ''
|
||||
|
||||
const topicDelta = topic.hasMessage() ? -1 : 0
|
||||
const childTopicsMessage =
|
||||
topicCount + topicDelta > 0
|
||||
? ` and ${topicCount + topicDelta} child ${topicCount + topicDelta === 1 ? 'topic' : 'topics'}`
|
||||
: ''
|
||||
|
||||
const confirmed = await dispatch(
|
||||
globalActions.requestConfirmation(
|
||||
'Confirm delete',
|
||||
`Do you want to delete "${topic.path()}"${childTopicsMessage}?${deleteLimitMessage}`
|
||||
)
|
||||
)
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(moveSelectionUpOrDownwards('next'))
|
||||
|
||||
const { connectionId } = getState().connection
|
||||
if (!connectionId) {
|
||||
return
|
||||
}
|
||||
const publishEvent = makePublishEvent(connectionId)
|
||||
const mqttMessage = {
|
||||
topic: topic.path(),
|
||||
payload: null,
|
||||
retain: true,
|
||||
qos: 0 as 0,
|
||||
}
|
||||
rendererEvents.emit(publishEvent, mqttMessage)
|
||||
if (recursive) {
|
||||
topic
|
||||
.childTopics()
|
||||
.filter(topic => Boolean(topic.message && topic.message.value))
|
||||
.slice(0, subtopicClearLimit)
|
||||
.forEach((topic, idx) => {
|
||||
const mqttMessage = {
|
||||
topic: topic.path(),
|
||||
payload: null,
|
||||
retain: true,
|
||||
qos: 0 as 0,
|
||||
}
|
||||
// Rate limit deletion
|
||||
setTimeout(() => rendererEvents.emit(publishEvent, mqttMessage), 20 * idx)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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'
|
||||
import { TopicViewModel } from '../model/TopicViewModel'
|
||||
|
||||
export const moveSelectionUpOrDownwards = (direction: 'next' | 'previous') => (
|
||||
dispatch: Dispatch<any>,
|
||||
getState: () => AppState
|
||||
): any => {
|
||||
const state = getState()
|
||||
const selected = state.tree.get('selectedTopic')
|
||||
const tree = state.tree.get('tree')
|
||||
|
||||
if (!selected || !tree) {
|
||||
if (tree) {
|
||||
dispatch(selectTopic(tree))
|
||||
}
|
||||
return
|
||||
}
|
||||
const nextTreeNode = nextVisibleElementInTree(state.settings, tree, selected, direction)
|
||||
if (nextTreeNode && nextTreeNode.viewModel) {
|
||||
dispatch(selectTopic(nextTreeNode))
|
||||
}
|
||||
}
|
||||
|
||||
export const moveInward = () => (dispatch: Dispatch<any>, getState: () => AppState): any => {
|
||||
const state = getState()
|
||||
const selected = state.tree.get('selectedTopic')
|
||||
if (!selected || !selected.viewModel) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!selected.viewModel.isExpanded() && selected.edgeCount() > 0) {
|
||||
selected.viewModel.setExpanded(true, true)
|
||||
} else {
|
||||
dispatch(moveSelectionUpOrDownwards('next'))
|
||||
}
|
||||
}
|
||||
|
||||
export const moveOutward = () => (dispatch: Dispatch<any>, getState: () => AppState): any => {
|
||||
const state = getState()
|
||||
const selected = state.tree.get('selectedTopic')
|
||||
if (!selected || !selected.viewModel) {
|
||||
return
|
||||
}
|
||||
|
||||
if (selected.viewModel.isExpanded() && selected.edgeCount() > 0) {
|
||||
selected.viewModel.setExpanded(false, true)
|
||||
} else {
|
||||
dispatch(moveSelectionUpOrDownwards('previous'))
|
||||
}
|
||||
}
|
||||
|
||||
function isTreeNodeVisible(treeNode: q.TreeNode<any>) {
|
||||
return Boolean(treeNode.viewModel)
|
||||
}
|
||||
|
||||
function nextVisibleElementInTree(
|
||||
settings: SettingsState,
|
||||
tree: q.Tree<TopicViewModel>,
|
||||
node: q.TreeNode<TopicViewModel>,
|
||||
direction: 'next' | 'previous'
|
||||
): q.TreeNode<TopicViewModel> | undefined {
|
||||
if (direction === 'next') {
|
||||
return findNextNodeDownward(settings, node)
|
||||
} else {
|
||||
return findNextNodeUpward(settings, node)
|
||||
}
|
||||
}
|
||||
|
||||
/** Not very efficient but easy to implement, complexity should not be an issue here */
|
||||
function findNextNodeUpward(
|
||||
settings: SettingsState,
|
||||
treeNode: q.TreeNode<TopicViewModel>
|
||||
): q.TreeNode<TopicViewModel> | undefined {
|
||||
const parent = treeNode.sourceEdge && treeNode.sourceEdge.source
|
||||
if (!parent) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const neighborNodes = sortedNodes(settings, parent)
|
||||
const nodeIdx = neighborNodes.findIndex(n => n.path() === treeNode.path())
|
||||
if (nodeIdx === 0) {
|
||||
return parent
|
||||
}
|
||||
const upwardNeighbor = neighborNodes[nodeIdx - 1]
|
||||
if (upwardNeighbor) {
|
||||
return lastVisibleChild(settings, upwardNeighbor)
|
||||
} else {
|
||||
return findNextNodeUpward(settings, parent)
|
||||
}
|
||||
}
|
||||
|
||||
function lastVisibleChild(settings: SettingsState, treeNode: q.TreeNode<TopicViewModel>): q.TreeNode<TopicViewModel> {
|
||||
const nodes = sortedNodes(settings, treeNode).filter(isTreeNodeVisible)
|
||||
if (nodes.length === 0) {
|
||||
return treeNode
|
||||
}
|
||||
return lastVisibleChild(settings, nodes[nodes.length - 1])
|
||||
}
|
||||
|
||||
function findNextNodeDownward(
|
||||
settings: SettingsState,
|
||||
treeNode: q.TreeNode<TopicViewModel>
|
||||
): q.TreeNode<TopicViewModel> | undefined {
|
||||
const children = sortedNodes(settings, treeNode).filter(isTreeNodeVisible)
|
||||
const firstChild = children[0]
|
||||
if (firstChild) {
|
||||
return firstChild
|
||||
}
|
||||
|
||||
return findNextNodeDownwardNeighbor(settings, treeNode)
|
||||
}
|
||||
|
||||
function findNextNodeDownwardNeighbor(
|
||||
settings: SettingsState,
|
||||
treeNode: q.TreeNode<TopicViewModel>
|
||||
): q.TreeNode<TopicViewModel> | undefined {
|
||||
const parent = treeNode.sourceEdge && treeNode.sourceEdge.source
|
||||
if (!parent) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const neighborNodes = sortedNodes(settings, parent).filter(isTreeNodeVisible)
|
||||
const nodeIdx = neighborNodes.findIndex(n => n.path() === treeNode.path())
|
||||
const downwardNeighbor = neighborNodes[nodeIdx + 1]
|
||||
if (downwardNeighbor) {
|
||||
return downwardNeighbor
|
||||
} else {
|
||||
return findNextNodeDownwardNeighbor(settings, parent)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import * as React from 'react'
|
||||
import ConfirmationDialog from './ConfirmationDialog'
|
||||
import ConnectionSetup from './ConnectionSetup/ConnectionSetup'
|
||||
import CssBaseline from '@material-ui/core/CssBaseline'
|
||||
import ErrorBoundary from './ErrorBoundary'
|
||||
import Notification from './Layout/Notification'
|
||||
import React from 'react'
|
||||
import TitleBar from './Layout/TitleBar'
|
||||
import UpdateNotifier from './UpdateNotifier'
|
||||
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, withStyles } from '@material-ui/core/styles'
|
||||
@@ -23,6 +25,7 @@ interface Props {
|
||||
actions: typeof globalActions
|
||||
settingsActions: typeof settingsActions
|
||||
launching: boolean
|
||||
confirmationRequests: Array<ConfirmationRequest>
|
||||
}
|
||||
|
||||
class App extends React.PureComponent<Props, {}> {
|
||||
@@ -67,6 +70,7 @@ class App extends React.PureComponent<Props, {}> {
|
||||
<div className={centerContent}>
|
||||
<CssBaseline />
|
||||
<ErrorBoundary>
|
||||
<ConfirmationDialog confirmationRequests={this.props.confirmationRequests} />
|
||||
{this.renderNotification()}
|
||||
<React.Suspense fallback={<div></div>}>
|
||||
<Settings />
|
||||
@@ -149,6 +153,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
notification: state.globalState.get('notification'),
|
||||
highlightTopicUpdates: state.settings.get('highlightTopicUpdates'),
|
||||
launching: state.globalState.get('launching'),
|
||||
confirmationRequests: state.globalState.get('confirmationRequests'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import DateFormatter from '../helper/DateFormatter'
|
||||
import NoData from './NoData'
|
||||
import NumberFormatter from '../helper/NumberFormatter'
|
||||
import React, { memo, useCallback } from 'react'
|
||||
import TooltipComponent from './TooltipComponent'
|
||||
import { default as ReactResizeDetector } from 'react-resize-detector'
|
||||
import { emphasize } from '@material-ui/core/styles'
|
||||
import { mapCurveType } from './mapCurveType'
|
||||
import { PlotCurveTypes } from '../../reducers/Charts'
|
||||
import { Point, Tooltip } from './Model'
|
||||
import { Theme, withTheme } from '@material-ui/core'
|
||||
import { useCustomXDomain } from './effects/useCustomXDomain'
|
||||
import { useCustomYDomain } from './effects/useCustomYDomain'
|
||||
import 'react-vis/dist/style.css'
|
||||
const { XYPlot, LineMarkSeries, YAxis, HorizontalGridLines, Hint } = require('react-vis')
|
||||
const abbreviate = require('number-abbreviate')
|
||||
|
||||
export interface Props {
|
||||
data: Array<{ x: number; y: number }>
|
||||
theme: Theme
|
||||
interpolation?: PlotCurveTypes
|
||||
range?: [number?, number?]
|
||||
timeRangeStart?: number
|
||||
color?: string
|
||||
}
|
||||
|
||||
export default withTheme(
|
||||
memo((props: Props) => {
|
||||
const [width, setWidth] = React.useState(300)
|
||||
const [tooltip, setTooltip] = React.useState<Tooltip | undefined>()
|
||||
const detectResize = React.useCallback(newWidth => setWidth(newWidth), [])
|
||||
|
||||
const hintFormatter = React.useCallback(
|
||||
(point: any) => [
|
||||
{ title: <b>Time</b>, value: <DateFormatter timeFirst={true} date={new Date(point.x)} /> },
|
||||
{ title: <b>Value</b>, value: <NumberFormatter value={point.y} /> },
|
||||
{ title: <b>Raw</b>, value: <span>{point.y}</span> },
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const onMouseLeave = React.useCallback(() => {
|
||||
setTooltip(undefined)
|
||||
}, [])
|
||||
|
||||
const showTooltip = React.useCallback((point: Point, something: { event: MouseEvent }) => {
|
||||
if (!something) {
|
||||
return
|
||||
}
|
||||
setTooltip({ point, value: hintFormatter(point), element: something.event.target as any })
|
||||
}, [])
|
||||
|
||||
const paletteColor =
|
||||
props.theme.palette.type === 'light' ? props.theme.palette.secondary.dark : props.theme.palette.primary.light
|
||||
const color = props.color ? props.color : paletteColor
|
||||
|
||||
const highlightSelectedPoint = useCallback(
|
||||
(point: Point) => {
|
||||
const highlight = tooltip && tooltip.point.x === point.x && tooltip.point.y === point.y
|
||||
return highlight ? emphasize(color, 0.8) : color
|
||||
},
|
||||
[tooltip, color]
|
||||
)
|
||||
|
||||
const formatYAxis = useCallback((num: number) => abbreviate(num), [])
|
||||
|
||||
const xDomain = useCustomXDomain(props)
|
||||
const yDomain = useCustomYDomain(props)
|
||||
|
||||
const data = props.data
|
||||
const hasData = data.length > 0
|
||||
const dummyDomain = [-1, 1]
|
||||
const dummyData = [{ x: -2, y: -2 }]
|
||||
return (
|
||||
<div>
|
||||
<div style={{ height: '150px', width: '100%', position: 'relative' }}>
|
||||
{data.length === 0 ? <NoData /> : null}
|
||||
<XYPlot
|
||||
width={width}
|
||||
height={180}
|
||||
yDomain={hasData ? yDomain : dummyDomain}
|
||||
xDomain={hasData ? xDomain : dummyDomain}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<HorizontalGridLines />
|
||||
<YAxis width={45} tickFormat={formatYAxis} />
|
||||
<LineMarkSeries
|
||||
color={color}
|
||||
colorType="literal"
|
||||
getColor={highlightSelectedPoint}
|
||||
onValueMouseOver={showTooltip}
|
||||
size={3}
|
||||
data={hasData ? data : dummyData}
|
||||
curve={mapCurveType(props.interpolation)}
|
||||
/>
|
||||
<Hint value={{ x: 0, y: 0 }} style={{ pointerEvents: 'none' }}>
|
||||
<TooltipComponent tooltip={tooltip} theme={props.theme} />
|
||||
</Hint>
|
||||
</XYPlot>
|
||||
<ReactResizeDetector handleWidth={true} onResize={detectResize} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Tooltip {
|
||||
value: Array<TooltipRows>
|
||||
point: Point
|
||||
element: Element | null
|
||||
}
|
||||
|
||||
export interface TooltipRows {
|
||||
title: React.ReactElement
|
||||
value: React.ReactElement
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React, { memo } from 'react'
|
||||
import { Typography } from '@material-ui/core'
|
||||
|
||||
function NoData() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
color: '#ccc',
|
||||
verticalAlign: 'middle',
|
||||
paddingLeft: '30px',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Typography style={{ fontWeight: 'bold' }} variant="h5">
|
||||
No Data
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(NoData)
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, { memo } from 'react'
|
||||
import { fade } from '@material-ui/core/styles'
|
||||
import { Fade, Grow, Paper, Popper, Theme, Typography, withTheme } from '@material-ui/core'
|
||||
import { Tooltip } from './Model'
|
||||
|
||||
function TooltipComponent(props: { tooltip?: Tooltip; theme: Theme }) {
|
||||
const { tooltip } = props
|
||||
return (
|
||||
<Popper
|
||||
style={Boolean(tooltip) ? { transition: 'all 0.1s ease-out' } : undefined}
|
||||
open={Boolean(tooltip)}
|
||||
transition={true}
|
||||
placement="top"
|
||||
anchorEl={tooltip && tooltip.element}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
paddingBottom: '8px',
|
||||
transition: 'all 0.5s ease',
|
||||
}}
|
||||
>
|
||||
<Fade in={Boolean(tooltip)} timeout={300}>
|
||||
<Grow in={Boolean(tooltip)} timeout={300}>
|
||||
<Paper
|
||||
style={{
|
||||
padding: '4px',
|
||||
marginTop: '-12px',
|
||||
backgroundColor: fade(
|
||||
props.theme.palette.type === 'light'
|
||||
? props.theme.palette.background.paper
|
||||
: props.theme.palette.background.default,
|
||||
0.7
|
||||
),
|
||||
}}
|
||||
>
|
||||
<table style={{ lineHeight: '1.25em' }}>
|
||||
<tbody>
|
||||
{tooltip &&
|
||||
tooltip.value.map((v: any, idx: number) => (
|
||||
<tr key={idx}>
|
||||
<td>
|
||||
<Typography style={{ lineHeight: '1.2' }}>{v.title}</Typography>
|
||||
</td>
|
||||
<td>
|
||||
<Typography style={{ lineHeight: '1.2' }}>{v.value}</Typography>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Paper>
|
||||
</Grow>
|
||||
</Fade>
|
||||
</div>
|
||||
</Popper>
|
||||
)
|
||||
}
|
||||
|
||||
export default withTheme(memo(TooltipComponent))
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Props } from '../Chart'
|
||||
|
||||
export function useCustomXDomain(props: Props): [number, number] | undefined {
|
||||
return useMemo(() => {
|
||||
const lastDataPoint = [...props.data].sort((a, b) => b.x - a.x)[0]
|
||||
const lastDataDate = lastDataPoint ? lastDataPoint.x : Date.now()
|
||||
return props.timeRangeStart ? [Date.now() - props.timeRangeStart, lastDataDate] : undefined
|
||||
}, [props.data, props.timeRangeStart])
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Props } from '../Chart'
|
||||
import { useMemo } from 'react'
|
||||
import { Point } from '../Model'
|
||||
|
||||
export function useCustomYDomain(props: Props) {
|
||||
return useMemo(() => {
|
||||
const data = props.data
|
||||
const calculatedDomain = domainForData(data)
|
||||
const yDomain: [number, number] = props.range
|
||||
? [props.range[0] || calculatedDomain[0], props.range[1] || calculatedDomain[1]]
|
||||
: calculatedDomain
|
||||
|
||||
return yDomain
|
||||
}, [props.data])
|
||||
}
|
||||
|
||||
function domainForData(data: Array<Point>): [number, number] {
|
||||
if (!data[0]) {
|
||||
const defaultDomain: [number, number] = [-1, 1]
|
||||
return defaultDomain
|
||||
}
|
||||
let max = data[0].y
|
||||
let min = data[0].y
|
||||
data.forEach(d => {
|
||||
if (max < d.y) {
|
||||
max = d.y
|
||||
}
|
||||
if (min > d.y) {
|
||||
min = d.y
|
||||
}
|
||||
})
|
||||
if ((max === 1 || max === 0) && (min === 1 || min === 0)) {
|
||||
return [0, 1]
|
||||
}
|
||||
if (min === max) {
|
||||
return [min - 0.5 * min, min + 0.5 * min]
|
||||
}
|
||||
return [min, max]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PlotCurveTypes } from '../../reducers/Charts'
|
||||
export function mapCurveType(type: PlotCurveTypes | undefined) {
|
||||
switch (type) {
|
||||
case 'curve':
|
||||
return 'curveMonotoneX'
|
||||
case 'linear':
|
||||
return 'curveLinear'
|
||||
case 'cubic_basis_spline':
|
||||
return 'curveBasis'
|
||||
case 'step_after':
|
||||
return 'curveStepAfter'
|
||||
case 'step_before':
|
||||
return 'curveStepBefore'
|
||||
default:
|
||||
return 'curveMonotoneX'
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import Clear from '@material-ui/icons/Clear'
|
||||
import CustomIconButton from '../helper/CustomIconButton'
|
||||
import TopicPlot from '../TopicPlot'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { chartActions } from '../../actions'
|
||||
import { ChartParameters } from '../../reducers/Charts'
|
||||
import { connect } from 'react-redux'
|
||||
import { Paper, Theme, Typography, withStyles } from '@material-ui/core'
|
||||
import { SettingsButton } from './ChartSettings/SettingsButton'
|
||||
const throttle = require('lodash.throttle')
|
||||
|
||||
interface Props {
|
||||
parameters: ChartParameters
|
||||
tree?: q.Tree<any>
|
||||
classes: any
|
||||
actions: {
|
||||
chart: typeof chartActions
|
||||
}
|
||||
}
|
||||
|
||||
function Chart(props: Props) {
|
||||
if (!props.tree) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { tree, parameters } = props
|
||||
const initialTreeNode = tree.findNode(parameters.topic)
|
||||
const [treeNode, setTreeNode] = React.useState<q.TreeNode<any> | undefined>(initialTreeNode)
|
||||
usePollingToFetchTreeNode(treeNode, tree, parameters, setTreeNode)
|
||||
|
||||
const onRemove = React.useCallback(() => {
|
||||
props.actions.chart.removeChart(props.parameters)
|
||||
}, [props.parameters])
|
||||
|
||||
return (
|
||||
<Paper style={{ padding: '8px' }}>
|
||||
<div style={{ float: 'right' }}>
|
||||
<SettingsButton parameters={parameters} />
|
||||
<CustomIconButton tooltip="Remove chart" onClick={onRemove}>
|
||||
<Clear />
|
||||
</CustomIconButton>
|
||||
</div>
|
||||
<Typography variant="caption" className={props.classes.topic}>
|
||||
{parameters.dotPath ? parameters.dotPath : ''}
|
||||
</Typography>
|
||||
<br />
|
||||
<Typography variant="caption" className={props.classes.topic}>
|
||||
{parameters.topic}
|
||||
</Typography>
|
||||
<br />
|
||||
{treeNode ? (
|
||||
<TopicPlot
|
||||
color={props.parameters.color}
|
||||
interpolation={props.parameters.interpolation}
|
||||
range={props.parameters.range ? [props.parameters.range.from, props.parameters.range.to] : undefined}
|
||||
history={treeNode.messageHistory}
|
||||
dotPath={parameters.dotPath}
|
||||
/>
|
||||
) : (
|
||||
<span>No data</span>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
tree: state.connection.tree,
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
chart: bindActionCreators(chartActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
topic: {
|
||||
wordBreak: 'break-all' as 'break-all',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(withStyles(styles)(Chart))
|
||||
|
||||
/**
|
||||
* If a node is not available when the plot is shown, keep polling until it has been created
|
||||
*/
|
||||
function usePollingToFetchTreeNode(
|
||||
treeNode: q.TreeNode<any> | undefined,
|
||||
tree: q.Tree<any>,
|
||||
parameters: ChartParameters,
|
||||
setTreeNode: React.Dispatch<React.SetStateAction<q.TreeNode<any> | undefined>>
|
||||
) {
|
||||
const [lastUpdate, setLastUpdate] = React.useState(0)
|
||||
|
||||
function pollForTreeNode() {
|
||||
const onUpdateCallback = throttle(() => setLastUpdate(treeNode ? treeNode.lastUpdate : 0), 300)
|
||||
let intervalTimer: any
|
||||
if (!treeNode) {
|
||||
intervalTimer = setInterval(() => {
|
||||
const node = tree.findNode(parameters.topic)
|
||||
if (node) {
|
||||
setTreeNode(node)
|
||||
node.onMessage.subscribe(onUpdateCallback)
|
||||
clearInterval(intervalTimer)
|
||||
}
|
||||
}, 500)
|
||||
} else {
|
||||
treeNode.onMessage.subscribe(onUpdateCallback)
|
||||
}
|
||||
return function cleanup() {
|
||||
treeNode && treeNode.onMessage.unsubscribe(onUpdateCallback)
|
||||
intervalTimer && clearInterval(intervalTimer)
|
||||
}
|
||||
}
|
||||
React.useEffect(pollForTreeNode)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import React, { useRef } from 'react'
|
||||
import Play from '@material-ui/icons/PlayArrow'
|
||||
import Pause from '@material-ui/icons/PauseCircleFilled'
|
||||
import Clear from '@material-ui/icons/Clear'
|
||||
import CustomIconButton from '../helper/CustomIconButton'
|
||||
import { ChartParameters } from '../../reducers/Charts'
|
||||
import { SettingsButton } from './ChartSettings/SettingsButton'
|
||||
|
||||
export function ChartActions(props: {
|
||||
paused: boolean
|
||||
togglePause: () => void
|
||||
parameters: ChartParameters
|
||||
onRemove: () => void
|
||||
resetDataAction: () => void
|
||||
}) {
|
||||
const menuAnchor = useRef()
|
||||
return (
|
||||
<div style={{ display: 'flex' }}>
|
||||
<CustomIconButton tooltip={props.paused ? 'Resume chart' : 'Pause chart'} onClick={props.togglePause}>
|
||||
{props.paused ? <Play /> : <Pause />}
|
||||
</CustomIconButton>
|
||||
<SettingsButton menuAnchor={menuAnchor} parameters={props.parameters} resetDataAction={props.resetDataAction} />
|
||||
<CustomIconButton tooltip="Remove chart" onClick={props.onRemove}>
|
||||
<Clear data-test-type="RemoveChart" data-test={`${props.parameters.topic}-${props.parameters.dotPath || ''}`} />
|
||||
</CustomIconButton>
|
||||
<div style={{ width: 0, overflow: 'hidden' }}>
|
||||
{/* Helper element to provide an anchor element for the menu,
|
||||
* so the menu prefers not to overlap with the chart
|
||||
*/}
|
||||
<div style={{ marginLeft: '11px' }} ref={menuAnchor as any} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from 'react'
|
||||
import React, { memo } from 'react'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { chartActions } from '../../../actions'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
@@ -68,4 +68,4 @@ const mapDispatchToProps = (dispatch: any) => {
|
||||
export default connect(
|
||||
undefined,
|
||||
mapDispatchToProps
|
||||
)(ColorSettings)
|
||||
)(memo(ColorSettings))
|
||||
|
||||
@@ -4,7 +4,7 @@ import { bindActionCreators } from 'redux'
|
||||
import { chartActions } from '../../../actions'
|
||||
import { ChartParameters, PlotCurveTypes } from '../../../reducers/Charts'
|
||||
import { connect } from 'react-redux'
|
||||
import { Menu, MenuItem } from '@material-ui/core'
|
||||
import { Menu, MenuItem, Typography } from '@material-ui/core'
|
||||
|
||||
function chartParametersForAction(chart: ChartParameters, action: string) {
|
||||
return {
|
||||
@@ -40,7 +40,7 @@ function InterpolationSettings(props: {
|
||||
const menuItems = React.useMemo(() => {
|
||||
return curves.map(curve => (
|
||||
<MenuItem key={curve} onClick={callbacks[curve]} selected={props.chart.interpolation === curve}>
|
||||
{curve.replace(/_/g, ' ')}
|
||||
<Typography variant="inherit">{curve.replace(/_/g, ' ')}</Typography>
|
||||
</MenuItem>
|
||||
))
|
||||
}, [curves, props.chart])
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import * as React from 'react'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
import { Menu, MenuItem, TextField, Typography } from '@material-ui/core'
|
||||
import { connect } from 'react-redux'
|
||||
import ArrowUpward from '@material-ui/icons/ArrowUpward'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { chartActions } from '../../../actions'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
import { connect } from 'react-redux'
|
||||
import { MenuItem, Typography, ListItemIcon } from '@material-ui/core'
|
||||
|
||||
function MoveUp(props: { actions: { chart: typeof chartActions }; chart: ChartParameters; close: () => void }) {
|
||||
const moveUp = React.useCallback(() => {
|
||||
@@ -16,7 +17,10 @@ function MoveUp(props: { actions: { chart: typeof chartActions }; chart: ChartPa
|
||||
|
||||
return (
|
||||
<MenuItem key="size" onClick={moveUp}>
|
||||
Move up
|
||||
<ListItemIcon>
|
||||
<ArrowUpward />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Move up</Typography>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,60 +1,89 @@
|
||||
import * as React from 'react'
|
||||
import React, { useCallback, useState, ChangeEvent, MouseEvent, useRef, useEffect, useMemo } from 'react'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
import { Menu, MenuItem, TextField, Typography } from '@material-ui/core'
|
||||
import { Menu, TextField, Typography } from '@material-ui/core'
|
||||
import { connect } from 'react-redux'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { chartActions } from '../../../actions'
|
||||
import { KeyCodes } from '../../../utils/KeyCodes'
|
||||
|
||||
function RangeSettings(props: {
|
||||
interface Props {
|
||||
actions: { chart: typeof chartActions }
|
||||
chart: ChartParameters
|
||||
anchorEl?: Element
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const dismissClick = React.useCallback((e: React.MouseEvent) => e.stopPropagation(), [])
|
||||
const [rangeFrom, setRangeFrom] = React.useState<string | number | undefined>(
|
||||
props.chart.range && props.chart.range.from
|
||||
)
|
||||
const [rangeTo, setRangeTo] = React.useState<string | number | undefined>(props.chart.range && props.chart.range.to)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const from = parseFloat(rangeFrom as any)
|
||||
const to = parseFloat(rangeTo as any)
|
||||
props.actions.chart.updateChart({
|
||||
topic: props.chart.topic,
|
||||
dotPath: props.chart.dotPath,
|
||||
range: {
|
||||
from: isNaN(from) ? undefined : from,
|
||||
to: isNaN(to) ? undefined : to,
|
||||
},
|
||||
})
|
||||
}, [rangeFrom, rangeTo])
|
||||
function RangeSettings(props: Props) {
|
||||
const dismissClick = useCallback((e: MouseEvent) => e.stopPropagation(), [])
|
||||
const [rangeFrom, setRangeFrom] = useState<string | number | undefined>(props.chart.range && props.chart.range.from)
|
||||
const [rangeTo, setRangeTo] = useState<string | number | undefined>(props.chart.range && props.chart.range.to)
|
||||
useRangeStateToFireUpdateAction(rangeFrom, rangeTo, props)
|
||||
const rangeFromRef = useRef<HTMLInputElement>()
|
||||
const rangeToRef = useRef<HTMLInputElement>()
|
||||
|
||||
const setFromHandler = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => setRangeFrom(e.target.value), [])
|
||||
const setToHandler = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => setRangeTo(e.target.value), [])
|
||||
return (
|
||||
<Menu style={{ textAlign: 'center' }} anchorEl={props.anchorEl} open={props.open} onClose={props.onClose}>
|
||||
<Typography>Define custom ranges for the Y-Axis</Typography>
|
||||
<div style={{ padding: '0 16px' }}>
|
||||
<TextField
|
||||
style={{ marginTop: '0' }}
|
||||
onClick={dismissClick}
|
||||
label="from"
|
||||
value={rangeFrom}
|
||||
onChange={setFromHandler}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
style={{ marginLeft: '8px', marginTop: '0' }}
|
||||
onClick={dismissClick}
|
||||
label="to"
|
||||
value={rangeTo}
|
||||
onChange={setToHandler}
|
||||
margin="normal"
|
||||
/>
|
||||
</div>
|
||||
</Menu>
|
||||
useEffect(() => {
|
||||
rangeFromRef.current && rangeFromRef.current.focus()
|
||||
}, [props.open])
|
||||
|
||||
const handleKeyEvents = (e: React.KeyboardEvent<any>) => {
|
||||
if (e.keyCode === KeyCodes.tab) {
|
||||
// Switch focus between those two
|
||||
if (document.activeElement === rangeFromRef.current) {
|
||||
rangeToRef.current && rangeToRef.current.focus()
|
||||
} else {
|
||||
rangeFromRef.current && rangeFromRef.current.focus()
|
||||
}
|
||||
|
||||
// Prevent closing the menu
|
||||
e.stopPropagation()
|
||||
// Prevent default tab behavior (focus/blur)
|
||||
e.preventDefault()
|
||||
} else if (e.keyCode === KeyCodes.enter) {
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const setFromHandler = useCallback((e: ChangeEvent<HTMLInputElement>) => setRangeFrom(e.target.value), [])
|
||||
const setToHandler = useCallback((e: ChangeEvent<HTMLInputElement>) => setRangeTo(e.target.value), [])
|
||||
return useMemo(
|
||||
() => (
|
||||
<Menu
|
||||
style={{ textAlign: 'center' }}
|
||||
keepMounted={true}
|
||||
anchorEl={props.anchorEl}
|
||||
open={props.open}
|
||||
onClose={props.onClose}
|
||||
onKeyDownCapture={handleKeyEvents}
|
||||
>
|
||||
<div style={{ padding: '0 16px', width: '275px' }}>
|
||||
<Typography>Define custom ranges for the Y-Axis</Typography>
|
||||
<TextField
|
||||
inputProps={{
|
||||
ref: rangeFromRef,
|
||||
}}
|
||||
autoFocus={true}
|
||||
style={{ marginTop: '0' }}
|
||||
label="from"
|
||||
value={rangeFrom}
|
||||
onChange={setFromHandler}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
inputProps={{
|
||||
ref: rangeToRef,
|
||||
}}
|
||||
style={{ marginLeft: '8px', marginTop: '0' }}
|
||||
onClick={dismissClick}
|
||||
label="to"
|
||||
value={rangeTo}
|
||||
onChange={setToHandler}
|
||||
margin="normal"
|
||||
/>
|
||||
</div>
|
||||
</Menu>
|
||||
),
|
||||
[rangeFrom, rangeTo, props.open]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,3 +99,22 @@ export default connect(
|
||||
undefined,
|
||||
mapDispatchToProps
|
||||
)(RangeSettings)
|
||||
|
||||
function useRangeStateToFireUpdateAction(
|
||||
rangeFrom: string | number | undefined,
|
||||
rangeTo: string | number | undefined,
|
||||
props: Props
|
||||
) {
|
||||
React.useEffect(() => {
|
||||
const from = parseFloat(rangeFrom as any)
|
||||
const to = parseFloat(rangeTo as any)
|
||||
props.actions.chart.updateChart({
|
||||
topic: props.chart.topic,
|
||||
dotPath: props.chart.dotPath,
|
||||
range: {
|
||||
from: isNaN(from) ? undefined : from,
|
||||
to: isNaN(to) ? undefined : to,
|
||||
},
|
||||
})
|
||||
}, [rangeFrom, rangeTo])
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@ import CustomIconButton from '../../helper/CustomIconButton'
|
||||
import MoreVertIcon from '@material-ui/icons/Settings'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
|
||||
export function SettingsButton(props: { parameters: ChartParameters }) {
|
||||
export function SettingsButton(props: {
|
||||
parameters: ChartParameters
|
||||
resetDataAction: () => void
|
||||
menuAnchor: React.MutableRefObject<undefined>
|
||||
}) {
|
||||
const [visible, setVisible] = React.useState(false)
|
||||
const settingsRef = React.useRef()
|
||||
const toggleSettings = React.useCallback(() => {
|
||||
setVisible(!visible)
|
||||
}, [visible])
|
||||
@@ -17,9 +20,18 @@ export function SettingsButton(props: { parameters: ChartParameters }) {
|
||||
|
||||
return (
|
||||
<span>
|
||||
<ChartSettings open={visible} close={close} anchorEl={settingsRef} chart={props.parameters} />
|
||||
<ChartSettings
|
||||
open={visible}
|
||||
close={close}
|
||||
anchorEl={props.menuAnchor}
|
||||
chart={props.parameters}
|
||||
resetDataAction={props.resetDataAction}
|
||||
/>
|
||||
<CustomIconButton tooltip="Chart settings" onClick={toggleSettings}>
|
||||
<MoreVertIcon ref={settingsRef as any} />
|
||||
<MoreVertIcon
|
||||
data-test-type="ChartSettings"
|
||||
data-test={`${props.parameters.topic}-${props.parameters.dotPath || ''}`}
|
||||
/>
|
||||
</CustomIconButton>
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from 'react'
|
||||
import React, { memo } from 'react'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
import { Menu, MenuItem, TextField, Typography } from '@material-ui/core'
|
||||
import { connect } from 'react-redux'
|
||||
@@ -24,16 +24,16 @@ function Size(props: {
|
||||
return (
|
||||
<Menu anchorEl={props.anchorEl} open={props.open} onClose={props.close}>
|
||||
<MenuItem selected={props.chart.width === undefined} onClick={setChartWidth()}>
|
||||
auto
|
||||
<Typography variant="inherit">auto</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem selected={props.chart.width === 'big'} onClick={setChartWidth('big')}>
|
||||
100% width
|
||||
<Typography variant="inherit">100% width</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem selected={props.chart.width === 'medium'} onClick={setChartWidth('medium')}>
|
||||
50% width
|
||||
<Typography variant="inherit">50% width</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem selected={props.chart.width === 'small'} onClick={setChartWidth('small')}>
|
||||
33% width
|
||||
<Typography variant="inherit">33% width</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
)
|
||||
@@ -50,4 +50,4 @@ const mapDispatchToProps = (dispatch: any) => {
|
||||
export default connect(
|
||||
undefined,
|
||||
mapDispatchToProps
|
||||
)(Size)
|
||||
)(memo(Size))
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { ChangeEvent, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { Button, Menu, TextField, Typography } from '@material-ui/core'
|
||||
import { chartActions } from '../../../actions'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
import { connect } from 'react-redux'
|
||||
const parseDuration = require('parse-duration')
|
||||
|
||||
interface Props {
|
||||
actions: { chart: typeof chartActions }
|
||||
chart: ChartParameters
|
||||
anchorEl?: Element
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function TimeRangeSettings(props: Props) {
|
||||
const dismissClick = useCallback((e: MouseEvent) => e.stopPropagation(), [])
|
||||
const [value, setValue] = useState<string | undefined>(
|
||||
props.chart.timeRange ? props.chart.timeRange.until : undefined
|
||||
)
|
||||
const ranges = ['all', '10s', '30s', '1m', '5m', '15m', '1h', '6h', '1d']
|
||||
|
||||
const manuallySetIntervalHandler = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.target.value)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
props.actions.chart.updateChart({
|
||||
...props.chart,
|
||||
timeRange: undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const canBeParsed = Boolean(parseDuration(value))
|
||||
if (canBeParsed) {
|
||||
props.actions.chart.updateChart({
|
||||
...props.chart,
|
||||
timeRange: {
|
||||
until: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [value])
|
||||
|
||||
return useMemo(() => {
|
||||
const createRangeHandler = (range: string) => (e: React.MouseEvent) => setValue(range === 'all' ? undefined : range)
|
||||
|
||||
return (
|
||||
<Menu
|
||||
style={{ textAlign: 'center' }}
|
||||
keepMounted={true}
|
||||
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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Typography style={{ fontSize: '0.75em' }}>
|
||||
<i>Limited to 500 data points</i>
|
||||
</Typography>
|
||||
<br />
|
||||
<TextField
|
||||
style={{ marginLeft: '8px', marginTop: '0' }}
|
||||
onClick={dismissClick}
|
||||
label="custom interval"
|
||||
value={value || ''}
|
||||
onChange={manuallySetIntervalHandler}
|
||||
margin="normal"
|
||||
/>
|
||||
</Menu>
|
||||
)
|
||||
}, [value, props.open])
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
chart: bindActionCreators(chartActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
undefined,
|
||||
mapDispatchToProps
|
||||
)(TimeRangeSettings)
|
||||
@@ -1,19 +1,27 @@
|
||||
import * as React from 'react'
|
||||
import InterpolationSettings from './InterpolationSettings'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
import { Menu, MenuItem } from '@material-ui/core'
|
||||
import RangeSettings from './RangeSettings'
|
||||
import Size from './Size'
|
||||
import MoveUp from './MoveUp'
|
||||
import BarChart from '@material-ui/icons/BarChart'
|
||||
import Clear from '@material-ui/icons/Refresh'
|
||||
import ColorLens from '@material-ui/icons/ColorLens'
|
||||
import ColorSettings from './ColorSettings'
|
||||
import InterpolationSettings from './InterpolationSettings'
|
||||
import MoveUp from './MoveUp'
|
||||
import MultilineChart from '@material-ui/icons/MultilineChart'
|
||||
import RangeSettings from './RangeSettings'
|
||||
import React, { memo } from 'react'
|
||||
import Size from './Size'
|
||||
import Sort from '@material-ui/icons/Sort'
|
||||
import TimeRangeSettings from './TimeRangeSettings'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
import { Menu, MenuItem, ListItemIcon, Typography } from '@material-ui/core'
|
||||
|
||||
function ChartSettings(props: {
|
||||
open: boolean
|
||||
close: () => void
|
||||
resetDataAction: () => void
|
||||
chart: ChartParameters
|
||||
anchorEl: React.MutableRefObject<undefined>
|
||||
}) {
|
||||
const [rangeVisible, setRangeVisible] = React.useState(false)
|
||||
const [timeRangeVisible, setTimeRangeVisible] = React.useState(false)
|
||||
const [interpolationVisible, setInterpolationVisible] = React.useState(false)
|
||||
const [sizeVisible, setSizeVisible] = React.useState(false)
|
||||
const [colorVisible, setColorVisible] = React.useState(false)
|
||||
@@ -25,6 +33,13 @@ function ChartSettings(props: {
|
||||
setRangeVisible(!rangeVisible)
|
||||
}, [rangeVisible, open])
|
||||
|
||||
const toggleTimeRange = React.useCallback(() => {
|
||||
if (open) {
|
||||
props.close()
|
||||
}
|
||||
setTimeRangeVisible(!timeRangeVisible)
|
||||
}, [timeRangeVisible, open])
|
||||
|
||||
const toggleInterpolation = React.useCallback(() => {
|
||||
if (open) {
|
||||
props.close()
|
||||
@@ -50,20 +65,50 @@ function ChartSettings(props: {
|
||||
<span>
|
||||
<Menu id="long-menu" anchorEl={props.anchorEl.current} open={props.open} onClose={props.close}>
|
||||
<MenuItem key="range" onClick={toggleRange}>
|
||||
Set range
|
||||
<ListItemIcon>
|
||||
<BarChart />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Y-Axis range (Values)</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="timeRange" onClick={toggleTimeRange}>
|
||||
<ListItemIcon>
|
||||
<BarChart />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">X-Axis range (Time)</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="interpolation" onClick={toggleInterpolation}>
|
||||
Curve interpolation
|
||||
<ListItemIcon>
|
||||
<MultilineChart />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Curve interpolation</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="size" onClick={toggleSize}>
|
||||
Size
|
||||
<ListItemIcon>
|
||||
<Sort />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Size</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="color" onClick={toggleColor}>
|
||||
Color
|
||||
<ListItemIcon>
|
||||
<ColorLens />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Color</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="clear" onClick={props.resetDataAction}>
|
||||
<ListItemIcon>
|
||||
<Clear />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Clear data</Typography>
|
||||
</MenuItem>
|
||||
<MoveUp chart={props.chart} close={props.close} />
|
||||
</Menu>
|
||||
<RangeSettings chart={props.chart} anchorEl={props.anchorEl.current} open={rangeVisible} onClose={toggleRange} />
|
||||
<TimeRangeSettings
|
||||
chart={props.chart}
|
||||
anchorEl={props.anchorEl.current}
|
||||
open={timeRangeVisible}
|
||||
onClose={toggleTimeRange}
|
||||
/>
|
||||
<InterpolationSettings
|
||||
chart={props.chart}
|
||||
anchorEl={props.anchorEl.current}
|
||||
@@ -76,4 +121,4 @@ function ChartSettings(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export default ChartSettings
|
||||
export default memo(ChartSettings)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react'
|
||||
import { ChartParameters } from '../../reducers/Charts'
|
||||
import { Typography, Theme, withStyles } from '@material-ui/core'
|
||||
|
||||
function ChartTitle(props: { parameters: ChartParameters; classes: any }) {
|
||||
const { classes, parameters } = props
|
||||
return (
|
||||
<div style={{ flexGrow: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
<Typography variant="caption" className={classes.topic}>
|
||||
{parameters.dotPath ? parameters.dotPath : parameters.topic}
|
||||
</Typography>
|
||||
<br />
|
||||
<Typography variant="caption" className={classes.topic}>
|
||||
{parameters.dotPath ? parameters.topic : <span dangerouslySetInnerHTML={{ __html: ' ' }}></span>}
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
topic: {
|
||||
wordBreak: 'break-all' as 'break-all',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
},
|
||||
})
|
||||
|
||||
export default withStyles(styles)(ChartTitle)
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import React from 'react'
|
||||
import TopicChart from './TopicChart'
|
||||
import { ChartParameters } from '../../reducers/Charts'
|
||||
import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode'
|
||||
|
||||
interface Props {
|
||||
tree?: q.Tree<any>
|
||||
parameters: ChartParameters
|
||||
}
|
||||
|
||||
export function ChartWithTreeNode(props: Props) {
|
||||
const { tree, parameters } = props
|
||||
if (!tree) {
|
||||
return null
|
||||
}
|
||||
|
||||
const treeNode = usePollingToFetchTreeNode(tree, parameters.topic)
|
||||
return <TopicChart treeNode={treeNode} parameters={parameters} />
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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 '@material-ui/core'
|
||||
const throttle = require('lodash.throttle')
|
||||
|
||||
class ClearableMessageBuffer extends q.RingBuffer<q.Message> {
|
||||
public clear() {
|
||||
this.items = []
|
||||
this.start = 0
|
||||
this.end = 0
|
||||
}
|
||||
|
||||
public static fromMessageBuffer(buffer: q.RingBuffer<q.Message>): ClearableMessageBuffer {
|
||||
return new ClearableMessageBuffer(buffer.capacity, buffer.maxItems, buffer.compactionFactor, buffer)
|
||||
}
|
||||
|
||||
public clone(): ClearableMessageBuffer {
|
||||
return ClearableMessageBuffer.fromMessageBuffer(this)
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
parameters: ChartParameters
|
||||
treeNode?: q.TreeNode<any>
|
||||
actions: {
|
||||
chart: typeof chartActions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to onMessages, stores more data points then the default
|
||||
*/
|
||||
function useMessageSubscriptionToUpdate(treeNode?: q.TreeNode<any>) {
|
||||
const [lastUpdated, setLastUpdate] = useState(0)
|
||||
const [messageHistory, setMessageHistory] = useState<ClearableMessageBuffer | undefined>()
|
||||
let amendMessageCallback: any
|
||||
|
||||
function subscribeToMessageUpdates() {
|
||||
const throttledUpdate = throttle(() => setLastUpdate(treeNode ? treeNode.lastUpdate : 0), 300)
|
||||
|
||||
if (treeNode) {
|
||||
const newMessageHistory = ClearableMessageBuffer.fromMessageBuffer(treeNode.messageHistory)
|
||||
newMessageHistory.setCapacity(500, 2 * 500 * 10000)
|
||||
|
||||
amendMessageCallback = (message: q.Message) => {
|
||||
newMessageHistory.add(message)
|
||||
throttledUpdate()
|
||||
}
|
||||
treeNode.onMessage.subscribe(amendMessageCallback)
|
||||
setMessageHistory(newMessageHistory)
|
||||
}
|
||||
|
||||
return function cleanup() {
|
||||
treeNode && treeNode.onMessage.unsubscribe(amendMessageCallback)
|
||||
setMessageHistory(undefined)
|
||||
}
|
||||
}
|
||||
React.useEffect(subscribeToMessageUpdates, [treeNode])
|
||||
|
||||
return messageHistory
|
||||
}
|
||||
|
||||
function useResetDataCallback(messageHistory: ClearableMessageBuffer | undefined) {
|
||||
const [lastUpdated, setLastUpdate] = useState(0)
|
||||
|
||||
return React.useCallback(() => {
|
||||
messageHistory && messageHistory.clear()
|
||||
setLastUpdate(Date.now())
|
||||
}, [messageHistory])
|
||||
}
|
||||
|
||||
function TopicChart(props: Props) {
|
||||
const { parameters, treeNode } = props
|
||||
const [frozenHistory, setFrozenHistory] = useState<q.MessageHistory | undefined>()
|
||||
const messageHistory = useMessageSubscriptionToUpdate(treeNode)
|
||||
|
||||
const togglePause = useCallback(() => {
|
||||
if (!treeNode) {
|
||||
return
|
||||
}
|
||||
|
||||
setFrozenHistory(frozenHistory ? undefined : messageHistory && messageHistory.clone())
|
||||
}, [props.treeNode, frozenHistory, messageHistory])
|
||||
|
||||
const onRemove = React.useCallback(() => {
|
||||
props.actions.chart.removeChart(props.parameters)
|
||||
}, [props.parameters])
|
||||
|
||||
const resetData = useResetDataCallback(messageHistory)
|
||||
|
||||
return (
|
||||
<Paper
|
||||
style={{ padding: '8px' }}
|
||||
data-test-type="ChartPaper"
|
||||
data-test={`${props.parameters.topic}-${props.parameters.dotPath || ''}`}
|
||||
>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<div style={{ display: 'flex', flexGrow: 1, overflow: 'hidden' }}>
|
||||
<ChartTitle parameters={parameters} />
|
||||
<ChartActions
|
||||
resetDataAction={resetData}
|
||||
parameters={parameters}
|
||||
onRemove={onRemove}
|
||||
paused={Boolean(frozenHistory)}
|
||||
togglePause={togglePause}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TopicPlot
|
||||
color={props.parameters.color}
|
||||
interpolation={props.parameters.interpolation}
|
||||
timeInterval={props.parameters.timeRange ? props.parameters.timeRange.until : undefined}
|
||||
range={props.parameters.range ? [props.parameters.range.from, props.parameters.range.to] : undefined}
|
||||
history={frozenHistory || messageHistory || new ClearableMessageBuffer(1)}
|
||||
dotPath={parameters.dotPath}
|
||||
/>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
chart: bindActionCreators(chartActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
undefined,
|
||||
mapDispatchToProps
|
||||
)(memo(TopicChart))
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import Chart from './Chart'
|
||||
import ShowChart from '@material-ui/icons/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, Theme, Typography, withStyles } from '@material-ui/core'
|
||||
import { List } from 'immutable'
|
||||
@@ -13,9 +14,11 @@ const { TransitionGroup, CSSTransition } = require('react-transition-group/esm')
|
||||
interface Props {
|
||||
charts: List<ChartParameters>
|
||||
connectionId?: string
|
||||
tree?: q.Tree<any>
|
||||
actions: {
|
||||
chart: typeof chartActions
|
||||
}
|
||||
classes: any
|
||||
}
|
||||
|
||||
function spacingForChartCount(count: number): 4 | 6 | 12 {
|
||||
@@ -67,13 +70,13 @@ function ChartPanel(props: Props) {
|
||||
classNames="example"
|
||||
>
|
||||
<Grid item xs={mapWidth(chartParameters.width, spacing)}>
|
||||
<Chart parameters={chartParameters} />
|
||||
<ChartWithTreeNode tree={props.tree} parameters={chartParameters} />
|
||||
</Grid>
|
||||
</CSSTransition>
|
||||
))
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', padding: '8px' }}>
|
||||
<div className={props.classes.container}>
|
||||
<Grid container spacing={1}>
|
||||
<TransitionGroup component={null} className="example">
|
||||
{charts}
|
||||
@@ -100,6 +103,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
charts: state.charts.get('charts'),
|
||||
connectionId: state.connection.connectionId,
|
||||
tree: state.connection.tree,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +115,16 @@ const mapDispatchToProps = (dispatch: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({})
|
||||
const styles = (theme: Theme) => ({
|
||||
container: {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '8px',
|
||||
flex: 1,
|
||||
overflow: 'hidden scroll',
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, { useRef, useCallback, memo } from 'react'
|
||||
import { ConfirmationRequest } from '../reducers/Global'
|
||||
import { Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button } from '@material-ui/core'
|
||||
import { KeyCodes } from '../utils/KeyCodes'
|
||||
|
||||
function ConfirmationDialog(props: { confirmationRequests: Array<ConfirmationRequest> }) {
|
||||
const request = props.confirmationRequests[0]
|
||||
const yesRef = useRef<HTMLButtonElement>()
|
||||
const noRef = useRef<HTMLButtonElement>()
|
||||
const arrowKeyHandler = useCallback((event: React.KeyboardEvent) => {
|
||||
const isArrowKey = event.keyCode === KeyCodes.arrow_left || event.keyCode === KeyCodes.arrow_right
|
||||
if (!isArrowKey) {
|
||||
return
|
||||
}
|
||||
|
||||
event.stopPropagation()
|
||||
if (document.activeElement === noRef.current) {
|
||||
yesRef.current && yesRef.current.focus()
|
||||
} else {
|
||||
noRef.current && noRef.current.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const confirm = React.useCallback(() => {
|
||||
request && request.callback(true)
|
||||
}, [request])
|
||||
const reject = React.useCallback(() => {
|
||||
request && request.callback(false)
|
||||
}, [request])
|
||||
|
||||
if (!request) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={true}
|
||||
onClose={reject}
|
||||
aria-labelledby="alert-dialog-title"
|
||||
aria-describedby="alert-dialog-description"
|
||||
onKeyDown={arrowKeyHandler}
|
||||
>
|
||||
<DialogTitle id="alert-dialog-title">{request.title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="alert-dialog-description">{request.inquiry}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button ref={yesRef as any} variant="contained" onClick={confirm} color="primary" autoFocus>
|
||||
Yes
|
||||
</Button>
|
||||
<Button ref={noRef as any} variant="contained" onClick={reject} color="secondary" style={{ marginLeft: '8px' }}>
|
||||
No
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ConfirmationDialog)
|
||||
@@ -10,17 +10,7 @@ import { connectionManagerActions } from '../../actions'
|
||||
import { ConnectionOptions } from '../../model/ConnectionOptions'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
|
||||
import {
|
||||
Button,
|
||||
Grid,
|
||||
IconButton,
|
||||
TextField,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@material-ui/core'
|
||||
import { Button, Grid, IconButton, TextField, List, ListItem, ListItemText, Tooltip } from '@material-ui/core'
|
||||
|
||||
interface Props {
|
||||
connection: ConnectionOptions
|
||||
@@ -44,29 +34,6 @@ class ConnectionSettings extends React.Component<Props, State> {
|
||||
})
|
||||
}
|
||||
|
||||
private renderCertificateInfo() {
|
||||
if (!this.props.connection.selfSignedCertificate) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<Tooltip title={this.props.connection.selfSignedCertificate.name}>
|
||||
<Typography className={this.props.classes.certificateName}>
|
||||
<ClearAdornment action={this.clearCertificate} value={this.props.connection.selfSignedCertificate.name} />
|
||||
{this.props.connection.selfSignedCertificate.name}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
private clearCertificate = () => {
|
||||
this.props.managerActions.updateConnection(this.props.connection.id, {
|
||||
selfSignedCertificate: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
private renderSubscriptions() {
|
||||
const connection = this.props.connection
|
||||
return connection.subscriptions.map(subscription => (
|
||||
@@ -122,16 +89,15 @@ class ConnectionSettings extends React.Component<Props, State> {
|
||||
</Grid>
|
||||
<Grid item={true} xs={3} className={classes.gridPadding}>
|
||||
<div>
|
||||
<Tooltip title="Select certificate to verify authenticity of a self-signed certificate" placement="top">
|
||||
<Tooltip title="Manage tls connection certificates" placement="top">
|
||||
<Button
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={() => this.props.managerActions.selectCertificate(this.props.connection.id)}
|
||||
onClick={() => this.props.managerActions.toggleCertificateSettings()}
|
||||
>
|
||||
<Lock /> Certificate
|
||||
<Lock /> Certificates
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{this.renderCertificateInfo()}
|
||||
</div>
|
||||
</Grid>
|
||||
<Grid item={true} xs={2} className={classes.gridPadding}>
|
||||
@@ -186,14 +152,6 @@ const styles = (theme: Theme) => ({
|
||||
marginTop: theme.spacing(3),
|
||||
float: 'right' as 'right',
|
||||
},
|
||||
certificateName: {
|
||||
width: '100%',
|
||||
height: 'calc(1em + 4px)',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
color: theme.palette.text.hint,
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as React from 'react'
|
||||
import Add from '@material-ui/icons/Add'
|
||||
import ClearAdornment from '../helper/ClearAdornment'
|
||||
import Delete from '@material-ui/icons/Delete'
|
||||
import Lock from '@material-ui/icons/Lock'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { Button, Theme, Tooltip, Typography } from '@material-ui/core'
|
||||
import { CertificateParameters, ConnectionOptions } from '../../model/ConnectionOptions'
|
||||
import { CertificateTypes } from '../../actions/ConnectionManager'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionManagerActions } from '../../actions'
|
||||
import { withStyles } from '@material-ui/styles'
|
||||
|
||||
function CertificateFileSelection(props: {
|
||||
certificateType: CertificateTypes
|
||||
title: string
|
||||
certificate?: CertificateParameters
|
||||
classes: any
|
||||
actions: {
|
||||
connectionManager: typeof connectionManagerActions
|
||||
}
|
||||
connection: ConnectionOptions
|
||||
}) {
|
||||
const clearCertificate = React.useCallback(() => {
|
||||
props.actions.connectionManager.updateConnection(props.connection.id, {
|
||||
[props.certificateType]: undefined,
|
||||
})
|
||||
}, [props.connection, props.certificateType])
|
||||
|
||||
return (
|
||||
<span>
|
||||
<Tooltip title="Select certificate" placement="top">
|
||||
<Button
|
||||
variant="contained"
|
||||
className={props.classes.button}
|
||||
onClick={() => props.actions.connectionManager.selectCertificate(props.certificateType, props.connection.id)}
|
||||
>
|
||||
<Lock /> {props.title}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<ClearCertificate classes={props.classes} certificate={props.certificate} action={clearCertificate} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ClearCertificate(props: { classes: any; certificate?: CertificateParameters; action: () => void }) {
|
||||
if (!props.certificate) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={props.certificate.name}>
|
||||
<Typography className={props.classes.certificateName}>
|
||||
<ClearAdornment action={props.action} value={props.certificate.name} />
|
||||
{props.certificate.name}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
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',
|
||||
color: theme.palette.text.hint,
|
||||
},
|
||||
button: {
|
||||
marginTop: theme.spacing(3),
|
||||
marginRight: theme.spacing(2),
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(
|
||||
undefined,
|
||||
mapDispatchToProps
|
||||
)(withStyles(styles)(CertificateFileSelection))
|
||||
@@ -0,0 +1,110 @@
|
||||
import * as React from 'react'
|
||||
import CertificateFileSelection from './CertificateFileSelection'
|
||||
import Undo from '@material-ui/icons/Undo'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { Button, Grid } from '@material-ui/core'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionManagerActions } from '../../actions'
|
||||
import { ConnectionOptions } from '../../model/ConnectionOptions'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
|
||||
interface Props {
|
||||
connection: ConnectionOptions
|
||||
classes: any
|
||||
managerActions: typeof connectionManagerActions
|
||||
}
|
||||
|
||||
interface State {
|
||||
subscription: string
|
||||
}
|
||||
|
||||
class Certificates extends React.PureComponent<Props, State> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = { subscription: '' }
|
||||
}
|
||||
|
||||
private handleChange = (name: string) => (event: any) => {
|
||||
this.props.managerActions.updateConnection(this.props.connection.id, {
|
||||
[name]: event.target.value,
|
||||
})
|
||||
}
|
||||
|
||||
private renderCertificateInfo() {
|
||||
if (!this.props.connection.selfSignedCertificate) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <span />
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { classes } = this.props
|
||||
return (
|
||||
<div>
|
||||
<form noValidate={true} autoComplete="off">
|
||||
<Grid container={true} spacing={3}>
|
||||
<Grid item={true} xs={12} className={classes.gridPadding}>
|
||||
<CertificateFileSelection
|
||||
connection={this.props.connection}
|
||||
certificate={this.props.connection.selfSignedCertificate}
|
||||
title="Server Certificate (CA)"
|
||||
certificateType="selfSignedCertificate"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={12} className={classes.gridPadding}>
|
||||
<CertificateFileSelection
|
||||
connection={this.props.connection}
|
||||
certificate={this.props.connection.clientCertificate}
|
||||
title="Client Certificate"
|
||||
certificateType="clientCertificate"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={12} className={classes.gridPadding}>
|
||||
<CertificateFileSelection
|
||||
connection={this.props.connection}
|
||||
certificate={this.props.connection.clientKey}
|
||||
title="Client Key"
|
||||
certificateType="clientKey"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={2} className={classes.gridPadding}>
|
||||
<br />
|
||||
<Button
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={this.props.managerActions.toggleCertificateSettings}
|
||||
>
|
||||
<Undo /> Back
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
managerActions: bindActionCreators(connectionManagerActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
fullWidth: {
|
||||
width: '100%',
|
||||
},
|
||||
gridPadding: {
|
||||
padding: '0 12px !important',
|
||||
},
|
||||
button: {
|
||||
marginTop: theme.spacing(3),
|
||||
marginRight: theme.spacing(2),
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(
|
||||
undefined,
|
||||
mapDispatchToProps
|
||||
)(withStyles(styles)(Certificates))
|
||||
@@ -0,0 +1,25 @@
|
||||
import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator'
|
||||
import PowerSettingsNew from '@material-ui/icons/PowerSettingsNew'
|
||||
import React from 'react'
|
||||
import { Button } from '@material-ui/core'
|
||||
|
||||
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}>
|
||||
<ConnectionHealthIndicator />
|
||||
Abort
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="contained" color="primary" className={classes.button} onClick={toggle}>
|
||||
<PowerSettingsNew /> Connect
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConnectButton
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from 'react'
|
||||
import ConnectButton from './ConnectButton'
|
||||
import Delete from '@material-ui/icons/Delete'
|
||||
import Settings from '@material-ui/icons/Settings'
|
||||
import PowerSettingsNew from '@material-ui/icons/PowerSettingsNew'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import Save from '@material-ui/icons/Save'
|
||||
import Settings from '@material-ui/icons/Settings'
|
||||
import Visibility from '@material-ui/icons/Visibility'
|
||||
import VisibilityOff from '@material-ui/icons/VisibilityOff'
|
||||
import { AppState } from '../../reducers'
|
||||
@@ -10,22 +10,21 @@ import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionActions, connectionManagerActions } from '../../actions'
|
||||
import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions'
|
||||
import { KeyCodes } from '../../utils/KeyCodes'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
|
||||
import { ToggleSwitch } from './ToggleSwitch'
|
||||
import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler'
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
IconButton,
|
||||
Input,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Switch,
|
||||
TextField,
|
||||
} from '@material-ui/core'
|
||||
import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator'
|
||||
|
||||
interface Props {
|
||||
connection: ConnectionOptions
|
||||
@@ -38,57 +37,66 @@ interface Props {
|
||||
|
||||
const protocols = ['mqtt', 'ws']
|
||||
|
||||
interface State {
|
||||
showPassword: boolean
|
||||
}
|
||||
function ConnectionSettings(props: Props) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
class ConnectionSettings extends React.Component<Props, State> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
showPassword: false,
|
||||
const toggleConnect = useCallback(() => {
|
||||
if (props.connecting) {
|
||||
props.actions.disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.connection) {
|
||||
return
|
||||
}
|
||||
|
||||
const mqttOptions = toMqttConnection(props.connection)
|
||||
if (mqttOptions) {
|
||||
props.actions.connect(mqttOptions, props.connection.id)
|
||||
}
|
||||
}, [props.connection, props.connecting])
|
||||
|
||||
useGlobalKeyEventHandler(KeyCodes.escape, props.actions.disconnect)
|
||||
useGlobalKeyEventHandler(KeyCodes.enter, toggleConnect, [props.connecting])
|
||||
|
||||
const handleClickShowPassword = useCallback(() => {
|
||||
setShowPassword(!showPassword)
|
||||
}, [showPassword])
|
||||
|
||||
function requiresBasePath() {
|
||||
return props.connection.protocol !== 'mqtt'
|
||||
}
|
||||
|
||||
private handleClickShowPassword = () => {
|
||||
this.setState({ showPassword: !this.state.showPassword })
|
||||
}
|
||||
|
||||
private requiresBasePath() {
|
||||
return this.props.connection.protocol !== 'mqtt'
|
||||
}
|
||||
|
||||
private renderBasePathInput() {
|
||||
function renderBasePathInput() {
|
||||
return (
|
||||
<Grid item={true} xs={4}>
|
||||
<TextField
|
||||
label="Basepath"
|
||||
className={this.props.classes.textField}
|
||||
value={this.props.connection.basePath}
|
||||
onChange={this.handleChange('basePath')}
|
||||
className={props.classes.textField}
|
||||
value={props.connection.basePath}
|
||||
onChange={handleChange('basePath')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
private handleChange = (name: string) => (event: any) => {
|
||||
if (!this.props.connection) {
|
||||
const handleChange = (name: string) => (event: any) => {
|
||||
if (!props.connection) {
|
||||
return
|
||||
}
|
||||
|
||||
this.updateConnection(name, event.target.value)
|
||||
updateConnection(name, event.target.value)
|
||||
}
|
||||
|
||||
private updateConnection(name: string, value: any) {
|
||||
this.props.managerActions.updateConnection(this.props.connection.id, {
|
||||
const updateConnection = (name: string, value: any) => {
|
||||
props.managerActions.updateConnection(props.connection.id, {
|
||||
[name]: value,
|
||||
})
|
||||
}
|
||||
|
||||
private renderProtocols() {
|
||||
const { classes, connection } = this.props
|
||||
const renderProtocols = () => {
|
||||
const { classes, connection } = props
|
||||
|
||||
const protocolItems = protocols.map((value: string) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
@@ -102,7 +110,7 @@ class ConnectionSettings extends React.Component<Props, State> {
|
||||
label="Protocol"
|
||||
className={classes.textField}
|
||||
value={connection.protocol}
|
||||
onChange={this.updateProtocol}
|
||||
onChange={updateProtocol}
|
||||
margin="normal"
|
||||
>
|
||||
{protocolItems}
|
||||
@@ -110,191 +118,142 @@ class ConnectionSettings extends React.Component<Props, State> {
|
||||
)
|
||||
}
|
||||
|
||||
private updateProtocol = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const updateProtocol = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value
|
||||
this.updateConnection('protocol', value)
|
||||
updateConnection('protocol', value)
|
||||
if (event.target.value === 'mqtt') {
|
||||
this.updateConnection('basePath', undefined)
|
||||
updateConnection('basePath', undefined)
|
||||
} else {
|
||||
this.updateConnection('basePath', 'ws')
|
||||
updateConnection('basePath', 'ws')
|
||||
}
|
||||
}
|
||||
|
||||
private renderCertValidationSwitch() {
|
||||
const { classes, connection } = this.props
|
||||
|
||||
const certSwitch = (
|
||||
<Switch checked={connection.certValidation} onChange={this.toggleCertValidation} color="primary" />
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={classes.switch}>
|
||||
<FormControlLabel control={certSwitch} label="Validate certificate" labelPlacement="bottom" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private toggleCertValidation = () => {
|
||||
this.props.managerActions.updateConnection(this.props.connection.id, {
|
||||
certValidation: !this.props.connection.certValidation,
|
||||
const toggleCertValidation = () => {
|
||||
props.managerActions.updateConnection(props.connection.id, {
|
||||
certValidation: !props.connection.certValidation,
|
||||
})
|
||||
}
|
||||
|
||||
private renderTlsSwitch() {
|
||||
const { classes, connection } = this.props
|
||||
|
||||
const tlsSwitch = <Switch checked={connection.encryption} onChange={this.toggleTls} color="primary" />
|
||||
|
||||
return (
|
||||
<div className={classes.switch}>
|
||||
<FormControlLabel control={tlsSwitch} label="Encryption (tls)" labelPlacement="bottom" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private toggleTls = () => {
|
||||
this.props.managerActions.updateConnection(this.props.connection.id, {
|
||||
encryption: !this.props.connection.encryption,
|
||||
const toggleTls = () => {
|
||||
props.managerActions.updateConnection(props.connection.id, {
|
||||
encryption: !props.connection.encryption,
|
||||
})
|
||||
}
|
||||
|
||||
private renderConnectButton() {
|
||||
const { classes, actions } = this.props
|
||||
|
||||
if (this.props.connecting) {
|
||||
return (
|
||||
<Button variant="contained" color="primary" className={classes.button} onClick={actions.disconnect}>
|
||||
<ConnectionHealthIndicator />
|
||||
Abort
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
function PasswordVisibilityButton(props: { showPassword: boolean; toggle: () => void }) {
|
||||
return (
|
||||
<Button variant="contained" color="primary" className={classes.button} onClick={this.onClickConnect}>
|
||||
<PowerSettingsNew /> Connect
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
private onClickConnect = () => {
|
||||
if (!this.props.connection) {
|
||||
return
|
||||
}
|
||||
|
||||
const mqttOptions = toMqttConnection(this.props.connection)
|
||||
if (mqttOptions) {
|
||||
this.props.actions.connect(mqttOptions, this.props.connection.id)
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { classes, connection } = this.props
|
||||
|
||||
const passwordVisibilityButton = (
|
||||
<InputAdornment position="end">
|
||||
<IconButton aria-label="Toggle password visibility" onClick={this.handleClickShowPassword}>
|
||||
{this.state.showPassword ? <Visibility /> : <VisibilityOff />}
|
||||
<IconButton aria-label="Toggle password visibility" onClick={props.toggle}>
|
||||
{props.showPassword ? <Visibility /> : <VisibilityOff />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form className={classes.container} noValidate={true} autoComplete="off">
|
||||
<Grid container={true} spacing={3}>
|
||||
<Grid item={true} xs={5}>
|
||||
<TextField
|
||||
label="Name"
|
||||
className={classes.textField}
|
||||
value={connection.name}
|
||||
onChange={this.handleChange('name')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={4}>
|
||||
{this.renderCertValidationSwitch()}
|
||||
</Grid>
|
||||
<Grid item={true} xs={3}>
|
||||
{this.renderTlsSwitch()}
|
||||
</Grid>
|
||||
<Grid item={true} xs={2}>
|
||||
{this.renderProtocols()}
|
||||
</Grid>
|
||||
<Grid item={true} xs={7}>
|
||||
<TextField
|
||||
label="Host"
|
||||
className={classes.textField}
|
||||
value={connection.host}
|
||||
onChange={this.handleChange('host')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={3}>
|
||||
<TextField
|
||||
label="Port"
|
||||
className={classes.textField}
|
||||
value={connection.port}
|
||||
onChange={this.handleChange('port')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
{this.requiresBasePath() ? this.renderBasePathInput() : null}
|
||||
<Grid item={true} xs={this.requiresBasePath() ? 4 : 6}>
|
||||
<TextField
|
||||
label="Username"
|
||||
className={classes.textField}
|
||||
value={connection.username}
|
||||
onChange={this.handleChange('username')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={this.requiresBasePath() ? 4 : 6}>
|
||||
<FormControl className={`${classes.textField} ${classes.inputFormControl}`}>
|
||||
<InputLabel htmlFor="adornment-password">Password</InputLabel>
|
||||
<Input
|
||||
id="adornment-password"
|
||||
type={this.state.showPassword ? 'text' : 'password'}
|
||||
value={connection.password}
|
||||
onChange={this.handleChange('password')}
|
||||
endAdornment={passwordVisibilityButton}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<br />
|
||||
<div>
|
||||
<div style={{ float: 'left' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={() => this.props.managerActions.deleteConnection(this.props.connection.id)}
|
||||
>
|
||||
Delete <Delete />
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={this.props.managerActions.toggleAdvancedSettings}
|
||||
>
|
||||
<Settings /> Advanced
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
className={classes.button}
|
||||
onClick={this.props.managerActions.saveConnectionSettings}
|
||||
>
|
||||
<Save /> Save
|
||||
</Button>
|
||||
{this.renderConnectButton()}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { classes, connection } = props
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form className={classes.container} noValidate={true} autoComplete="off">
|
||||
<Grid container={true} spacing={3}>
|
||||
<Grid item={true} xs={5}>
|
||||
<TextField
|
||||
autoFocus={true}
|
||||
label="Name"
|
||||
className={classes.textField}
|
||||
value={connection.name}
|
||||
onChange={handleChange('name')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={4}>
|
||||
<ToggleSwitch
|
||||
label="Validate certificate"
|
||||
classes={classes}
|
||||
value={connection.certValidation}
|
||||
toggle={toggleCertValidation}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={3}>
|
||||
<ToggleSwitch label="Encryption (tls)" classes={classes} value={connection.encryption} toggle={toggleTls} />
|
||||
</Grid>
|
||||
<Grid item={true} xs={2}>
|
||||
{renderProtocols()}
|
||||
</Grid>
|
||||
<Grid item={true} xs={7}>
|
||||
<TextField
|
||||
label="Host"
|
||||
className={classes.textField}
|
||||
value={connection.host}
|
||||
onChange={handleChange('host')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={3}>
|
||||
<TextField
|
||||
label="Port"
|
||||
className={classes.textField}
|
||||
value={connection.port}
|
||||
onChange={handleChange('port')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
{requiresBasePath() ? renderBasePathInput() : null}
|
||||
<Grid item={true} xs={requiresBasePath() ? 4 : 6}>
|
||||
<TextField
|
||||
label="Username"
|
||||
className={classes.textField}
|
||||
value={connection.username}
|
||||
onChange={handleChange('username')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={requiresBasePath() ? 4 : 6}>
|
||||
<FormControl className={`${classes.textField} ${classes.inputFormControl}`}>
|
||||
<InputLabel htmlFor="adornment-password">Password</InputLabel>
|
||||
<Input
|
||||
id="adornment-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={connection.password}
|
||||
onChange={handleChange('password')}
|
||||
endAdornment={<PasswordVisibilityButton showPassword={showPassword} toggle={handleClickShowPassword} />}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<br />
|
||||
<div>
|
||||
<div style={{ float: 'left' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={() => props.managerActions.deleteConnection(props.connection.id)}
|
||||
>
|
||||
Delete <Delete />
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={props.managerActions.toggleAdvancedSettings}
|
||||
>
|
||||
<Settings /> Advanced
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
className={classes.button}
|
||||
onClick={props.managerActions.saveConnectionSettings}
|
||||
>
|
||||
<Save /> Save
|
||||
</Button>
|
||||
<ConnectButton toggle={toggleConnect} connecting={props.connecting} classes={classes} />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptio
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { Modal, Paper, Toolbar, Typography, Collapse } from '@material-ui/core'
|
||||
import AdvancedConnectionSettings from './AdvancedConnectionSettings'
|
||||
import Certificates from './Certificates'
|
||||
|
||||
interface Props {
|
||||
actions: any
|
||||
@@ -16,27 +17,31 @@ interface Props {
|
||||
connection?: ConnectionOptions
|
||||
visible: boolean
|
||||
showAdvancedSettings: boolean
|
||||
showCertificateSettings: boolean
|
||||
}
|
||||
|
||||
class ConnectionSetup extends React.Component<Props, {}> {
|
||||
class ConnectionSetup extends React.PureComponent<Props, {}> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
private renderSettings() {
|
||||
const { connection, showAdvancedSettings } = this.props
|
||||
const { connection, showAdvancedSettings, showCertificateSettings } = this.props
|
||||
if (!connection) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Collapse in={!showAdvancedSettings}>
|
||||
<Collapse in={!showAdvancedSettings && !showCertificateSettings}>
|
||||
<ConnectionSettings connection={connection} />
|
||||
</Collapse>
|
||||
<Collapse in={showAdvancedSettings}>
|
||||
<Collapse in={showAdvancedSettings && !showCertificateSettings}>
|
||||
<AdvancedConnectionSettings connection={connection} />
|
||||
</Collapse>
|
||||
<Collapse in={showCertificateSettings}>
|
||||
<Certificates connection={connection} />
|
||||
</Collapse>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -90,9 +95,10 @@ const styles = (theme: Theme) => ({
|
||||
borderRadius: `${theme.shape.borderRadius}px 0 0 ${theme.shape.borderRadius}px`,
|
||||
paddingTop: theme.spacing(2),
|
||||
flex: 3,
|
||||
overflow: 'hidden',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
backgroundColor: theme.palette.background.default,
|
||||
color: theme.palette.text.primary,
|
||||
overflowY: 'auto' as 'auto',
|
||||
},
|
||||
right: {
|
||||
borderRadius: `0 ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0`,
|
||||
@@ -115,6 +121,7 @@ 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,
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import * as 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, toMqttConnection } from '../../model/ConnectionOptions'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { List, ListItem, ListSubheader, Typography } from '@material-ui/core'
|
||||
|
||||
interface Props {
|
||||
classes: any
|
||||
selected?: string
|
||||
connections: { [s: string]: ConnectionOptions }
|
||||
actions: any
|
||||
}
|
||||
|
||||
class ProfileList extends React.Component<Props, {}> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
private addConnectionButton() {
|
||||
return (
|
||||
<span id="addProfileButton" style={{ marginRight: '12px' }}>
|
||||
<AddButton action={this.props.actions.createConnection} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<List
|
||||
style={{ height: '100%' }}
|
||||
component="nav"
|
||||
subheader={<ListSubheader component="div">{this.addConnectionButton()}Connections</ListSubheader>}
|
||||
>
|
||||
<div className={this.props.classes.list}>
|
||||
{Object.values(this.props.connections).map(connection => (
|
||||
<ConnectionItem
|
||||
connection={connection}
|
||||
key={connection.id}
|
||||
selected={this.props.selected === connection.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</List>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
list: {
|
||||
marginTop: theme.spacing(1),
|
||||
height: `calc(100% - ${theme.spacing(6)})`,
|
||||
overflowY: 'auto' as 'auto',
|
||||
},
|
||||
})
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(connectionManagerActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
interface ConnectionItemProps {
|
||||
connection: ConnectionOptions
|
||||
actions: any
|
||||
selected: boolean
|
||||
classes: any
|
||||
}
|
||||
|
||||
const connectionItemStyle = (theme: Theme) => ({
|
||||
name: {
|
||||
width: '100%',
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
},
|
||||
details: {
|
||||
width: '100%',
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
color: theme.palette.text.hint,
|
||||
fontSize: '0.7em',
|
||||
},
|
||||
})
|
||||
|
||||
const connectionItemRenderer = withStyles(connectionItemStyle)((props: ConnectionItemProps) => {
|
||||
const connection = props.connection.host && toMqttConnection(props.connection)
|
||||
return (
|
||||
<ListItem
|
||||
button={true}
|
||||
selected={props.selected}
|
||||
style={{ display: 'block' }}
|
||||
onClick={() => props.actions.selectConnection(props.connection.id)}
|
||||
>
|
||||
<Typography className={props.classes.name}>{props.connection.name || 'mqtt broker'}</Typography>
|
||||
<Typography className={props.classes.details}>{connection && connection.url}</Typography>
|
||||
</ListItem>
|
||||
)
|
||||
})
|
||||
|
||||
const ConnectionItem = connect(
|
||||
null,
|
||||
mapDispatchToProps
|
||||
)(connectionItemRenderer)
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
connections: state.connectionManager.connections,
|
||||
selected: state.connectionManager.selected,
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(withStyles(styles)(ProfileList))
|
||||
+5
-3
@@ -16,8 +16,10 @@ const styles = (theme: Theme) => ({
|
||||
|
||||
export const AddButton = withStyles(styles)((props: { classes: any; action: any }) => {
|
||||
return (
|
||||
<Fab size="small" color="secondary" aria-label="Add" className={props.classes.addButton} onClick={props.action}>
|
||||
<Add className={props.classes.addIcon} />
|
||||
</Fab>
|
||||
<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>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { ListItem, Typography } from '@material-ui/core'
|
||||
import { toMqttConnection, ConnectionOptions } from '../../../model/ConnectionOptions'
|
||||
import { withStyles, Theme } from '@material-ui/core/styles'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connectionManagerActions } from '../../../actions'
|
||||
|
||||
export interface Props {
|
||||
connection: ConnectionOptions
|
||||
actions: any
|
||||
selected: boolean
|
||||
classes: any
|
||||
}
|
||||
|
||||
const ConnectionItem = (props: Props) => {
|
||||
const connection = props.connection.host && toMqttConnection(props.connection)
|
||||
return (
|
||||
<ListItem
|
||||
button={true}
|
||||
selected={props.selected}
|
||||
style={{ display: 'block' }}
|
||||
onClick={() => props.actions.selectConnection(props.connection.id)}
|
||||
>
|
||||
<Typography className={props.classes.name}>{props.connection.name || 'mqtt broker'}</Typography>
|
||||
<Typography className={props.classes.details}>{connection && connection.url}</Typography>
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
|
||||
export const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(connectionManagerActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
export const connectionItemStyle = (theme: Theme) => ({
|
||||
name: {
|
||||
width: '100%',
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
},
|
||||
details: {
|
||||
width: '100%',
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
color: theme.palette.text.hint,
|
||||
fontSize: '0.7em',
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(
|
||||
null,
|
||||
mapDispatchToProps
|
||||
)(withStyles(connectionItemStyle)(ConnectionItem))
|
||||
@@ -0,0 +1,83 @@
|
||||
import ConnectionItem from './ConnectionItem'
|
||||
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, ListSubheader } from '@material-ui/core'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { useGlobalKeyEventHandler } from '../../../effects/useGlobalKeyEventHandler'
|
||||
|
||||
interface Props {
|
||||
classes: any
|
||||
selected?: string
|
||||
connections: { [s: string]: ConnectionOptions }
|
||||
actions: typeof connectionManagerActions
|
||||
}
|
||||
|
||||
function ProfileList(props: Props) {
|
||||
const { actions, classes, connections, selected } = props
|
||||
|
||||
const selectConnection = (dir: 'next' | 'previous') => (event: KeyboardEvent) => {
|
||||
if (!selected) {
|
||||
return
|
||||
}
|
||||
const indexDirection = dir === 'next' ? 1 : -1
|
||||
const connectionArray = Object.values(connections)
|
||||
const selectedIndex = connectionArray.map(connection => connection.id).indexOf(selected)
|
||||
const nextConnection = connectionArray[selectedIndex + indexDirection]
|
||||
if (nextConnection) {
|
||||
actions.selectConnection(nextConnection.id)
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
useGlobalKeyEventHandler(KeyCodes.arrow_down, selectConnection('next'))
|
||||
useGlobalKeyEventHandler(KeyCodes.arrow_up, selectConnection('previous'))
|
||||
|
||||
const createConnectionButton = (
|
||||
<div style={{ padding: '8px 16px' }}>
|
||||
<AddButton action={actions.createConnection} />
|
||||
Connections
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<List style={{ height: '100%' }} component="nav" subheader={createConnectionButton}>
|
||||
<div className={classes.list}>
|
||||
{Object.values(connections).map(connection => (
|
||||
<ConnectionItem connection={connection} key={connection.id} selected={selected === connection.id} />
|
||||
))}
|
||||
</div>
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
list: {
|
||||
marginTop: theme.spacing(1),
|
||||
height: `calc(100% - ${theme.spacing(6)})`,
|
||||
overflowY: 'auto' as 'auto',
|
||||
},
|
||||
})
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(connectionManagerActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
connections: state.connectionManager.connections,
|
||||
selected: state.connectionManager.selected,
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(withStyles(styles)(ProfileList))
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import { FormControlLabel, Switch } from '@material-ui/core'
|
||||
|
||||
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} color="primary" />
|
||||
return (
|
||||
<div className={classes.switch}>
|
||||
<FormControlLabel control={toggleSwitch} label={label} labelPlacement="bottom" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import * as React from 'react'
|
||||
import PersistentStorage from '../utils/PersistentStorage'
|
||||
import SentimentDissatisfied from '@material-ui/icons/SentimentDissatisfied'
|
||||
import Warning from '@material-ui/icons/Warning'
|
||||
import { electronRendererTelementry } from 'electron-telemetry'
|
||||
import { electronRendererTelemetry } from 'electron-telemetry'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { Button, Modal, Paper, Toolbar, Typography } from '@material-ui/core'
|
||||
|
||||
@@ -14,7 +14,7 @@ interface Props {
|
||||
classes: any
|
||||
}
|
||||
|
||||
class ErrorBoundary extends React.Component<Props, State> {
|
||||
class ErrorBoundary extends React.PureComponent<Props, State> {
|
||||
public static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
@@ -33,7 +33,7 @@ class ErrorBoundary extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: any) {
|
||||
electronRendererTelementry.trackError(error)
|
||||
electronRendererTelemetry.trackError(error)
|
||||
console.log('did catch', error)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import ChartPanel from '../ChartPanel'
|
||||
import ReactSplitPane from 'react-split-pane'
|
||||
import Tree from '../Tree/Tree'
|
||||
import Tree from '../Tree'
|
||||
import { AppState } from '../../reducers'
|
||||
import { ChartParameters } from '../../reducers/Charts'
|
||||
import { connect } from 'react-redux'
|
||||
@@ -18,11 +18,29 @@ interface Props {
|
||||
|
||||
function ContentView(props: Props) {
|
||||
const [height, setHeight] = React.useState<string | number>('100%')
|
||||
const [sidebarWidth, setSidebarWidth] = React.useState<string | number>('40%')
|
||||
const [detectedHeight, setDetectedHeight] = React.useState(0)
|
||||
const [detectedSidebarWidth, setDetectedSidebarWidth] = React.useState(0)
|
||||
const detectSize = React.useCallback((width, newHeight) => {
|
||||
setDetectedHeight(newHeight)
|
||||
}, [])
|
||||
|
||||
const detectSidebarSize = React.useCallback(width => {
|
||||
setDetectedSidebarWidth(width)
|
||||
}, [])
|
||||
|
||||
const closeDrawerCompletelyIfItSitsOnTheEdge = React.useCallback(() => {
|
||||
if (detectedHeight < 30) {
|
||||
setHeight('100%')
|
||||
}
|
||||
}, [detectedHeight])
|
||||
|
||||
const closeSidebarCompletelyIfItSitsOnTheEdge = React.useCallback(() => {
|
||||
if (detectedSidebarWidth < 30) {
|
||||
setSidebarWidth('0%')
|
||||
}
|
||||
}, [detectedSidebarWidth])
|
||||
|
||||
// Open chart panel on start and when a new chart is added but the panel is closed
|
||||
React.useEffect(() => {
|
||||
const almostClosed = !isNaN(height as any) && detectedHeight < 30
|
||||
@@ -37,42 +55,54 @@ function ContentView(props: Props) {
|
||||
|
||||
return (
|
||||
<div className={props.paneDefaults}>
|
||||
<ReactSplitPane
|
||||
step={10}
|
||||
split="horizontal"
|
||||
minSize={0}
|
||||
size={height}
|
||||
defaultSize={'100%'}
|
||||
allowResize={true}
|
||||
style={{ height: 'calc(100vh - 64px)' }}
|
||||
pane1Style={{ maxHeight: '100%' }}
|
||||
pane2Style={{ borderTop: '1px solid #999', overflow: 'hidden scroll' }}
|
||||
onChange={setHeight}
|
||||
>
|
||||
<span>
|
||||
<ReactSplitPane
|
||||
step={20}
|
||||
primary="second"
|
||||
className={props.heightProperty}
|
||||
split="vertical"
|
||||
minSize={250}
|
||||
defaultSize={500}
|
||||
allowResize={true}
|
||||
style={{ height: '100%' }}
|
||||
pane1Style={{ overflowX: 'hidden' }}
|
||||
resizerStyle={{ height: '100%' }}
|
||||
>
|
||||
<Tree />
|
||||
<div className={props.paneDefaults} style={{ height: '100%', overflowY: 'auto', overflowX: 'hidden' }}>
|
||||
<span>
|
||||
<ReactSplitPane
|
||||
step={20}
|
||||
primary="second"
|
||||
className={props.heightProperty}
|
||||
split="vertical"
|
||||
minSize={0}
|
||||
size={sidebarWidth}
|
||||
onChange={setSidebarWidth}
|
||||
onDragFinished={closeSidebarCompletelyIfItSitsOnTheEdge}
|
||||
allowResize={true}
|
||||
style={{ height: '100%' }}
|
||||
pane1Style={{ overflowX: 'hidden' }}
|
||||
resizerStyle={{ height: '100%' }}
|
||||
>
|
||||
<span>
|
||||
<ReactSplitPane
|
||||
step={10}
|
||||
split="horizontal"
|
||||
minSize={0}
|
||||
size={height}
|
||||
allowResize={true}
|
||||
style={{ height: 'calc(100vh - 64px)' }}
|
||||
pane1Style={{ maxHeight: '100%' }}
|
||||
pane2Style={{ borderTop: '1px solid #999', display: 'flex' }}
|
||||
onChange={setHeight}
|
||||
onDragFinished={closeDrawerCompletelyIfItSitsOnTheEdge}
|
||||
>
|
||||
<Tree />
|
||||
{/** Passing height constraints via flex options down */}
|
||||
<div 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 */}
|
||||
<ReactResizeDetector handleHeight={true} onResize={detectSize} />
|
||||
<ChartPanel />
|
||||
</div>
|
||||
</ReactSplitPane>
|
||||
</span>
|
||||
<div style={{ height: '100%' }}>
|
||||
<ReactResizeDetector handleWidth={true} onResize={detectSidebarSize} />
|
||||
<div
|
||||
className={props.paneDefaults}
|
||||
style={{ minWidth: '250px', height: '100%', overflowY: 'auto', overflowX: 'hidden' }}
|
||||
>
|
||||
<Sidebar connectionId={props.connectionId} />
|
||||
</div>
|
||||
</ReactSplitPane>
|
||||
</span>
|
||||
<div>
|
||||
<ReactResizeDetector handleHeight={true} onResize={detectSize} />
|
||||
<ChartPanel />
|
||||
</div>
|
||||
</ReactSplitPane>
|
||||
</div>
|
||||
</ReactSplitPane>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ interface Props {
|
||||
classes: any
|
||||
}
|
||||
|
||||
class Notification extends React.Component<Props, {}> {
|
||||
class Notification extends React.PureComponent<Props, {}> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from 'react'
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import CustomIconButton from '../helper/CustomIconButton'
|
||||
import Pause from '@material-ui/icons/PauseCircleFilled'
|
||||
import Resume from '@material-ui/icons/PlayCircleFilled'
|
||||
import Resume from '@material-ui/icons/PlayArrow'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
@@ -28,7 +28,7 @@ interface Props {
|
||||
tree?: q.Tree<any>
|
||||
}
|
||||
|
||||
class PauseButton extends React.Component<Props, { changes: number }> {
|
||||
class PauseButton extends React.PureComponent<Props, { changes: number }> {
|
||||
private timer?: any
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useCallback, useState, useRef } from 'react'
|
||||
import ClearAdornment from '../helper/ClearAdornment'
|
||||
import Search from '@material-ui/icons/Search'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { InputBase } from '@material-ui/core'
|
||||
import { settingsActions } from '../../actions'
|
||||
import { fade, Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler'
|
||||
import { KeyCodes } from '../../utils/KeyCodes'
|
||||
|
||||
function SearchBar(props: {
|
||||
classes: any
|
||||
topicFilter?: string
|
||||
hasConnection: boolean
|
||||
actions: {
|
||||
settings: typeof settingsActions
|
||||
}
|
||||
}) {
|
||||
const { actions, classes, hasConnection, topicFilter } = props
|
||||
|
||||
const [hasFocus, setHasFocus] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>()
|
||||
const onFocus = useCallback(() => setHasFocus(true), [])
|
||||
const onBlur = useCallback(() => setHasFocus(false), [])
|
||||
|
||||
const clearFilter = useCallback(() => {
|
||||
actions.settings.filterTopics('')
|
||||
}, [])
|
||||
|
||||
const onFilterChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
actions.settings.filterTopics(event.target.value)
|
||||
}, [])
|
||||
|
||||
useGlobalKeyEventHandler(undefined, event => {
|
||||
const isCharacter = event.key.length === 1
|
||||
const isAllowedControlCharacter = event.keyCode === KeyCodes.backspace || event.keyCode === KeyCodes.delete
|
||||
const tagNameBlacklist = ['INPUT', 'TEXTAREA', 'RADIO', 'CHECKBOX', 'OPTION', 'FORM']
|
||||
|
||||
const tagElementIsNotBlacklisted =
|
||||
document.activeElement && tagNameBlacklist.indexOf(document.activeElement.tagName) === -1
|
||||
|
||||
if (
|
||||
(isCharacter || isAllowedControlCharacter) &&
|
||||
!event.defaultPrevented &&
|
||||
!hasFocus &&
|
||||
tagElementIsNotBlacklisted &&
|
||||
hasConnection
|
||||
) {
|
||||
// Focus input field, no preventDefault the event will reach the input element after it has been focussed
|
||||
inputRef.current && inputRef.current.focus()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={classes.search}>
|
||||
<div className={classes.searchIcon}>
|
||||
<Search />
|
||||
</div>
|
||||
<InputBase
|
||||
value={topicFilter}
|
||||
inputProps={{
|
||||
onFocus,
|
||||
onBlur,
|
||||
ref: inputRef,
|
||||
}}
|
||||
onChange={onFilterChange}
|
||||
placeholder="Search…"
|
||||
endAdornment={
|
||||
<div style={{ width: '24px', paddingRight: '8px' }}>
|
||||
<ClearAdornment variant="primary" action={clearFilter} value={topicFilter} />
|
||||
</div>
|
||||
}
|
||||
classes={{ root: classes.inputRoot, input: classes.inputInput }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
topicFilter: state.settings.get('topicFilter'),
|
||||
hasConnection: Boolean(state.connection.connectionId),
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
settings: bindActionCreators(settingsActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
search: {
|
||||
position: 'relative' as 'relative',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: fade(theme.palette.common.white, 0.15),
|
||||
'&:hover': {
|
||||
backgroundColor: fade(theme.palette.common.white, 0.25),
|
||||
},
|
||||
marginRight: theme.spacing(2),
|
||||
marginLeft: 0,
|
||||
flexGrow: 1,
|
||||
maxWidth: '60%',
|
||||
|
||||
[theme.breakpoints.up('md')]: {
|
||||
maxWidth: '30%',
|
||||
|
||||
marginLeft: theme.spacing(4),
|
||||
width: 'auto' as 'auto',
|
||||
},
|
||||
[theme.breakpoints.up(750)]: {
|
||||
marginLeft: theme.spacing(4),
|
||||
width: 'auto' as 'auto',
|
||||
},
|
||||
},
|
||||
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',
|
||||
},
|
||||
inputRoot: {
|
||||
color: 'inherit' as 'inherit',
|
||||
width: '100%',
|
||||
},
|
||||
inputInput: {
|
||||
paddingTop: theme.spacing(1),
|
||||
paddingRight: theme.spacing(1),
|
||||
paddingBottom: theme.spacing(1),
|
||||
paddingLeft: theme.spacing(6),
|
||||
transition: theme.transitions.create('width'),
|
||||
width: '100%',
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(withStyles(styles)(SearchBar))
|
||||
@@ -1,17 +1,15 @@
|
||||
import * as React from 'react'
|
||||
import ClearAdornment from '../helper/ClearAdornment'
|
||||
import CloudOff from '@material-ui/icons/CloudOff'
|
||||
import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator'
|
||||
import Menu from '@material-ui/icons/Menu'
|
||||
import PauseButton from './PauseButton'
|
||||
import Search from '@material-ui/icons/Search'
|
||||
import SearchBar from './SearchBar'
|
||||
import { AppBar, Button, IconButton, Toolbar, Typography } from '@material-ui/core'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionActions, globalActions, settingsActions } from '../../actions'
|
||||
import { fade } from '@material-ui/core/styles/colorManipulator'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { AppBar, Button, IconButton, InputBase, Toolbar, Typography } from '@material-ui/core'
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
title: {
|
||||
@@ -21,29 +19,6 @@ const styles = (theme: Theme) => ({
|
||||
},
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
},
|
||||
search: {
|
||||
position: 'relative' as 'relative',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: fade(theme.palette.common.white, 0.15),
|
||||
'&:hover': {
|
||||
backgroundColor: fade(theme.palette.common.white, 0.25),
|
||||
},
|
||||
marginRight: theme.spacing(2),
|
||||
marginLeft: 0,
|
||||
flexGrow: 1,
|
||||
maxWidth: '60%',
|
||||
|
||||
[theme.breakpoints.up('md')]: {
|
||||
maxWidth: '30%',
|
||||
|
||||
marginLeft: theme.spacing(4),
|
||||
width: 'auto' as 'auto',
|
||||
},
|
||||
[theme.breakpoints.up(750)]: {
|
||||
marginLeft: theme.spacing(4),
|
||||
width: 'auto' as 'auto',
|
||||
},
|
||||
},
|
||||
disconnectIcon: {
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
display: 'none' as 'none',
|
||||
@@ -51,33 +26,14 @@ const styles = (theme: Theme) => ({
|
||||
marginRight: '8px',
|
||||
paddingLeft: '8px',
|
||||
},
|
||||
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',
|
||||
},
|
||||
inputRoot: {
|
||||
color: 'inherit' as 'inherit',
|
||||
width: '100%',
|
||||
},
|
||||
inputInput: {
|
||||
paddingTop: theme.spacing(1),
|
||||
paddingRight: theme.spacing(1),
|
||||
paddingBottom: theme.spacing(1),
|
||||
paddingLeft: theme.spacing(6),
|
||||
transition: theme.transitions.create('width'),
|
||||
width: '100%',
|
||||
},
|
||||
menuButton: {
|
||||
marginLeft: -12,
|
||||
marginRight: 20,
|
||||
},
|
||||
disconnect: {
|
||||
margin: 'auto 8px auto auto',
|
||||
},
|
||||
disconnectLabel: {
|
||||
color: theme.palette.primary.contrastText,
|
||||
},
|
||||
})
|
||||
@@ -92,43 +48,12 @@ interface Props {
|
||||
topicFilter?: string
|
||||
}
|
||||
|
||||
class TitleBar extends React.Component<Props, {}> {
|
||||
class TitleBar extends React.PureComponent<Props, {}> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
private renderSearch() {
|
||||
const { classes, topicFilter } = this.props
|
||||
|
||||
return (
|
||||
<div className={classes.search}>
|
||||
<div className={classes.searchIcon}>
|
||||
<Search />
|
||||
</div>
|
||||
<InputBase
|
||||
value={topicFilter}
|
||||
onChange={this.onFilterChange}
|
||||
placeholder="Search…"
|
||||
endAdornment={
|
||||
<div style={{ width: '24px', paddingRight: '8px' }}>
|
||||
<ClearAdornment variant="primary" action={this.clearFilter} value={topicFilter} />
|
||||
</div>
|
||||
}
|
||||
classes={{ root: classes.inputRoot, input: classes.inputInput }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private clearFilter = () => {
|
||||
this.props.actions.settings.filterTopics('')
|
||||
}
|
||||
|
||||
private onFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.props.actions.settings.filterTopics(event.target.value)
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { actions, classes } = this.props
|
||||
|
||||
@@ -146,9 +71,13 @@ class TitleBar extends React.Component<Props, {}> {
|
||||
<Typography className={classes.title} variant="h6" color="inherit">
|
||||
MQTT Explorer
|
||||
</Typography>
|
||||
{this.renderSearch()}
|
||||
<SearchBar />
|
||||
<PauseButton />
|
||||
<Button className={classes.disconnect} onClick={actions.connection.disconnect}>
|
||||
<Button
|
||||
className={classes.disconnect}
|
||||
classes={{ label: classes.disconnectLabel }}
|
||||
onClick={actions.connection.disconnect}
|
||||
>
|
||||
Disconnect <CloudOff className={classes.disconnectIcon} />
|
||||
</Button>
|
||||
<ConnectionHealthIndicator withBackground={true} />
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { AppState } from '../../reducers'
|
||||
import { Base64Message } from '../../../../backend/src/Model/Base64Message'
|
||||
import { connect } from 'react-redux'
|
||||
import { StyleRulesCallback, withStyles, Theme } from '@material-ui/core/styles'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { TopicViewModel } from '../../model/TopicViewModel'
|
||||
import { Typography } from '@material-ui/core'
|
||||
import { Base64Message } from '../../../../backend/src/Model/Base64Message'
|
||||
import teal from '@material-ui/core/colors/teal'
|
||||
|
||||
import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode'
|
||||
import { useUpdateComponentWhenNodeUpdates } from '../helper/useUpdateComponentWhenNodeUpdates'
|
||||
const abbreviate = require('number-abbreviate')
|
||||
|
||||
interface Stats {
|
||||
@@ -16,10 +16,6 @@ interface Stats {
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
flex: {
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
},
|
||||
container: {
|
||||
width: '100%',
|
||||
height: '224px',
|
||||
@@ -34,24 +30,13 @@ interface Props {
|
||||
tree?: q.Tree<TopicViewModel>
|
||||
}
|
||||
|
||||
class BrokerStatistics extends React.Component<Props, {}> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
}
|
||||
function BrokerStatistics(props: Props) {
|
||||
const { tree, classes } = props
|
||||
const sysTopic = usePollingToFetchTreeNode(props.tree, '$SYS')
|
||||
useUpdateComponentWhenNodeUpdates(sysTopic)
|
||||
|
||||
private renderPair(tree: q.Tree<TopicViewModel>, a: Stats, b: Stats) {
|
||||
return (
|
||||
<div className={this.props.classes.flex}>
|
||||
<div style={{ flex: 1 }}>{this.renderStat(tree, a)}</div>
|
||||
<div style={{ flex: 1 }}>{this.renderStat(tree, b)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { tree, classes } = this.props
|
||||
if (!tree || !tree.findNode('$SYS/broker/clients/total')) {
|
||||
return useMemo(() => {
|
||||
if (!Boolean(sysTopic)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -94,38 +79,20 @@ class BrokerStatistics extends React.Component<Props, {}> {
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
{this.renderStat(tree, stats.broker)}
|
||||
{this.renderPair(tree, stats.sent, stats.received)}
|
||||
{this.renderPair(tree, stats.clients, stats.subscriptions)}
|
||||
{this.renderPair(tree, stats.sent5m, stats.received5m)}
|
||||
{this.renderPair(tree, stats.heap, stats.heapMax)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
public renderStat(tree: q.Tree<TopicViewModel>, stat: Stats) {
|
||||
const node = tree.findNode(stat.topic)
|
||||
if (!node || !node.message) {
|
||||
if (!tree) {
|
||||
return null
|
||||
}
|
||||
|
||||
const str = node.message.value ? Base64Message.toUnicodeString(node.message.value) : ''
|
||||
let value = node.message && node.message.value ? parseFloat(str) : NaN
|
||||
value = !isNaN(value) ? abbreviate(value) : str
|
||||
|
||||
return (
|
||||
<div key={stat.title}>
|
||||
<Typography>
|
||||
<b>{stat.title}</b>
|
||||
</Typography>
|
||||
<Typography style={{ paddingLeft: '8px' }}>
|
||||
<i>{value}</i>
|
||||
</Typography>
|
||||
<div className={classes.container}>
|
||||
{renderStat(tree, stats.broker)}
|
||||
{renderPair(tree, stats.sent, stats.received)}
|
||||
{renderPair(tree, stats.clients, stats.subscriptions)}
|
||||
{renderPair(tree, stats.sent5m, stats.received5m)}
|
||||
{renderPair(tree, stats.heap, stats.heapMax)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}, [sysTopic && sysTopic.lastUpdate, props.classes])
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
@@ -135,3 +102,39 @@ const mapStateToProps = (state: AppState) => {
|
||||
}
|
||||
|
||||
export default withStyles(styles)(connect(mapStateToProps)(BrokerStatistics))
|
||||
|
||||
function renderPair(tree: q.Tree<TopicViewModel>, a: Stats, b: Stats) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>{renderStat(tree, a)}</div>
|
||||
<div style={{ flex: 1 }}>{renderStat(tree, b)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderStat(tree: q.Tree<TopicViewModel>, stat: Stats) {
|
||||
const node = tree.findNode(stat.topic)
|
||||
if (!node || !node.message) {
|
||||
return null
|
||||
}
|
||||
|
||||
const str = node.message.value ? Base64Message.toUnicodeString(node.message.value) : ''
|
||||
let value = node.message && node.message.value ? parseFloat(str) : NaN
|
||||
value = !isNaN(value) ? abbreviate(value) : str
|
||||
|
||||
return (
|
||||
<div key={stat.title}>
|
||||
<Typography>
|
||||
<b>{stat.title}</b>
|
||||
</Typography>
|
||||
<Typography style={{ paddingLeft: '8px' }}>
|
||||
<i>{value}</i>
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ interface Props {
|
||||
theme: 'light' | 'dark'
|
||||
}
|
||||
|
||||
class Settings extends React.Component<Props, {}> {
|
||||
class Settings extends React.PureComponent<Props, {}> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
|
||||
@@ -24,7 +24,7 @@ function ChartPreview(props: Props) {
|
||||
const onClick = React.useCallback(() => {
|
||||
props.actions.chart.addChart({
|
||||
topic: props.treeNode.path(),
|
||||
dotPath: props.literal.path,
|
||||
dotPath: props.literal.path !== '' ? props.literal.path : undefined,
|
||||
})
|
||||
setOpen(false)
|
||||
}, [props.literal.path, props.treeNode])
|
||||
@@ -47,11 +47,19 @@ function ChartPreview(props: Props) {
|
||||
onMouseEnter={mouseOver}
|
||||
onMouseLeave={mouseOut}
|
||||
onClick={onClick}
|
||||
data-test-type="ShowChart"
|
||||
data-test={props.literal.path}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Add to chart panel, not enough data for preview">
|
||||
<ShowChart onClick={onClick} className={props.classes.icon} style={{ color: '#aaa' }} />
|
||||
<ShowChart
|
||||
onClick={onClick}
|
||||
className={props.classes.icon}
|
||||
style={{ color: '#aaa' }}
|
||||
data-test-type="ShowChart"
|
||||
data-test={props.literal.path}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ function changeAmount(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<span style={{ display: 'block', marginBottom: '8px', float: 'right' }}>
|
||||
<span style={{ display: 'block', textAlign: 'right' }}>
|
||||
<span>
|
||||
Comparing with <b>{props.nameOfCompareMessage}</b> message:
|
||||
<span className={props.classes.additions}>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { isPlottable, lineChangeStyle, trimNewlineRight } from './util'
|
||||
import { JsonPropertyLocation, literalsMappedByLines } from '../../../../../backend/src/JsonAstParser'
|
||||
import { selectTextWithCtrlA } from '../../../utils/handleTextSelectWithCtrlA'
|
||||
import { style } from './style'
|
||||
import { withStyles } from '@material-ui/core'
|
||||
import { withStyles, Typography } from '@material-ui/core'
|
||||
import 'prismjs/components/prism-json'
|
||||
|
||||
interface Props {
|
||||
@@ -17,12 +17,13 @@ interface Props {
|
||||
current: string
|
||||
nameOfCompareMessage: string
|
||||
language?: 'json'
|
||||
title?: string
|
||||
classes: any
|
||||
}
|
||||
|
||||
interface State {}
|
||||
|
||||
class CodeDiff extends React.Component<Props, State> {
|
||||
class CodeDiff extends React.PureComponent<Props, State> {
|
||||
private handleCtrlA = selectTextWithCtrlA({ targetSelector: 'pre ~ pre' })
|
||||
|
||||
constructor(props: Props) {
|
||||
@@ -88,7 +89,8 @@ class CodeDiff extends React.Component<Props, State> {
|
||||
const code = this.renderStyledCodeLines(changes)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
{this.props.title ? <Typography className={this.props.classes.title}>{this.props.title}</Typography> : null}
|
||||
<div tabIndex={0} onKeyDown={this.handleCtrlA} className={this.props.classes.codeWrapper}>
|
||||
<Gutters
|
||||
className={this.props.classes.gutters}
|
||||
|
||||
@@ -11,6 +11,11 @@ export const style = (theme: Theme) => {
|
||||
}
|
||||
|
||||
return {
|
||||
title: {
|
||||
font: codeBaseStyle.font,
|
||||
paddingLeft: '4px',
|
||||
backgroundColor: codeBlockColors.gutters,
|
||||
},
|
||||
line: {
|
||||
lineHeight: 'normal' as 'normal',
|
||||
paddingLeft: '4px',
|
||||
@@ -22,7 +27,6 @@ export const style = (theme: Theme) => {
|
||||
maxHeight: '15em',
|
||||
overflow: 'auto',
|
||||
backgroundColor: `${codeBlockColors.background}`,
|
||||
margin: '8px 0 0 0',
|
||||
},
|
||||
gutters: {
|
||||
...codeBaseStyle,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import React, { useCallback, useState, useEffect, memo } from 'react'
|
||||
import { Badge, Typography } from '@material-ui/core'
|
||||
import { selectTextWithCtrlA } from '../../utils/handleTextSelectWithCtrlA'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { Theme, withStyles, emphasize } from '@material-ui/core/styles'
|
||||
|
||||
interface HistoryItem {
|
||||
key: string
|
||||
@@ -15,45 +15,49 @@ interface Props {
|
||||
onClick?: (index: number, element: EventTarget) => void
|
||||
classes: any
|
||||
contentTypeIndicator?: JSX.Element
|
||||
autoOpen?: boolean
|
||||
theme: Theme
|
||||
children?: any
|
||||
}
|
||||
|
||||
interface State {
|
||||
collapsed: boolean
|
||||
}
|
||||
function HistoryDrawer(props: Props) {
|
||||
const handleCtrlA = selectTextWithCtrlA({ targetSelector: 'pre' })
|
||||
const [expanded, setExpanded] = useState<boolean | undefined>(undefined)
|
||||
|
||||
class HistoryDrawer extends React.Component<Props, State> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = {
|
||||
collapsed: true,
|
||||
const toggle = useCallback(() => {
|
||||
setExpanded(!expanded)
|
||||
}, [expanded])
|
||||
|
||||
useEffect(() => {
|
||||
if (props.autoOpen && expanded === undefined && props.items.length > 0) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [props.autoOpen, expanded, props.items])
|
||||
|
||||
const createSelectionHandler = (index: number) => (event: React.MouseEvent) => {
|
||||
props.onClick && props.onClick(index, event.target)
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
private toggle = () => {
|
||||
this.setState({ collapsed: !this.state.collapsed })
|
||||
}
|
||||
|
||||
private handleCtrlA = selectTextWithCtrlA({ targetSelector: 'pre' })
|
||||
|
||||
public renderHistory() {
|
||||
function renderHistory() {
|
||||
const style = (element: HistoryItem) => ({
|
||||
backgroundColor: element.selected
|
||||
? this.props.theme.palette.action.selected
|
||||
: this.props.theme.palette.action.hover,
|
||||
? emphasize(props.theme.palette.background.default, 0.2)
|
||||
: emphasize(props.theme.palette.background.default, 0.07),
|
||||
margin: '8px',
|
||||
padding: '8px 8px 0 8px',
|
||||
cursor: this.props.onClick ? 'pointer' : 'inherit',
|
||||
cursor: props.onClick ? 'pointer' : 'inherit',
|
||||
})
|
||||
|
||||
const messageStyle: React.CSSProperties = { textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden' }
|
||||
const elements = this.props.items.map((element, index) => (
|
||||
const elements = props.items.map((element, index) => (
|
||||
<div
|
||||
key={element.key}
|
||||
style={style(element)}
|
||||
onClick={(event: React.MouseEvent) => this.props.onClick && this.props.onClick(index, event.target)}
|
||||
onClick={createSelectionHandler(index)}
|
||||
tabIndex={0}
|
||||
onKeyDown={this.handleCtrlA}
|
||||
onKeyDown={handleCtrlA}
|
||||
>
|
||||
<div>
|
||||
<i>{element.title}</i>
|
||||
@@ -66,40 +70,47 @@ class HistoryDrawer extends React.Component<Props, State> {
|
||||
</div>
|
||||
))
|
||||
|
||||
const visible = this.props.items.length > 0 && this.state.collapsed
|
||||
const visible = props.items.length > 0 && !expanded
|
||||
|
||||
return (
|
||||
<div className={this.props.classes.main}>
|
||||
<Typography component={'span'} onClick={this.toggle} style={{ cursor: 'pointer' }}>
|
||||
<Badge
|
||||
classes={{ badge: this.props.classes.badge }}
|
||||
invisible={!visible}
|
||||
badgeContent={this.props.items.length}
|
||||
color="primary"
|
||||
>
|
||||
{this.state.collapsed ? '▶ History' : '▼ History'}
|
||||
</Badge>
|
||||
<div style={{ float: 'right' }}>{this.state.collapsed ? this.props.contentTypeIndicator : null}</div>
|
||||
</Typography>
|
||||
<div className={props.classes.main}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: emphasize(props.theme.palette.background.default, 0.07),
|
||||
borderBottom: expanded ? `1px solid ${emphasize(props.theme.palette.background.default, 0.2)}` : undefined,
|
||||
}}
|
||||
>
|
||||
<Typography component={'span'} onClick={toggle} style={{ cursor: 'pointer', display: 'flex' }}>
|
||||
<span style={{ flexGrow: 1 }}>
|
||||
<Badge
|
||||
classes={{ badge: props.classes.badge }}
|
||||
invisible={!visible}
|
||||
badgeContent={props.items.length}
|
||||
color="primary"
|
||||
>
|
||||
{expanded ? '▼ History' : '▶ History'}
|
||||
</Badge>
|
||||
</span>
|
||||
<span>{props.contentTypeIndicator}</span>
|
||||
</Typography>
|
||||
</div>
|
||||
<div style={{ maxHeight: '230px', overflowY: 'scroll' }}>
|
||||
{this.state.collapsed ? null : this.props.children}
|
||||
{this.state.collapsed ? null : elements}
|
||||
{expanded ? props.children : null}
|
||||
{expanded ? elements : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
return <div style={{ display: 'block', width: '100%' }}>{this.renderHistory()}</div>
|
||||
}
|
||||
return <div style={{ display: 'block', width: '100%' }}>{renderHistory()}</div>
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
main: {
|
||||
backgroundColor: 'rgba(170, 170, 170, 0.2)',
|
||||
backgroundColor: theme.palette.background.default,
|
||||
marginTop: '16px',
|
||||
},
|
||||
badge: { right: '-25px' },
|
||||
})
|
||||
|
||||
export default withStyles(styles, { withTheme: true })(HistoryDrawer)
|
||||
export default withStyles(styles, { withTheme: true })(memo(HistoryDrawer))
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TopicViewModel } from '../../model/TopicViewModel'
|
||||
import { Typography } from '@material-ui/core'
|
||||
|
||||
interface Props {
|
||||
node: q.TreeNode<TopicViewModel>
|
||||
node?: q.TreeNode<TopicViewModel>
|
||||
}
|
||||
|
||||
class NodeStats extends React.Component<Props, {}> {
|
||||
@@ -14,6 +14,9 @@ class NodeStats extends React.Component<Props, {}> {
|
||||
|
||||
public render() {
|
||||
const { node } = this.props
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react'
|
||||
import ExpandMore from '@material-ui/icons/ExpandMore'
|
||||
import { ExpansionPanel, ExpansionPanelDetails, ExpansionPanelSummary, Typography, Theme } from '@material-ui/core'
|
||||
import { withStyles } from '@material-ui/styles'
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
summary: { minHeight: '0' },
|
||||
details: { padding: '0px 16px 8px 8px', display: 'block' },
|
||||
heading: {
|
||||
fontSize: theme.typography.pxToRem(15),
|
||||
fontWeight: theme.typography.fontWeightRegular,
|
||||
},
|
||||
})
|
||||
|
||||
const Panel = (props: {
|
||||
classes: any
|
||||
children: [React.ReactElement, React.ReactElement]
|
||||
disabled?: boolean
|
||||
detailsHidden?: boolean
|
||||
}) => {
|
||||
return (
|
||||
<ExpansionPanel defaultExpanded={true} disabled={props.disabled}>
|
||||
<ExpansionPanelSummary expandIcon={<ExpandMore />} className={props.classes.summary}>
|
||||
<Typography className={props.classes.heading}>{props.children[0]}</Typography>
|
||||
</ExpansionPanelSummary>
|
||||
{props.detailsHidden ? null : (
|
||||
<ExpansionPanelDetails className={props.classes.detail}>{props.children[1]}</ExpansionPanelDetails>
|
||||
)}
|
||||
</ExpansionPanel>
|
||||
)
|
||||
}
|
||||
|
||||
export default withStyles(styles)(Panel)
|
||||
@@ -1,111 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import DateFormatter from '../helper/DateFormatter'
|
||||
import { default as ReactResizeDetector } from 'react-resize-detector'
|
||||
import { PlotCurveTypes } from '../../reducers/Charts'
|
||||
import { Theme, withTheme } from '@material-ui/core'
|
||||
import 'react-vis/dist/style.css'
|
||||
const { XYPlot, LineMarkSeries, Hint, XAxis, YAxis, HorizontalGridLines } = require('react-vis')
|
||||
const abbreviate = require('number-abbreviate')
|
||||
|
||||
interface Props {
|
||||
data: Array<{ x: number; y: number }>
|
||||
theme: Theme
|
||||
interpolation?: PlotCurveTypes
|
||||
range?: [number?, number?]
|
||||
color?: string
|
||||
}
|
||||
|
||||
function mapCurveType(type: PlotCurveTypes | undefined) {
|
||||
switch (type) {
|
||||
case 'curve':
|
||||
return 'curveMonotoneX'
|
||||
case 'linear':
|
||||
return 'curveLinear'
|
||||
case 'cubic_basis_spline':
|
||||
return 'curveBasis'
|
||||
case 'step_after':
|
||||
return 'curveStepAfter'
|
||||
case 'step_before':
|
||||
return 'curveStepBefore'
|
||||
default:
|
||||
return 'curveMonotoneX'
|
||||
}
|
||||
}
|
||||
|
||||
export default withTheme((props: Props) => {
|
||||
const [width, setWidth] = React.useState(300)
|
||||
const [tooltip, setTooltip] = React.useState({ value: undefined })
|
||||
const detectResize = React.useCallback(newWidth => setWidth(newWidth), [])
|
||||
|
||||
const hintFormatter = React.useCallback((point: any) => {
|
||||
return [
|
||||
{ title: <b>Time</b>, value: <DateFormatter date={new Date(point.x)} /> },
|
||||
{ title: <b>Value</b>, value: point.y },
|
||||
]
|
||||
}, [])
|
||||
|
||||
const hideTooltip = React.useCallback(() => {
|
||||
setTooltip({ value: undefined })
|
||||
}, [])
|
||||
|
||||
const showTooltip = React.useCallback((value: any) => {
|
||||
setTooltip({ value })
|
||||
}, [])
|
||||
|
||||
return React.useMemo(() => {
|
||||
const data = props.data
|
||||
const calculatedDomain = domainForData(data)
|
||||
let yDomain: [number, number] = props.range
|
||||
? [props.range[0] || calculatedDomain[0], props.range[1] || calculatedDomain[1]]
|
||||
: calculatedDomain
|
||||
|
||||
let color: string =
|
||||
props.theme.palette.type === 'light' ? props.theme.palette.secondary.dark : props.theme.palette.primary.light
|
||||
if (props.color) {
|
||||
color = props.color
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '150px', overflow: 'hidden' }}>
|
||||
<XYPlot width={width} height={180} yDomain={yDomain}>
|
||||
<HorizontalGridLines />
|
||||
<XAxis />
|
||||
<YAxis width={45} tickFormat={(num: number) => abbreviate(num)} />
|
||||
<LineMarkSeries
|
||||
color={color}
|
||||
onValueMouseOver={showTooltip}
|
||||
onValueMouseOut={hideTooltip}
|
||||
size={3}
|
||||
data={data}
|
||||
curve={mapCurveType(props.interpolation)}
|
||||
/>
|
||||
{tooltip.value ? <Hint format={hintFormatter} value={tooltip.value} /> : null}
|
||||
</XYPlot>
|
||||
<ReactResizeDetector handleWidth={true} onResize={detectResize} />
|
||||
</div>
|
||||
)
|
||||
}, [width, props.data, tooltip, props.interpolation, props.range])
|
||||
})
|
||||
|
||||
function domainForData(data: Array<{ x: number; y: number }>): [number, number] {
|
||||
let max = data[0].y
|
||||
let min = data[0].y
|
||||
data.forEach(d => {
|
||||
if (max < d.y) {
|
||||
max = d.y
|
||||
}
|
||||
if (min > d.y) {
|
||||
min = d.y
|
||||
}
|
||||
})
|
||||
|
||||
if ((max === 1 || max === 0) && (min === 1 || min === 0)) {
|
||||
return [0, 1]
|
||||
}
|
||||
|
||||
if (min === max) {
|
||||
return [min - 0.5 * min, min + 0.5 * min]
|
||||
}
|
||||
|
||||
return [min, max]
|
||||
}
|
||||
@@ -1,32 +1,18 @@
|
||||
import * as q from '../../../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import ClearAdornment from '../../helper/ClearAdornment'
|
||||
import Editor from './Editor'
|
||||
import FormatAlignLeft from '@material-ui/icons/FormatAlignLeft'
|
||||
import History from '../HistoryDrawer'
|
||||
import Message from './Model/Message'
|
||||
import Navigation from '@material-ui/icons/Navigation'
|
||||
import PublishHistory from './PublishHistory'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import RetainSwitch from './RetainSwitch'
|
||||
import TopicInput from './TopicInput'
|
||||
import { AppState } from '../../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { Button, Fab, Theme, Tooltip, withTheme } from '@material-ui/core'
|
||||
import { connect } from 'react-redux'
|
||||
import { globalActions, publishActions } from '../../../actions'
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControlLabel,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Input,
|
||||
Checkbox,
|
||||
Tooltip,
|
||||
Fab,
|
||||
Theme,
|
||||
withTheme,
|
||||
} from '@material-ui/core'
|
||||
import { EditorModeSelect } from './EditorModeSelect'
|
||||
import QosSelect from './QosSelect'
|
||||
import Editor from './Editor'
|
||||
|
||||
const sha1 = require('sha1')
|
||||
import { globalActions, publishActions } from '../../../actions'
|
||||
import { KeyCodes } from '../../../utils/KeyCodes'
|
||||
|
||||
interface Props {
|
||||
connectionId?: string
|
||||
@@ -39,186 +25,146 @@ interface Props {
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
interface State {
|
||||
history: Array<Message>
|
||||
function useHistory(): [Array<Message>, (topic: string, payload?: string) => void] {
|
||||
const [history, setHistory] = useState<Array<Message>>([])
|
||||
const amendToHistory = useCallback(
|
||||
(topic: string, payload?: string) => {
|
||||
// Remove duplicates
|
||||
let filteredHistory = history.filter(e => e.payload !== payload || e.topic !== topic)
|
||||
filteredHistory = filteredHistory.slice(-7)
|
||||
setHistory([...filteredHistory, { topic, payload, sent: new Date() }])
|
||||
},
|
||||
[history]
|
||||
)
|
||||
|
||||
return [history, amendToHistory]
|
||||
}
|
||||
|
||||
class Publish extends React.Component<Props, State> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = { history: [] }
|
||||
}
|
||||
function Publish(props: Props) {
|
||||
const [history, amendToHistory] = useHistory()
|
||||
|
||||
private updatePayload = (payload: string) => {
|
||||
this.props.actions.setPayload(payload)
|
||||
}
|
||||
|
||||
private updateTopic = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.props.actions.setTopic(e.target.value)
|
||||
}
|
||||
|
||||
private updateMode = (e: React.ChangeEvent<{}>, value: string) => {
|
||||
this.props.actions.setEditorMode(value)
|
||||
}
|
||||
|
||||
private publish = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!this.props.connectionId) {
|
||||
const publish = useCallback(() => {
|
||||
if (!props.connectionId) {
|
||||
return
|
||||
}
|
||||
|
||||
this.props.actions.publish(this.props.connectionId)
|
||||
props.actions.publish(props.connectionId)
|
||||
|
||||
const topic = this.props.topic || ''
|
||||
const payload = this.props.payload
|
||||
if (this.props.connectionId && topic) {
|
||||
this.addMessageToHistory(topic, payload)
|
||||
const topic = props.topic || ''
|
||||
const payload = props.payload
|
||||
if (props.connectionId && topic) {
|
||||
amendToHistory(topic, payload)
|
||||
}
|
||||
}
|
||||
}, [props, props.connectionId, props.topic, props.payload, amendToHistory])
|
||||
|
||||
private addMessageToHistory(topic: string, payload?: string) {
|
||||
// Remove duplicates
|
||||
let filteredHistory = this.state.history.filter(e => e.payload !== payload || e.topic !== topic)
|
||||
filteredHistory = filteredHistory.slice(-7)
|
||||
const history: Array<Message> = [...filteredHistory, { topic, payload, sent: new Date() }]
|
||||
this.setState({ history })
|
||||
}
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.keyCode === KeyCodes.enter && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
publish()
|
||||
}
|
||||
},
|
||||
[publish]
|
||||
)
|
||||
|
||||
private clearTopic = () => {
|
||||
this.props.actions.setTopic('')
|
||||
}
|
||||
|
||||
private topic() {
|
||||
const topicStr = this.props.topic || ''
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormControl style={{ width: '100%' }}>
|
||||
<InputLabel htmlFor="publish-topic">Topic</InputLabel>
|
||||
<Input
|
||||
id="publish-topic"
|
||||
value={topicStr}
|
||||
startAdornment={<span />}
|
||||
endAdornment={<ClearAdornment action={this.clearTopic} value={topicStr} />}
|
||||
onBlur={this.onTopicBlur}
|
||||
onChange={this.updateTopic}
|
||||
multiline={true}
|
||||
placeholder="example/topic"
|
||||
return useMemo(
|
||||
() => (
|
||||
<div style={{ flexGrow: 1 }} onKeyDown={handleSubmit}>
|
||||
<TopicInput />
|
||||
<div style={{ width: '100%', display: 'block' }}>
|
||||
<EditorMode
|
||||
actions={props.actions}
|
||||
globalActions={props.globalActions}
|
||||
payload={props.payload}
|
||||
editorMode={props.editorMode}
|
||||
publish={publish}
|
||||
/>
|
||||
</FormControl>
|
||||
<Editor value={props.payload} editorMode={props.editorMode} onChange={props.actions.setPayload} />
|
||||
<RetainSwitch />
|
||||
</div>
|
||||
<PublishHistory history={history} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
),
|
||||
[props.payload, props.editorMode, history, handleSubmit, publish]
|
||||
)
|
||||
}
|
||||
|
||||
private onTopicBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (!e.target.value) {
|
||||
this.props.actions.setTopic(undefined)
|
||||
}
|
||||
}
|
||||
function EditorMode(props: {
|
||||
payload?: string
|
||||
editorMode: string
|
||||
actions: typeof publishActions
|
||||
globalActions: typeof globalActions
|
||||
publish: () => void
|
||||
}) {
|
||||
const updatePayload = props.actions.setPayload
|
||||
|
||||
private publishButton() {
|
||||
return (
|
||||
<Button variant="contained" size="small" color="primary" onClick={this.publish} id="publish-button">
|
||||
<Navigation style={{ marginRight: '8px' }} /> Publish
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
const updateMode = useCallback((e: React.ChangeEvent<{}>, value: string) => {
|
||||
props.actions.setEditorMode(value)
|
||||
}, [])
|
||||
|
||||
private formatJson = () => {
|
||||
if (this.props.payload) {
|
||||
const formatJson = useCallback(() => {
|
||||
if (props.payload) {
|
||||
try {
|
||||
const str = JSON.stringify(JSON.parse(this.props.payload), undefined, ' ')
|
||||
this.updatePayload(str)
|
||||
const str = JSON.stringify(JSON.parse(props.payload), undefined, ' ')
|
||||
updatePayload(str)
|
||||
} catch (error) {
|
||||
this.props.globalActions.showError(`Format error: ${error.message}`)
|
||||
props.globalActions.showError(`Format error: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [props.payload])
|
||||
|
||||
private renderFormatJson() {
|
||||
if (this.props.editorMode !== 'json') {
|
||||
const renderFormatJson = useCallback(() => {
|
||||
if (props.editorMode !== 'json') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title="Format JSON">
|
||||
<Fab
|
||||
style={{ width: '36px', height: '36px', marginLeft: '8px' }}
|
||||
onClick={this.formatJson}
|
||||
style={{ width: '36px', height: '36px', margin: '0 8px' }}
|
||||
onClick={formatJson}
|
||||
id="sidebar-publish-format-json"
|
||||
>
|
||||
<FormatAlignLeft style={{ fontSize: '20px' }} />
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
}, [formatJson, props.editorMode, props.publish])
|
||||
|
||||
private editorMode() {
|
||||
return (
|
||||
return useMemo(
|
||||
() => (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<div style={{ width: '100%', lineHeight: '64px' }}>
|
||||
<EditorModeSelect value={this.props.editorMode} onChange={this.updateMode} />
|
||||
{this.renderFormatJson()}
|
||||
<div style={{ float: 'right', marginRight: '16px' }}>{this.publishButton()}</div>
|
||||
<div style={{ width: '100%', lineHeight: '64px', textAlign: 'center' }}>
|
||||
<EditorModeSelect value={props.editorMode} onChange={updateMode} />
|
||||
{renderFormatJson()}
|
||||
<div style={{ float: 'right' }}>
|
||||
<PublishButton publish={props.publish} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
),
|
||||
[props.editorMode, renderFormatJson]
|
||||
)
|
||||
}
|
||||
|
||||
private publishMode() {
|
||||
const labelStyle = { margin: '0 8px 0 8px' }
|
||||
const PublishButton = (props: { publish: () => void }) => {
|
||||
const handleClickPublish = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
props.publish()
|
||||
},
|
||||
[props.publish]
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '8px', clear: 'both' }}>
|
||||
<div style={{ width: '100%', textAlign: 'right' }}>
|
||||
<FormControlLabel style={labelStyle} control={<QosSelect />} label="QoS" labelPlacement="start" />
|
||||
<Tooltip
|
||||
title="Retained messages only appear to be retained, when client subscribes after the initial publish."
|
||||
placement="top"
|
||||
>
|
||||
<FormControlLabel
|
||||
value="retain"
|
||||
style={labelStyle}
|
||||
control={
|
||||
<Checkbox color="primary" checked={this.props.retain} onChange={this.props.actions.toggleRetain} />
|
||||
}
|
||||
label="retain"
|
||||
labelPlacement="end"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private history() {
|
||||
const items = this.state.history.reverse().map(message => ({
|
||||
key: sha1(message.topic + message.payload),
|
||||
title: message.topic,
|
||||
value: message.payload || '',
|
||||
}))
|
||||
return <History items={items} onClick={this.didSelectHistoryEntry} />
|
||||
}
|
||||
|
||||
private didSelectHistoryEntry = (index: number) => {
|
||||
const message = this.state.history[index]
|
||||
this.props.actions.setTopic(message.topic)
|
||||
this.props.actions.setPayload(message.payload)
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div style={{ flexGrow: 1, marginLeft: '8px' }}>
|
||||
{this.topic()}
|
||||
<div style={{ width: '100%', display: 'block' }}>
|
||||
{this.editorMode()}
|
||||
<Editor value={this.props.payload} editorMode={this.props.editorMode} onChange={this.updatePayload} />
|
||||
{this.publishMode()}
|
||||
</div>
|
||||
{this.history()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return useMemo(
|
||||
() => (
|
||||
<Button variant="contained" size="small" color="primary" onClick={handleClickPublish} id="publish-button">
|
||||
<Navigation style={{ marginRight: '8px' }} /> Publish
|
||||
</Button>
|
||||
),
|
||||
[handleClickPublish]
|
||||
)
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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 { publishActions } from '../../../actions'
|
||||
const sha1 = require('sha1')
|
||||
|
||||
function PublishHistory(props: { history: Array<Message>; actions: typeof publishActions }) {
|
||||
const didSelectHistoryEntry = useCallback(
|
||||
(index: number) => {
|
||||
const items = [...props.history].reverse()
|
||||
const message = items[index]
|
||||
props.actions.setTopic(message.topic)
|
||||
props.actions.setPayload(message.payload)
|
||||
},
|
||||
[props.history]
|
||||
)
|
||||
|
||||
return useMemo(() => {
|
||||
const items = [...props.history].reverse().map(message => ({
|
||||
key: sha1(message.topic + message.payload),
|
||||
title: message.topic,
|
||||
value: message.payload || '',
|
||||
}))
|
||||
|
||||
return <History autoOpen={true} items={items} onClick={didSelectHistoryEntry} />
|
||||
}, [props.history])
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(publishActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
undefined,
|
||||
mapDispatchToProps
|
||||
)(PublishHistory)
|
||||
@@ -0,0 +1,47 @@
|
||||
import QosSelect from './QosSelect'
|
||||
import React from 'react'
|
||||
import { Checkbox, FormControlLabel, Tooltip } from '@material-ui/core'
|
||||
import { publishActions } from '../../../actions'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { AppState } from '../../../reducers'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
export function RetainSwitch(props: { retain: boolean; actions: typeof publishActions }) {
|
||||
const labelStyle = { margin: '0 8px 0 8px' }
|
||||
return (
|
||||
<div style={{ marginTop: '8px', clear: 'both' }}>
|
||||
<div style={{ width: '100%', textAlign: 'right' }}>
|
||||
<FormControlLabel style={labelStyle} control={<QosSelect />} label="QoS" labelPlacement="start" />
|
||||
<Tooltip
|
||||
title="Retained messages only appear to be retained, when client subscribes after the initial publish."
|
||||
placement="top"
|
||||
>
|
||||
<FormControlLabel
|
||||
value="retain"
|
||||
style={labelStyle}
|
||||
control={<Checkbox color="primary" checked={props.retain} onChange={props.actions.toggleRetain} />}
|
||||
label="retain"
|
||||
labelPlacement="end"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(publishActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
retain: state.publish.retain,
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(RetainSwitch)
|
||||
@@ -0,0 +1,63 @@
|
||||
import ClearAdornment from '../../helper/ClearAdornment'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { FormControl, Input, InputLabel } from '@material-ui/core'
|
||||
import { publishActions } from '../../../actions'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { AppState } from '../../../reducers'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
function TopicInput(props: { actions: typeof publishActions; topic?: string }) {
|
||||
const updateTopic = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
props.actions.setTopic(e.target.value)
|
||||
}, [])
|
||||
|
||||
const clearTopic = useCallback(() => {
|
||||
props.actions.setTopic('')
|
||||
}, [])
|
||||
|
||||
const onTopicBlur = useCallback((e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (!e.target.value) {
|
||||
props.actions.setTopic(undefined)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const topicStr = props.topic || ''
|
||||
|
||||
return useMemo(
|
||||
() => (
|
||||
<div>
|
||||
<FormControl style={{ width: '100%' }}>
|
||||
<InputLabel htmlFor="publish-topic">Topic</InputLabel>
|
||||
<Input
|
||||
id="publish-topic"
|
||||
value={topicStr}
|
||||
startAdornment={<span />}
|
||||
endAdornment={<ClearAdornment action={clearTopic} value={topicStr} />}
|
||||
onBlur={onTopicBlur}
|
||||
onChange={updateTopic}
|
||||
multiline={false}
|
||||
placeholder="example/topic"
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
),
|
||||
[topicStr]
|
||||
)
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(publishActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
topic: state.publish.topic,
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(TopicInput)
|
||||
@@ -1,11 +1,7 @@
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import Copy from '../helper/Copy'
|
||||
import CustomIconButton from '../helper/CustomIconButton'
|
||||
import Delete from '@material-ui/icons/Delete'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import ExpandMore from '@material-ui/icons/ExpandMore'
|
||||
import NodeStats from './NodeStats'
|
||||
import Topic from './Topic'
|
||||
import ValuePanel from './ValueRenderer/ValuePanel'
|
||||
import { AppState } from '../../reducers'
|
||||
import { Badge, ExpansionPanel, ExpansionPanelDetails, ExpansionPanelSummary, Typography } from '@material-ui/core'
|
||||
@@ -14,166 +10,75 @@ import { connect } from 'react-redux'
|
||||
import { settingsActions, sidebarActions } from '../../actions'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { TopicViewModel } from '../../model/TopicViewModel'
|
||||
import TopicPanel from './TopicPanel/TopicPanel'
|
||||
import Panel from './Panel'
|
||||
import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode'
|
||||
|
||||
const throttle = require('lodash.throttle')
|
||||
|
||||
const Publish = React.lazy(() => import('./Publish/Publish'))
|
||||
|
||||
interface Props {
|
||||
node?: q.TreeNode<TopicViewModel>
|
||||
nodePath?: string
|
||||
tree?: q.Tree<TopicViewModel>
|
||||
actions: typeof sidebarActions
|
||||
settingsActions: typeof settingsActions
|
||||
classes: any
|
||||
connectionId?: string
|
||||
}
|
||||
|
||||
interface State {
|
||||
compareMessage?: q.Message
|
||||
valueRenderWidth: number
|
||||
function useUpdateNodeWhenNodeReceivesUpdates(node?: q.TreeNode<any>) {
|
||||
const [lastUpdate, setLastUpdate] = useState(0)
|
||||
const updateNode = useCallback(
|
||||
throttle(() => {
|
||||
setLastUpdate(node ? node.lastUpdate : 0)
|
||||
}, 300),
|
||||
[node]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const updateCallback = updateNode
|
||||
node && node.onMerge.subscribe(updateCallback)
|
||||
node && node.onMessage.subscribe(updateCallback)
|
||||
|
||||
return function cleanup() {
|
||||
node && node.onMerge.unsubscribe(updateCallback)
|
||||
node && node.onMessage.unsubscribe(updateCallback)
|
||||
}
|
||||
}, [node])
|
||||
}
|
||||
|
||||
class Sidebar extends React.Component<Props, State> {
|
||||
private updateNode = throttle(() => {
|
||||
this.setState(this.state)
|
||||
}, 300)
|
||||
function Sidebar(props: Props) {
|
||||
const { classes, tree, nodePath } = props
|
||||
const node = usePollingToFetchTreeNode(tree, nodePath || '')
|
||||
useUpdateNodeWhenNodeReceivesUpdates(node)
|
||||
// console.log(node && node.path(), tree, nodePath)
|
||||
|
||||
private detailsStyle = { padding: '0px 16px 8px 8px', display: 'block' }
|
||||
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = { valueRenderWidth: 300 }
|
||||
}
|
||||
|
||||
private registerUpdateListener(node: q.TreeNode<TopicViewModel>) {
|
||||
node.onMerge.subscribe(this.updateNode)
|
||||
node.onMessage.subscribe(this.updateNode)
|
||||
}
|
||||
|
||||
private removeUpdateListener(node: q.TreeNode<TopicViewModel>) {
|
||||
node.onMerge.unsubscribe(this.updateNode)
|
||||
node.onMessage.unsubscribe(this.updateNode)
|
||||
}
|
||||
|
||||
private renderTopicDeleteButton() {
|
||||
if (!this.props.node || (!this.props.node.message || !this.props.node.message.value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomIconButton onClick={() => this.deleteTopic(this.props.node)} tooltip="Clear this topic">
|
||||
<Delete style={{ marginTop: '-3px' }} />
|
||||
</CustomIconButton>
|
||||
)
|
||||
}
|
||||
|
||||
private renderRecursiveTopicDeleteButton() {
|
||||
const deleteLimit = 50
|
||||
const topicCount = this.props.node ? this.props.node.childTopicCount() : 0
|
||||
if (!this.props.node || topicCount === 0 || (this.props.node.message && topicCount === 1)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
classes={{ badge: this.props.classes.badge }}
|
||||
badgeContent={<span style={{ whiteSpace: 'nowrap' }}>{topicCount >= deleteLimit ? '50+' : topicCount}</span>}
|
||||
color="secondary"
|
||||
>
|
||||
<CustomIconButton
|
||||
onClick={() => this.deleteTopic(this.props.node, true, deleteLimit)}
|
||||
tooltip={`Deletes up to ${deleteLimit} sub-topics with a single click`}
|
||||
>
|
||||
<Delete style={{ marginTop: '-3px' }} color="action" />
|
||||
</CustomIconButton>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
private deleteTopic = (topic?: q.TreeNode<TopicViewModel>, recursive: boolean = false, maxCount = 50) => {
|
||||
if (!topic) {
|
||||
return
|
||||
}
|
||||
|
||||
this.props.actions.clearTopic(topic, recursive, maxCount)
|
||||
}
|
||||
|
||||
private renderNode() {
|
||||
const { classes, node } = this.props
|
||||
|
||||
const copyTopic = node ? <Copy value={node.path()} /> : null
|
||||
const deleteTopic = this.renderTopicDeleteButton()
|
||||
const deleteRecursiveTopic = this.renderRecursiveTopicDeleteButton()
|
||||
const summaryStyle = { minHeight: '0' }
|
||||
return (
|
||||
return (
|
||||
<div id="Sidebar" className={classes.drawer}>
|
||||
<div>
|
||||
<ExpansionPanel key="topic" defaultExpanded={true} disabled={!Boolean(this.props.node)}>
|
||||
<ExpansionPanelSummary expandIcon={<ExpandMore />} style={summaryStyle}>
|
||||
<Typography className={classes.heading}>
|
||||
Topic {copyTopic} {deleteTopic} {deleteRecursiveTopic}
|
||||
</Typography>
|
||||
</ExpansionPanelSummary>
|
||||
<ExpansionPanelDetails style={this.detailsStyle}>
|
||||
<Topic node={this.props.node} didSelectNode={this.updateNode} />
|
||||
<TopicPanel node={node} />
|
||||
<ValuePanel lastUpdate={node ? node.lastUpdate : 0} />
|
||||
<Panel>
|
||||
<span>Publish</span>
|
||||
<Publish connectionId={props.connectionId} />
|
||||
</Panel>
|
||||
<Panel detailsHidden={!node}>
|
||||
<span>Stats</span>
|
||||
<ExpansionPanelDetails className={classes.details}>
|
||||
<NodeStats node={node} />
|
||||
</ExpansionPanelDetails>
|
||||
</ExpansionPanel>
|
||||
<ValuePanel lastUpdate={this.props.node ? this.props.node.lastUpdate : 0} />
|
||||
<ExpansionPanel defaultExpanded={true}>
|
||||
<ExpansionPanelSummary expandIcon={<ExpandMore />} style={summaryStyle}>
|
||||
<Typography className={classes.heading}>Publish</Typography>
|
||||
</ExpansionPanelSummary>
|
||||
<ExpansionPanelDetails style={this.detailsStyle}>
|
||||
<React.Suspense fallback={<div>Loading...</div>}>
|
||||
<Publish connectionId={this.props.connectionId} />
|
||||
</React.Suspense>
|
||||
</ExpansionPanelDetails>
|
||||
</ExpansionPanel>
|
||||
<ExpansionPanel defaultExpanded={Boolean(this.props.node)}>
|
||||
<ExpansionPanelSummary expandIcon={<ExpandMore />} style={summaryStyle}>
|
||||
<Typography className={classes.heading}>Stats</Typography>
|
||||
</ExpansionPanelSummary>
|
||||
{this.renderNodeStats()}
|
||||
</ExpansionPanel>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private renderNodeStats() {
|
||||
if (!this.props.node) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpansionPanelDetails style={this.detailsStyle}>
|
||||
<NodeStats node={this.props.node} />
|
||||
</ExpansionPanelDetails>
|
||||
)
|
||||
}
|
||||
|
||||
public componentWillReceiveProps(nextProps: Props) {
|
||||
this.props.node && this.removeUpdateListener(this.props.node)
|
||||
nextProps.node && this.registerUpdateListener(nextProps.node)
|
||||
|
||||
if (this.props.node !== nextProps.node) {
|
||||
this.setState({ compareMessage: undefined })
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.props.node && this.removeUpdateListener(this.props.node)
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div id="Sidebar" className={this.props.classes.drawer}>
|
||||
{this.renderNode()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
const node = state.tree.get('selectedTopic')
|
||||
return {
|
||||
node: state.tree.get('selectedTopic'),
|
||||
tree: state.connection.tree,
|
||||
nodePath: node && node.path(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,16 +93,9 @@ const styles = (theme: Theme) => ({
|
||||
drawer: {
|
||||
display: 'block' as 'block',
|
||||
},
|
||||
badge: {
|
||||
top: '3px',
|
||||
right: '3px',
|
||||
},
|
||||
valuePaper: {
|
||||
margin: theme.spacing(1),
|
||||
},
|
||||
heading: {
|
||||
fontSize: theme.typography.pxToRem(15),
|
||||
fontWeight: theme.typography.fontWeightRegular,
|
||||
details: {
|
||||
padding: '0px 16px 8px 8px',
|
||||
display: 'block',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as q from '../../../../../backend/src/Model'
|
||||
import CustomIconButton from '../../helper/CustomIconButton'
|
||||
import Delete from '@material-ui/icons/Delete'
|
||||
import React, { useCallback } from 'react'
|
||||
import { Badge } from '@material-ui/core'
|
||||
|
||||
export const 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) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
props.deleteTopicAction(props.node, true, deleteLimit)
|
||||
}
|
||||
},
|
||||
[props.node]
|
||||
)
|
||||
if (!props.node) {
|
||||
return null
|
||||
}
|
||||
const deleteLimit = 50
|
||||
const topicCount = props.node ? props.node.childTopicCount() : 0
|
||||
if (topicCount === 0 || (props.node.message && topicCount === 1)) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
badgeContent={<span style={{ whiteSpace: 'nowrap' }}>{topicCount >= deleteLimit ? '50+' : topicCount}</span>}
|
||||
color="secondary"
|
||||
>
|
||||
<CustomIconButton onClick={onClick} tooltip={`Deletes up to ${deleteLimit} sub-topics with a single click`}>
|
||||
<Delete color="action" />
|
||||
</CustomIconButton>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
+5
-6
@@ -1,11 +1,11 @@
|
||||
import * as React from 'react'
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import React from 'react'
|
||||
import * as q from '../../../../../backend/src/Model'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import { withStyles, Theme } from '@material-ui/core/styles'
|
||||
import { treeActions } from '../../actions'
|
||||
import { treeActions } from '../../../actions'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { TopicViewModel } from '../../model/TopicViewModel'
|
||||
import { TopicViewModel } from '../../../model/TopicViewModel'
|
||||
|
||||
interface Props {
|
||||
classes: any
|
||||
@@ -13,7 +13,6 @@ interface Props {
|
||||
node?: q.TreeNode<TopicViewModel>
|
||||
selected?: q.TreeNode<TopicViewModel>
|
||||
actions: typeof treeActions
|
||||
didSelectNode: (node: q.TreeNode<TopicViewModel>) => void
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
@@ -24,7 +23,7 @@ const styles = (theme: Theme) => ({
|
||||
},
|
||||
})
|
||||
|
||||
class Topic extends React.Component<Props, {}> {
|
||||
class Topic extends React.PureComponent<Props, {}> {
|
||||
public render() {
|
||||
const { node, theme } = this.props
|
||||
if (!node) {
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as q from '../../../../../backend/src/Model'
|
||||
import CustomIconButton from '../../helper/CustomIconButton'
|
||||
import Delete from '@material-ui/icons/Delete'
|
||||
import React from 'react'
|
||||
|
||||
export const TopicDeleteButton = (props: {
|
||||
node?: q.TreeNode<any>
|
||||
deleteTopicAction: (node: q.TreeNode<any>) => void
|
||||
}) => {
|
||||
const { node } = props
|
||||
if (!node || !node.message || !node.message.value) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<CustomIconButton onClick={() => props.deleteTopicAction(node)} tooltip="Clear this topic">
|
||||
<Delete />
|
||||
</CustomIconButton>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { RecursiveTopicDeleteButton } from './RecursiveTopicDeleteButton'
|
||||
import { sidebarActions } from '../../../actions'
|
||||
import { TopicDeleteButton } from './TopicDeleteButton'
|
||||
|
||||
const TopicPanel = (props: { node?: q.TreeNode<any>; actions: typeof sidebarActions }) => {
|
||||
const { node } = props
|
||||
console.log(node && node.path())
|
||||
const copyTopic = node ? <Copy value={node.path()} /> : null
|
||||
|
||||
const deleteTopic = useCallback((topic?: q.TreeNode<any>, recursive: boolean = false, maxCount = 50) => {
|
||||
if (!topic) {
|
||||
return
|
||||
}
|
||||
|
||||
props.actions.clearTopic(topic, recursive, maxCount)
|
||||
}, [])
|
||||
|
||||
return useMemo(
|
||||
() => (
|
||||
<Panel disabled={!Boolean(node)}>
|
||||
<span>
|
||||
Topic {copyTopic}
|
||||
<TopicDeleteButton node={node} deleteTopicAction={deleteTopic} />
|
||||
<RecursiveTopicDeleteButton node={node} deleteTopicAction={deleteTopic} />
|
||||
</span>
|
||||
<Topic node={node} />
|
||||
</Panel>
|
||||
),
|
||||
[node, node && node.childTopicCount()]
|
||||
)
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(sidebarActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
undefined,
|
||||
mapDispatchToProps
|
||||
)(TopicPanel)
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import Code from '@material-ui/icons/Code'
|
||||
import Reorder from '@material-ui/icons/Reorder'
|
||||
import ToggleButton from '@material-ui/lab/ToggleButton'
|
||||
import ToggleButtonGroup from '@material-ui/lab/ToggleButtonGroup'
|
||||
import { settingsActions } from '../../../actions'
|
||||
import { Tooltip, withStyles, Theme } from '@material-ui/core'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { AppState } from '../../../reducers'
|
||||
import { connect } from 'react-redux'
|
||||
import { ValueRendererDisplayMode } from '../../../reducers/Settings'
|
||||
|
||||
function ActionButtons(props: {
|
||||
actions: { settings: typeof settingsActions }
|
||||
valueRendererDisplayMode: ValueRendererDisplayMode
|
||||
classes: any
|
||||
}) {
|
||||
const handleValue = useCallback(
|
||||
(mouseEvent: React.MouseEvent, value: any) => {
|
||||
if (value === null) {
|
||||
return
|
||||
}
|
||||
props.actions.settings.setValueDisplayMode(value)
|
||||
},
|
||||
[props.actions.settings.setValueDisplayMode]
|
||||
)
|
||||
|
||||
return (
|
||||
<ToggleButtonGroup
|
||||
id="valueRendererDisplayMode"
|
||||
value={props.valueRendererDisplayMode}
|
||||
exclusive={true}
|
||||
onChange={handleValue}
|
||||
>
|
||||
<ToggleButton className={props.classes.toggleButton} value="diff" id="valueRendererDisplayMode-diff">
|
||||
<Tooltip title="Show difference between the current and the last message">
|
||||
<span>
|
||||
<Code className={props.classes.toggleButtonIcon} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</ToggleButton>
|
||||
<ToggleButton className={props.classes.toggleButton} value="raw" id="valueRendererDisplayMode-raw">
|
||||
<Tooltip title="Raw / formatted JSON">
|
||||
<span>
|
||||
<Reorder className={props.classes.toggleButtonIcon} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
toggleButton: {
|
||||
height: '36px',
|
||||
},
|
||||
toggleButtonIcon: {
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
})
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
settings: bindActionCreators(settingsActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
valueRendererDisplayMode: state.settings.get('valueRendererDisplayMode'),
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(withStyles(styles)(ActionButtons))
|
||||
@@ -0,0 +1,39 @@
|
||||
import Clear from '@material-ui/icons/Clear'
|
||||
import React, { useMemo } from 'react'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { Button, Tooltip } from '@material-ui/core'
|
||||
import { connect } from 'react-redux'
|
||||
import { sidebarActions } from '../../../actions'
|
||||
|
||||
const DeleteSelectedTopicButton = (props: {
|
||||
actions: {
|
||||
sidebar: typeof sidebarActions
|
||||
}
|
||||
}) =>
|
||||
useMemo(
|
||||
() => (
|
||||
<Tooltip title="Delete retained topic" placement="top">
|
||||
<Button
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
style={{ marginTop: '11px', padding: '0px 4px', minHeight: '24px' }}
|
||||
onClick={props.actions.sidebar.clearRetainedTopic}
|
||||
>
|
||||
retained <Clear style={{ fontSize: '16px', marginLeft: '2px' }} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
[props.actions.sidebar.clearRetainedTopic]
|
||||
)
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: { sidebar: bindActionCreators(sidebarActions, dispatch) },
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
undefined,
|
||||
mapDispatchToProps
|
||||
)(DeleteSelectedTopicButton)
|
||||
@@ -27,16 +27,17 @@ interface Props {
|
||||
interface State {
|
||||
displayMessage?: q.Message
|
||||
anchorEl?: HTMLElement
|
||||
lastUpdate: number
|
||||
}
|
||||
|
||||
class MessageHistory extends React.Component<Props, State> {
|
||||
class MessageHistory extends React.PureComponent<Props, State> {
|
||||
private updateNode = throttle(() => {
|
||||
this.setState(this.state)
|
||||
this.setState({ lastUpdate: Date.now() })
|
||||
}, 300)
|
||||
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
this.state = { lastUpdate: 0 }
|
||||
}
|
||||
|
||||
private addNodeToCharts = (event: React.MouseEvent) => {
|
||||
@@ -79,7 +80,7 @@ class MessageHistory extends React.Component<Props, State> {
|
||||
|
||||
const history = node.messageHistory.toArray()
|
||||
let previousMessage: q.Message | undefined = node.message
|
||||
const historyElements = history.reverse().map((message, idx) => {
|
||||
const historyElements = [...history].reverse().map((message, idx) => {
|
||||
const value = message.value ? Base64Message.toUnicodeString(message.value) : ''
|
||||
const element = {
|
||||
value,
|
||||
|
||||
@@ -1,196 +1,117 @@
|
||||
import * as q from '../../../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import Clear from '@material-ui/icons/Clear'
|
||||
import Code from '@material-ui/icons/Code'
|
||||
import ActionButtons from './ActionButtons'
|
||||
import Copy from '../../helper/Copy'
|
||||
import DateFormatter from '../../helper/DateFormatter'
|
||||
import ExpandMore from '@material-ui/icons/ExpandMore'
|
||||
import MessageHistory from './MessageHistory'
|
||||
import Reorder from '@material-ui/icons/Reorder'
|
||||
import ToggleButton from '@material-ui/lab/ToggleButton'
|
||||
import ToggleButtonGroup from '@material-ui/lab/ToggleButtonGroup'
|
||||
import Panel from '../Panel'
|
||||
import React, { useCallback } from 'react'
|
||||
import ValueRenderer from './ValueRenderer'
|
||||
import { AppState } from '../../../reducers'
|
||||
import { Base64Message } from '../../../../../backend/src/Model/Base64Message'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { Theme, Typography, withStyles } from '@material-ui/core'
|
||||
import { connect } from 'react-redux'
|
||||
import { settingsActions, sidebarActions } from '../../../actions'
|
||||
import { ValueRendererDisplayMode } from '../../../reducers/Settings'
|
||||
|
||||
import {
|
||||
Button,
|
||||
ExpansionPanel,
|
||||
ExpansionPanelDetails,
|
||||
ExpansionPanelSummary,
|
||||
Tooltip,
|
||||
Typography,
|
||||
StyleRulesCallback,
|
||||
withStyles,
|
||||
Theme,
|
||||
} from '@material-ui/core'
|
||||
import { sidebarActions } from '../../../actions'
|
||||
import DeleteSelectedTopicButton from './DeleteSelectedTopicButton'
|
||||
|
||||
interface Props {
|
||||
node?: q.TreeNode<any>
|
||||
valueRendererDisplayMode: ValueRendererDisplayMode
|
||||
sidebarActions: typeof sidebarActions
|
||||
settingsActions: typeof settingsActions
|
||||
classes: any
|
||||
lastUpdate: number
|
||||
compareMessage?: q.Message
|
||||
}
|
||||
|
||||
interface State {}
|
||||
function RenderedValue(props: { node?: q.TreeNode<any>; compareMessage?: q.Message }) {
|
||||
const { node, compareMessage } = props
|
||||
|
||||
class ValuePanel extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
if (!node || !node.message) {
|
||||
return null
|
||||
}
|
||||
|
||||
private renderValue() {
|
||||
const node = this.props.node
|
||||
if (!node || !node.message) {
|
||||
return <ValueRenderer treeNode={node} message={node.message} compareWith={compareMessage} />
|
||||
}
|
||||
|
||||
function ValuePanel(props: Props) {
|
||||
const { node, compareMessage } = props
|
||||
|
||||
function renderViewOptions() {
|
||||
if (!props.node || !props.node.message || !props.node.mqttMessage) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <ValueRenderer treeNode={node} message={node.message} compareWith={this.props.compareMessage} />
|
||||
}
|
||||
|
||||
private renderViewOptions() {
|
||||
if (!this.props.node || !this.props.node.message || !this.props.node.mqttMessage) {
|
||||
return null
|
||||
}
|
||||
|
||||
const retainedButton = (
|
||||
<Tooltip title="Delete retained topic" placement="top">
|
||||
<Button
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
style={{ marginTop: '11px', padding: '0px 4px', minHeight: '24px' }}
|
||||
onClick={this.props.sidebarActions.clearRetainedTopic}
|
||||
>
|
||||
retained <Clear style={{ fontSize: '16px', marginLeft: '2px' }} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', display: 'flex', paddingLeft: '8px' }}>
|
||||
<span style={{ marginTop: '2px', flexGrow: 1 }}>{this.renderActionButtons()}</span>
|
||||
<div style={{ flex: 6, textAlign: 'right' }}>{this.props.node.mqttMessage.retain ? retainedButton : null}</div>
|
||||
{this.messageMetaInfo()}
|
||||
<div style={{ width: '100%', display: 'flex', flexWrap: 'wrap' }}>
|
||||
<span style={{ marginTop: '2px', flexGrow: 1 }}>
|
||||
<ActionButtons />
|
||||
</span>
|
||||
<span style={{ flex: 6, textAlign: 'right' }}>
|
||||
{props.node.mqttMessage.retain ? <DeleteSelectedTopicButton /> : null}
|
||||
</span>
|
||||
{messageMetaInfo()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private messageMetaInfo() {
|
||||
if (!this.props.node || !this.props.node.message || !this.props.node.mqttMessage) {
|
||||
function messageMetaInfo() {
|
||||
if (!props.node || !props.node.message || !props.node.mqttMessage) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span style={{ width: '100%', paddingLeft: '8px', flex: 6 }}>
|
||||
<Typography style={{ textAlign: 'right' }}>QoS: {this.props.node.mqttMessage.qos}</Typography>
|
||||
<Typography style={{ textAlign: 'right' }}>QoS: {props.node.mqttMessage.qos}</Typography>
|
||||
<Typography style={{ textAlign: 'right' }}>
|
||||
<i>
|
||||
<DateFormatter date={this.props.node.message.received} />
|
||||
<DateFormatter date={props.node.message.received} />
|
||||
</i>
|
||||
</Typography>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
private renderActionButtons() {
|
||||
const handleValue = (mouseEvent: React.MouseEvent, value: any) => {
|
||||
if (value === null) {
|
||||
return
|
||||
const handleMessageHistorySelect = useCallback(
|
||||
(message: q.Message) => {
|
||||
if (message !== compareMessage) {
|
||||
props.sidebarActions.setCompareMessage(message)
|
||||
} else {
|
||||
props.sidebarActions.setCompareMessage(undefined)
|
||||
}
|
||||
this.props.settingsActions.setValueDisplayMode(value)
|
||||
}
|
||||
},
|
||||
[compareMessage]
|
||||
)
|
||||
|
||||
return (
|
||||
<ToggleButtonGroup
|
||||
id="valueRendererDisplayMode"
|
||||
value={this.props.valueRendererDisplayMode}
|
||||
exclusive={true}
|
||||
onChange={handleValue}
|
||||
>
|
||||
<ToggleButton className={this.props.classes.toggleButton} value="diff" id="valueRendererDisplayMode-diff">
|
||||
<Tooltip title="Show difference between the current and the last message">
|
||||
<span>
|
||||
<Code className={this.props.classes.toggleButtonIcon} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</ToggleButton>
|
||||
<ToggleButton className={this.props.classes.toggleButton} value="raw" id="valueRendererDisplayMode-raw">
|
||||
<Tooltip title="Raw value">
|
||||
<span>
|
||||
<Reorder className={this.props.classes.toggleButtonIcon} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
)
|
||||
}
|
||||
const copyValue =
|
||||
node && node.message && node.message.value ? (
|
||||
<Copy value={Base64Message.toUnicodeString(node.message.value)} />
|
||||
) : null
|
||||
|
||||
private panelStyle() {
|
||||
return {
|
||||
detailsStyle: { padding: '0px 16px 8px 8px', display: 'block' },
|
||||
summaryStyle: { minHeight: '0' },
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessageHistorySelect = (message: q.Message) => {
|
||||
if (message !== this.props.compareMessage) {
|
||||
this.props.sidebarActions.setCompareMessage(message)
|
||||
} else {
|
||||
this.props.sidebarActions.setCompareMessage(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { node, classes } = this.props
|
||||
const { detailsStyle, summaryStyle } = this.panelStyle()
|
||||
|
||||
const copyValue =
|
||||
node && node.message && node.message.value ? (
|
||||
<Copy value={Base64Message.toUnicodeString(node.message.value)} />
|
||||
) : null
|
||||
|
||||
return (
|
||||
<ExpansionPanel key="value" defaultExpanded={true}>
|
||||
<ExpansionPanelSummary expandIcon={<ExpandMore />} style={summaryStyle}>
|
||||
<Typography className={classes.heading}>Value {copyValue}</Typography>
|
||||
</ExpansionPanelSummary>
|
||||
<ExpansionPanelDetails style={detailsStyle}>
|
||||
{this.renderViewOptions()}
|
||||
<div>
|
||||
<React.Suspense fallback={<div>Loading...</div>}>{this.renderValue()}</React.Suspense>
|
||||
</div>
|
||||
<div>
|
||||
<MessageHistory
|
||||
onSelect={this.handleMessageHistorySelect}
|
||||
selected={this.props.compareMessage}
|
||||
node={this.props.node}
|
||||
/>
|
||||
</div>
|
||||
</ExpansionPanelDetails>
|
||||
</ExpansionPanel>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Panel>
|
||||
<span>Value {copyValue}</span>
|
||||
<span style={{ width: '100%' }}>
|
||||
{renderViewOptions()}
|
||||
<div style={{ marginBottom: '-8px', marginTop: '8px' }}>
|
||||
<React.Suspense fallback={<div>Loading...</div>}>
|
||||
<RenderedValue node={node} compareMessage={compareMessage} />
|
||||
</React.Suspense>
|
||||
</div>
|
||||
<div>
|
||||
<MessageHistory onSelect={handleMessageHistorySelect} selected={props.compareMessage} node={props.node} />
|
||||
</div>
|
||||
</span>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
sidebarActions: bindActionCreators(sidebarActions, dispatch),
|
||||
settingsActions: bindActionCreators(settingsActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
valueRendererDisplayMode: state.settings.get('valueRendererDisplayMode'),
|
||||
node: state.tree.get('selectedTopic'),
|
||||
compareMessage: state.sidebar.get('compareMessage'),
|
||||
}
|
||||
@@ -201,12 +122,6 @@ const styles = (theme: Theme) => ({
|
||||
fontSize: theme.typography.pxToRem(15),
|
||||
fontWeight: theme.typography.fontWeightRegular,
|
||||
},
|
||||
toggleButton: {
|
||||
height: '36px',
|
||||
},
|
||||
toggleButtonIcon: {
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Base64Message } from '../../../../../backend/src/Model/Base64Message'
|
||||
import { connect } from 'react-redux'
|
||||
import { default as ReactResizeDetector } from 'react-resize-detector'
|
||||
import { ValueRendererDisplayMode } from '../../../reducers/Settings'
|
||||
import { Typography, Fade, Grow } from '@material-ui/core'
|
||||
|
||||
interface Props {
|
||||
message: q.Message
|
||||
@@ -24,18 +25,34 @@ class ValueRenderer extends React.Component<Props, State> {
|
||||
this.state = { width: 0 }
|
||||
}
|
||||
|
||||
private renderDiff(current: string = '', previous: string = '', language?: 'json') {
|
||||
private renderDiff(current: string = '', previous: string = '', title?: string, language?: 'json') {
|
||||
return (
|
||||
<CodeDiff
|
||||
treeNode={this.props.treeNode}
|
||||
previous={previous}
|
||||
current={current}
|
||||
title={title}
|
||||
language={language}
|
||||
nameOfCompareMessage={this.props.compareWith ? 'selected' : 'previous'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
private convertMessage(msg?: Base64Message): [string | undefined, 'json' | undefined] {
|
||||
if (!msg) {
|
||||
return [undefined, undefined]
|
||||
}
|
||||
|
||||
const str = Base64Message.toUnicodeString(msg)
|
||||
try {
|
||||
JSON.parse(str)
|
||||
} catch (error) {
|
||||
return [str, undefined]
|
||||
}
|
||||
|
||||
return [this.messageToPrettyJson(str), 'json']
|
||||
}
|
||||
|
||||
private messageToPrettyJson(str: string): string | undefined {
|
||||
try {
|
||||
const json = JSON.parse(str)
|
||||
@@ -45,61 +62,50 @@ class ValueRenderer extends React.Component<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
private updateWidth = (width: number) => {
|
||||
if (width !== this.state.width) {
|
||||
this.setState({ width })
|
||||
private renderRawMode(message: q.Message, compare?: q.Message) {
|
||||
if (!message.value) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const [value, valueLanguage] = this.convertMessage(message.value)
|
||||
const [compareStr, compareStrLanguage] =
|
||||
compare && compare.value ? this.convertMessage(compare.value) : [undefined, undefined]
|
||||
|
||||
private renderRawValue(value: string, compare: string) {
|
||||
return this.renderDiff(value, compare)
|
||||
return (
|
||||
<div>
|
||||
{this.renderDiff(value, value, undefined, valueLanguage)}
|
||||
<Fade in={Boolean(compareStr)} timeout={400}>
|
||||
<div>
|
||||
{Boolean(compareStr) ? this.renderDiff(compareStr, compareStr, 'selected', compareStrLanguage) : null}
|
||||
</div>
|
||||
</Fade>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div style={{ padding: '0px 0px 8px 8px' }}>
|
||||
<ReactResizeDetector handleWidth={true} onResize={this.updateWidth} />
|
||||
{this.renderValue()}
|
||||
</div>
|
||||
)
|
||||
return <div style={{ padding: '0px 0px 8px 0px', width: '100%' }}>{this.renderValue()}</div>
|
||||
}
|
||||
|
||||
public renderValue() {
|
||||
const { message, treeNode, compareWith, renderMode } = this.props
|
||||
const previousMessages = treeNode.messageHistory.toArray()
|
||||
const previousMessage = previousMessages[previousMessages.length - 2]
|
||||
let compareMessage = compareWith || previousMessage || message
|
||||
const compareMessage = compareWith || previousMessage || message
|
||||
|
||||
if (renderMode === 'raw') {
|
||||
compareMessage = message
|
||||
return this.renderRawMode(message, compareWith)
|
||||
}
|
||||
if (!message.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const compareValue = compareMessage.value || message.value
|
||||
const str = Base64Message.toUnicodeString(message.value)
|
||||
const compareStr = Base64Message.toUnicodeString(compareValue)
|
||||
const [current, currentLanguage] = this.convertMessage(message.value)
|
||||
const [compare, compareLanguage] = this.convertMessage(compareValue)
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(str)
|
||||
} catch (error) {
|
||||
return this.renderRawValue(str, compareStr)
|
||||
}
|
||||
const language = currentLanguage === compareLanguage && compareLanguage === 'json' ? 'json' : undefined
|
||||
|
||||
if (typeof json === 'string') {
|
||||
return this.renderRawValue(str, compareStr)
|
||||
} else if (typeof json === 'number') {
|
||||
return this.renderRawValue(str, compareStr)
|
||||
} else if (typeof json === 'boolean') {
|
||||
return this.renderRawValue(str, compareStr)
|
||||
} else {
|
||||
const current = this.messageToPrettyJson(str) || str
|
||||
const compare = this.messageToPrettyJson(compareStr) || compareStr
|
||||
const language = current && compare ? 'json' : undefined
|
||||
|
||||
return this.renderDiff(current, compare, language)
|
||||
}
|
||||
return this.renderDiff(current, compare, undefined, language)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
import * as dotProp from 'dot-prop'
|
||||
import * as q from '../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import PlotHistory from './Sidebar/PlotHistory'
|
||||
import PlotHistory from './Chart/Chart'
|
||||
import { Base64Message } from '../../../backend/src/Model/Base64Message'
|
||||
import { toPlottableValue } from './Sidebar/CodeDiff/util'
|
||||
import { PlotCurveTypes } from '../reducers/Charts'
|
||||
const parseDuration = require('parse-duration')
|
||||
|
||||
interface Props {
|
||||
history: q.MessageHistory
|
||||
dotPath?: string
|
||||
timeInterval?: string
|
||||
interpolation?: PlotCurveTypes
|
||||
range?: [number?, number?]
|
||||
color?: string
|
||||
}
|
||||
|
||||
function nodeToHistory(history: q.MessageHistory) {
|
||||
return history
|
||||
.toArray()
|
||||
function filterUsingTimeRange(startTime: number | undefined, data: Array<q.Message>) {
|
||||
if (startTime) {
|
||||
const threshold = new Date(Date.now() - startTime)
|
||||
return data.filter(d => d.received >= threshold)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
function nodeToHistory(startTime: number | undefined, history: q.MessageHistory) {
|
||||
return filterUsingTimeRange(startTime, history.toArray())
|
||||
.map((message: q.Message) => {
|
||||
const value = message.value ? toPlottableValue(Base64Message.toUnicodeString(message.value)) : NaN
|
||||
return { x: message.received.getTime(), y: toPlottableValue(value) }
|
||||
@@ -24,25 +34,40 @@ function nodeToHistory(history: q.MessageHistory) {
|
||||
.filter(data => !isNaN(data.y as any)) as any
|
||||
}
|
||||
|
||||
function nodeDotPathToHistory(history: q.MessageHistory, dotPath: string) {
|
||||
return history
|
||||
.toArray()
|
||||
function nodeDotPathToHistory(startTime: number | undefined, history: q.MessageHistory, dotPath: string) {
|
||||
return filterUsingTimeRange(startTime, history.toArray())
|
||||
.map((message: q.Message) => {
|
||||
let json = {}
|
||||
try {
|
||||
json = message.value ? JSON.parse(Base64Message.toUnicodeString(message.value)) : {}
|
||||
} catch (ignore) {}
|
||||
|
||||
let value = dotProp.get(json, dotPath)
|
||||
const value = dotProp.get(json, dotPath)
|
||||
|
||||
return { x: message.received.getTime(), y: toPlottableValue(value) }
|
||||
})
|
||||
.filter(data => !isNaN(data.y as any)) as any
|
||||
}
|
||||
|
||||
function render(props: Props) {
|
||||
const data = props.dotPath ? nodeDotPathToHistory(props.history, props.dotPath) : nodeToHistory(props.history)
|
||||
return <PlotHistory color={props.color} range={props.range} interpolation={props.interpolation} data={data} />
|
||||
function TopicPlot(props: Props) {
|
||||
const startOffset = props.timeInterval ? parseDuration(props.timeInterval) : undefined
|
||||
const data = React.useMemo(
|
||||
() =>
|
||||
props.dotPath
|
||||
? nodeDotPathToHistory(startOffset, props.history, props.dotPath)
|
||||
: nodeToHistory(startOffset, props.history),
|
||||
[props.history.last(), startOffset, props.dotPath]
|
||||
)
|
||||
|
||||
return (
|
||||
<PlotHistory
|
||||
timeRangeStart={startOffset}
|
||||
color={props.color}
|
||||
range={props.range}
|
||||
interpolation={props.interpolation}
|
||||
data={data}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default render
|
||||
export default TopicPlot
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import TreeNodeSubnodes from './TreeNodeSubnodes'
|
||||
import TreeNodeTitle from './TreeNodeTitle'
|
||||
import { Record } from 'immutable'
|
||||
import { SettingsState } from '../../reducers/Settings'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { TopicViewModel } from '../../model/TopicViewModel'
|
||||
|
||||
const debounce = require('lodash.debounce')
|
||||
|
||||
declare var performance: any
|
||||
|
||||
const styles = (theme: Theme) => {
|
||||
return {
|
||||
collapsedSubnodes: {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
displayBlock: {
|
||||
display: 'block',
|
||||
},
|
||||
node: {
|
||||
display: 'block',
|
||||
marginLeft: '10px',
|
||||
},
|
||||
topicSelect: {
|
||||
float: 'right' as 'right',
|
||||
opacity: 0,
|
||||
cursor: 'pointer',
|
||||
marginTop: '-1px',
|
||||
},
|
||||
subnodes: {
|
||||
marginLeft: theme.spacing(1.5),
|
||||
},
|
||||
selected: {
|
||||
backgroundColor: theme.palette.type === 'dark' ? 'rgba(170, 170, 170, 0.55)' : 'rgba(170, 170, 170, 0.55)',
|
||||
},
|
||||
hover: {
|
||||
backgroundColor: theme.palette.type === 'dark' ? 'rgba(100, 100, 100, 0.55)' : 'rgba(200, 200, 200, 0.55)',
|
||||
},
|
||||
title: {
|
||||
borderRadius: '4px',
|
||||
lineHeight: '1em',
|
||||
display: 'inline-block' as 'inline-block',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
padding: '1px 4px 0px 4px',
|
||||
height: '14px',
|
||||
margin: '1px 0px 2px 0px',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isRoot?: boolean
|
||||
treeNode: q.TreeNode<TopicViewModel>
|
||||
name?: string | undefined
|
||||
collapsed?: boolean | undefined
|
||||
performanceCallback?: ((ms: number) => void) | undefined
|
||||
classes: any
|
||||
className?: string
|
||||
lastUpdate: number
|
||||
didSelectTopic: any
|
||||
theme: Theme
|
||||
settings: Record<SettingsState>
|
||||
}
|
||||
|
||||
interface State {
|
||||
collapsedOverride: boolean | undefined
|
||||
mouseOver: boolean
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
class TreeNodeComponent extends React.Component<Props, State> {
|
||||
private animationDirty: boolean = false
|
||||
|
||||
private cssAnimationWasSetAt?: number
|
||||
|
||||
private willUpdateTime: number = performance.now()
|
||||
private nodeRef?: React.RefObject<HTMLDivElement> = React.createRef<HTMLDivElement>()
|
||||
|
||||
private setHover = debounce((hover: boolean) => {
|
||||
this.setState({ mouseOver: hover })
|
||||
}, 45)
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
collapsedOverride: props.collapsed,
|
||||
mouseOver: false,
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
private addSubscriber(treeNode: q.TreeNode<TopicViewModel>) {
|
||||
treeNode.viewModel = new TopicViewModel()
|
||||
treeNode.viewModel.change.subscribe(this.viewStateHasChanged)
|
||||
}
|
||||
|
||||
private viewStateHasChanged = () => {
|
||||
this.props.treeNode.viewModel && this.setState({ selected: this.props.treeNode.viewModel.isSelected() })
|
||||
}
|
||||
|
||||
private removeSubscriber(treeNode: q.TreeNode<TopicViewModel>) {
|
||||
treeNode.viewModel && treeNode.viewModel.change.unsubscribe(this.viewStateHasChanged)
|
||||
treeNode.viewModel = undefined
|
||||
}
|
||||
|
||||
private stateHasChanged(newState: State) {
|
||||
return (
|
||||
this.state.collapsedOverride !== newState.collapsedOverride ||
|
||||
this.state.mouseOver !== newState.mouseOver ||
|
||||
this.state.selected !== newState.selected
|
||||
)
|
||||
}
|
||||
|
||||
private toggle() {
|
||||
this.setState({ collapsedOverride: !this.collapsed() })
|
||||
}
|
||||
|
||||
private collapsed() {
|
||||
if (this.state.collapsedOverride !== undefined) {
|
||||
return this.state.collapsedOverride
|
||||
}
|
||||
|
||||
return this.props.treeNode.edgeCount() > this.props.settings.get('autoExpandLimit')
|
||||
}
|
||||
|
||||
private didSelectTopic = () => {
|
||||
this.props.didSelectTopic(this.props.treeNode)
|
||||
}
|
||||
|
||||
private didClickTitle = (event: React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
this.props.didSelectTopic(this.props.treeNode)
|
||||
this.toggle()
|
||||
}
|
||||
|
||||
private mouseOver = (event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
this.setHover(true)
|
||||
if (
|
||||
this.props.settings.get('selectTopicWithMouseOver') &&
|
||||
this.props.treeNode &&
|
||||
this.props.treeNode.message &&
|
||||
this.props.treeNode.message.value
|
||||
) {
|
||||
this.props.didSelectTopic(this.props.treeNode)
|
||||
}
|
||||
}
|
||||
|
||||
private mouseOut = (event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
this.setHover(false)
|
||||
}
|
||||
|
||||
private toggleCollapsed = (event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
this.toggle()
|
||||
}
|
||||
|
||||
private renderNodes() {
|
||||
if (this.collapsed()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeNodeSubnodes
|
||||
collapsed={this.collapsed()}
|
||||
treeNode={this.props.treeNode}
|
||||
lastUpdate={this.props.treeNode.lastUpdate}
|
||||
didSelectTopic={this.props.didSelectTopic}
|
||||
settings={this.props.settings}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
const { treeNode } = this.props
|
||||
this.addSubscriber(treeNode)
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
const { treeNode } = this.props
|
||||
this.removeSubscriber(treeNode)
|
||||
this.nodeRef = undefined
|
||||
}
|
||||
|
||||
public shouldComponentUpdate(nextProps: Props, nextState: State) {
|
||||
const shouldRenderToRemoveCssAnimation = this.cssAnimationWasSetAt !== undefined
|
||||
return (
|
||||
this.stateHasChanged(nextState) ||
|
||||
this.props.settings !== nextProps.settings ||
|
||||
this.props.lastUpdate !== nextProps.lastUpdate ||
|
||||
this.animationDirty ||
|
||||
shouldRenderToRemoveCssAnimation
|
||||
)
|
||||
}
|
||||
|
||||
public componentDidUpdate() {
|
||||
if (this.props.performanceCallback) {
|
||||
const renderTime = performance.now() - this.willUpdateTime
|
||||
this.props.performanceCallback(renderTime)
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUpdate() {
|
||||
if (this.props.performanceCallback) {
|
||||
this.willUpdateTime = performance.now()
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { classes } = this.props
|
||||
|
||||
const shouldStartAnimation =
|
||||
!this.animationDirty && !this.props.isRoot && this.props.settings.get('highlightTopicUpdates')
|
||||
const animationName = this.props.theme.palette.type === 'light' ? 'updateLight' : 'updateDark'
|
||||
const animation = shouldStartAnimation
|
||||
? { willChange: 'auto', translateZ: 0, animation: `${animationName} 0.5s` }
|
||||
: {}
|
||||
this.animationDirty = shouldStartAnimation
|
||||
|
||||
const highlightClass = this.state.selected
|
||||
? this.props.classes.selected
|
||||
: this.state.mouseOver
|
||||
? this.props.classes.hover
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
key={this.props.treeNode.hash()}
|
||||
className={`${classes.node} ${this.props.className} ${highlightClass} ${classes.title}`}
|
||||
style={animation}
|
||||
onMouseOver={this.mouseOver}
|
||||
onMouseOut={this.mouseOut}
|
||||
onClick={this.didClickTitle}
|
||||
ref={this.nodeRef}
|
||||
>
|
||||
<TreeNodeTitle
|
||||
toggleCollapsed={this.toggleCollapsed}
|
||||
didSelectNode={this.didSelectTopic}
|
||||
collapsed={this.collapsed()}
|
||||
treeNode={this.props.treeNode}
|
||||
name={this.props.name}
|
||||
/>
|
||||
</div>
|
||||
{this.renderNodes()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(styles, { withTheme: true })(TreeNodeComponent)
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as q from '../../../../../backend/src/Model'
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import TreeNode from '.'
|
||||
import { SettingsState } from '../../../reducers/Settings'
|
||||
import { sortedNodes } from '../../../sortedNodes'
|
||||
import { Theme, withStyles } from '@material-ui/core'
|
||||
import { TopicViewModel } from '../../../model/TopicViewModel'
|
||||
import { treeActions } from '../../../actions'
|
||||
|
||||
export interface Props {
|
||||
treeNode: q.TreeNode<TopicViewModel>
|
||||
filter?: string
|
||||
classes: any
|
||||
lastUpdate: number
|
||||
selectedTopic?: q.TreeNode<TopicViewModel>
|
||||
selectTopicAction: (treeNode: q.TreeNode<any>) => void
|
||||
settings: SettingsState
|
||||
actions: typeof treeActions
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
function useStagedRendering(treeNode: q.TreeNode<any>) {
|
||||
const [alreadyAdded, setAlreadyAdded] = useState(10)
|
||||
const edges = treeNode.edgeArray
|
||||
|
||||
useEffect(() => {
|
||||
let renderMoreAnimationFrame: any
|
||||
|
||||
if (alreadyAdded < edges.length) {
|
||||
renderMoreAnimationFrame = (window as any).requestIdleCallback(
|
||||
() => {
|
||||
setAlreadyAdded(Math.max(25, alreadyAdded * 1.5))
|
||||
},
|
||||
{ timeout: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return function cleanup() {
|
||||
;(window as any).cancelIdleCallback(renderMoreAnimationFrame)
|
||||
}
|
||||
}, [alreadyAdded, edges.length])
|
||||
|
||||
return alreadyAdded
|
||||
}
|
||||
|
||||
function TreeNodeSubnodes(props: Props) {
|
||||
const alreadyAdded = useStagedRendering(props.treeNode)
|
||||
|
||||
return useMemo(() => {
|
||||
const nodes = sortedNodes(props.settings, props.treeNode).slice(0, alreadyAdded)
|
||||
const listItems = nodes.map(node => {
|
||||
return (
|
||||
<TreeNode
|
||||
key={`${node.hash()}-${props.filter}`}
|
||||
treeNode={node}
|
||||
lastUpdate={node.lastUpdate}
|
||||
selectTopicAction={props.selectTopicAction}
|
||||
settings={props.settings}
|
||||
actions={props.actions}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return <span className={props.classes.list}>{listItems}</span>
|
||||
}, [alreadyAdded, props.treeNode.lastUpdate, props.theme, props.settings])
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
list: {
|
||||
display: 'block' as 'block',
|
||||
clear: 'both' as 'both',
|
||||
marginLeft: theme.spacing(1.5),
|
||||
},
|
||||
})
|
||||
|
||||
export default withStyles(styles, { withTheme: true })(TreeNodeSubnodes)
|
||||
+23
-12
@@ -1,11 +1,12 @@
|
||||
import * as React from 'react'
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import { withStyles, Theme } from '@material-ui/core'
|
||||
import { TopicViewModel } from '../../model/TopicViewModel'
|
||||
import { Base64Message } from '../../../../backend/src/Model/Base64Message'
|
||||
import * as q from '../../../../../backend/src/Model'
|
||||
import React, { memo } from 'react'
|
||||
import { Base64Message } from '../../../../../backend/src/Model/Base64Message'
|
||||
import { Theme, withStyles } from '@material-ui/core'
|
||||
import { TopicViewModel } from '../../../model/TopicViewModel'
|
||||
|
||||
export interface TreeNodeProps extends React.HTMLAttributes<HTMLElement> {
|
||||
treeNode: q.TreeNode<TopicViewModel>
|
||||
lastUpdate: number
|
||||
name?: string | undefined
|
||||
collapsed?: boolean | undefined
|
||||
didSelectNode: any
|
||||
@@ -13,7 +14,7 @@ export interface TreeNodeProps extends React.HTMLAttributes<HTMLElement> {
|
||||
classes: any
|
||||
}
|
||||
|
||||
class TreeNodeTitle extends React.Component<TreeNodeProps, {}> {
|
||||
class TreeNodeTitle extends React.PureComponent<TreeNodeProps, {}> {
|
||||
private renderSourceEdge() {
|
||||
const name = this.props.name || (this.props.treeNode.sourceEdge && this.props.treeNode.sourceEdge.name)
|
||||
|
||||
@@ -24,13 +25,23 @@ class TreeNodeTitle extends React.Component<TreeNodeProps, {}> {
|
||||
)
|
||||
}
|
||||
|
||||
private truncatedMessage() {
|
||||
const limit = 400
|
||||
if (!this.props.treeNode.message || !this.props.treeNode.message.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const str = Base64Message.toUnicodeString(this.props.treeNode.message.value)
|
||||
return str.length > limit ? `${str.slice(0, limit)}…` : str
|
||||
}
|
||||
|
||||
private renderValue() {
|
||||
return this.props.treeNode.message &&
|
||||
this.props.treeNode.message.value &&
|
||||
this.props.treeNode.message.length > 0 ? (
|
||||
<span key="value" className={this.props.classes.value}>
|
||||
{' '}
|
||||
= {Base64Message.toUnicodeString(this.props.treeNode.message.value).slice(0, 120)}
|
||||
= {this.truncatedMessage()}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
@@ -53,11 +64,11 @@ class TreeNodeTitle extends React.Component<TreeNodeProps, {}> {
|
||||
}
|
||||
|
||||
const messages = this.props.treeNode.leafMessageCount()
|
||||
const topicCount = this.props.treeNode.childTopicCount()
|
||||
return (
|
||||
<span
|
||||
key="metadata"
|
||||
className={this.props.classes.collapsedSubnodes}
|
||||
>{`(${this.props.treeNode.childTopicCount()} topics, ${messages} messages)`}</span>
|
||||
<span key="metadata" className={this.props.classes.collapsedSubnodes}>{` (${topicCount} ${
|
||||
topicCount === 1 ? 'topic' : 'topics'
|
||||
}, ${messages} ${messages === 1 ? 'message' : 'messages'})`}</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,4 +100,4 @@ const styles = (theme: Theme) => ({
|
||||
},
|
||||
})
|
||||
|
||||
export default withStyles(styles)(TreeNodeTitle)
|
||||
export default withStyles(styles)(memo(TreeNodeTitle))
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { useEffect } from 'react'
|
||||
const inViewport = require('in-viewport')
|
||||
|
||||
export function useAnimationToIndicateTopicUpdate(
|
||||
ref: React.MutableRefObject<HTMLDivElement | undefined>,
|
||||
lastUpdate: number,
|
||||
className: string,
|
||||
selected: boolean,
|
||||
selectionLastUpdate: number,
|
||||
shouldAnimate: boolean
|
||||
) {
|
||||
useEffect(() => {
|
||||
const selectionDidChange = Date.now() - selectionLastUpdate < 500
|
||||
if (ref.current && shouldAnimate && Date.now() - lastUpdate < 3000 && !selected && !selectionDidChange) {
|
||||
if (!inViewport(ref.current)) {
|
||||
return
|
||||
}
|
||||
|
||||
let timeout: any
|
||||
let animationFrame = requestAnimationFrame(() => {
|
||||
ref.current && ref.current.classList.add(className)
|
||||
|
||||
timeout = setTimeout(
|
||||
() =>
|
||||
(animationFrame = requestAnimationFrame(() => {
|
||||
ref.current && ref.current.classList.remove(className)
|
||||
})),
|
||||
500
|
||||
)
|
||||
})
|
||||
|
||||
return function cleanup() {
|
||||
timeout && clearTimeout(timeout)
|
||||
animationFrame && cancelAnimationFrame(animationFrame)
|
||||
}
|
||||
}
|
||||
}, [lastUpdate, selected, selectionLastUpdate, shouldAnimate, className])
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as q from '../../../../../../backend/src/Model'
|
||||
import React, { useCallback } from 'react'
|
||||
import { KeyCodes } from '../../../../utils/KeyCodes'
|
||||
import { treeActions } from '../../../../actions'
|
||||
|
||||
export function useDeleteKeyCallback(topic: q.TreeNode<any>, actions: typeof treeActions) {
|
||||
return useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (event.keyCode === KeyCodes.delete || event.keyCode === KeyCodes.backspace) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
actions.clearTopic(topic, true, 50)
|
||||
}
|
||||
},
|
||||
[topic]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Props } from '..'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function useIsAllowedToAutoExpandState(props: Props): boolean {
|
||||
const { settings, treeNode, isRoot } = props
|
||||
const [isAllowedToAutoExpand, setAllowAutoExpand] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const newIsAllowedToAutoExpand = isRoot || treeNode.edgeCount() <= settings.get('autoExpandLimit')
|
||||
if (newIsAllowedToAutoExpand !== isAllowedToAutoExpand) {
|
||||
setAllowAutoExpand(newIsAllowedToAutoExpand)
|
||||
}
|
||||
}, [treeNode.edgeCount(), settings.get('autoExpandLimit')])
|
||||
return isAllowedToAutoExpand
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
interface SelectedState {
|
||||
selected: boolean
|
||||
lastUpdate: number
|
||||
}
|
||||
|
||||
export function useSelectionState(initialSelectionState: boolean): [boolean, number, (selected: boolean) => void] {
|
||||
const [state, setState] = useState<SelectedState>({ selected: initialSelectionState, lastUpdate: 0 })
|
||||
const setSelected = useCallback((selected: boolean) => {
|
||||
setState({ selected, lastUpdate: Date.now() })
|
||||
}, [])
|
||||
return [state.selected, state.lastUpdate, setSelected]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as q from '../../../../../../backend/src/Model'
|
||||
import React, { useEffect } from 'react'
|
||||
import { TopicViewModel } from '../../../../model/TopicViewModel'
|
||||
|
||||
export function useViewModelSubscriptions(
|
||||
treeNode: q.TreeNode<TopicViewModel>,
|
||||
nodeRef: React.MutableRefObject<HTMLDivElement | undefined>,
|
||||
setSelected: (value: boolean) => void,
|
||||
setCollapsedOverride: (value: boolean) => void
|
||||
) {
|
||||
useEffect(() => {
|
||||
const selectionDidChange = () => {
|
||||
const selected = treeNode.viewModel && treeNode.viewModel.isSelected()
|
||||
treeNode.viewModel && setSelected(Boolean(selected))
|
||||
|
||||
if (selected && nodeRef && nodeRef.current) {
|
||||
nodeRef.current.focus({ preventScroll: false })
|
||||
}
|
||||
}
|
||||
|
||||
const expandedDidChange = () => {
|
||||
treeNode.viewModel && setCollapsedOverride(!treeNode.viewModel.isExpanded())
|
||||
}
|
||||
|
||||
function addSubscriber() {
|
||||
treeNode.viewModel = new TopicViewModel()
|
||||
treeNode.viewModel.selectionChange.subscribe(selectionDidChange)
|
||||
treeNode.viewModel.expandedChange.subscribe(expandedDidChange)
|
||||
}
|
||||
|
||||
function removeSubscriber() {
|
||||
if (treeNode.viewModel) {
|
||||
treeNode.viewModel.selectionChange.unsubscribe(selectionDidChange)
|
||||
treeNode.viewModel.expandedChange.unsubscribe(expandedDidChange)
|
||||
treeNode.viewModel = undefined
|
||||
}
|
||||
}
|
||||
|
||||
addSubscriber()
|
||||
return function cleanup() {
|
||||
removeSubscriber()
|
||||
}
|
||||
}, [treeNode])
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import * as q from '../../../../../backend/src/Model'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import TreeNodeSubnodes from './TreeNodeSubnodes'
|
||||
import TreeNodeTitle from './TreeNodeTitle'
|
||||
import { SettingsState } from '../../../reducers/Settings'
|
||||
import { styles } from './styles'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { TopicViewModel } from '../../../model/TopicViewModel'
|
||||
import { treeActions } from '../../../actions'
|
||||
import { useAnimationToIndicateTopicUpdate } from './effects/useAnimationToIndicateTopicUpdate'
|
||||
import { useDeleteKeyCallback } from './effects/useDeleteKeyCallback'
|
||||
import { useIsAllowedToAutoExpandState } from './effects/useIsAllowedToAutoExpandState'
|
||||
import { useViewModelSubscriptions } from './effects/useViewModelSubscriptions'
|
||||
import { useSelectionState } from './effects/useSelectionState'
|
||||
|
||||
export interface Props {
|
||||
isRoot?: boolean
|
||||
treeNode: q.TreeNode<TopicViewModel>
|
||||
name?: string | undefined
|
||||
collapsed?: boolean | undefined
|
||||
classes: any
|
||||
lastUpdate: number
|
||||
actions: typeof treeActions
|
||||
selectTopicAction: (treeNode: q.TreeNode<any>) => void
|
||||
theme: Theme
|
||||
settings: SettingsState
|
||||
}
|
||||
|
||||
function TreeNodeComponent(props: Props) {
|
||||
const { actions, classes, settings, theme, treeNode, lastUpdate, name } = props
|
||||
const [collapsedOverride, setCollapsedOverride] = useState<boolean | undefined>(undefined)
|
||||
const [selected, selectionLastUpdate, setSelected] = useSelectionState(false)
|
||||
const nodeRef = useRef<HTMLDivElement>()
|
||||
const isAllowedToAutoExpand = useIsAllowedToAutoExpandState(props)
|
||||
const deleteTopicCallback = useDeleteKeyCallback(treeNode, actions)
|
||||
useViewModelSubscriptions(treeNode, nodeRef, setSelected, setCollapsedOverride)
|
||||
const animationClass =
|
||||
props.theme.palette.type === 'light' ? props.classes.animationLight : props.classes.animationDark
|
||||
|
||||
useAnimationToIndicateTopicUpdate(
|
||||
nodeRef,
|
||||
lastUpdate,
|
||||
animationClass,
|
||||
selected,
|
||||
selectionLastUpdate,
|
||||
settings.get('highlightTopicUpdates')
|
||||
)
|
||||
|
||||
const isCollapsed =
|
||||
Boolean(collapsedOverride) === collapsedOverride ? Boolean(collapsedOverride) : !isAllowedToAutoExpand
|
||||
|
||||
const didSelectTopic = useCallback(
|
||||
(event?: React.MouseEvent) => {
|
||||
event && event.stopPropagation()
|
||||
props.selectTopicAction(treeNode)
|
||||
},
|
||||
[treeNode]
|
||||
)
|
||||
|
||||
const didClickTitle = React.useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
didSelectTopic()
|
||||
setCollapsedOverride(!isCollapsed)
|
||||
},
|
||||
[isCollapsed, didSelectTopic]
|
||||
)
|
||||
|
||||
const toggleCollapsed = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
setCollapsedOverride(!isCollapsed)
|
||||
},
|
||||
[isCollapsed]
|
||||
)
|
||||
|
||||
const didObtainFocus = useCallback(() => {
|
||||
didSelectTopic()
|
||||
}, [didSelectTopic])
|
||||
|
||||
const mouseOver = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
if (settings.get('selectTopicWithMouseOver') && treeNode && treeNode.message && treeNode.message.value) {
|
||||
didSelectTopic()
|
||||
}
|
||||
},
|
||||
[didSelectTopic, settings]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
treeNode.viewModel && treeNode.viewModel.setExpanded(!isCollapsed, false)
|
||||
}, [isCollapsed])
|
||||
|
||||
return useMemo(() => {
|
||||
function renderNodes() {
|
||||
if (isCollapsed) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeNodeSubnodes
|
||||
treeNode={treeNode}
|
||||
lastUpdate={treeNode.lastUpdate}
|
||||
selectTopicAction={props.selectTopicAction}
|
||||
settings={settings}
|
||||
actions={props.actions}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const highlightClass = selected ? classes.selected : ''
|
||||
return (
|
||||
<div className={classes.node}>
|
||||
<span
|
||||
ref={nodeRef as any}
|
||||
key={treeNode.hash()}
|
||||
className={`${classes.title} ${highlightClass}`}
|
||||
onMouseEnter={mouseOver}
|
||||
onFocus={didObtainFocus}
|
||||
onClick={didClickTitle}
|
||||
tabIndex={-1}
|
||||
onKeyDown={deleteTopicCallback}
|
||||
>
|
||||
<TreeNodeTitle
|
||||
lastUpdate={treeNode.lastUpdate}
|
||||
toggleCollapsed={toggleCollapsed}
|
||||
didSelectNode={didSelectTopic}
|
||||
collapsed={isCollapsed}
|
||||
treeNode={treeNode}
|
||||
name={name}
|
||||
/>
|
||||
</span>
|
||||
{renderNodes()}
|
||||
</div>
|
||||
)
|
||||
}, [treeNode.lastUpdate, treeNode, name, isCollapsed, selected, theme, mouseOver, settings])
|
||||
}
|
||||
|
||||
export default withStyles(styles, { withTheme: true })(React.memo(TreeNodeComponent))
|
||||
@@ -0,0 +1,56 @@
|
||||
import { blueGrey } from '@material-ui/core/colors'
|
||||
import { Theme } from '@material-ui/core/styles'
|
||||
|
||||
export const styles = (theme: Theme) => {
|
||||
return {
|
||||
animationLight: {
|
||||
willChange: 'auto',
|
||||
translateZ: 0,
|
||||
animation: 'updateLight 0.5s',
|
||||
},
|
||||
animationDark: {
|
||||
willChange: 'auto',
|
||||
translateZ: 0,
|
||||
animation: 'updateDark 0.5s',
|
||||
},
|
||||
collapsedSubnodes: {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
displayBlock: {
|
||||
display: 'block' as 'block',
|
||||
},
|
||||
node: {
|
||||
display: 'block' as 'block',
|
||||
width: '100%',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
padding: '1px 0px 0px 0px',
|
||||
},
|
||||
topicSelect: {
|
||||
float: 'right' as 'right',
|
||||
opacity: 0,
|
||||
cursor: 'pointer',
|
||||
marginTop: '-1px',
|
||||
},
|
||||
subnodes: {
|
||||
marginLeft: theme.spacing(1.5),
|
||||
},
|
||||
selected: {
|
||||
backgroundColor: (theme.palette.type === 'light' ? blueGrey[300] : theme.palette.primary.main) + ' !important',
|
||||
},
|
||||
hover: {},
|
||||
title: {
|
||||
borderRadius: '4px',
|
||||
lineHeight: '1em',
|
||||
display: 'inline-block' as 'inline-block',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
height: '14px',
|
||||
padding: '1px 4px 0 4px',
|
||||
margin: '1px 0px',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.type === 'light' ? blueGrey[100] : theme.palette.primary.light,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import TreeNode from './TreeNode'
|
||||
import { Record } from 'immutable'
|
||||
import { SettingsState, TopicOrder } from '../../reducers/Settings'
|
||||
import { Theme, withStyles } from '@material-ui/core'
|
||||
import { TopicViewModel } from '../../model/TopicViewModel'
|
||||
|
||||
export interface Props {
|
||||
treeNode: q.TreeNode<TopicViewModel>
|
||||
filter?: string
|
||||
collapsed?: boolean | undefined
|
||||
classes: any
|
||||
lastUpdate: number
|
||||
selectedTopic?: q.TreeNode<TopicViewModel>
|
||||
didSelectTopic: any
|
||||
settings: Record<SettingsState>
|
||||
}
|
||||
|
||||
interface State {
|
||||
alreadyAdded: number
|
||||
}
|
||||
|
||||
class TreeNodeSubnodes extends React.Component<Props, State> {
|
||||
private renderMoreAnimationFrame?: any
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { alreadyAdded: 10 }
|
||||
}
|
||||
|
||||
private sortedNodes(): Array<q.TreeNode<TopicViewModel>> {
|
||||
const { settings, treeNode } = this.props
|
||||
const topicOrder = settings.get('topicOrder')
|
||||
|
||||
let edges = treeNode.edgeArray
|
||||
if (topicOrder === TopicOrder.abc) {
|
||||
edges = edges.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
let nodes = edges.map(edge => edge.target)
|
||||
if (topicOrder === TopicOrder.messages) {
|
||||
nodes = nodes.sort((a, b) => b.leafMessageCount() - a.leafMessageCount())
|
||||
}
|
||||
if (topicOrder === TopicOrder.topics) {
|
||||
nodes = nodes.sort((a, b) => b.childTopicCount() - a.childTopicCount())
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
private renderMore() {
|
||||
this.renderMoreAnimationFrame = (window as any).requestIdleCallback(
|
||||
() => {
|
||||
this.setState({ ...this.state, alreadyAdded: this.state.alreadyAdded * 1.5 })
|
||||
},
|
||||
{ timeout: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
;(window as any).cancelIdleCallback(this.renderMoreAnimationFrame)
|
||||
}
|
||||
|
||||
public render() {
|
||||
const edges = this.props.treeNode.edgeArray
|
||||
if (edges.length === 0 || this.props.collapsed) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (this.state.alreadyAdded < edges.length) {
|
||||
this.renderMore()
|
||||
}
|
||||
|
||||
const nodes = this.sortedNodes().slice(0, this.state.alreadyAdded)
|
||||
const listItems = nodes.map(node => {
|
||||
return (
|
||||
<TreeNode
|
||||
key={`${node.hash()}-${this.props.filter}`}
|
||||
treeNode={node}
|
||||
className={this.props.classes.listItem}
|
||||
lastUpdate={node.lastUpdate}
|
||||
didSelectTopic={this.props.didSelectTopic}
|
||||
settings={this.props.settings}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return <span className={this.props.classes.list}>{listItems}</span>
|
||||
}
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
list: {
|
||||
display: 'block' as 'block',
|
||||
clear: 'both' as 'both',
|
||||
marginLeft: theme.spacing(1.5),
|
||||
},
|
||||
})
|
||||
|
||||
export default withStyles(styles)(TreeNodeSubnodes)
|
||||
@@ -1,14 +1,13 @@
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import React from 'react'
|
||||
import TreeNode from './TreeNode'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { Record } from 'immutable'
|
||||
import { KeyCodes } from '../../utils/KeyCodes'
|
||||
import { SettingsState } from '../../reducers/Settings'
|
||||
import { TopicViewModel } from '../../model/TopicViewModel'
|
||||
import { treeActions } from '../../actions'
|
||||
|
||||
const MovingAverage = require('moving-average')
|
||||
|
||||
const averagingTimeInterval = 10 * 1000
|
||||
@@ -20,16 +19,38 @@ interface Props {
|
||||
actions: typeof treeActions
|
||||
connectionId?: string
|
||||
tree?: q.Tree<TopicViewModel>
|
||||
filter: string
|
||||
host?: string
|
||||
paused: boolean
|
||||
settings: Record<SettingsState>
|
||||
settings: SettingsState
|
||||
}
|
||||
|
||||
interface State {
|
||||
lastUpdate: number
|
||||
}
|
||||
|
||||
function useArrowKeyEventHandler(actions: typeof treeActions) {
|
||||
return (event: React.KeyboardEvent) => {
|
||||
switch (event.keyCode) {
|
||||
case KeyCodes.arrow_down:
|
||||
actions.moveSelectionUpOrDownwards('next')
|
||||
event.preventDefault()
|
||||
break
|
||||
case KeyCodes.arrow_up:
|
||||
actions.moveSelectionUpOrDownwards('previous')
|
||||
event.preventDefault()
|
||||
break
|
||||
case KeyCodes.arrow_left:
|
||||
actions.moveOutward()
|
||||
event.preventDefault()
|
||||
break
|
||||
case KeyCodes.arrow_right:
|
||||
actions.moveInward()
|
||||
event.preventDefault()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TreeComponent extends React.PureComponent<Props, State> {
|
||||
private updateTimer?: any
|
||||
private perf: number = 0
|
||||
@@ -40,31 +61,25 @@ class TreeComponent extends React.PureComponent<Props, State> {
|
||||
this.state = { lastUpdate: 0 }
|
||||
}
|
||||
|
||||
private keyEventHandler = useArrowKeyEventHandler(this.props.actions)
|
||||
private performanceCallback = (ms: number) => {
|
||||
average.push(Date.now(), ms)
|
||||
}
|
||||
|
||||
public time(): number {
|
||||
const time = performance.now() - this.perf
|
||||
this.perf = performance.now()
|
||||
|
||||
return time
|
||||
}
|
||||
|
||||
public componentWillReceiveProps(nextProps: Props) {
|
||||
if (this.props.tree !== nextProps.tree) {
|
||||
if (this.props.tree) {
|
||||
this.props.tree.didReceive.unsubscribe(this.throttledTreeUpdate)
|
||||
this.props.tree.didUpdate.unsubscribe(this.throttledTreeUpdate)
|
||||
}
|
||||
if (nextProps.tree) {
|
||||
nextProps.tree.didReceive.subscribe(this.throttledTreeUpdate)
|
||||
nextProps.tree.didUpdate.subscribe(this.throttledTreeUpdate)
|
||||
}
|
||||
this.setState(this.state)
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.props.tree && this.props.tree.didReceive.unsubscribe(this.throttledTreeUpdate)
|
||||
this.props.tree && this.props.tree.didUpdate.unsubscribe(this.throttledTreeUpdate)
|
||||
}
|
||||
|
||||
public throttledTreeUpdate = () => {
|
||||
@@ -83,9 +98,6 @@ class TreeComponent extends React.PureComponent<Props, State> {
|
||||
this.updateTimer = undefined
|
||||
this.renderTime = performance.now()
|
||||
|
||||
if (!this.props.paused) {
|
||||
this.props.tree && this.props.tree.applyUnmergedChanges()
|
||||
}
|
||||
window.requestIdleCallback(
|
||||
() => {
|
||||
this.setState({ lastUpdate: this.renderTime })
|
||||
@@ -98,8 +110,16 @@ class TreeComponent extends React.PureComponent<Props, State> {
|
||||
}, Math.max(0, timeUntilNextUpdate))
|
||||
}
|
||||
|
||||
public componentWillUpdate() {
|
||||
this.perf = performance.now()
|
||||
}
|
||||
|
||||
public componentDidUpdate() {
|
||||
this.performanceCallback(performance.now() - this.perf)
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { tree, filter } = this.props
|
||||
const { tree } = this.props
|
||||
if (!tree) {
|
||||
return null
|
||||
}
|
||||
@@ -110,21 +130,23 @@ class TreeComponent extends React.PureComponent<Props, State> {
|
||||
overflowY: 'scroll',
|
||||
overflowX: 'hidden',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
outline: '24px black !important',
|
||||
paddingBottom: '16px', // avoid conflict with chart panel Resizer
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<div style={style} tabIndex={0} onKeyDown={this.keyEventHandler}>
|
||||
<TreeNode
|
||||
key={tree.hash()}
|
||||
isRoot={true}
|
||||
treeNode={tree}
|
||||
name={this.props.host}
|
||||
collapsed={false}
|
||||
performanceCallback={this.performanceCallback}
|
||||
settings={this.props.settings}
|
||||
lastUpdate={tree.lastUpdate}
|
||||
didSelectTopic={this.props.actions.selectTopic}
|
||||
actions={this.props.actions}
|
||||
selectTopicAction={this.props.actions.selectTopic}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -45,7 +45,7 @@ interface State {
|
||||
newerVersions: Array<GithubRelease>
|
||||
}
|
||||
|
||||
class UpdateNotifier extends React.Component<Props, State> {
|
||||
class UpdateNotifier extends React.PureComponent<Props, State> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = { newerVersions: [] }
|
||||
@@ -180,7 +180,7 @@ class UpdateNotifier extends React.Component<Props, State> {
|
||||
let regex: RegExp
|
||||
if (os.platform() === 'darwin') {
|
||||
regex = /\.dmg$/
|
||||
} else if (os.platform() === 'darwin') {
|
||||
} else if (os.platform() === 'win32') {
|
||||
regex = /\.exe$/
|
||||
} else {
|
||||
regex = /\.AppImage$/
|
||||
|
||||
@@ -37,7 +37,7 @@ interface Props {
|
||||
withBackground?: boolean
|
||||
}
|
||||
|
||||
class ConnectionHealthIndicator extends React.Component<Props, {}> {
|
||||
class ConnectionHealthIndicator extends React.PureComponent<Props, {}> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ interface State {
|
||||
didCopy: boolean
|
||||
}
|
||||
|
||||
class Copy extends React.Component<Props, State> {
|
||||
class Copy extends React.PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { didCopy: false }
|
||||
@@ -44,13 +44,9 @@ class Copy extends React.Component<Props, State> {
|
||||
)
|
||||
|
||||
return (
|
||||
<span>
|
||||
<span style={{ fontSize: '16px' }}>
|
||||
<CustomIconButton onClick={this.handleClick} tooltip="Copy to clipboard">
|
||||
{icon}
|
||||
</CustomIconButton>
|
||||
</span>
|
||||
</span>
|
||||
<CustomIconButton onClick={this.handleClick} tooltip="Copy to clipboard">
|
||||
<div style={{ marginTop: '2px' }}>{icon}</div>
|
||||
</CustomIconButton>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,15 @@ const styles = (theme: Theme) => ({
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
},
|
||||
tooltip: {
|
||||
marginTop: '-16px',
|
||||
},
|
||||
label: {
|
||||
marginTop: '-2px',
|
||||
},
|
||||
})
|
||||
|
||||
class CustomIconButton extends React.Component<Props, {}> {
|
||||
class CustomIconButton extends React.PureComponent<Props, {}> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
}
|
||||
@@ -34,8 +37,8 @@ class CustomIconButton extends React.Component<Props, {}> {
|
||||
public render() {
|
||||
return (
|
||||
<IconButton className={this.props.classes.button} style={this.props.style} onClick={this.onClick}>
|
||||
<Tooltip title={this.props.tooltip} className={this.props.classes.label}>
|
||||
<span>{this.props.children}</span>
|
||||
<Tooltip title={this.props.tooltip} classes={{ popper: this.props.classes.tooltip }}>
|
||||
<span className={this.props.classes.label}>{this.props.children}</span>
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { connect } from 'react-redux'
|
||||
|
||||
interface Props {
|
||||
date: Date
|
||||
timeFirst?: boolean
|
||||
overrideLocale?: string
|
||||
locale?: string
|
||||
intervalSince?: Date
|
||||
@@ -16,7 +17,7 @@ const unitMapping = {
|
||||
h: 'hours',
|
||||
}
|
||||
|
||||
class DateFormatter extends React.Component<Props, {}> {
|
||||
class DateFormatter extends React.PureComponent<Props, {}> {
|
||||
private intervalSince(intervalSince: Date) {
|
||||
const interval = intervalSince.getTime() - this.props.date.getTime()
|
||||
const unit = this.unitForInterval(interval)
|
||||
@@ -30,7 +31,7 @@ class DateFormatter extends React.Component<Props, {}> {
|
||||
private localizedDate(locale: string) {
|
||||
return moment(this.props.date)
|
||||
.locale(locale)
|
||||
.format('L LTS')
|
||||
.format(this.props.timeFirst ? 'LTS L' : 'L LTS')
|
||||
}
|
||||
|
||||
private unitForInterval(milliseconds: number) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react'
|
||||
import { AppState } from '../../reducers'
|
||||
import { connect } from 'react-redux'
|
||||
import { Tooltip } from '@material-ui/core'
|
||||
|
||||
function NumberFormatter(props: { locale: string; value: number; grouping?: boolean }) {
|
||||
let formatter: Intl.NumberFormat | undefined
|
||||
|
||||
const formatterOptions = { useGrouping: Boolean(props.grouping) }
|
||||
const defaultFormatter = Intl.NumberFormat(undefined, formatterOptions)
|
||||
|
||||
try {
|
||||
formatter = Intl.NumberFormat(props.locale, formatterOptions)
|
||||
} catch {
|
||||
// locale unknown
|
||||
}
|
||||
|
||||
try {
|
||||
const formatted = (formatter || defaultFormatter).format(props.value)
|
||||
|
||||
return (
|
||||
<Tooltip title={props.value}>
|
||||
<span>{formatted}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
} catch {
|
||||
// localization fail, use fallback
|
||||
}
|
||||
|
||||
return <span>props.value</span>
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
locale: state.settings.get('timeLocale'),
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(NumberFormatter)
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* If a node is not available when the plot is shown, keep polling until it has been created
|
||||
*/
|
||||
export function usePollingToFetchTreeNode(tree: q.Tree<any> | undefined, path: string) {
|
||||
const [treeNode, setTreeNode] = useState<q.TreeNode<any> | undefined>()
|
||||
useEffect(() => {
|
||||
// If treeNode has been destroyed set treeNode to undefined
|
||||
if (treeNode) {
|
||||
const resetTreeNodeCallback = () => setTreeNode(undefined)
|
||||
treeNode.onDestroy.subscribe(resetTreeNodeCallback)
|
||||
|
||||
return function cleanup() {
|
||||
treeNode.onDestroy.unsubscribe(resetTreeNodeCallback)
|
||||
}
|
||||
}
|
||||
}, [treeNode])
|
||||
|
||||
function pollUntilTreeNodeHasBeenFound() {
|
||||
if (!tree) {
|
||||
return
|
||||
}
|
||||
|
||||
const initialTreeNode = tree.findNode(path)
|
||||
if (initialTreeNode && initialTreeNode !== treeNode) {
|
||||
setTreeNode(initialTreeNode)
|
||||
return
|
||||
}
|
||||
|
||||
let intervalTimer: any
|
||||
if (!treeNode) {
|
||||
intervalTimer = setInterval(() => {
|
||||
const node = tree.findNode(path)
|
||||
if (node) {
|
||||
setTreeNode(node)
|
||||
clearInterval(intervalTimer)
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
return function cleanup() {
|
||||
intervalTimer && clearInterval(intervalTimer)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(pollUntilTreeNodeHasBeenFound, [tree, treeNode, path])
|
||||
return treeNode
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function useUpdateComponentWhenNodeUpdates(node?: q.TreeNode<any>): number {
|
||||
const [lastUpdate, setLastUpdate] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
const updateComponent = () => setLastUpdate(Date.now())
|
||||
node.onMerge.subscribe(updateComponent)
|
||||
return function cleanup() {
|
||||
node.onMerge.unsubscribe(updateComponent)
|
||||
}
|
||||
}, [node])
|
||||
return lastUpdate
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from 'react'
|
||||
import { KeyCodes } from '../utils/KeyCodes'
|
||||
|
||||
export function useGlobalKeyEventHandler(
|
||||
key: KeyCodes | undefined,
|
||||
callback: (event: KeyboardEvent) => void,
|
||||
dependencies?: Array<any>
|
||||
) {
|
||||
useEffect(() => {
|
||||
function handleKeyEvent(event: KeyboardEvent) {
|
||||
if (key === undefined) {
|
||||
callback(event)
|
||||
} else if (event.keyCode === key) {
|
||||
callback(event)
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyEvent, false)
|
||||
return function cleanup() {
|
||||
document.removeEventListener('keydown', handleKeyEvent, false)
|
||||
}
|
||||
}, dependencies)
|
||||
}
|
||||
@@ -20,6 +20,8 @@ export interface ConnectionOptions {
|
||||
encryption: boolean
|
||||
certValidation: boolean
|
||||
selfSignedCertificate?: CertificateParameters
|
||||
clientCertificate?: CertificateParameters
|
||||
clientKey?: CertificateParameters
|
||||
clientId?: string
|
||||
subscriptions: Array<string>
|
||||
}
|
||||
@@ -38,6 +40,8 @@ export function toMqttConnection(options: ConnectionOptions): MqttOptions | unde
|
||||
certValidation: options.certValidation,
|
||||
subscriptions: options.subscriptions,
|
||||
certificateAuthority: options.selfSignedCertificate ? options.selfSignedCertificate.data : undefined,
|
||||
clientCertificate: options.clientCertificate ? options.clientCertificate.data : undefined,
|
||||
clientKey: options.clientKey ? options.clientKey.data : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user