2.6.20: reboot/blink/trace UI fixes, now using global websocket to update UI and notify user on device connection/disconnection

This commit is contained in:
Charles
2022-05-04 21:28:47 +01:00
parent 746a812ae8
commit d2fd895582
15 changed files with 381 additions and 37 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "ucentral-client", "name": "ucentral-client",
"version": "2.6.14", "version": "2.6.20",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ucentral-client", "name": "ucentral-client",
"version": "2.6.14", "version": "2.6.20",
"dependencies": { "dependencies": {
"@coreui/coreui": "^3.4.0", "@coreui/coreui": "^3.4.0",
"@coreui/icons": "^2.0.1", "@coreui/icons": "^2.0.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "ucentral-client", "name": "ucentral-client",
"version": "2.6.14", "version": "2.6.20",
"dependencies": { "dependencies": {
"@coreui/coreui": "^3.4.0", "@coreui/coreui": "^3.4.0",
"@coreui/icons": "^2.0.1", "@coreui/icons": "^2.0.1",

View File

@@ -62,12 +62,14 @@ const BlinkModal = ({ show, toggleModal }) => {
{ headers }, { headers },
) )
.then(() => { .then(() => {
addToast({ if (chosenPattern !== 'blink') {
title: t('common.success'), addToast({
body: t('commands.command_success'), title: t('common.success'),
color: 'success', body: t('commands.command_success'),
autohide: true, color: 'success',
}); autohide: true,
});
}
toggleModal(); toggleModal();
}) })
.catch(() => { .catch(() => {
@@ -145,8 +147,10 @@ const BlinkModal = ({ show, toggleModal }) => {
</CModalBody> </CModalBody>
<CModalFooter> <CModalFooter>
<LoadingButton <LoadingButton
label={t('blink.set_leds')} label={t('common.submit')}
isLoadingLabel={t('common.loading_ellipsis')} isLoadingLabel={
chosenPattern === 'blink' ? 'LEDs are blinking... ' : t('common.loading_ellipsis')
}
isLoading={waiting} isLoading={waiting}
action={doAction} action={doAction}
block={false} block={false}

View File

@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import { useAuth, useToast, useToggle } from 'ucentral-libs'; import { useAuth, useToast, useToggle } from 'ucentral-libs';
import axiosInstance from 'utils/axiosInstance'; import axiosInstance from 'utils/axiosInstance';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useGlobalWebSocket } from 'contexts/WebSocketProvider';
import Modal from './Modal'; import Modal from './Modal';
const DeviceFirmwareModal = ({ const DeviceFirmwareModal = ({
@@ -19,6 +20,7 @@ const DeviceFirmwareModal = ({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [firmwareVersions, setFirmwareVersions] = useState([]); const [firmwareVersions, setFirmwareVersions] = useState([]);
const [keepRedirector, toggleKeepRedirector, setKeepRedirector] = useToggle(true); const [keepRedirector, toggleKeepRedirector, setKeepRedirector] = useToggle(true);
const { addDeviceListener } = useGlobalWebSocket();
const getPartialFirmware = async (offset) => { const getPartialFirmware = async (offset) => {
const headers = { const headers = {
@@ -90,6 +92,17 @@ const DeviceFirmwareModal = ({
headers, headers,
}) })
.then((response) => { .then((response) => {
addDeviceListener({
serialNumber: device.serialNumber,
types: ['device_firmware_upgrade'],
addToast: (title, body) =>
addToast({
title,
body,
color: 'info',
autohide: true,
}),
});
setUpgradeStatus({ setUpgradeStatus({
loading: false, loading: false,
result: { result: {

View File

@@ -6,6 +6,7 @@ import { getItem, setItem } from 'utils/localStorageHelper';
import DeviceSearchBar from 'components/DeviceSearchBar'; import DeviceSearchBar from 'components/DeviceSearchBar';
import DeviceFirmwareModal from 'components/DeviceFirmwareModal'; import DeviceFirmwareModal from 'components/DeviceFirmwareModal';
import FirmwareHistoryModal from 'components/FirmwareHistoryModal'; import FirmwareHistoryModal from 'components/FirmwareHistoryModal';
import { useGlobalWebSocket } from 'contexts/WebSocketProvider';
import { useAuth, useToast } from 'ucentral-libs'; import { useAuth, useToast } from 'ucentral-libs';
import Table from './Table'; import Table from './Table';
import meshIcon from '../../assets/icons/Mesh.png'; import meshIcon from '../../assets/icons/Mesh.png';
@@ -36,6 +37,7 @@ const DeviceList = () => {
deviceType: '', deviceType: '',
serialNumber: '', serialNumber: '',
}); });
const { lastMessage } = useGlobalWebSocket();
const deviceIcons = { const deviceIcons = {
meshIcon, meshIcon,
@@ -359,6 +361,22 @@ const DeviceList = () => {
getCount(); getCount();
}, []); }, []);
useEffect(() => {
if (lastMessage && lastMessage.type === 'DEVICE') {
const { serialNumber: msgSerial, isConnected } = lastMessage;
if (devices.find(({ serialNumber }) => serialNumber === msgSerial)) {
const newDevices = devices.map((device) => {
if (device.serialNumber !== msgSerial) return device;
return {
...device,
connected: isConnected,
};
});
setDevices(newDevices);
}
}
}, [lastMessage, devices]);
useEffect(() => { useEffect(() => {
if (upgradeStatus.result !== undefined) { if (upgradeStatus.result !== undefined) {
addToast({ addToast({

View File

@@ -133,18 +133,18 @@ const StatisticsChartList = ({ setOptions, section, setStart, setEnd, time }) =>
if (version > 0) { if (version > 0) {
const prevTx = prevTxObj[inter.name] !== undefined ? prevTxObj[inter.name] : 0; const prevTx = prevTxObj[inter.name] !== undefined ? prevTxObj[inter.name] : 0;
const prevRx = prevTxObj[inter.name] !== undefined ? prevRxObj[inter.name] : 0; const prevRx = prevTxObj[inter.name] !== undefined ? prevRxObj[inter.name] : 0;
const tx = inter.counters ? inter.counters.tx_bytes : 0; const tx = inter.counters ? Math.floor(inter.counters.tx_bytes / 1024) : 0;
const rx = inter.counters ? inter.counters.rx_bytes : 0; const rx = inter.counters ? Math.floor(inter.counters.rx_bytes / 1024) : 0;
interfaceList[interfaceTypes[inter.name]][0].data.push(Math.max(0, tx - prevTx)); interfaceList[interfaceTypes[inter.name]][0].data.push(Math.max(0, tx - prevTx));
interfaceList[interfaceTypes[inter.name]][1].data.push(Math.max(0, rx - prevRx)); interfaceList[interfaceTypes[inter.name]][1].data.push(Math.max(0, rx - prevRx));
prevTxObj[inter.name] = tx; prevTxObj[inter.name] = tx;
prevRxObj[inter.name] = rx; prevRxObj[inter.name] = rx;
} else { } else {
interfaceList[interfaceTypes[inter.name]][0].data.push( interfaceList[interfaceTypes[inter.name]][0].data.push(
inter.counters ? Math.floor(inter.counters.tx_bytes) : 0, inter.counters ? Math.floor(inter.counters.tx_bytes / 1024) : 0,
); );
interfaceList[interfaceTypes[inter.name]][1].data.push( interfaceList[interfaceTypes[inter.name]][1].data.push(
inter.counters ? Math.floor(inter.counters.rx_bytes) : 0, inter.counters ? Math.floor(inter.counters.rx_bytes / 1024) : 0,
); );
} }
} }

View File

@@ -22,11 +22,13 @@ import axiosInstance from 'utils/axiosInstance';
import eventBus from 'utils/eventBus'; import eventBus from 'utils/eventBus';
import { LoadingButton, useAuth, useDevice, useToast } from 'ucentral-libs'; import { LoadingButton, useAuth, useDevice, useToast } from 'ucentral-libs';
import SuccessfulActionModalBody from 'components/SuccessfulActionModalBody'; import SuccessfulActionModalBody from 'components/SuccessfulActionModalBody';
import { useGlobalWebSocket } from 'contexts/WebSocketProvider';
const ActionModal = ({ show, toggleModal }) => { const ActionModal = ({ show, toggleModal }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { currentToken, endpoints } = useAuth(); const { currentToken, endpoints } = useAuth();
const { deviceSerialNumber } = useDevice(); const { deviceSerialNumber } = useDevice();
const { addDeviceListener } = useGlobalWebSocket();
const { addToast } = useToast(); const { addToast } = useToast();
const [waiting, setWaiting] = useState(false); const [waiting, setWaiting] = useState(false);
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
@@ -74,9 +76,20 @@ const ActionModal = ({ show, toggleModal }) => {
{ headers }, { headers },
) )
.then(() => { .then(() => {
addDeviceListener({
serialNumber: deviceSerialNumber,
types: ['device_connection', 'device_disconnection'],
addToast: (title, body) =>
addToast({
title,
body,
color: 'info',
autohide: true,
}),
});
addToast({ addToast({
title: t('common.success'), title: t('common.success'),
body: t('commands.command_success'), body: t('commands.reboot_start'),
color: 'success', color: 'success',
autohide: true, autohide: true,
}); });

View File

@@ -40,7 +40,7 @@ const TraceModal = ({ show, toggleModal }) => {
const [responseBody, setResponseBody] = useState(''); const [responseBody, setResponseBody] = useState('');
const [chosenInterface, setChosenInterface] = useState('up'); const [chosenInterface, setChosenInterface] = useState('up');
const [isDeviceConnected, setIsDeviceConnected] = useState(false); const [isDeviceConnected, setIsDeviceConnected] = useState(false);
const [waitForTrace, setWaitForTrace] = useState(false); const [waitForTrace, setWaitForTrace] = useState(true);
const [waitingForTrace, setWaitingForTrace] = useState(false); const [waitingForTrace, setWaitingForTrace] = useState(false);
const [commandUuid, setCommandUuid] = useState(null); const [commandUuid, setCommandUuid] = useState(null);
@@ -49,7 +49,7 @@ const TraceModal = ({ show, toggleModal }) => {
}; };
useEffect(() => { useEffect(() => {
setWaitForTrace(false); setWaitForTrace(true);
setHadSuccess(false); setHadSuccess(false);
setHadFailure(false); setHadFailure(false);
setResponseBody(''); setResponseBody('');
@@ -137,25 +137,19 @@ const TraceModal = ({ show, toggleModal }) => {
<CModalBody> <CModalBody>
<h6>{t('trace.directions')}</h6> <h6>{t('trace.directions')}</h6>
<CRow className="mt-3"> <CRow className="mt-3">
<CCol> <CCol md="4" className="pt-2">
<CButton {t('contact.type')}
disabled={blockFields}
block
color="primary"
onClick={() => setUsingDuration(true)}
>
{t('common.duration')}
</CButton>
</CCol> </CCol>
<CCol> <CCol xs="12" md="8">
<CButton <CSelect
custom
value={usingDuration ? 'duration' : 'packets'}
disabled={blockFields} disabled={blockFields}
block onChange={(e) => setUsingDuration(e.target.value === 'duration')}
color="primary"
onClick={() => setUsingDuration(false)}
> >
{t('trace.packets')} <option value="duration">{t('common.duration')}</option>
</CButton> <option value="packets">{t('trace.packets')}</option>
</CSelect>
</CCol> </CCol>
</CRow> </CRow>
<CRow className="mt-3"> <CRow className="mt-3">
@@ -220,7 +214,7 @@ const TraceModal = ({ show, toggleModal }) => {
</CCol> </CCol>
</CRow> </CRow>
<CRow className="mt-3" hidden={!isDeviceConnected}> <CRow className="mt-3" hidden={!isDeviceConnected}>
<CCol md="8"> <CCol md="7">
<p>{t('trace.wait_for_file')}</p> <p>{t('trace.wait_for_file')}</p>
</CCol> </CCol>
<CCol> <CCol>

View File

@@ -0,0 +1,26 @@
import { useCallback, useMemo } from 'react';
import { useToast } from 'ucentral-libs';
const useWebSocketNotification = () => {
const { addToast } = useToast();
const pushNotification = useCallback((notification) => {
addToast({
title: notification.content.title,
body: notification.content.details,
color: 'info',
autohide: true,
});
}, []);
const toReturn = useMemo(
() => ({
pushNotification,
}),
[],
);
return toReturn;
};
export default useWebSocketNotification;

View File

@@ -0,0 +1,90 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAuth } from 'ucentral-libs';
import PropTypes from 'prop-types';
import useWebSocketNotification from './hooks/NotificationContent/useWebSocketNotification';
import useSocketReducer from './useSocketReducer';
import { extractWebSocketResponse } from './utils';
const WebSocketContext = React.createContext({
webSocket: undefined,
isOpen: false,
allMessages: [],
addDeviceListener: () => {},
});
export const WebSocketProvider = ({ children }) => {
const { currentToken, endpoints } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const ws = useRef(undefined);
const { allMessages, lastMessage, dispatch } = useSocketReducer();
const { pushNotification } = useWebSocketNotification();
const onMessage = useCallback((message) => {
const result = extractWebSocketResponse(message);
if (result?.type === 'NOTIFICATION') {
dispatch({ type: 'NEW_NOTIFICATION', notification: result.notification });
pushNotification(result.notification);
}
if (result?.type === 'DEVICE_NOTIFICATION') {
dispatch({
type: 'NEW_DEVICE_NOTIFICATION',
serialNumber: result.serialNumber,
subType: result.subType,
});
}
if (result?.type === 'COMMAND') {
dispatch({ type: 'NEW_COMMAND', data: result.data });
}
}, []);
// useEffect for created the WebSocket and 'storing' it in useRef
useEffect(() => {
ws.current = new WebSocket(`${endpoints.owgw.replace('https', 'wss')}/api/v1/ws`);
ws.current.onopen = () => {
setIsOpen(true);
ws.current?.send(`token:${currentToken}`);
};
ws.current.onclose = () => {
setIsOpen(false);
};
ws.current.onerror = () => {
setIsOpen(false);
};
const wsCurrent = ws?.current;
return () => wsCurrent?.close();
}, []);
// useEffect for generating global notifications
useEffect(() => {
if (ws?.current) {
ws.current.addEventListener('message', onMessage);
}
const wsCurrent = ws?.current;
return () => {
if (wsCurrent) wsCurrent.removeEventListener('message', onMessage);
};
}, [ws?.current]);
const values = useMemo(
() => ({
allMessages,
lastMessage,
webSocket: ws.current,
addDeviceListener: ({ serialNumber, types, addToast, onTrigger }) =>
dispatch({ type: 'ADD_DEVICE_LISTENER', serialNumber, types, addToast, onTrigger }),
removeDeviceListener: ({ serialNumber }) =>
dispatch({ type: 'REMOVE_DEVICE_LISTENER', serialNumber }),
isOpen,
}),
[ws, isOpen, allMessages, lastMessage],
);
return <WebSocketContext.Provider value={values}>{children}</WebSocketContext.Provider>;
};
WebSocketProvider.propTypes = {
children: PropTypes.node.isRequired,
};
export const useGlobalWebSocket = () => React.useContext(WebSocketContext);

View File

@@ -0,0 +1,114 @@
import { useReducer } from 'react';
const titles = {
device_connection: 'Connected',
device_disconnection: 'Disconnected',
device_firmware_upgrade: 'Firmware Upgraded',
};
const bodies = {
device_connection: 'This device has rebooted and is now connected!',
device_disconnection: 'This device has started rebooting and is now disconnected!',
device_firmware_upgrade: 'This device has updated to new firmware!',
};
const reducer = (state, action) => {
switch (action.type) {
case 'NEW_NOTIFICATION': {
const obj = { type: 'NOTIFICATION', data: action.notification, timestamp: new Date() };
return { allMessages: [...state.allMessages, obj], lastMessage: obj };
}
case 'NEW_COMMAND': {
const obj = {
type: 'COMMAND',
response: action.data.response,
timestamp: new Date(),
id: action.data.command_response_id,
};
return { allMessages: [...state.allMessages, obj], lastMessage: obj };
}
case 'NEW_DEVICE_NOTIFICATION': {
const newListeners = state.deviceListeners;
let obj;
if (action.subType === 'device_connection' || action.subType === 'device_disconnection') {
obj = {
type: 'DEVICE',
isConnected: action.subType === 'device_connection',
serialNumber: action.serialNumber,
timestamp: new Date(),
};
}
for (let i = 0; i < state.deviceListeners.length; i += 1) {
if (
state.deviceListeners[i].serialNumber === action.serialNumber &&
state.deviceListeners[i].type === action.subType
) {
if (state.deviceListeners[i].onTrigger) {
setTimeout(() => state.deviceListeners[i].onTrigger(action.subType), 1000);
} else if (state.deviceListeners[i].addToast) {
state.deviceListeners[i].addToast(
`${action.serialNumber} ${titles[state.deviceListeners[i].type]}`,
bodies[state.deviceListeners[i].type],
);
const found = newListeners.findIndex(
(listener) =>
listener.serialNumber === action.serialNumber && listener.type === action.subType,
);
if (found >= 0) newListeners.splice(found, 1);
}
}
}
return {
allMessages: state.allMessages,
lastMessage: obj ?? state.lastMessage,
deviceListeners: newListeners,
};
}
case 'ADD_DEVICE_LISTENER': {
let newListeners = action.types.map((actionType) => ({
type: actionType,
serialNumber: action.serialNumber,
addToast: action.addToast,
onTrigger: action.onTrigger,
}));
newListeners = newListeners.concat(state.deviceListeners);
return {
allMessages: state.allMessages,
lastMessage: state.lastMessage,
deviceListeners: newListeners,
};
}
case 'REMOVE_DEVICE_LISTENER': {
const newListeners = state.deviceListeners.filter(
(listener) =>
listener.serialNumber !== action.serialNumber || listener.onTrigger === undefined,
);
return {
allMessages: state.allMessages,
lastMessage: state.lastMessage,
deviceListeners: newListeners,
};
}
case 'UNKNOWN': {
const obj = { type: 'UNKNOWN', data: action.newMessage, timestamp: new Date() };
return {
allMessages: [...state.allMessages, obj],
lastMessage: obj,
};
}
default:
throw new Error();
}
};
const useSocketReducer = () => {
const [{ allMessages, lastMessage, deviceListeners }, dispatch] = useReducer(reducer, {
allMessages: [],
deviceListeners: [],
});
return { allMessages, lastMessage, deviceListeners, dispatch };
};
export default useSocketReducer;

View File

@@ -0,0 +1,54 @@
export const acceptedNotificationTypes = [
'venue_configuration_update',
'entity_configuration_update',
];
export const deviceNotificationTypes = [
'device_connection',
'device_disconnection',
'device_firmware_upgrade',
];
export const extractWebSocketResponse = (message) => {
try {
const data = JSON.parse(message.data);
if (data.notification && acceptedNotificationTypes.includes(data.notification.type)) {
const { notification } = data;
return { notification, type: 'NOTIFICATION' };
}
if (data.notification && deviceNotificationTypes.includes(data.notification.type)) {
return {
serialNumber: data.notification.content.serialNumber,
type: 'DEVICE_NOTIFICATION',
subType: data.notification.type,
};
}
if (data.command_response_id) {
return { data, type: 'COMMAND' };
}
} catch {
return undefined;
}
return undefined;
};
export const getStatusFromNotification = (notification) => {
let status = 'success';
if (notification.content.warning?.length > 0) status = 'warning';
if (notification.content.error?.length > 0) status = 'error';
return status;
};
export const getNotificationDescription = (t, notification) => {
if (
notification.content.type === 'venue_configuration_update' ||
notification.content.type === 'entity_configuration_update'
) {
return t('configurations.notification_details', {
success: notification.content.success.length,
warning: notification.content.warning.length,
error: notification.content.error.length,
});
}
return notification.content.details;
};

View File

@@ -5,6 +5,7 @@ import { CSidebarNavItem } from '@coreui/react';
import { cilBarcode, cilRouter, cilSave, cilSettings, cilPeople } from '@coreui/icons'; import { cilBarcode, cilRouter, cilSave, cilSettings, cilPeople } from '@coreui/icons';
import CIcon from '@coreui/icons-react'; import CIcon from '@coreui/icons-react';
import { Header, Sidebar, Footer, PageContainer, ToastProvider, useAuth } from 'ucentral-libs'; import { Header, Sidebar, Footer, PageContainer, ToastProvider, useAuth } from 'ucentral-libs';
import { WebSocketProvider } from 'contexts/WebSocketProvider';
const TheLayout = () => { const TheLayout = () => {
const [showSidebar, setShowSidebar] = useState('responsive'); const [showSidebar, setShowSidebar] = useState('responsive');
@@ -70,7 +71,9 @@ const TheLayout = () => {
/> />
<div className="c-body"> <div className="c-body">
<ToastProvider> <ToastProvider>
<PageContainer t={t} routes={routes} redirectTo="/devices" /> <WebSocketProvider>
<PageContainer t={t} routes={routes} redirectTo="/devices" />
</WebSocketProvider>
</ToastProvider> </ToastProvider>
</div> </div>
<Footer t={t} version={process.env.VERSION} /> <Footer t={t} version={process.env.VERSION} />

View File

@@ -12,6 +12,7 @@ import { useTranslation } from 'react-i18next';
import ConfigurationDisplay from 'components/ConfigurationDisplay'; import ConfigurationDisplay from 'components/ConfigurationDisplay';
import WifiAnalysis from 'components/WifiAnalysis'; import WifiAnalysis from 'components/WifiAnalysis';
import CapabilitiesDisplay from 'components/CapabilitiesDisplay'; import CapabilitiesDisplay from 'components/CapabilitiesDisplay';
import { useGlobalWebSocket } from 'contexts/WebSocketProvider';
import NotesTab from './NotesTab'; import NotesTab from './NotesTab';
import DeviceDetails from './Details'; import DeviceDetails from './Details';
import DeviceStatusCard from './DeviceStatusCard'; import DeviceStatusCard from './DeviceStatusCard';
@@ -26,6 +27,7 @@ const DevicePage = () => {
const [deviceConfig, setDeviceConfig] = useState(null); const [deviceConfig, setDeviceConfig] = useState(null);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { addDeviceListener, removeDeviceListener } = useGlobalWebSocket();
const updateNav = (target) => { const updateNav = (target) => {
sessionStorage.setItem('devicePageIndex', target); sessionStorage.setItem('devicePageIndex', target);
@@ -115,9 +117,22 @@ const DevicePage = () => {
useEffect(() => { useEffect(() => {
setError(false); setError(false);
if (deviceId) { if (deviceId) {
addDeviceListener({
serialNumber: deviceId,
types: ['device_connection', 'device_disconnection', 'device_firmware_upgrade'],
onTrigger: () => getData(),
});
getDevice(); getDevice();
getData(); getData();
} }
return () => {
if (deviceId) {
removeDeviceListener({
serialNumber: deviceId,
});
}
};
}, [deviceId]); }, [deviceId]);
return ( return (

View File

@@ -8,7 +8,7 @@ axiosRetry(axiosInstance, {
retryDelay: () => axiosRetry.exponentialDelay, retryDelay: () => axiosRetry.exponentialDelay,
}); });
axiosInstance.defaults.timeout = 60000; axiosInstance.defaults.timeout = 160000;
axiosInstance.defaults.headers.get.Accept = 'application/json'; axiosInstance.defaults.headers.get.Accept = 'application/json';
axiosInstance.defaults.headers.post.Accept = 'application/json'; axiosInstance.defaults.headers.post.Accept = 'application/json';