[WIFI-11564] Add logs page

Signed-off-by: Charles <charles.bourque96@gmail.com>
This commit is contained in:
Charles
2022-11-09 17:35:57 +00:00
parent 006e402d9f
commit 34450144ba
39 changed files with 2396 additions and 294 deletions

87
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "ucentral-client",
"version": "2.8.0(8)",
"version": "2.8.0(9)",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "ucentral-client",
"version": "2.8.0(8)",
"version": "2.8.0(9)",
"license": "ISC",
"dependencies": {
"@chakra-ui/icons": "^2.0.11",
@@ -44,6 +44,8 @@
"react-masonry-css": "^1.0.16",
"react-router-dom": "^6.4.2",
"react-table": "^7.8.0",
"react-virtualized-auto-sizer": "^1.0.7",
"react-window": "^1.8.8",
"source-map-explorer": "^2.5.3",
"typescript": "^4.8.4",
"uuid": "^9.0.0",
@@ -57,6 +59,8 @@
"@types/react-csv": "^1.1.3",
"@types/react-dom": "^18.0.6",
"@types/react-table": "^7.7.12",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/uuid": "^8.3.4",
"@vitejs/plugin-react": "^2.1.0",
"eslint": "8.25.0",
@@ -3577,6 +3581,24 @@
"@types/react": "*"
}
},
"node_modules/@types/react-virtualized-auto-sizer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.1.tgz",
"integrity": "sha512-GH8sAnBEM5GV9LTeiz56r4ZhMOUSrP43tAQNSRVxNexDjcNKLCEtnxusAItg1owFUFE6k0NslV26gqVClVvong==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-window": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.5.tgz",
"integrity": "sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/resolve": {
"version": "1.17.1",
"dev": true,
@@ -7937,6 +7959,34 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/react-virtualized-auto-sizer": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.7.tgz",
"integrity": "sha512-Mxi6lwOmjwIjC1X4gABXMJcKHsOo0xWl3E3ugOgufB8GJU+MqrtY35aBuvCYv/razQ1Vbp7h1gWJjGjoNN5pmA==",
"engines": {
"node": ">8.0.0"
},
"peerDependencies": {
"react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc",
"react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc"
}
},
"node_modules/react-window": {
"version": "1.8.8",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.8.tgz",
"integrity": "sha512-D4IiBeRtGXziZ1n0XklnFGu7h9gU684zepqyKzgPNzrsrk7xOCxni+TCckjg2Nr/DiaEEGVVmnhYSlT2rB47dQ==",
"dependencies": {
"@babel/runtime": "^7.0.0",
"memoize-one": ">=3.1.1 <6"
},
"engines": {
"node": ">8.0.0"
},
"peerDependencies": {
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/recrawl-sync": {
"version": "2.2.2",
"dev": true,
@@ -11791,6 +11841,24 @@
"@types/react": "*"
}
},
"@types/react-virtualized-auto-sizer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.1.tgz",
"integrity": "sha512-GH8sAnBEM5GV9LTeiz56r4ZhMOUSrP43tAQNSRVxNexDjcNKLCEtnxusAItg1owFUFE6k0NslV26gqVClVvong==",
"dev": true,
"requires": {
"@types/react": "*"
}
},
"@types/react-window": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.5.tgz",
"integrity": "sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==",
"dev": true,
"requires": {
"@types/react": "*"
}
},
"@types/resolve": {
"version": "1.17.1",
"dev": true,
@@ -14355,6 +14423,21 @@
"prop-types": "^15.6.2"
}
},
"react-virtualized-auto-sizer": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.7.tgz",
"integrity": "sha512-Mxi6lwOmjwIjC1X4gABXMJcKHsOo0xWl3E3ugOgufB8GJU+MqrtY35aBuvCYv/razQ1Vbp7h1gWJjGjoNN5pmA==",
"requires": {}
},
"react-window": {
"version": "1.8.8",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.8.tgz",
"integrity": "sha512-D4IiBeRtGXziZ1n0XklnFGu7h9gU684zepqyKzgPNzrsrk7xOCxni+TCckjg2Nr/DiaEEGVVmnhYSlT2rB47dQ==",
"requires": {
"@babel/runtime": "^7.0.0",
"memoize-one": ">=3.1.1 <6"
}
},
"recrawl-sync": {
"version": "2.2.2",
"dev": true,

View File

@@ -1,6 +1,6 @@
{
"name": "ucentral-client",
"version": "2.8.0(8)",
"version": "2.8.0(9)",
"description": "",
"private": true,
"main": "index.tsx",
@@ -50,6 +50,8 @@
"@tanstack/react-query": "^4.12.0",
"react-router-dom": "^6.4.2",
"react-table": "^7.8.0",
"react-virtualized-auto-sizer": "^1.0.7",
"react-window": "^1.8.8",
"source-map-explorer": "^2.5.3",
"vite": "^3.1.8",
"typescript": "^4.8.4",
@@ -64,6 +66,8 @@
"@types/react-dom": "^18.0.6",
"@types/react-table": "^7.7.12",
"@types/uuid": "^8.3.4",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"eslint": "8.25.0",
"vite-tsconfig-paths": "^3.5.1",
"lint-staged": "^13.0.3",

View File

@@ -788,6 +788,22 @@
"your_new_password": "Dein neues Passwort",
"your_password": "Ihr Passwort"
},
"logs": {
"configuration_upgrade": "Konfigurationsaktualisierung",
"device_firmware_upgrade": "Firmware-Aktualisierung",
"device_statistics": "Gerätestatistik",
"export": "Export",
"filter": "Filter",
"firmware": "Firmware",
"global_connections": "Globale Verbindungen",
"level": "Niveau",
"message": "Botschaft",
"one": "Log",
"receiving_types": "Typen empfangen",
"security": "Sicherheit",
"source": "Quelle",
"thread": "Faden"
},
"map": {
"auto_align": "Automatisch ausrichten",
"auto_map": "Automatische Karte",
@@ -804,6 +820,10 @@
"title": "Karte",
"visibility": "Sichtweite"
},
"notification": {
"one": "Benachrichtigung",
"other": "Benachrichtigungen"
},
"operator": {
"delete_explanation": "Möchten Sie diesen Operator wirklich löschen? Dieser Vorgang ist nicht umkehrbar",
"delete_operator": "Betreiber löschen",
@@ -849,6 +869,21 @@
"vendor": "Verkäufer",
"version": "Ausführung"
},
"script": {
"automatic": "Automatik",
"custom_domain": "Benutzerdefinierten Domain",
"deferred": "Aufgeschoben",
"device_title": "Führen Sie das Geräteskript aus",
"explanation": "Führen Sie ein benutzerdefiniertes Skript auf diesem Gerät aus und laden Sie die Ergebnisse herunter",
"file_too_large": "Bitte wählen Sie eine Datei aus, die kleiner als 500 KB ist",
"now": "Jetzt",
"one": "Skript",
"schedule_success": "Geplante Skriptausführung!",
"signature": "Unterschrift",
"timeout": "Auszeit",
"upload_destination": "Ziel hochladen",
"when": "Ausführung planen"
},
"service": {
"billing_code": "Abrechnungscode",
"billing_frequency": "Abrechnungshäufigkeit",

View File

@@ -788,6 +788,22 @@
"your_new_password": "Your new password",
"your_password": "Your password"
},
"logs": {
"configuration_upgrade": "Configuration Update",
"device_firmware_upgrade": "Firmware Upgrade",
"device_statistics": "Device Statistics",
"export": "Export",
"filter": "Filter",
"firmware": "Firmware",
"global_connections": "Global Connections",
"level": "Level",
"message": "Message",
"one": "Log",
"receiving_types": "Receiving Types",
"security": "Security",
"source": "Source",
"thread": "Thread"
},
"map": {
"auto_align": "Auto Align",
"auto_map": "Auto Map",
@@ -804,6 +820,10 @@
"title": "Map",
"visibility": "Visibility"
},
"notification": {
"one": "Notification",
"other": "Notifications"
},
"operator": {
"delete_explanation": "Are you sure you want to delete this operator? This operation is not reversible",
"delete_operator": "Delete Operator",
@@ -849,6 +869,21 @@
"vendor": "Vendor",
"version": "Version"
},
"script": {
"automatic": "Automatic",
"custom_domain": "Custom Domain",
"deferred": "Deferred",
"device_title": "Run Device Script",
"explanation": "Run a custom script on this device and download its results",
"file_too_large": "Please select a file that is less than 500KB",
"now": "Now",
"one": "Script",
"schedule_success": "Scheduled script execution!",
"signature": "Signature",
"timeout": "Timeout",
"upload_destination": "Upload Destination",
"when": "Schedule Execution"
},
"service": {
"billing_code": "Billing Code",
"billing_frequency": "Billing Frequency",

View File

@@ -788,6 +788,22 @@
"your_new_password": "Tu nueva contraseña",
"your_password": "Tu contraseña"
},
"logs": {
"configuration_upgrade": "Actualización de configuración",
"device_firmware_upgrade": "Actualización de firmware",
"device_statistics": "Estadísticas del dispositivo",
"export": "Exportar",
"filter": "Filtrar",
"firmware": "Firmware",
"global_connections": "Conexiones globales",
"level": "Nivel",
"message": "Mensaje",
"one": "Iniciar sesión",
"receiving_types": "Tipos de recepción",
"security": "SEGURIDAD",
"source": "Fuente",
"thread": "Hilo"
},
"map": {
"auto_align": "Alineación automática",
"auto_map": "Mapa automático",
@@ -804,6 +820,10 @@
"title": "Mapa",
"visibility": "Visibilidad"
},
"notification": {
"one": "Notificación",
"other": "Notificaciones"
},
"operator": {
"delete_explanation": "¿Está seguro de que desea eliminar este operador? Esta operación no es reversible.",
"delete_operator": "Eliminar operador",
@@ -849,6 +869,21 @@
"vendor": "Vendedor",
"version": "Versión"
},
"script": {
"automatic": "Automático",
"custom_domain": "Dominio personalizado",
"deferred": "Diferido",
"device_title": "Ejecutar secuencia de comandos del dispositivo",
"explanation": "Ejecute un script personalizado en este dispositivo y descargue sus resultados",
"file_too_large": "Seleccione un archivo que tenga menos de 500 KB",
"now": "ahora",
"one": "Guión",
"schedule_success": "¡Ejecución de script programada!",
"signature": "Firma",
"timeout": "Se acabó el tiempo",
"upload_destination": "Cargar destino",
"when": "Programar Ejecucion"
},
"service": {
"billing_code": "Código de facturación",
"billing_frequency": "Frecuencia de facturación",

View File

@@ -788,6 +788,22 @@
"your_new_password": "Votre nouveau mot de passe",
"your_password": "Votre mot de passe"
},
"logs": {
"configuration_upgrade": "Mise à jour de la configuration",
"device_firmware_upgrade": "Mise à jour du firmware",
"device_statistics": "Statistiques de l'appareil",
"export": "Exportation",
"filter": "Filtre",
"firmware": "Micrologiciel",
"global_connections": "Connexions mondiales",
"level": "Niveau",
"message": "Message",
"one": "Bûche",
"receiving_types": "Types de réception",
"security": "SÉCURITÉ",
"source": "La source",
"thread": "Fil de discussion"
},
"map": {
"auto_align": "Alignement automatique",
"auto_map": "Carte automatique",
@@ -804,6 +820,10 @@
"title": "Carte",
"visibility": "Visibilité"
},
"notification": {
"one": "Notification",
"other": "Les notifications"
},
"operator": {
"delete_explanation": "Voulez-vous vraiment supprimer cet opérateur ? Cette opération n'est pas réversible",
"delete_operator": "Supprimer l'opérateur",
@@ -849,6 +869,21 @@
"vendor": "vendeur",
"version": "Version"
},
"script": {
"automatic": "Automatique",
"custom_domain": "Domaine personnalisé",
"deferred": "Différé",
"device_title": "Exécuter le script de périphérique",
"explanation": "Exécutez un script personnalisé sur cet appareil et téléchargez ses résultats",
"file_too_large": "Veuillez sélectionner un fichier de moins de 500 Ko",
"now": "À présent",
"one": "Scénario",
"schedule_success": "Exécution du script planifié !",
"signature": "signature",
"timeout": "Temps libre",
"upload_destination": "Destination de téléchargement",
"when": "Planifier l'exécution"
},
"service": {
"billing_code": "Code de facturation",
"billing_frequency": "Fréquence de facturation",

View File

@@ -788,6 +788,22 @@
"your_new_password": "Sua nova senha",
"your_password": "Sua senha"
},
"logs": {
"configuration_upgrade": "Atualização de configuração",
"device_firmware_upgrade": "Atualização de firmware",
"device_statistics": "Estatísticas do dispositivo",
"export": "Exportar",
"filter": "Filtro",
"firmware": "Firmware",
"global_connections": "Conexões Globais",
"level": "Nível",
"message": "mensagem",
"one": "Registro",
"receiving_types": "Tipos de recebimento",
"security": "SEGURANÇA",
"source": "Fonte",
"thread": "FIO"
},
"map": {
"auto_align": "Alinhamento Automático",
"auto_map": "Mapa automático",
@@ -804,6 +820,10 @@
"title": "Mapa",
"visibility": "visibilidade"
},
"notification": {
"one": "Notificação",
"other": "Notificações"
},
"operator": {
"delete_explanation": "Tem certeza de que deseja excluir este operador? Esta operação não é reversível",
"delete_operator": "Excluir operador",
@@ -849,6 +869,21 @@
"vendor": "fornecedor",
"version": "Versão"
},
"script": {
"automatic": "Automático",
"custom_domain": "Domínio personalizado",
"deferred": "Diferido",
"device_title": "Executar script de dispositivo",
"explanation": "Execute um script personalizado neste dispositivo e baixe seus resultados",
"file_too_large": "Selecione um arquivo com menos de 500 KB",
"now": "agora",
"one": "Roteiro",
"schedule_success": "Execução de script agendada!",
"signature": "Assinatura",
"timeout": "Tempo esgotado",
"upload_destination": "Carregar destino",
"when": "Agendar Execução"
},
"service": {
"billing_code": "código de cobrança",
"billing_frequency": "Freqüência de cobrança",

View File

@@ -4,7 +4,9 @@ import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { HashRouter } from 'react-router-dom';
import { AuthProvider } from 'contexts/AuthProvider';
import { ControllerSocketProvider } from 'contexts/ControllerSocketProvider';
import { FirmwareSocketProvider } from 'contexts/FirmwareSocketProvider';
import { ProvisioningSocketProvider } from 'contexts/ProvisioningSocketProvider';
import { SecuritySocketProvider } from 'contexts/SecuritySocketProvider';
import Router from 'router';
const queryClient = new QueryClient({
@@ -24,11 +26,15 @@ const App = () => {
<HashRouter>
<Suspense fallback={<Spinner />}>
<AuthProvider token={storageToken !== null ? storageToken : undefined}>
<ProvisioningSocketProvider>
<ControllerSocketProvider>
<Router />
</ControllerSocketProvider>
</ProvisioningSocketProvider>
<SecuritySocketProvider>
<FirmwareSocketProvider>
<ProvisioningSocketProvider>
<ControllerSocketProvider>
<Router />
</ControllerSocketProvider>
</ProvisioningSocketProvider>
</FirmwareSocketProvider>
</SecuritySocketProvider>
</AuthProvider>
</Suspense>
</HashRouter>

View File

@@ -0,0 +1,56 @@
import * as React from 'react';
import { ChevronDownIcon } from '@chakra-ui/icons';
import { Button, Checkbox, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
type Props = {
availableLogTypes: { id: number; helper?: string }[];
hiddenLogIds: number[];
setHiddenLogIds: (ids: number[]) => void;
};
const ShownLogsDropdown = ({ availableLogTypes, hiddenLogIds, setHiddenLogIds }: Props) => {
const { t } = useTranslation();
const labels: { [key: number]: string } = {
1: t('logs.one'),
1000: t('controller.dashboard.device_dashboard_refresh'),
2000: t('logs.configuration_upgrade'),
3000: t('logs.device_firmware_upgrade'),
4000: t('common.connected'),
5000: t('common.disconnected'),
6000: t('controller.devices.new_statistics'),
};
const isActive = (id: number) => !hiddenLogIds.includes(id);
const onToggle = (id: number) => () => {
if (isActive(id)) {
setHiddenLogIds([...hiddenLogIds, id]);
} else {
setHiddenLogIds(hiddenLogIds.filter((hid) => hid !== id));
}
};
const label = (id: number, helper?: string) => {
if (labels[id] !== undefined) {
return labels[id];
}
return helper ?? id;
};
return (
<Menu closeOnSelect={false}>
<MenuButton as={Button} rightIcon={<ChevronDownIcon />} isDisabled={availableLogTypes.length === 0}>
{t('logs.receiving_types')} ({availableLogTypes.length - hiddenLogIds.length})
</MenuButton>
<MenuList>
{availableLogTypes.map((logType) => (
<MenuItem key={uuid()} onClick={onToggle(logType.id)} isFocusable={false}>
<Checkbox isChecked={isActive(logType.id)}>{label(logType.id, logType.helper)}</Checkbox>
</MenuItem>
))}
</MenuList>
</Menu>
);
};
export default ShownLogsDropdown;

View File

@@ -45,8 +45,8 @@ export const useControllerDeviceSearch = ({ minLength = 4 }: UseControllerDevice
const onInputChange = useCallback(
(v: string) => {
setTempValue(v);
if (v !== tempValue) {
setTempValue(v);
debounceChange(v);
}
},

View File

@@ -1,71 +1,9 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import { QueryClient, useQueryClient } from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query';
import { useControllerStore } from './useStore';
import { SerialSearchMessage, WebSocketInitialMessage, WebSocketNotification } from './utils';
import { ControllerSocketRawMessage } from './utils';
import { axiosGw, axiosSec } from 'constants/axiosInstances';
import { useAuth } from 'contexts/AuthProvider';
import { DevicesStats, DeviceWithStatus } from 'hooks/Network/Devices';
const extractWebSocketNotification = (
message?: WebSocketInitialMessage | SerialSearchMessage,
): WebSocketNotification | undefined => {
if (message && message.notification) {
if (message.notification.type === 'device_connection') {
return {
type: 'DEVICE_CONNECTION',
serialNumber: message.notification.content.serialNumber,
};
}
if (message.notification.type === 'device_disconnection') {
return {
type: 'DEVICE_DISCONNECTION',
serialNumber: message.notification.content.serialNumber,
};
}
if (message.notification.type === 'device_statistics') {
return {
type: 'DEVICE_STATISTICS',
serialNumber: message.notification.content.serialNumber,
};
}
if (message.notification.type === 'device_connections_statistics') {
return {
type: 'DEVICE_CONNECTIONS_STATISTICS',
statistics: message.notification.content,
serialNumber: undefined,
};
}
} else if (message?.serialNumbers) {
return {
type: 'DEVICE_SEARCH_RESULTS',
serialNumbers: message.serialNumbers,
serialNumber: undefined,
};
}
return undefined;
};
// Invalidate and update queries related to device connection status
const connectedNotification = (serialNumber: string, isConnected: boolean, queryClient: QueryClient) => {
queryClient.invalidateQueries(['device', 'status', serialNumber]);
const queries = queryClient.getQueriesData(['devices', 'all']);
for (const query of queries) {
if (query[1] && query) {
const { devicesWithStatus } = query[1] as { devicesWithStatus: DeviceWithStatus[] };
for (let i = 0; i < devicesWithStatus?.length ?? 0; i += 1) {
const device = devicesWithStatus[i];
if (device && device.serialNumber === serialNumber) {
device.connected = isConnected;
devicesWithStatus[i] = device;
queryClient.setQueryData(query[0], { ...{ devicesWithStatus: [...devicesWithStatus] } });
break;
}
}
}
}
};
export type ControllerSocketContextReturn = Record<string, unknown>;
@@ -76,43 +14,20 @@ const ControllerSocketContext = React.createContext<ControllerSocketContextRetur
export const ControllerSocketProvider = ({ children }: { children: React.ReactElement }) => {
const { token, isUserLoaded } = useAuth();
const { addMessage, isOpen, setIsOpen, setLastSearchResults, webSocket, onStartWebSocket } = useControllerStore(
(state) => ({
addMessage: state.addMessage,
setIsOpen: state.setWebSocketOpen,
isOpen: state.isWebSocketOpen,
lastSearchResults: state.lastSearchResults,
setLastSearchResults: state.setLastSearchResults,
webSocket: state.webSocket,
onStartWebSocket: state.startWebSocket,
}),
);
const { addMessage, isOpen, webSocket, onStartWebSocket } = useControllerStore((state) => ({
addMessage: state.addMessage,
isOpen: state.isWebSocketOpen,
webSocket: state.webSocket,
onStartWebSocket: state.startWebSocket,
}));
const queryClient = useQueryClient();
const onMessage = useCallback((message: MessageEvent<string>) => {
try {
const data = JSON.parse(message.data) as WebSocketInitialMessage | undefined;
const extracted = extractWebSocketNotification(data);
if (extracted) {
addMessage(extracted);
if (extracted.type === 'DEVICE_CONNECTION') {
queryClient.invalidateQueries(['device', extracted.serialNumber]);
connectedNotification(extracted.serialNumber, true, queryClient);
} else if (extracted.type === 'DEVICE_DISCONNECTION') {
queryClient.invalidateQueries(['device', extracted.serialNumber]);
connectedNotification(extracted.serialNumber, false, queryClient);
} else if (extracted.type === 'DEVICE_STATISTICS') {
queryClient.invalidateQueries(['deviceStatistics', extracted.serialNumber, 'latestHour']);
} else if (extracted.type === 'DEVICE_CONNECTIONS_STATISTICS') {
queryClient.setQueryData(['devices', 'all', 'connection-statistics'], {
connectedDevices: extracted.statistics.numberOfDevices,
connectingDevices: extracted.statistics.numberOfConnectingDevices,
averageConnectionTime: extracted.statistics.averageConnectedTime,
} as DevicesStats);
} else if (extracted.type === 'DEVICE_SEARCH_RESULTS') {
setLastSearchResults(extracted.serialNumbers);
}
const data = JSON.parse(message.data) as ControllerSocketRawMessage | undefined;
if (data) {
addMessage(data, queryClient);
}
return undefined;
} catch {
@@ -147,12 +62,13 @@ export const ControllerSocketProvider = ({ children }: { children: React.ReactEl
if (webSocket) {
if (document.visibilityState === 'hidden') {
timeoutId = setTimeout(() => {
/* timeoutId = setTimeout(() => {
if (webSocket) webSocket.onclose = () => {};
webSocket?.close();
setIsOpen(false);
}, 5000);
}, 5000); */
} else {
// If tab is active again, verify if browser killed the WS
clearTimeout(timeoutId);
if (!isOpen && isUserLoaded && axiosGw?.defaults?.baseURL !== axiosSec?.defaults?.baseURL) {

View File

@@ -1,19 +1,117 @@
import { QueryClient } from '@tanstack/react-query';
import { v4 as uuid } from 'uuid';
import create from 'zustand';
import { SocketEventCallback, WebSocketNotification } from './utils';
import { ControllerSocketRawMessage, SocketEventCallback, SocketWebSocketNotificationData } from './utils';
import { axiosGw } from 'constants/axiosInstances';
import { DevicesStats, DeviceWithStatus } from 'hooks/Network/Devices';
import { NotificationType } from 'models/Socket';
export type WebSocketMessage =
export type ControllerWebSocketMessage =
| {
type: 'NOTIFICATION';
data: WebSocketNotification;
data: SocketWebSocketNotificationData;
timestamp: Date;
id: string;
}
| { type: 'UNKNOWN'; data: Record<string, unknown>; timestamp: Date };
| {
type: 'UNKNOWN';
data: {
type?: string;
type_id?: number;
[key: string]: unknown;
};
timestamp: Date;
id: string;
};
const parseRawWebSocketMessage = (
message?: ControllerSocketRawMessage,
): SocketWebSocketNotificationData | undefined => {
if (message && message.notification) {
if (message.notification.type_id === 4000 || message.notification.type === 'device_connection') {
return {
type: 'DEVICE_CONNECTION',
serialNumber: message.notification.content.serialNumber,
};
}
if (message.notification.type_id === 5000 || message.notification.type === 'device_disconnection') {
return {
type: 'DEVICE_DISCONNECTION',
serialNumber: message.notification.content.serialNumber,
};
}
if (message.notification.type_id === 6000 || message.notification.type === 'device_statistics') {
return {
type: 'DEVICE_STATISTICS',
serialNumber: message.notification.content.serialNumber,
};
}
if (message.notification.type_id === 1000 || message.notification.type === 'device_connections_statistics') {
return {
type: 'DEVICE_CONNECTIONS_STATISTICS',
statistics: message.notification.content,
};
}
if (message.notification.type_id === 1) {
return {
type: 'LOG',
log: message.notification.content,
};
}
} else if (message?.serialNumbers) {
return {
type: 'DEVICE_SEARCH_RESULTS',
serialNumbers: message.serialNumbers,
};
} else if (message?.notificationTypes) {
return {
type: 'INITIAL_MESSAGE',
message,
};
}
return undefined;
};
// Invalidate and update queries related to device connection status
const handleConnectionNotification = (serialNumber: string, isConnected: boolean, queryClient: QueryClient) => {
queryClient.invalidateQueries(['device', serialNumber]);
queryClient.invalidateQueries(['device', 'status', serialNumber]);
const queries = queryClient.getQueriesData(['devices', 'all']);
for (const query of queries) {
if (query[1] && query) {
const { devicesWithStatus } = query[1] as { devicesWithStatus: DeviceWithStatus[] };
for (let i = 0; i < devicesWithStatus?.length ?? 0; i += 1) {
const device = devicesWithStatus[i];
if (device && device.serialNumber === serialNumber) {
device.connected = isConnected;
devicesWithStatus[i] = device;
queryClient.setQueryData(query[0], { ...{ devicesWithStatus: [...devicesWithStatus] } });
break;
}
}
}
}
};
// Invalidate latest device stats
const handleDeviceStatsNotification = (serialNumber: string, queryClient: QueryClient) => {
queryClient.invalidateQueries(['deviceStatistics', serialNumber, 'latestHour']);
};
// Set new global connection stats
const handleGlobalConnectionStats = (stats: DevicesStats, queryClient: QueryClient) => {
queryClient.setQueryData(['devices', 'all', 'connection-statistics'], stats);
};
export type ControllerStoreState = {
lastMessage?: WebSocketMessage;
allMessages: WebSocketMessage[];
addMessage: (message: WebSocketNotification) => void;
availableLogTypes: NotificationType[];
hiddenLogIds: number[];
setHiddenLogIds: (logsToHide: number[]) => void;
lastMessage?: ControllerWebSocketMessage;
allMessages: ControllerWebSocketMessage[];
addMessage: (rawMsg: ControllerSocketRawMessage, queryClient: QueryClient) => void;
eventListeners: SocketEventCallback[];
addEventListeners: (callback: SocketEventCallback[]) => void;
webSocket?: WebSocket;
@@ -23,39 +121,84 @@ export type ControllerStoreState = {
setWebSocketOpen: (isOpen: boolean) => void;
lastSearchResults: string[];
setLastSearchResults: (result: string[]) => void;
errors: { str: string; timestamp: Date }[];
};
export const useControllerStore = create<ControllerStoreState>((set, get) => ({
allMessages: [] as WebSocketMessage[],
addMessage: (msg: WebSocketNotification) => {
const obj: WebSocketMessage = {
type: 'NOTIFICATION',
data: msg,
timestamp: new Date(),
};
availableLogTypes: [],
hiddenLogIds: [],
setHiddenLogIds: (logsToHide: number[]) => {
get().send(JSON.stringify({ 'drop-notifications': logsToHide }));
set(() => ({
hiddenLogIds: logsToHide,
}));
},
allMessages: [] as ControllerWebSocketMessage[],
addMessage: (rawMsg: ControllerSocketRawMessage, queryClient: QueryClient) => {
try {
const msg = parseRawWebSocketMessage(rawMsg);
if (msg) {
// Handle notification-specific logic
if (msg.type === 'DEVICE_CONNECTION' || msg.type === 'DEVICE_DISCONNECTION') {
handleConnectionNotification(msg.serialNumber, true, queryClient);
} else if (msg.type === 'DEVICE_STATISTICS') {
handleDeviceStatsNotification(msg.serialNumber, queryClient);
} else if (msg.type === 'DEVICE_CONNECTIONS_STATISTICS') {
handleGlobalConnectionStats(
{
connectedDevices: msg.statistics.numberOfDevices,
connectingDevices: msg.statistics.numberOfConnectingDevices,
averageConnectionTime: msg.statistics.averageConnectedTime,
},
queryClient,
);
} else if (msg.type === 'DEVICE_SEARCH_RESULTS') {
set({ lastSearchResults: msg.serialNumbers });
} else if (msg.type === 'INITIAL_MESSAGE') {
if (msg.message.notificationTypes) {
set({ availableLogTypes: msg.message.notificationTypes });
}
}
// General handling
const obj: ControllerWebSocketMessage = {
type: 'NOTIFICATION',
data: msg,
timestamp: msg.log?.timestamp ? new Date(msg.log.timestamp * 1000) : new Date(),
id: uuid(),
};
const eventsToFire = get().eventListeners.filter(
({ type, serialNumber }) => type === msg.type && serialNumber === msg.serialNumber,
);
const eventsToFire = get().eventListeners.filter(
({ type, serialNumber }) => type === msg.type && serialNumber === msg.serialNumber,
);
if (eventsToFire.length > 0) {
for (const event of eventsToFire) {
event.callback();
if (eventsToFire.length > 0) {
for (const event of eventsToFire) {
event.callback();
}
return set((state) => ({
allMessages:
state.allMessages.length <= 1000 ? [...state.allMessages, obj] : [...state.allMessages.slice(1), obj],
lastMessage: obj,
eventListeners: get().eventListeners.filter(
({ id }) => !eventsToFire.find(({ id: findId }) => findId === id),
),
}));
}
return set((state) => ({
allMessages:
state.allMessages.length <= 1000 ? [...state.allMessages, obj] : [...state.allMessages.slice(1), obj],
lastMessage: obj,
}));
}
return undefined;
} catch {
// TODO - Add error message to socket logs
return set((state) => ({
allMessages:
state.allMessages.length <= 1000 ? [...state.allMessages, obj] : [...state.allMessages.slice(1), obj],
lastMessage: obj,
eventListeners: get().eventListeners.filter(({ id }) => !eventsToFire.find(({ id: findId }) => findId === id)),
errors: [...state.errors, { str: JSON.stringify(rawMsg), timestamp: new Date() }],
}));
}
return set((state) => ({
allMessages:
state.allMessages.length <= 1000 ? [...state.allMessages, obj] : [...state.allMessages.slice(1), obj],
lastMessage: obj,
}));
},
eventListeners: [] as SocketEventCallback[],
addEventListeners: (events: SocketEventCallback[]) =>
@@ -71,7 +214,9 @@ export const useControllerStore = create<ControllerStoreState>((set, get) => ({
if (tries <= 10) {
set({
webSocket: new WebSocket(
`${axiosGw?.defaults?.baseURL ? axiosGw.defaults.baseURL.replace('https', 'wss') : ''}/ws`,
`${
axiosGw?.defaults?.baseURL ? axiosGw.defaults.baseURL.replace('https', 'wss').replace('http', 'ws') : ''
}/ws`,
),
});
const ws = get().webSocket;
@@ -89,4 +234,5 @@ export const useControllerStore = create<ControllerStoreState>((set, get) => ({
},
lastSearchResults: [] as string[],
setLastSearchResults: (results: string[]) => set({ lastSearchResults: results }),
errors: [],
}));

View File

@@ -1,36 +1,83 @@
import { InitialSocketMessage } from 'models/Socket';
export type ControllerSocketNotificationTypeId = 1 | 1000 | 2000 | 3000 | 4000 | 5000 | 6000;
export const ControllerSocketNotificationTypeMap = {
1: 'logs',
1000: 'device_connections_statistics',
2000: 'device_configuration_upgrade',
3000: 'device_firmware_upgrade',
4000: 'device_connection',
5000: 'device_disconnection',
6000: 'device_statistics',
};
type ConnectionMessage = {
notification: {
notificationId: number;
type: 'device_disconnection' | 'device_connection' | 'device_statistics';
type?: 'device_disconnection' | 'device_connection' | 'device_statistics';
type_id?: 4000 | 5000 | 6000;
content: {
serialNumber: string;
};
};
serialNumbers?: undefined;
notificationTypes?: undefined;
};
type LogMessage = {
notification: {
notificationId: number;
type?: undefined;
type_id: 1;
content: {
level: LogLevel;
msg: string;
source: string;
thread_id: number;
thread_name: string;
timestamp: number;
};
};
serialNumbers?: undefined;
notificationTypes?: undefined;
};
type ConnectionStatisticsMessage = {
notification: {
notificationId: number;
type: 'device_connections_statistics';
type?: 'device_connections_statistics';
type_id?: 1000;
content: {
serialNumber?: undefined;
numberOfDevices: number;
numberOfConnectingDevices: number;
averageConnectedTime: number;
};
};
serialNumbers?: undefined;
notificationTypes?: undefined;
};
export type SerialSearchMessage = {
serialNumbers: string[];
notification: undefined;
notification?: undefined;
};
export type WebSocketInitialMessage = ConnectionMessage | ConnectionStatisticsMessage;
export type ControllerSocketRawMessage =
| Partial<ConnectionMessage>
| Partial<ConnectionStatisticsMessage>
| Partial<LogMessage>
| InitialSocketMessage
| SerialSearchMessage;
export type WebSocketNotification =
export type LogLevel = 'information' | 'critical' | 'debug' | 'error' | 'fatal' | 'notice' | 'trace' | 'warning';
export type SocketWebSocketNotificationData =
| {
type: 'DEVICE_CONNECTION' | 'DEVICE_DISCONNECTION' | 'DEVICE_STATISTICS';
serialNumber: string;
log?: undefined;
notificationTypes?: undefined;
}
| {
type: 'DEVICE_CONNECTIONS_STATISTICS';
@@ -39,12 +86,31 @@ export type WebSocketNotification =
numberOfConnectingDevices: number;
averageConnectedTime: number;
};
serialNumber: undefined;
serialNumber?: undefined;
log?: undefined;
notificationTypes?: undefined;
}
| {
type: 'DEVICE_SEARCH_RESULTS';
serialNumbers: string[];
serialNumber: undefined;
serialNumber?: undefined;
log?: undefined;
notificationTypes?: undefined;
}
| {
type: 'LOG';
serialNumber?: undefined;
serialNumbers?: undefined;
notificationTypes?: undefined;
log: LogMessage['notification']['content'];
}
| {
type: 'INITIAL_MESSAGE';
serialNumber?: undefined;
serialNumbers?: undefined;
notificationTypes?: undefined;
log?: undefined;
message: InitialSocketMessage;
};
export type SocketEventCallback = {
id: string;

View File

@@ -0,0 +1,92 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useFirmwareStore } from './useStore';
import { FirmwareSocketRawMessage } from './utils';
import { axiosFms, axiosSec } from 'constants/axiosInstances';
import { useAuth } from 'contexts/AuthProvider';
export type FirmwareSocketContextReturn = Record<string, unknown>;
const FirmwareSocketContext = React.createContext<FirmwareSocketContextReturn>({
webSocket: undefined,
isOpen: false,
});
export const FirmwareSocketProvider = ({ children }: { children: React.ReactElement }) => {
const { token, isUserLoaded } = useAuth();
const { addMessage, isOpen, webSocket, onStartWebSocket } = useFirmwareStore((state) => ({
addMessage: state.addMessage,
isOpen: state.isWebSocketOpen,
webSocket: state.webSocket,
onStartWebSocket: state.startWebSocket,
}));
const queryClient = useQueryClient();
const onMessage = useCallback((message: MessageEvent<string>) => {
try {
const data = JSON.parse(message.data) as FirmwareSocketRawMessage | undefined;
if (data) {
addMessage(data, queryClient);
}
return undefined;
} catch {
return undefined;
}
}, []);
// useEffect for created the WebSocket and 'storing' it in useRef
useEffect(() => {
if (isUserLoaded && axiosFms?.defaults?.baseURL !== axiosSec?.defaults?.baseURL) {
onStartWebSocket(token ?? '');
}
const wsCurrent = webSocket;
return () => wsCurrent?.close();
}, [isUserLoaded]);
// useEffect for generating global notifications
useEffect(() => {
if (webSocket) {
webSocket.addEventListener('message', onMessage);
}
return () => {
if (webSocket) webSocket.removeEventListener('message', onMessage);
};
}, [webSocket]);
useEffect(() => {
const handleVisibilityChange = () => {
let timeoutId;
if (webSocket) {
if (document.visibilityState === 'hidden') {
/* timeoutId = setTimeout(() => {
if (webSocket) webSocket.onclose = () => {};
webSocket?.close();
setIsOpen(false);
}, 5000); */
} else {
// If tab is active again, verify if browser killed the WS
clearTimeout(timeoutId);
if (!isOpen && isUserLoaded && axiosFms?.defaults?.baseURL !== axiosSec?.defaults?.baseURL) {
onStartWebSocket(token ?? '');
}
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [webSocket, isOpen]);
const values: FirmwareSocketContextReturn = useMemo(() => ({}), []);
return <FirmwareSocketContext.Provider value={values}>{children}</FirmwareSocketContext.Provider>;
};
export const useGlobalFirmwareSocket: () => FirmwareSocketContextReturn = () => React.useContext(FirmwareSocketContext);

View File

@@ -0,0 +1,158 @@
import { QueryClient } from '@tanstack/react-query';
import { v4 as uuid } from 'uuid';
import create from 'zustand';
import { FirmwareSocketRawMessage, SocketEventCallback, SocketWebSocketNotificationData } from './utils';
import { axiosFms } from 'constants/axiosInstances';
import { NotificationType } from 'models/Socket';
export type FirmwareWebSocketMessage =
| {
type: 'NOTIFICATION';
data: SocketWebSocketNotificationData;
timestamp: Date;
id: string;
}
| {
type: 'UNKNOWN';
data: {
type?: string;
type_id?: number;
[key: string]: unknown;
};
timestamp: Date;
id: string;
};
const parseRawWebSocketMessage = (message?: FirmwareSocketRawMessage): SocketWebSocketNotificationData | undefined => {
if (message && message.notification) {
if (message.notification.type_id === 1) {
return {
type: 'LOG',
log: message.notification.content,
};
}
} else if (message?.notificationTypes) {
return {
type: 'INITIAL_MESSAGE',
message,
};
}
return undefined;
};
export type FirmwareStoreState = {
availableLogTypes: NotificationType[];
hiddenLogIds: number[];
setHiddenLogIds: (logsToHide: number[]) => void;
lastMessage?: FirmwareWebSocketMessage;
allMessages: FirmwareWebSocketMessage[];
addMessage: (rawMsg: FirmwareSocketRawMessage, queryClient: QueryClient) => void;
eventListeners: SocketEventCallback[];
addEventListeners: (callback: SocketEventCallback[]) => void;
webSocket?: WebSocket;
send: (str: string) => void;
startWebSocket: (token: string, tries?: number) => void;
isWebSocketOpen: boolean;
setWebSocketOpen: (isOpen: boolean) => void;
lastSearchResults: string[];
setLastSearchResults: (result: string[]) => void;
errors: { str: string; timestamp: Date }[];
};
export const useFirmwareStore = create<FirmwareStoreState>((set, get) => ({
availableLogTypes: [],
hiddenLogIds: [],
setHiddenLogIds: (logsToHide: number[]) => {
get().send(JSON.stringify({ 'drop-notifications': logsToHide }));
set(() => ({
hiddenLogIds: logsToHide,
}));
},
allMessages: [] as FirmwareWebSocketMessage[],
addMessage: (rawMsg: FirmwareSocketRawMessage) => {
try {
const msg = parseRawWebSocketMessage(rawMsg);
if (msg) {
// Handle notification-specific logic
if (msg.type === 'INITIAL_MESSAGE') {
if (msg.message.notificationTypes) {
set({ availableLogTypes: msg.message.notificationTypes });
}
}
// General handling
const obj: FirmwareWebSocketMessage = {
type: 'NOTIFICATION',
data: msg,
timestamp: msg.log?.timestamp ? new Date(msg.log.timestamp * 1000) : new Date(),
id: uuid(),
};
const eventsToFire = get().eventListeners.filter(
({ type, serialNumber }) => type === msg.type && serialNumber === msg.serialNumber,
);
if (eventsToFire.length > 0) {
for (const event of eventsToFire) {
event.callback();
}
return set((state) => ({
allMessages:
state.allMessages.length <= 1000 ? [...state.allMessages, obj] : [...state.allMessages.slice(1), obj],
lastMessage: obj,
eventListeners: get().eventListeners.filter(
({ id }) => !eventsToFire.find(({ id: findId }) => findId === id),
),
}));
}
return set((state) => ({
allMessages:
state.allMessages.length <= 1000 ? [...state.allMessages, obj] : [...state.allMessages.slice(1), obj],
lastMessage: obj,
}));
}
return undefined;
} catch {
// TODO - Add error message to socket logs
return set((state) => ({
errors: [...state.errors, { str: JSON.stringify(rawMsg), timestamp: new Date() }],
}));
}
},
eventListeners: [] as SocketEventCallback[],
addEventListeners: (events: SocketEventCallback[]) =>
set((state) => ({ eventListeners: [...state.eventListeners, ...events] })),
isWebSocketOpen: false,
setWebSocketOpen: (isOpen: boolean) => set({ isWebSocketOpen: isOpen }),
send: (str: string) => {
const ws = get().webSocket;
if (ws) ws.send(str);
},
startWebSocket: (token: string, tries = 0) => {
const newTries = tries + 1;
if (tries <= 10) {
set({
webSocket: new WebSocket(
`${
axiosFms?.defaults?.baseURL ? axiosFms.defaults.baseURL.replace('https', 'wss').replace('http', 'ws') : ''
}/ws`,
),
});
const ws = get().webSocket;
if (ws) {
ws.onopen = () => {
set({ isWebSocketOpen: true });
ws.send(`token:${token}`);
};
ws.onclose = () => {
set({ isWebSocketOpen: false });
setTimeout(() => get().startWebSocket(token, newTries), 3000);
};
}
}
},
lastSearchResults: [] as string[],
setLastSearchResults: (results: string[]) => set({ lastSearchResults: results }),
errors: [],
}));

View File

@@ -0,0 +1,51 @@
import { InitialSocketMessage } from 'models/Socket';
export type FirmwareSocketNotificationTypeId = 1 | 1000 | 2000 | 3000 | 4000 | 5000 | 6000;
export const FirmwareSocketNotificationTypeMap = {
1: 'logs',
};
type LogMessage = {
notification: {
notificationId: number;
type?: undefined;
type_id: 1;
content: {
level: LogLevel;
msg: string;
source: string;
thread_id: number;
thread_name: string;
timestamp: number;
};
};
serialNumbers?: undefined;
notificationTypes?: undefined;
};
export type FirmwareSocketRawMessage = Partial<LogMessage> | InitialSocketMessage;
export type LogLevel = 'information' | 'critical' | 'debug' | 'error' | 'fatal' | 'notice' | 'trace' | 'warning';
export type SocketWebSocketNotificationData =
| {
type: 'LOG';
serialNumber?: undefined;
serialNumbers?: undefined;
notificationTypes?: undefined;
log: LogMessage['notification']['content'];
}
| {
type: 'INITIAL_MESSAGE';
serialNumber?: undefined;
serialNumbers?: undefined;
notificationTypes?: undefined;
log?: undefined;
message: InitialSocketMessage;
};
export type SocketEventCallback = {
id: string;
type: 'LOG';
serialNumber: string;
callback: () => void;
};

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import debounce from '../../../../helpers/debounce';
import useWebSocketCommand from './useWebSocketCommand';
import { ProviderCommandResponse } from 'contexts/ProvisioningSocketProvider/utils';
import debounce from 'helpers/debounce';
import { ProvisioningCommandResponse } from 'contexts/ProvisioningSocketProvider/utils';
export type UseDeviceSearchProps = {
minLength?: number;
@@ -14,7 +14,7 @@ export const useProviderDeviceSearch = ({ minLength = 4, operatorId }: UseDevice
{ command: string; serial_prefix: string; operatorId?: string } | undefined
>(undefined);
const [results, setResults] = useState<string[]>([]);
const onNewResult = (newResult: ProviderCommandResponse) => {
const onNewResult = (newResult: ProvisioningCommandResponse) => {
if (newResult.response.serialNumbers) setResults(newResult.response.serialNumbers as string[]);
};
const { isOpen, send } = useWebSocketCommand({ callback: onNewResult });

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import useWebSocketCommand from './useWebSocketCommand';
import { ProviderCommandResponse } from 'contexts/ProvisioningSocketProvider/utils';
import { ProvisioningCommandResponse } from 'contexts/ProvisioningSocketProvider/utils';
import debounce from 'helpers/debounce';
export type UseLocationSearchProps = {
@@ -11,7 +11,7 @@ export const useLocationSearch = ({ minLength = 8 }: UseLocationSearchProps) =>
const [tempValue, setTempValue] = useState('');
const [waitingSearch, setWaitingSearch] = useState<{ command: string; address: string } | undefined>(undefined);
const [results, setResults] = useState<string[]>([]);
const onNewResult = (newResult: ProviderCommandResponse) => {
const onNewResult = (newResult: ProvisioningCommandResponse) => {
if (newResult.response.results) setResults(newResult.response.results as string[]);
};
const { isOpen, send } = useWebSocketCommand({ callback: onNewResult });

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import useWebSocketCommand from './useWebSocketCommand';
import { ProviderCommandResponse } from 'contexts/ProvisioningSocketProvider/utils';
import { ProvisioningCommandResponse } from 'contexts/ProvisioningSocketProvider/utils';
import debounce from 'helpers/debounce';
import { Subscriber } from 'models/Subscriber';
@@ -15,7 +15,7 @@ export const useSubscriberSearch = ({ minLength = 4, operatorId, mode }: UseSubs
{ command: string; emailSearch?: string; nameSearch?: string; operatorId?: string } | undefined
>(undefined);
const [results, setResults] = useState<Subscriber[]>([]);
const onNewResult = (newResult: ProviderCommandResponse) => {
const onNewResult = (newResult: ProvisioningCommandResponse) => {
if (newResult.response.users) setResults(newResult.response.users as Subscriber[]);
};
const { isOpen, send } = useWebSocketCommand({ callback: onNewResult });

View File

@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useProviderStore } from 'contexts/ProvisioningSocketProvider/useStore';
import { ProviderCommandResponse } from 'contexts/ProvisioningSocketProvider/utils';
import { useProvisioningStore } from '../../useStore';
import { ProvisioningCommandResponse } from '../../utils';
import { randomIntId } from 'helpers/stringHelper';
const useProviderWebSocketCommand = ({ callback }: { callback: (command: ProviderCommandResponse) => void }) => {
const { isOpen, webSocket, lastMessage } = useProviderStore((state) => ({
const useProviderWebSocketCommand = ({ callback }: { callback: (command: ProvisioningCommandResponse) => void }) => {
const { isOpen, webSocket, lastMessage } = useProvisioningStore((state) => ({
isOpen: state.isWebSocketOpen,
webSocket: state.webSocket,
lastMessage: state.lastMessage,

View File

@@ -2,10 +2,10 @@ import React from 'react';
import { Box, Heading, ListItem, UnorderedList } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import { ProviderWebSocketVenueUpdateResponse } from 'contexts/ProvisioningSocketProvider/utils';
import { ProvisioningVenueNotificationMessage } from 'contexts/ProvisioningSocketProvider/utils';
interface Props {
notification: ProviderWebSocketVenueUpdateResponse;
notification: ProvisioningVenueNotificationMessage['notification'];
}
const ConfigurationPushesNotificationContent = ({ notification }: Props) => {

View File

@@ -2,10 +2,10 @@ import React from 'react';
import { Box, Heading, ListItem, UnorderedList } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import { ProviderWebSocketVenueUpdateResponse } from 'contexts/ProvisioningSocketProvider/utils';
import { ProvisioningVenueNotificationMessage } from 'contexts/ProvisioningSocketProvider/utils';
interface Props {
notification: ProviderWebSocketVenueUpdateResponse;
notification: ProvisioningVenueNotificationMessage['notification'];
}
const DeviceRebootNotificationContent = ({ notification }: Props) => {

View File

@@ -2,10 +2,10 @@ import React from 'react';
import { Box, Heading, ListItem, UnorderedList } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import { ProviderWebSocketVenueUpdateResponse } from 'contexts/ProvisioningSocketProvider/utils';
import { ProvisioningVenueNotificationMessage } from 'contexts/ProvisioningSocketProvider/utils';
interface Props {
notification: ProviderWebSocketVenueUpdateResponse;
notification: ProvisioningVenueNotificationMessage['notification'];
}
const DeviceUpgradeNotificationContent = ({ notification }: Props) => {

View File

@@ -2,24 +2,24 @@ import React from 'react';
import ConfigurationPushesNotificationContent from './ConfigurationPushes';
import DeviceRebootNotificationContent from './DeviceReboot';
import DeviceUpgradeNotificationContent from './DeviceUpgrade';
import { ProviderWebSocketVenueUpdateResponse } from 'contexts/ProvisioningSocketProvider/utils';
import { ProvisioningVenueNotificationMessage } from 'contexts/ProvisioningSocketProvider/utils';
interface Props {
notification?: ProviderWebSocketVenueUpdateResponse;
notification?: ProvisioningVenueNotificationMessage['notification'];
}
const NotificationContent = ({ notification }: Props) => {
if (!notification) return null;
if (notification.type === 'entity_configuration_update' || notification.type === 'venue_configuration_update') {
if (notification.type_id === 2000 || notification.type === 'venue_config_update') {
return <ConfigurationPushesNotificationContent notification={notification} />;
}
if (notification.type === 'venue_rebooter') {
if (notification.type_id === 3000 || notification.type === 'venue_rebooter') {
return <DeviceRebootNotificationContent notification={notification} />;
}
if (notification.type === 'venue_upgrader') {
if (notification.type_id === 1000 || notification.type === 'venue_fw_upgrade') {
return <DeviceUpgradeNotificationContent notification={notification} />;
}
return null;

View File

@@ -19,14 +19,14 @@ import {
IconButton,
Tooltip,
} from '@chakra-ui/react';
import { StringMap, TOptions } from 'i18next';
import { TOptions } from 'i18next';
import { X } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import { ProviderWebSocketVenueUpdateResponse } from '../../utils';
import { ProvisioningVenueNotificationMessage } from '../../utils';
import NotificationContent from '.';
const getStatusFromNotification = (notification: ProviderWebSocketVenueUpdateResponse) => {
const getStatusFromNotification = (notification: ProvisioningVenueNotificationMessage['notification']) => {
let status: 'success' | 'warning' | 'error' = 'success';
if (notification.content.warning?.length > 0) status = 'warning';
if (notification.content.error?.length > 0) status = 'error';
@@ -35,15 +35,10 @@ const getStatusFromNotification = (notification: ProviderWebSocketVenueUpdateRes
};
const getNotificationDescription = (
t: (key: string, options?: string | TOptions<StringMap> | undefined) => string,
notification: ProviderWebSocketVenueUpdateResponse,
t: (key: string, options?: string | TOptions<Record<string, number>> | undefined) => string,
notification: ProvisioningVenueNotificationMessage['notification'],
) => {
if (
notification.content.type === 'venue_configuration_update' ||
notification.content.type === 'entity_configuration_update' ||
notification.content.type === 'venue_rebooter' ||
notification.content.type === 'venue_upgrader'
) {
if (notification.type_id === 1000 || notification.type_id === 2000 || notification.type_id === 3000) {
return t('configurations.notification_details', {
success: notification.content.success?.length ?? 0,
warning: notification.content.warning?.length ?? 0,
@@ -56,16 +51,19 @@ const getNotificationDescription = (
const useWebSocketNotification = () => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const [notif, setNotif] = useState<ProviderWebSocketVenueUpdateResponse | undefined>(undefined);
const [notif, setNotif] = useState<ProvisioningVenueNotificationMessage['notification'] | undefined>(undefined);
const toast = useToast();
const openDetails = useCallback((newObj: ProviderWebSocketVenueUpdateResponse, closeToast?: () => void) => {
setNotif(newObj);
if (closeToast) closeToast();
onOpen();
}, []);
const openDetails = useCallback(
(newObj: ProvisioningVenueNotificationMessage['notification'], closeToast?: () => void) => {
setNotif(newObj);
if (closeToast) closeToast();
onOpen();
},
[],
);
const pushNotification = useCallback((notification: ProviderWebSocketVenueUpdateResponse) => {
const pushNotification = useCallback((notification: ProvisioningVenueNotificationMessage['notification']) => {
toast({
id: uuid(),
duration: 5000,

View File

@@ -1,13 +1,13 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import useWebSocketNotification from './hooks/NotificationContent/useWebSocketNotification';
import { useProviderStore } from './useStore';
import { extractProviderWebSocketResponse } from './utils';
import { useProvisioningStore } from './useStore';
import { ProvisioningSocketRawMessage } from './utils';
import { axiosProv, axiosSec } from 'constants/axiosInstances';
import { useAuth } from 'contexts/AuthProvider';
export type ProviderSocketContextReturn = Record<string, unknown>;
export type ProvisioningSocketContextReturn = Record<string, unknown>;
const ProviderSocketContext = React.createContext<ProviderSocketContextReturn>({
const ProvisioningSocketContext = React.createContext<ProvisioningSocketContextReturn>({
webSocket: undefined,
isOpen: false,
});
@@ -15,22 +15,18 @@ const ProviderSocketContext = React.createContext<ProviderSocketContextReturn>({
export const ProvisioningSocketProvider = ({ children }: { children: React.ReactElement }) => {
const { token, isUserLoaded } = useAuth();
const { pushNotification, modal } = useWebSocketNotification();
const { addMessage, isOpen, setIsOpen, webSocket, onStartWebSocket } = useProviderStore((state) => ({
const { addMessage, isOpen, webSocket, onStartWebSocket } = useProvisioningStore((state) => ({
addMessage: state.addMessage,
setIsOpen: state.setWebSocketOpen,
isOpen: state.isWebSocketOpen,
webSocket: state.webSocket,
onStartWebSocket: state.startWebSocket,
}));
const onMessage = useCallback((msg: MessageEvent<string>) => {
const onMessage = useCallback((message: MessageEvent<string>) => {
try {
const extracted = extractProviderWebSocketResponse(msg);
if (extracted) {
addMessage(extracted);
if (extracted.type === 'NOTIFICATION') {
pushNotification(extracted.data);
}
const data = JSON.parse(message.data) as ProvisioningSocketRawMessage | undefined;
if (data) {
addMessage(data, pushNotification);
}
return undefined;
} catch {
@@ -65,12 +61,13 @@ export const ProvisioningSocketProvider = ({ children }: { children: React.React
if (webSocket) {
if (document.visibilityState === 'hidden') {
timeoutId = setTimeout(() => {
/* timeoutId = setTimeout(() => {
if (webSocket) webSocket.onclose = () => {};
webSocket?.close();
setIsOpen(false);
}, 5000);
}, 5000); */
} else {
// If tab is active again, verify if browser killed the WS
clearTimeout(timeoutId);
if (!isOpen && isUserLoaded && axiosProv?.defaults?.baseURL !== axiosSec?.defaults?.baseURL) {
@@ -86,17 +83,17 @@ export const ProvisioningSocketProvider = ({ children }: { children: React.React
};
}, [webSocket, isOpen]);
const values: ProviderSocketContextReturn = useMemo(() => ({}), []);
const values: ProvisioningSocketContextReturn = useMemo(() => ({}), []);
return (
<ProviderSocketContext.Provider value={values}>
<ProvisioningSocketContext.Provider value={values}>
<>
{children}
{modal}
</>
</ProviderSocketContext.Provider>
</ProvisioningSocketContext.Provider>
);
};
export const useGlobalProvisioningSocket: () => ProviderSocketContextReturn = () =>
React.useContext(ProviderSocketContext);
export const useGlobalProvisioningSocket: () => ProvisioningSocketContextReturn = () =>
React.useContext(ProvisioningSocketContext);

View File

@@ -1,41 +1,170 @@
import { v4 as uuid } from 'uuid';
import create from 'zustand';
import { ProviderWebSocketMessage, ProviderWebSocketParsedMessage } from './utils';
import {
ACCEPTED_VENUE_NOTIFICATION_TYPES,
ProvisioningCommandResponse,
ProvisioningSocketRawMessage,
ProvisioningVenueNotificationMessage,
SocketEventCallback,
SocketWebSocketNotificationData,
} from './utils';
import { axiosProv } from 'constants/axiosInstances';
import { NotificationType } from 'models/Socket';
export type ProviderStoreState = {
lastMessage?: ProviderWebSocketMessage;
allMessages: ProviderWebSocketMessage[];
addMessage: (message: ProviderWebSocketParsedMessage) => void;
export type ProvisioningWebSocketMessage =
| {
type: 'NOTIFICATION';
data: SocketWebSocketNotificationData;
timestamp: Date;
id: string;
}
| {
type: 'COMMAND';
data: ProvisioningCommandResponse;
timestamp: Date;
id: string;
}
| {
type: 'UNKNOWN';
data: {
type?: string;
type_id?: number;
[key: string]: unknown;
};
timestamp: Date;
id: string;
};
const parseRawWebSocketMessage = (
message?: ProvisioningSocketRawMessage,
): SocketWebSocketNotificationData | undefined => {
if (message && message.notification) {
if (message.notification.type_id === 1) {
return {
type: 'LOG',
log: message.notification.content,
};
}
if (
message.notification.type_id === 1000 ||
message.notification.type_id === 2000 ||
message.notification.type_id === 3000
) {
return {
type: 'NOTIFICATION',
data: message.notification,
};
}
} else if (message?.notificationTypes) {
return {
type: 'INITIAL_MESSAGE',
message,
};
} else if (message?.response && message.command_response_id) {
return {
type: 'COMMAND',
data: message as ProvisioningCommandResponse,
};
}
return undefined;
};
export type ProvisioningStoreState = {
availableLogTypes: NotificationType[];
hiddenLogIds: number[];
setHiddenLogIds: (logsToHide: number[]) => void;
lastMessage?: ProvisioningWebSocketMessage;
allMessages: ProvisioningWebSocketMessage[];
addMessage: (
rawMsg: ProvisioningSocketRawMessage,
pushNotification: (notification: ProvisioningVenueNotificationMessage['notification']) => void,
) => void;
eventListeners: SocketEventCallback[];
addEventListeners: (callback: SocketEventCallback[]) => void;
webSocket?: WebSocket;
send: (str: string) => void;
startWebSocket: (token: string, tries?: number) => void;
isWebSocketOpen: boolean;
setWebSocketOpen: (isOpen: boolean) => void;
lastSearchResults: string[];
setLastSearchResults: (result: string[]) => void;
errors: { str: string; timestamp: Date }[];
};
export const useProviderStore = create<ProviderStoreState>((set, get) => ({
allMessages: [] as ProviderWebSocketMessage[],
addMessage: (msg: ProviderWebSocketParsedMessage) => {
// @ts-ignore
const obj: ProviderWebSocketMessage =
msg.type === 'COMMAND'
? {
type: msg.type,
data: msg.data,
timestamp: new Date(),
}
: {
type: msg.type,
data: msg.data,
timestamp: new Date(),
};
return set((state) => ({
allMessages:
state.allMessages.length <= 1000 ? [...state.allMessages, obj] : [...state.allMessages.slice(1), obj],
lastMessage: obj,
export const useProvisioningStore = create<ProvisioningStoreState>((set, get) => ({
availableLogTypes: [],
hiddenLogIds: [],
setHiddenLogIds: (logsToHide: number[]) => {
get().send(JSON.stringify({ 'drop-notifications': logsToHide }));
set(() => ({
hiddenLogIds: logsToHide,
}));
},
allMessages: [] as ProvisioningWebSocketMessage[],
addMessage: (rawMsg, pushNotification) => {
try {
const msg = parseRawWebSocketMessage(rawMsg);
if (msg) {
// Handle notification-specific logic
if (msg.type === 'INITIAL_MESSAGE') {
if (msg.message.notificationTypes) {
set({ availableLogTypes: msg.message.notificationTypes });
}
}
// Handle venue notifications
if (msg.type === 'NOTIFICATION' && ACCEPTED_VENUE_NOTIFICATION_TYPES.includes(msg.data.type_id)) {
pushNotification(msg.data);
}
// General handling
const obj: ProvisioningWebSocketMessage =
msg.type === 'COMMAND'
? {
type: 'COMMAND',
data: msg.data,
timestamp: new Date(),
id: uuid(),
}
: {
type: 'NOTIFICATION',
data: msg,
timestamp: msg.log?.timestamp ? new Date(msg.log.timestamp * 1000) : new Date(),
id: uuid(),
};
const eventsToFire = get().eventListeners.filter(({ type }) => type === msg.type);
if (eventsToFire.length > 0) {
for (const event of eventsToFire) {
event.callback();
}
return set((state) => ({
allMessages:
state.allMessages.length <= 1000 ? [...state.allMessages, obj] : [...state.allMessages.slice(1), obj],
lastMessage: obj,
eventListeners: get().eventListeners.filter(
({ id }) => !eventsToFire.find(({ id: findId }) => findId === id),
),
}));
}
return set((state) => ({
allMessages:
state.allMessages.length <= 1000 ? [...state.allMessages, obj] : [...state.allMessages.slice(1), obj],
lastMessage: obj,
}));
}
return undefined;
} catch {
// TODO - Add error message to socket logs
return set((state) => ({
errors: [...state.errors, { str: JSON.stringify(rawMsg), timestamp: new Date() }],
}));
}
},
eventListeners: [] as SocketEventCallback[],
addEventListeners: (events: SocketEventCallback[]) =>
set((state) => ({ eventListeners: [...state.eventListeners, ...events] })),
isWebSocketOpen: false,
setWebSocketOpen: (isOpen: boolean) => set({ isWebSocketOpen: isOpen }),
send: (str: string) => {
@@ -65,4 +194,7 @@ export const useProviderStore = create<ProviderStoreState>((set, get) => ({
}
}
},
lastSearchResults: [] as string[],
setLastSearchResults: (results: string[]) => set({ lastSearchResults: results }),
errors: [],
}));

View File

@@ -1,76 +1,99 @@
import { InitialSocketMessage } from 'models/Socket';
import { Subscriber } from 'models/Subscriber';
// Notifications we react to from the WS
export const acceptedNotificationTypes = [
'venue_configuration_update',
'entity_configuration_update',
'venue_rebooter',
'venue_upgrader',
];
// Data received from WS on Venue update notification
export type ProviderWebSocketVenueUpdateResponse = {
notification_id: number;
type: 'venue_configuration_update' | 'entity_configuration_update' | 'venue_rebooter' | 'venue_upgrader';
content: {
type: 'venue_configuration_update' | 'entity_configuration_update' | 'venue_rebooter' | 'venue_upgrader';
title: string;
details: string;
success: string[];
noFirmware?: string[];
notConnected?: string[];
skipped?: string[];
warning: string[];
error: string[];
timeStamp: number;
};
export type ProvisioningSocketNotificationTypeId = 1 | 1000 | 2000 | 3000 | 4000 | 5000 | 6000;
export const ProvisioningSocketNotificationTypeMap = {
1: 'logs',
1000: 'venue_fw_upgrade',
2000: 'venue_config_update',
3000: 'venue_rebooter',
};
export type ProviderCommandResponse = {
export const ACCEPTED_VENUE_NOTIFICATION_TYPES = [1000, 2000, 3000];
export type ProvisioningCommandResponse = {
command_response_id: number;
response: { serialNumbers?: string[]; users?: Subscriber[]; results?: string[] };
notification?: undefined;
notificationTypes?: undefined;
};
// Parsed WebSocket message
export type ProviderWebSocketParsedMessage =
type LogMessage = {
notification: {
notificationId: number;
type?: undefined;
type_id: 1;
content: {
level: LogLevel;
msg: string;
source: string;
thread_id: number;
thread_name: string;
timestamp: number;
};
};
command_response_id?: undefined;
response?: undefined;
notificationTypes?: undefined;
};
export type ProvisioningVenueNotificationMessage = {
notification: {
notification_id: number;
type?: 'venue_fw_upgrade' | 'venue_config_update' | 'venue_rebooter';
type_id: 1000 | 2000 | 3000;
content: {
title: string;
details: string;
success: string[];
noFirmware?: string[];
notConnected?: string[];
skipped?: string[];
warning: string[];
error: string[];
timeStamp: number;
};
};
command_response_id?: undefined;
response?: undefined;
notificationTypes?: undefined;
};
export type ProvisioningSocketRawMessage =
| Partial<LogMessage>
| Partial<ProvisioningVenueNotificationMessage>
| Partial<ProvisioningCommandResponse>
| InitialSocketMessage;
export type LogLevel = 'information' | 'critical' | 'debug' | 'error' | 'fatal' | 'notice' | 'trace' | 'warning';
export type SocketWebSocketNotificationData =
| {
type: 'NOTIFICATION';
data: ProviderWebSocketVenueUpdateResponse;
data: ProvisioningVenueNotificationMessage['notification'];
log?: undefined;
notificationTypes?: undefined;
}
| {
type: 'LOG';
notificationTypes?: undefined;
log: LogMessage['notification']['content'];
}
| {
type: 'COMMAND';
data: ProviderCommandResponse;
data: ProvisioningCommandResponse;
notificationTypes?: undefined;
log?: undefined;
}
| {
type: 'INITIAL_MESSAGE';
notificationTypes?: undefined;
log?: undefined;
message: InitialSocketMessage;
};
// Parsing raw WS messages into a more usable format
export const extractProviderWebSocketResponse = (message: MessageEvent): ProviderWebSocketParsedMessage | undefined => {
try {
const data = JSON.parse(message.data);
if (data.notification && acceptedNotificationTypes.includes(data.notification.type)) {
const notification = data.notification as ProviderWebSocketVenueUpdateResponse;
return { data: notification, type: 'NOTIFICATION' };
}
if (data.command_response_id) {
return { data, type: 'COMMAND' } as {
type: 'COMMAND';
data: ProviderCommandResponse;
};
}
} catch {
return undefined;
}
return undefined;
export type SocketEventCallback = {
id: string;
type: 'LOG';
serialNumber: string;
callback: () => void;
};
// What we store in the store
export type ProviderWebSocketMessage =
| {
type: 'NOTIFICATION';
data: ProviderWebSocketParsedMessage;
timestamp: Date;
}
| {
type: 'COMMAND';
data: ProviderCommandResponse;
timestamp: Date;
};

View File

@@ -0,0 +1,91 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useSecurityStore } from './useStore';
import { SecuritySocketRawMessage } from './utils';
import { useAuth } from 'contexts/AuthProvider';
export type SecuritySocketContextReturn = Record<string, unknown>;
const SecuritySocketContext = React.createContext<SecuritySocketContextReturn>({
webSocket: undefined,
isOpen: false,
});
export const SecuritySocketProvider = ({ children }: { children: React.ReactElement }) => {
const { token, isUserLoaded } = useAuth();
const { addMessage, isOpen, webSocket, onStartWebSocket } = useSecurityStore((state) => ({
addMessage: state.addMessage,
isOpen: state.isWebSocketOpen,
webSocket: state.webSocket,
onStartWebSocket: state.startWebSocket,
}));
const queryClient = useQueryClient();
const onMessage = useCallback((message: MessageEvent<string>) => {
try {
const data = JSON.parse(message.data) as SecuritySocketRawMessage | undefined;
if (data) {
addMessage(data, queryClient);
}
return undefined;
} catch {
return undefined;
}
}, []);
// useEffect for created the WebSocket and 'storing' it in useRef
useEffect(() => {
if (isUserLoaded) {
onStartWebSocket(token ?? '');
}
const wsCurrent = webSocket;
return () => wsCurrent?.close();
}, [isUserLoaded]);
// useEffect for generating global notifications
useEffect(() => {
if (webSocket) {
webSocket.addEventListener('message', onMessage);
}
return () => {
if (webSocket) webSocket.removeEventListener('message', onMessage);
};
}, [webSocket]);
useEffect(() => {
const handleVisibilityChange = () => {
let timeoutId;
if (webSocket) {
if (document.visibilityState === 'hidden') {
/* timeoutId = setTimeout(() => {
if (webSocket) webSocket.onclose = () => {};
webSocket?.close();
setIsOpen(false);
}, 5000); */
} else {
// If tab is active again, verify if browser killed the WS
clearTimeout(timeoutId);
if (!isOpen && isUserLoaded) {
onStartWebSocket(token ?? '');
}
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [webSocket, isOpen]);
const values: SecuritySocketContextReturn = useMemo(() => ({}), []);
return <SecuritySocketContext.Provider value={values}>{children}</SecuritySocketContext.Provider>;
};
export const useGlobalSecuritySocket: () => SecuritySocketContextReturn = () => React.useContext(SecuritySocketContext);

View File

@@ -0,0 +1,158 @@
import { QueryClient } from '@tanstack/react-query';
import { v4 as uuid } from 'uuid';
import create from 'zustand';
import { SecuritySocketRawMessage, SocketEventCallback, SocketWebSocketNotificationData } from './utils';
import { axiosSec } from 'constants/axiosInstances';
import { NotificationType } from 'models/Socket';
export type SecurityWebSocketMessage =
| {
type: 'NOTIFICATION';
data: SocketWebSocketNotificationData;
timestamp: Date;
id: string;
}
| {
type: 'UNKNOWN';
data: {
type?: string;
type_id?: number;
[key: string]: unknown;
};
timestamp: Date;
id: string;
};
const parseRawWebSocketMessage = (message?: SecuritySocketRawMessage): SocketWebSocketNotificationData | undefined => {
if (message && message.notification) {
if (message.notification.type_id === 1) {
return {
type: 'LOG',
log: message.notification.content,
};
}
} else if (message?.notificationTypes) {
return {
type: 'INITIAL_MESSAGE',
message,
};
}
return undefined;
};
export type SecurityStoreState = {
availableLogTypes: NotificationType[];
hiddenLogIds: number[];
setHiddenLogIds: (logsToHide: number[]) => void;
lastMessage?: SecurityWebSocketMessage;
allMessages: SecurityWebSocketMessage[];
addMessage: (rawMsg: SecuritySocketRawMessage, queryClient: QueryClient) => void;
eventListeners: SocketEventCallback[];
addEventListeners: (callback: SocketEventCallback[]) => void;
webSocket?: WebSocket;
send: (str: string) => void;
startWebSocket: (token: string, tries?: number) => void;
isWebSocketOpen: boolean;
setWebSocketOpen: (isOpen: boolean) => void;
lastSearchResults: string[];
setLastSearchResults: (result: string[]) => void;
errors: { str: string; timestamp: Date }[];
};
export const useSecurityStore = create<SecurityStoreState>((set, get) => ({
availableLogTypes: [],
hiddenLogIds: [],
setHiddenLogIds: (logsToHide: number[]) => {
get().send(JSON.stringify({ 'drop-notifications': logsToHide }));
set(() => ({
hiddenLogIds: logsToHide,
}));
},
allMessages: [] as SecurityWebSocketMessage[],
addMessage: (rawMsg: SecuritySocketRawMessage) => {
try {
const msg = parseRawWebSocketMessage(rawMsg);
if (msg) {
// Handle notification-specific logic
if (msg.type === 'INITIAL_MESSAGE') {
if (msg.message.notificationTypes) {
set({ availableLogTypes: msg.message.notificationTypes });
}
}
// General handling
const obj: SecurityWebSocketMessage = {
type: 'NOTIFICATION',
data: msg,
timestamp: msg.log?.timestamp ? new Date(msg.log.timestamp * 1000) : new Date(),
id: uuid(),
};
const eventsToFire = get().eventListeners.filter(
({ type, serialNumber }) => type === msg.type && serialNumber === msg.serialNumber,
);
if (eventsToFire.length > 0) {
for (const event of eventsToFire) {
event.callback();
}
return set((state) => ({
allMessages:
state.allMessages.length <= 1000 ? [...state.allMessages, obj] : [...state.allMessages.slice(1), obj],
lastMessage: obj,
eventListeners: get().eventListeners.filter(
({ id }) => !eventsToFire.find(({ id: findId }) => findId === id),
),
}));
}
return set((state) => ({
allMessages:
state.allMessages.length <= 1000 ? [...state.allMessages, obj] : [...state.allMessages.slice(1), obj],
lastMessage: obj,
}));
}
return undefined;
} catch {
// TODO - Add error message to socket logs
return set((state) => ({
errors: [...state.errors, { str: JSON.stringify(rawMsg), timestamp: new Date() }],
}));
}
},
eventListeners: [] as SocketEventCallback[],
addEventListeners: (events: SocketEventCallback[]) =>
set((state) => ({ eventListeners: [...state.eventListeners, ...events] })),
isWebSocketOpen: false,
setWebSocketOpen: (isOpen: boolean) => set({ isWebSocketOpen: isOpen }),
send: (str: string) => {
const ws = get().webSocket;
if (ws) ws.send(str);
},
startWebSocket: (token: string, tries = 0) => {
const newTries = tries + 1;
if (tries <= 10) {
set({
webSocket: new WebSocket(
`${
axiosSec?.defaults?.baseURL ? axiosSec.defaults.baseURL.replace('https', 'wss').replace('http', 'ws') : ''
}/ws`,
),
});
const ws = get().webSocket;
if (ws) {
ws.onopen = () => {
set({ isWebSocketOpen: true });
ws.send(`token:${token}`);
};
ws.onclose = () => {
set({ isWebSocketOpen: false });
setTimeout(() => get().startWebSocket(token, newTries), 3000);
};
}
}
},
lastSearchResults: [] as string[],
setLastSearchResults: (results: string[]) => set({ lastSearchResults: results }),
errors: [],
}));

View File

@@ -0,0 +1,51 @@
import { InitialSocketMessage } from 'models/Socket';
export type SecuritySocketNotificationTypeId = 1 | 1000 | 2000 | 3000 | 4000 | 5000 | 6000;
export const SecuritySocketNotificationTypeMap = {
1: 'logs',
};
type LogMessage = {
notification: {
notificationId: number;
type?: undefined;
type_id: 1;
content: {
level: LogLevel;
msg: string;
source: string;
thread_id: number;
thread_name: string;
timestamp: number;
};
};
serialNumbers?: undefined;
notificationTypes?: undefined;
};
export type SecuritySocketRawMessage = Partial<LogMessage> | InitialSocketMessage;
export type LogLevel = 'information' | 'critical' | 'debug' | 'error' | 'fatal' | 'notice' | 'trace' | 'warning';
export type SocketWebSocketNotificationData =
| {
type: 'LOG';
serialNumber?: undefined;
serialNumbers?: undefined;
notificationTypes?: undefined;
log: LogMessage['notification']['content'];
}
| {
type: 'INITIAL_MESSAGE';
serialNumber?: undefined;
serialNumbers?: undefined;
notificationTypes?: undefined;
log?: undefined;
message: InitialSocketMessage;
};
export type SocketEventCallback = {
id: string;
type: 'LOG';
serialNumber: string;
callback: () => void;
};

14
src/models/Socket.ts Normal file
View File

@@ -0,0 +1,14 @@
export type NotificationType = {
helper?: string;
id: number;
};
export type InitialSocketMessage = {
notification: undefined;
serialNumbers: undefined;
command_response_id: undefined;
response: undefined;
success?: string;
error?: string;
notificationTypes?: NotificationType[];
};

View File

@@ -0,0 +1,82 @@
import * as React from 'react';
import { Heading } from '@chakra-ui/react';
import { InputActionMeta, Select } from 'chakra-react-select';
import { useTranslation } from 'react-i18next';
import { useControllerDeviceSearch } from 'contexts/ControllerSocketProvider/hooks/Commands/useDeviceSearch';
type Props = {
onSearchSelect: (value: string) => void;
};
const DeviceLogsSearchBar = ({ onSearchSelect }: Props) => {
const { t } = useTranslation();
const { inputValue, results, onInputChange, isOpen } = useControllerDeviceSearch({
minLength: 2,
});
const NoOptionsMessage = React.useCallback(
() => (
<Heading size="sm" textAlign="center">
{t('common.no_devices_found')}
</Heading>
),
[t],
);
const onClick = React.useCallback((v: { value: string }) => {
onSearchSelect(v.value);
onInputChange(v.value);
}, []);
const onChange = React.useCallback((v: string, action: InputActionMeta) => {
if (action.action !== 'input-blur' && action.action !== 'menu-close') {
if (v.length === 0 || v.match('^[a-fA-F0-9-*]+$')) onInputChange(v);
}
}, []);
const onFocus = React.useCallback(() => {
onSearchSelect('');
onInputChange('');
}, []);
return (
<Select
chakraStyles={{
control: (provided) => ({
...provided,
borderRadius: '15px',
color: 'unset',
}),
input: (provided) => ({
...provided,
width: '140px',
opacity: 1,
}),
dropdownIndicator: (provided) => ({
...provided,
backgroundColor: 'unset',
border: 'unset',
}),
menu: (provided) => ({
...provided,
color: 'black',
}),
}}
components={{ NoOptionsMessage }}
hideSelectedOptions={false}
isClearable
blurInputOnSelect
onFocus={onFocus}
// @ts-ignore
options={results.map((v: string) => ({ label: v, value: v }))}
filterOption={() => true}
inputValue={inputValue}
value={inputValue}
placeholder={t('logs.filter')}
onInputChange={onChange}
// @ts-ignore
onChange={onClick}
isDisabled={!isOpen}
/>
);
};
export default React.memo(DeviceLogsSearchBar);

View File

@@ -0,0 +1,165 @@
import * as React from 'react';
import { Box, Button, Flex, HStack, Select, Spacer, Table, Text, Th, Thead, Tr } from '@chakra-ui/react';
import { Download } from 'phosphor-react';
import { CSVLink } from 'react-csv';
import { useTranslation } from 'react-i18next';
import ReactVirtualizedAutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList as List } from 'react-window';
import DeviceLogsSearchBar from './DeviceLogsSearchBar';
import { CardBody } from 'components/Containers/Card/CardBody';
import { CardHeader } from 'components/Containers/Card/CardHeader';
import ShownLogsDropdown from 'components/ShownLogsDropdown';
import { useControllerStore } from 'contexts/ControllerSocketProvider/useStore';
import { dateForFilename } from 'helpers/dateFormatting';
const LogsCard = () => {
const { t } = useTranslation();
const { availableLogTypes, hiddenLogIds, setHiddenLogIds, logs } = useControllerStore((state) => ({
logs: state.allMessages,
availableLogTypes: state.availableLogTypes,
hiddenLogIds: state.hiddenLogIds,
setHiddenLogIds: state.setHiddenLogIds,
}));
const [show, setShow] = React.useState<'' | 'connections' | 'statistics' | 'global_statistics'>('');
const [serialNumber, setSerialNumber] = React.useState<string>('');
const onSerialSelect = React.useCallback((serial: string) => setSerialNumber(serial), []);
const labels = {
DEVICE_CONNECTION: t('common.connected'),
DEVICE_DISCONNECTION: t('common.disconnected'),
DEVICE_STATISTICS: t('controller.devices.new_statistics'),
DEVICE_CONNECTIONS_STATISTICS: t('controller.dashboard.device_dashboard_refresh'),
DEVICE_SEARCH_RESULTS: undefined,
};
const data = React.useMemo(() => {
let arr = logs.filter(
({ data: d }) =>
d.type === 'DEVICE_CONNECTION' || d.type === 'DEVICE_DISCONNECTION' || d.type === 'DEVICE_STATISTICS',
);
if (show === 'connections') {
arr = arr.filter(
(msg) =>
msg.type === 'NOTIFICATION' &&
(msg.data.type === 'DEVICE_CONNECTION' || msg.data.type === 'DEVICE_DISCONNECTION'),
);
} else if (show === 'statistics') {
arr = arr.filter((msg) => msg.type === 'NOTIFICATION' && msg.data.type === 'DEVICE_STATISTICS');
} else if (show === 'global_statistics') {
arr = arr.filter((msg) => msg.type === 'NOTIFICATION' && msg.data.type === 'DEVICE_CONNECTIONS_STATISTICS');
}
if (serialNumber.trim().length > 0) {
arr = arr.filter(
(msg) =>
msg.data?.serialNumber !== undefined &&
typeof msg.data.serialNumber === 'string' &&
msg.data?.serialNumber.includes(serialNumber.trim()),
);
}
return arr.reverse();
}, [logs, show, serialNumber]);
type RowProps = { index: number; style: React.CSSProperties };
const Row = React.useCallback(
({ index, style }: RowProps) => {
const msg = data[index];
if (msg) {
if (msg.type === 'NOTIFICATION' && msg.data.serialNumber) {
return (
<Box style={style}>
<Flex w="100%">
<Box flex="0 1 110px">
<Text>{msg.timestamp.toLocaleTimeString()}</Text>
</Box>
<Box flex="0 1 130px" textAlign="left">
<Text fontFamily="mono">{msg.data?.serialNumber ?? '-'}</Text>
</Box>
<Box flex="0 1 140px">
<Text>{labels[msg.data.type] ?? msg.data.type}</Text>
</Box>
<Box textAlign="left" w="calc(100% - 80px - 120px - 140px - 60px)">
<Text textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{JSON.stringify(msg.data)}
</Text>
</Box>
</Flex>
</Box>
);
}
}
return null;
},
[t, data],
);
const downloadableLogs = React.useMemo(
() =>
data.map((msg) => ({
timestamp: msg.timestamp.toLocaleString(),
serialNumber: msg.data?.serialNumber ?? '-',
type: labels[msg.data?.type] ?? msg.data.type,
data: JSON.stringify(msg.data),
})),
[data],
);
return (
<>
<CardHeader px={4} pt={4}>
<DeviceLogsSearchBar onSearchSelect={onSerialSelect} />
<Spacer />
<HStack spacing={2}>
<ShownLogsDropdown
availableLogTypes={availableLogTypes}
setHiddenLogIds={setHiddenLogIds}
hiddenLogIds={hiddenLogIds}
/>
<Select size="md" value={show} onChange={(e) => setShow(e.target.value as '' | 'connections')} w="200px">
<option value="">{t('common.select_all')}</option>
<option value="connections">{t('controller.devices.connection_changes')}</option>
<option value="statistics">{t('logs.device_statistics')}</option>
<option value="global_statistics">{t('logs.global_connections')}</option>
</Select>
<CSVLink
filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`}
data={downloadableLogs as object[]}
>
<Button onClick={() => {}} colorScheme="blue" leftIcon={<Download />}>
{t('logs.export')}
</Button>
</CSVLink>
</HStack>
</CardHeader>
<CardBody p={4}>
<Box overflowX="auto" w="100%">
<Table size="sm">
<Thead>
<Tr>
<Th w="110px">{t('common.time')}</Th>
<Th w="150px">{t('inventory.serial_number')}</Th>
<Th w="120px" pl={0}>
{t('common.type')}
</Th>
<Th>{t('analytics.raw_data')}</Th>
</Tr>
</Thead>
</Table>
<Box ml={4} h="calc(70vh)">
<ReactVirtualizedAutoSizer>
{({ height, width }) => (
<List height={height} width={width} itemCount={data.length} itemSize={35}>
{Row}
</List>
)}
</ReactVirtualizedAutoSizer>
</Box>
</Box>
</CardBody>
</>
);
};
export default LogsCard;

View File

@@ -0,0 +1,175 @@
import * as React from 'react';
import { Badge, Box, Button, Flex, HStack, Select, Spacer, Table, Text, Th, Thead, Tr } from '@chakra-ui/react';
import { Download } from 'phosphor-react';
import { CSVLink } from 'react-csv';
import { useTranslation } from 'react-i18next';
import ReactVirtualizedAutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList as List } from 'react-window';
import { v4 as uuid } from 'uuid';
import { CardBody } from 'components/Containers/Card/CardBody';
import { CardHeader } from 'components/Containers/Card/CardHeader';
import ShownLogsDropdown from 'components/ShownLogsDropdown';
import { useFirmwareStore } from 'contexts/FirmwareSocketProvider/useStore';
import { LogLevel } from 'contexts/FirmwareSocketProvider/utils';
import { dateForFilename } from 'helpers/dateFormatting';
import { uppercaseFirstLetter } from 'helpers/stringHelper';
const FmsLogsCard = () => {
const { t } = useTranslation();
const { availableLogTypes, hiddenLogIds, setHiddenLogIds, logs } = useFirmwareStore((state) => ({
logs: state.allMessages,
availableLogTypes: state.availableLogTypes,
hiddenLogIds: state.hiddenLogIds,
setHiddenLogIds: state.setHiddenLogIds,
}));
const [level, setLevel] = React.useState<'' | LogLevel>('');
const data = React.useMemo(() => {
const arr = logs.filter(
(d) => d.type === 'NOTIFICATION' && d.data.type === 'LOG' && (level === '' || d.data.log.level === level),
);
return arr.reverse();
}, [logs, level]);
const colorSchemeMap: Record<LogLevel, string> = {
information: 'blue',
critical: 'red',
debug: 'teal',
error: 'red',
fatal: 'purple',
notice: 'blue',
trace: 'blue',
warning: 'yellow',
};
type RowProps = { index: number; style: React.CSSProperties };
const Row = React.useCallback(
({ index, style }: RowProps) => {
const msg = data[index];
if (msg) {
if (msg.type === 'NOTIFICATION' && msg.data.type === 'LOG') {
return (
<Box style={style}>
<Flex w="100%">
<Box flex="0 1 110px">
<Text>{msg.timestamp.toLocaleTimeString()}</Text>
</Box>
<Box flex="0 1 200px">
<Text w="200px" textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{msg.data.log.source}
</Text>
</Box>
<Box flex="0 1 140px">
<Text w="140px" textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{msg.data.log.thread_id}-{msg.data.log.thread_name}
</Text>
</Box>
<Box flex="0 1 110px">
<Badge
ml={1}
size="lg"
fontSize="0.9em"
variant="solid"
colorScheme={colorSchemeMap[msg.data.log.level]}
>
{msg.data.log.level}
</Badge>
</Box>
<Box textAlign="left" w="calc(100% - 180px - 210px - 120px - 60px)">
<Text textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{JSON.stringify(msg.data.log.msg).replace(/"/g, '')}
</Text>
</Box>
</Flex>
</Box>
);
}
}
return null;
},
[t, data],
);
const downloadableLogs = React.useMemo(
() =>
data.map((msg) =>
msg.type === 'NOTIFICATION' && msg.data.type === 'LOG'
? {
timestamp: msg.timestamp.toLocaleString(),
thread: `${msg.data.log.thread_id}-${msg.data.log.thread_name}`,
source: msg.data?.log?.source ?? '-',
level: msg.data?.log?.level,
message: JSON.stringify(msg.data?.log?.msg),
}
: {},
),
[data],
);
return (
<>
<CardHeader px={4} pt={4}>
<Spacer />
<HStack spacing={2}>
<ShownLogsDropdown
availableLogTypes={availableLogTypes}
setHiddenLogIds={setHiddenLogIds}
hiddenLogIds={hiddenLogIds}
/>
<Select size="md" value={level} onChange={(e) => setLevel(e.target.value as '' | LogLevel)} w="130px">
<option value="">{t('common.select_all')}</option>
{Object.keys(colorSchemeMap).map((key) => (
<option key={uuid()} value={key}>
{uppercaseFirstLetter(key)}
</option>
))}
</Select>
<CSVLink
filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`}
data={downloadableLogs as object[]}
>
<Button onClick={() => {}} colorScheme="blue" leftIcon={<Download />}>
{t('logs.export')}
</Button>
</CSVLink>
</HStack>
</CardHeader>
<CardBody p={4}>
<Box overflowX="auto" w="100%">
<Table size="sm">
<Thead>
<Tr>
<Th w="110px">{t('common.time')}</Th>
<Th w="200px">{t('logs.source')}</Th>
<Th w="160px">
{t('logs.thread')} ID-{t('common.name')}
</Th>
<Th w="90px" pl={0}>
{t('logs.level')}
</Th>
<Th>{t('logs.message')}</Th>
</Tr>
</Thead>
</Table>
<Box ml={4} h="calc(70vh)">
<ReactVirtualizedAutoSizer>
{({ height, width }) => (
<List
height={height}
width={width}
itemCount={data.length}
itemSize={35}
itemKey={(index) => data[index]?.id ?? uuid()}
>
{Row}
</List>
)}
</ReactVirtualizedAutoSizer>
</Box>
</Box>
</CardBody>
</>
);
};
export default FmsLogsCard;

View File

@@ -0,0 +1,175 @@
import * as React from 'react';
import { Badge, Box, Button, Flex, HStack, Select, Spacer, Table, Text, Th, Thead, Tr } from '@chakra-ui/react';
import { Download } from 'phosphor-react';
import { CSVLink } from 'react-csv';
import { useTranslation } from 'react-i18next';
import ReactVirtualizedAutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList as List } from 'react-window';
import { v4 as uuid } from 'uuid';
import { CardBody } from 'components/Containers/Card/CardBody';
import { CardHeader } from 'components/Containers/Card/CardHeader';
import ShownLogsDropdown from 'components/ShownLogsDropdown';
import { useControllerStore } from 'contexts/ControllerSocketProvider/useStore';
import { LogLevel } from 'contexts/ControllerSocketProvider/utils';
import { dateForFilename } from 'helpers/dateFormatting';
import { uppercaseFirstLetter } from 'helpers/stringHelper';
const GeneralLogsCard = () => {
const { t } = useTranslation();
const { availableLogTypes, hiddenLogIds, setHiddenLogIds, logs } = useControllerStore((state) => ({
logs: state.allMessages,
availableLogTypes: state.availableLogTypes,
hiddenLogIds: state.hiddenLogIds,
setHiddenLogIds: state.setHiddenLogIds,
}));
const [level, setLevel] = React.useState<'' | LogLevel>('');
const data = React.useMemo(() => {
const arr = logs.filter(
(d) => d.type === 'NOTIFICATION' && d.data.type === 'LOG' && (level === '' || d.data.log.level === level),
);
return arr.reverse();
}, [logs, level]);
const colorSchemeMap: Record<LogLevel, string> = {
information: 'blue',
critical: 'red',
debug: 'teal',
error: 'red',
fatal: 'purple',
notice: 'blue',
trace: 'blue',
warning: 'yellow',
};
type RowProps = { index: number; style: React.CSSProperties };
const Row = React.useCallback(
({ index, style }: RowProps) => {
const msg = data[index];
if (msg) {
if (msg.type === 'NOTIFICATION' && msg.data.type === 'LOG') {
return (
<Box style={style}>
<Flex w="100%">
<Box flex="0 1 110px">
<Text>{msg.timestamp.toLocaleTimeString()}</Text>
</Box>
<Box flex="0 1 200px">
<Text w="200px" textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{msg.data.log.source}
</Text>
</Box>
<Box flex="0 1 140px">
<Text w="140px" textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{msg.data.log.thread_id}-{msg.data.log.thread_name}
</Text>
</Box>
<Box flex="0 1 110px">
<Badge
ml={1}
size="lg"
fontSize="0.9em"
variant="solid"
colorScheme={colorSchemeMap[msg.data.log.level]}
>
{msg.data.log.level}
</Badge>
</Box>
<Box textAlign="left" w="calc(100% - 180px - 210px - 120px - 60px)">
<Text textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{JSON.stringify(msg.data.log.msg).replace(/"/g, '')}
</Text>
</Box>
</Flex>
</Box>
);
}
}
return null;
},
[t, data],
);
const downloadableLogs = React.useMemo(
() =>
data.map((msg) =>
msg.type === 'NOTIFICATION' && msg.data.type === 'LOG'
? {
timestamp: msg.timestamp.toLocaleString(),
thread: `${msg.data.log.thread_id}-${msg.data.log.thread_name}`,
source: msg.data?.log?.source ?? '-',
level: msg.data?.log?.level,
message: JSON.stringify(msg.data?.log?.msg),
}
: {},
),
[data],
);
return (
<>
<CardHeader px={4} pt={4}>
<Spacer />
<HStack spacing={2}>
<ShownLogsDropdown
availableLogTypes={availableLogTypes}
setHiddenLogIds={setHiddenLogIds}
hiddenLogIds={hiddenLogIds}
/>
<Select size="md" value={level} onChange={(e) => setLevel(e.target.value as '' | LogLevel)} w="130px">
<option value="">{t('common.select_all')}</option>
{Object.keys(colorSchemeMap).map((key) => (
<option key={uuid()} value={key}>
{uppercaseFirstLetter(key)}
</option>
))}
</Select>
<CSVLink
filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`}
data={downloadableLogs as object[]}
>
<Button onClick={() => {}} colorScheme="blue" leftIcon={<Download />}>
{t('logs.export')}
</Button>
</CSVLink>
</HStack>
</CardHeader>
<CardBody p={4}>
<Box overflowX="auto" w="100%">
<Table size="sm">
<Thead>
<Tr>
<Th w="110px">{t('common.time')}</Th>
<Th w="200px">{t('logs.source')}</Th>
<Th w="160px">
{t('logs.thread')} ID-{t('common.name')}
</Th>
<Th w="90px" pl={0}>
{t('logs.level')}
</Th>
<Th>{t('logs.message')}</Th>
</Tr>
</Thead>
</Table>
<Box ml={4} h="calc(70vh)">
<ReactVirtualizedAutoSizer>
{({ height, width }) => (
<List
height={height}
width={width}
itemCount={data.length}
itemSize={35}
itemKey={(index) => data[index]?.id ?? uuid()}
>
{Row}
</List>
)}
</ReactVirtualizedAutoSizer>
</Box>
</Box>
</CardBody>
</>
);
};
export default GeneralLogsCard;

View File

@@ -0,0 +1,175 @@
import * as React from 'react';
import { Badge, Box, Button, Flex, HStack, Select, Spacer, Table, Text, Th, Thead, Tr } from '@chakra-ui/react';
import { Download } from 'phosphor-react';
import { CSVLink } from 'react-csv';
import { useTranslation } from 'react-i18next';
import ReactVirtualizedAutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList as List } from 'react-window';
import { v4 as uuid } from 'uuid';
import { CardBody } from 'components/Containers/Card/CardBody';
import { CardHeader } from 'components/Containers/Card/CardHeader';
import ShownLogsDropdown from 'components/ShownLogsDropdown';
import { useSecurityStore } from 'contexts/SecuritySocketProvider/useStore';
import { LogLevel } from 'contexts/SecuritySocketProvider/utils';
import { dateForFilename } from 'helpers/dateFormatting';
import { uppercaseFirstLetter } from 'helpers/stringHelper';
const SecLogsCard = () => {
const { t } = useTranslation();
const { availableLogTypes, hiddenLogIds, setHiddenLogIds, logs } = useSecurityStore((state) => ({
logs: state.allMessages,
availableLogTypes: state.availableLogTypes,
hiddenLogIds: state.hiddenLogIds,
setHiddenLogIds: state.setHiddenLogIds,
}));
const [level, setLevel] = React.useState<'' | LogLevel>('');
const data = React.useMemo(() => {
const arr = logs.filter(
(d) => d.type === 'NOTIFICATION' && d.data.type === 'LOG' && (level === '' || d.data.log.level === level),
);
return arr.reverse();
}, [logs, level]);
const colorSchemeMap: Record<LogLevel, string> = {
information: 'blue',
critical: 'red',
debug: 'teal',
error: 'red',
fatal: 'purple',
notice: 'blue',
trace: 'blue',
warning: 'yellow',
};
type RowProps = { index: number; style: React.CSSProperties };
const Row = React.useCallback(
({ index, style }: RowProps) => {
const msg = data[index];
if (msg) {
if (msg.type === 'NOTIFICATION' && msg.data.type === 'LOG') {
return (
<Box style={style}>
<Flex w="100%">
<Box flex="0 1 110px">
<Text>{msg.timestamp.toLocaleTimeString()}</Text>
</Box>
<Box flex="0 1 200px">
<Text w="200px" textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{msg.data.log.source}
</Text>
</Box>
<Box flex="0 1 140px">
<Text w="140px" textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{msg.data.log.thread_id}-{msg.data.log.thread_name}
</Text>
</Box>
<Box flex="0 1 110px">
<Badge
ml={1}
size="lg"
fontSize="0.9em"
variant="solid"
colorScheme={colorSchemeMap[msg.data.log.level]}
>
{msg.data.log.level}
</Badge>
</Box>
<Box textAlign="left" w="calc(100% - 180px - 210px - 120px - 60px)">
<Text textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{JSON.stringify(msg.data.log.msg).replace(/"/g, '')}
</Text>
</Box>
</Flex>
</Box>
);
}
}
return null;
},
[t, data],
);
const downloadableLogs = React.useMemo(
() =>
data.map((msg) =>
msg.type === 'NOTIFICATION' && msg.data.type === 'LOG'
? {
timestamp: msg.timestamp.toLocaleString(),
thread: `${msg.data.log.thread_id}-${msg.data.log.thread_name}`,
source: msg.data?.log?.source ?? '-',
level: msg.data?.log?.level,
message: JSON.stringify(msg.data?.log?.msg),
}
: {},
),
[data],
);
return (
<>
<CardHeader px={4} pt={4}>
<Spacer />
<HStack spacing={2}>
<ShownLogsDropdown
availableLogTypes={availableLogTypes}
setHiddenLogIds={setHiddenLogIds}
hiddenLogIds={hiddenLogIds}
/>
<Select size="md" value={level} onChange={(e) => setLevel(e.target.value as '' | LogLevel)} w="130px">
<option value="">{t('common.select_all')}</option>
{Object.keys(colorSchemeMap).map((key) => (
<option key={uuid()} value={key}>
{uppercaseFirstLetter(key)}
</option>
))}
</Select>
<CSVLink
filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`}
data={downloadableLogs as object[]}
>
<Button onClick={() => {}} colorScheme="blue" leftIcon={<Download />}>
{t('logs.export')}
</Button>
</CSVLink>
</HStack>
</CardHeader>
<CardBody p={4}>
<Box overflowX="auto" w="100%">
<Table size="sm">
<Thead>
<Tr>
<Th w="110px">{t('common.time')}</Th>
<Th w="200px">{t('logs.source')}</Th>
<Th w="160px">
{t('logs.thread')} ID-{t('common.name')}
</Th>
<Th w="90px" pl={0}>
{t('logs.level')}
</Th>
<Th>{t('logs.message')}</Th>
</Tr>
</Thead>
</Table>
<Box ml={4} h="calc(70vh)">
<ReactVirtualizedAutoSizer>
{({ height, width }) => (
<List
height={height}
width={width}
itemCount={data.length}
itemSize={35}
itemKey={(index) => data[index]?.id ?? uuid()}
>
{Row}
</List>
)}
</ReactVirtualizedAutoSizer>
</Box>
</Box>
</CardBody>
</>
);
};
export default SecLogsCard;

View File

@@ -0,0 +1,103 @@
import React from 'react';
import { Box, Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import LogsCard from './DeviceLogs';
import FmsLogsCard from './FmsLogs';
import GeneralLogsCard from './GeneralLogs';
import SecLogsCard from './SecLogs';
import { Card } from 'components/Containers/Card';
import { CardHeader } from 'components/Containers/Card/CardHeader';
import { useAuth } from 'contexts/AuthProvider';
const INDEX_PARAM = 'notifications-tab-index';
const getDefaultTabIndex = () => {
const index = localStorage.getItem(INDEX_PARAM) || '0';
try {
return parseInt(index, 10);
} catch {
return 0;
}
};
const NotificationsPage = () => {
const { t } = useTranslation();
const { isUserLoaded } = useAuth();
const [tabIndex, setTabIndex] = React.useState(getDefaultTabIndex());
const handleTabChange = (index: number) => {
setTabIndex(index);
localStorage.setItem(INDEX_PARAM, index.toString());
};
return (
<Flex flexDirection="column" pt="75px">
{isUserLoaded && (
<Card p={0}>
<Tabs index={tabIndex} onChange={handleTabChange} variant="enclosed" isLazy>
<TabList>
<CardHeader>
<Tab>{t('devices.notifications')}</Tab>
<Tab>{t('simulation.controller')}</Tab>
<Tab>{t('logs.security')}</Tab>
<Tab>{t('logs.firmware')}</Tab>
</CardHeader>
</TabList>
<TabPanels>
<TabPanel p={0}>
<Box
borderLeft="1px solid"
borderRight="1px solid"
borderBottom="1px solid"
borderColor="var(--chakra-colors-chakra-border-color)"
borderBottomLeftRadius="15px"
borderBottomRightRadius="15px"
>
<LogsCard />
</Box>
</TabPanel>
<TabPanel p={0}>
<Box
borderLeft="1px solid"
borderRight="1px solid"
borderBottom="1px solid"
borderColor="var(--chakra-colors-chakra-border-color)"
borderBottomLeftRadius="15px"
borderBottomRightRadius="15px"
>
<GeneralLogsCard />
</Box>
</TabPanel>
<TabPanel p={0}>
<Box
borderLeft="1px solid"
borderRight="1px solid"
borderBottom="1px solid"
borderColor="var(--chakra-colors-chakra-border-color)"
borderBottomLeftRadius="15px"
borderBottomRightRadius="15px"
>
<SecLogsCard />
</Box>
</TabPanel>
<TabPanel p={0}>
<Box
borderLeft="1px solid"
borderRight="1px solid"
borderBottom="1px solid"
borderColor="var(--chakra-colors-chakra-border-color)"
borderBottomLeftRadius="15px"
borderBottomRightRadius="15px"
>
<FmsLogsCard />
</Box>
</TabPanel>
</TabPanels>
</Tabs>
</Card>
)}
</Flex>
);
};
export default NotificationsPage;

View File

@@ -1,12 +1,13 @@
import React from 'react';
import { Icon } from '@chakra-ui/react';
import { Barcode, FloppyDisk, Info, UsersThree, WifiHigh } from 'phosphor-react';
import { Barcode, FloppyDisk, Info, ListBullets, UsersThree, WifiHigh } from 'phosphor-react';
import { Route } from 'models/Routes';
const DefaultConfigurationsPage = React.lazy(() => import('pages/DefaultConfigurations'));
const DevicePage = React.lazy(() => import('pages/Device'));
const DevicesPage = React.lazy(() => import('pages/Devices'));
const FirmwarePage = React.lazy(() => import('pages/Firmware'));
const NotificationsPage = React.lazy(() => import('pages/Notifications'));
const ProfilePage = React.lazy(() => import('pages/Profile'));
const SystemPage = React.lazy(() => import('pages/SystemPage'));
const UsersPage = React.lazy(() => import('pages/UsersPage'));
@@ -39,6 +40,15 @@ const routes: Route[] = [
),
component: DefaultConfigurationsPage,
},
{
authorized: ['root', 'partner', 'admin', 'csr', 'system'],
path: '/logs',
name: 'controller.devices.logs',
icon: (active: boolean) => (
<Icon as={ListBullets} color="inherit" h={active ? '32px' : '24px'} w={active ? '32px' : '24px'} />
),
component: NotificationsPage,
},
{
hidden: true,
authorized: ['root', 'partner', 'admin', 'csr', 'system'],