diff --git a/package-lock.json b/package-lock.json index 1b8a791..a47b02e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wlan-cloud-owprov-ui", - "version": "2.8.0(13)", + "version": "2.8.0(17)", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "wlan-cloud-owprov-ui", - "version": "2.8.0(13)", + "version": "2.8.0(17)", "license": "ISC", "dependencies": { "@chakra-ui/icons": "^2.0.11", diff --git a/package.json b/package.json index c869fa3..4629aea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wlan-cloud-owprov-ui", - "version": "2.8.0(13)", + "version": "2.8.0(17)", "description": "", "main": "index.tsx", "scripts": { diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index 34a32e5..70086e6 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -79,6 +79,7 @@ "live_view_help": "Hilfe zur Live-Ansicht", "memory": "Erinnerung", "memory_used": "Verwendeter Speicher", + "missing_board": "Die Analytics-Überwachung an diesem Veranstaltungsort ist nicht mehr aktiv. Bitte starten Sie die Überwachung über das obere Menü neu", "mode": "Modus", "noise": "Lärm", "packets": "Pakete", @@ -613,6 +614,7 @@ "import_explanation": "Für den Massenimport von Geräten müssen Sie eine CSV-Datei mit den folgenden Spalten verwenden: SerialNumber, DeviceType, Name, Description, Note", "invalid_serial_number": "Ungültige Seriennummer (muss 12 HEX-Zeichen lang sein)", "new_devices": "Neue Geräte", + "no_model_image": "Kein Modellbild gefunden", "not_connected": "Nicht verbunden", "not_found_gateway": "Fehler: Gerät hat sich noch nicht mit dem Gateway verbunden", "notifications": "Gerätebenachrichtigungen", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 1b71a80..190f3d5 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -79,6 +79,7 @@ "live_view_help": "Live View Help", "memory": "Memory", "memory_used": "Memory Used", + "missing_board": "Analytics monitoring on this venue is no longer active, please restart monitoring using the top menu", "mode": "Mode", "noise": "Noise", "packets": "Packets", @@ -613,6 +614,7 @@ "import_explanation": "To bulk import devices, you need to use a CSV file with the following columns: SerialNumber, DeviceType, Name, Description, Note", "invalid_serial_number": "Invalid Serial Number (needs to be 12 HEX chars)", "new_devices": "new devices", + "no_model_image": "No Model Image Found", "not_connected": "Not Connected", "not_found_gateway": "Error: device has not yet connected to the controller", "notifications": "Device Notifications", diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json index 7740c57..659259a 100644 --- a/public/locales/es/translation.json +++ b/public/locales/es/translation.json @@ -79,6 +79,7 @@ "live_view_help": "Ayuda de visualización en vivo", "memory": "Memoria", "memory_used": "Memoria usada", + "missing_board": "El monitoreo analítico en este lugar ya no está activo, reinicie el monitoreo usando el menú superior", "mode": "Modo", "noise": "Ruido", "packets": "Paquetes", @@ -613,6 +614,7 @@ "import_explanation": "Para importar dispositivos de forma masiva, debe usar un archivo CSV con las siguientes columnas: Número de serie, Tipo de dispositivo, Nombre, Descripción, Nota", "invalid_serial_number": "Número de serie no válido (debe tener 12 caracteres HEX)", "new_devices": "Nuevos dispositivos", + "no_model_image": "No se encontró ninguna imagen de modelo", "not_connected": "No conectado", "not_found_gateway": "Error: el dispositivo aún no se ha conectado a la puerta de enlace", "notifications": "notificaciones de dispositivos", diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index d07aba4..72cf9b0 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -79,6 +79,7 @@ "live_view_help": "Aide sur l'affichage en direct", "memory": "mémoire", "memory_used": "Mémoire utilisée", + "missing_board": "La surveillance analytique sur ce lieu n'est plus active, veuillez redémarrer la surveillance en utilisant le menu du haut", "mode": "Mode", "noise": "Bruit", "packets": "Paquets", @@ -613,6 +614,7 @@ "import_explanation": "Pour importer en masse des appareils, vous devez utiliser un fichier CSV avec les colonnes suivantes : SerialNumber, DeviceType, Name, Description, Note", "invalid_serial_number": "Numéro de série non valide (doit être composé de 12 caractères HEX)", "new_devices": "nouveaux appareils", + "no_model_image": "Aucune image de modèle trouvée", "not_connected": "Pas connecté", "not_found_gateway": "Erreur : l'appareil n'est pas encore connecté à la passerelle", "notifications": "notifications de l'appareil", diff --git a/public/locales/pt/translation.json b/public/locales/pt/translation.json index 16c8740..0597cbf 100644 --- a/public/locales/pt/translation.json +++ b/public/locales/pt/translation.json @@ -79,6 +79,7 @@ "live_view_help": "Ajuda da visualização ao vivo", "memory": "Memória", "memory_used": "Memória Usada", + "missing_board": "O monitoramento analítico neste local não está mais ativo, reinicie o monitoramento usando o menu superior", "mode": "Modo", "noise": "Barulho", "packets": "Pacotes", @@ -613,6 +614,7 @@ "import_explanation": "Para importar dispositivos em massa, você precisa usar um arquivo CSV com as seguintes colunas: SerialNumber, DeviceType, Name, Description, Note", "invalid_serial_number": "Número de série inválido (precisa ter 12 caracteres HEX)", "new_devices": "novos dispositivos", + "no_model_image": "Nenhuma imagem de modelo encontrada", "not_connected": "Não conectado", "not_found_gateway": "Erro: o dispositivo ainda não se conectou ao gateway", "notifications": "Notificações do dispositivo", diff --git a/src/hooks/Network/Analytics.ts b/src/hooks/Network/Analytics.ts index c3cd50d..5d0ca0c 100644 --- a/src/hooks/Network/Analytics.ts +++ b/src/hooks/Network/Analytics.ts @@ -1,45 +1,259 @@ import { useToast } from '@chakra-ui/react'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { AxiosError } from 'axios'; import { useTranslation } from 'react-i18next'; +import { AxiosError } from 'models/Axios'; +import { Note } from 'models/Note'; import { PageInfo, SortInfo } from 'models/Table'; import { axiosAnalytics } from 'utils/axiosInstances'; +export type AnalyticsBoardDevice = { + associations_2g: number; + associations_5g: number; + associations_6g: number; + boardId: string; + connected: boolean; + connectionIp: string; + deviceType: string; + health: number; + lastConnection: number; + lastContact: number; + lastDisconnection: number; + lastFirmware: string; + lastFirmwareUpdate: number; + lastHealth: number; + lastPing: number; + lastState: number; + locale: string; + memory: number; + pings: number; + serialNumber: string; + states: number; + type: string; + uptime: number; +}; + +export type AnalyticsBoardDevicesApiResponse = { + devices: AnalyticsBoardDevice[]; +}; + +export type AnalyticsBoardApiResponse = { + created: number; + description: string; + id: string; + modified: number; + name: string; + notes: Note[]; + tags: string[]; + venueList: { + description: string; + id: string; + interval: number; + monitorSubVenues: boolean; + name: string; + retention: number; + }; +}; + +export type AnalyticsClientLifecycleApiResponse = { + ack_signal: number; + ack_signal_avg: number; + active_ms: number; + bssid: string; + busy_ms: number; + channel: number; + channel_width: number; + connected: number; + inactive: number; + ipv4: string; + ipv6: string; + mode: string; + noise: number; + receive_ms: number; + rssi: number; + rx_bitrate: number; + rx_bytes: number; + rx_chwidth: number; + rx_duration: number; + rx_mcs: number; + rx_nss: number; + rx_packets: number; + rx_vht: boolean; + ssid: string; + station_id: string; + timestamp: number; + tx_bitrate: number; + tx_bytes: number; + tx_chwidth: number; + tx_duration: number; + tx_mcs: number; + tx_nss: number; + tx_packets: number; + tx_power: number; + tx_retries: number; + tx_vht: boolean; + venue_id: string; +}; + +export type AnalyticsApData = { + collisions: number; + multicast: number; + rx_bytes: number; + rx_bytes_bw: number; + rx_bytes_delta: number; + rx_dropped: number; + rx_dropped_delta: number; + rx_dropped_pct: number; + rx_errors: number; + rx_errors_delta: number; + rx_errors_pct: number; + rx_packets: number; + rx_packets_bw: number; + rx_packets_delta: number; + tx_bytes: number; + tx_bytes_bw: number; + tx_bytes_delta: number; + tx_dropped: number; + tx_dropped_delta: number; + tx_dropped_pct: number; + tx_errors: number; + tx_errors_delta: number; + tx_errors_pct: number; + tx_packets: number; + tx_packets_bw: number; + tx_packets_delta: number; +}; + +export type AnalyticsRadioData = { + active_ms: number; + active_pct: number; + band: number; + busy_ms: number; + busy_pct: number; + channel: number; + channel_width: number; + noise: number; + receive_ms: number; + receive_pct: number; + temperature: number; + transmit_ms: number; + transmit_pct: number; + tx_power: number; +}; + +export type AnalyticsAssociationData = { + connected: number; + inactive: number; + rssi: number; + rx_bytes: number; + rx_bytes_bw: number; + rx_bytes_delta: number; + rx_packets: number; + rx_packets_bw: number; + rx_packets_delta: number; + rx_rate: { + bitrate: number; + chwidth: number; + ht: boolean; + mcs: number; + nss: number; + sgi: boolean; + }; + station: string; + tx_bytes: number; + tx_bytes_bw: number; + tx_bytes_delta: number; + tx_duration: number; + tx_duration_delta: number; + tx_duration_pct: number; + tx_failed: number; + tx_failed_delta: number; + tx_failed_pct: number; + tx_packets: number; + tx_packets_bw: number; + tx_packets_delta: number; + tx_rate: { + bitrate: number; + chwidth: number; + ht: boolean; + mcs: number; + nss: number; + sgi: boolean; + }; + tx_retries: number; + tx_retries_delta: number; + tx_retries_pct: number; +}; + +export type AnalyticsSsidData = { + associations: AnalyticsAssociationData[]; + band: 2; + bssid: string; + channel: number; + mode: string; + rx_bytes_bw: { + avg: number; + max: number; + min: number; + }; + rx_packets_bw: { + avg: number; + max: number; + min: number; + }; + ssid: string; + tx_bytes_bw: { + avg: number; + max: number; + min: number; + }; + tx_duration_pct: { + avg: number; + max: number; + min: number; + }; + tx_failed_pct: { + avg: number; + max: number; + min: number; + }; + tx_packets_bw: { + avg: number; + max: number; + min: number; + }; + tx_retries_pct: { + avg: number; + max: number; + min: number; + }; +}; + +export type AnalyticsTimePointApiResponse = { + ap_data: AnalyticsApData; + boardId: string; + device_info: AnalyticsBoardDevice; + id: string; + radio_data: AnalyticsRadioData[]; + serialNumber: string; + ssid_data: AnalyticsSsidData[]; + timestamp: number; +}; + +export type AnalyticsTimePointsApiResponse = { + points: AnalyticsTimePointApiResponse[][]; +}; + export const useGetAnalyticsBoard = ({ id }: { id?: string }) => { const { t } = useTranslation(); const toast = useToast(); - return useQuery(['get-board', id], () => axiosAnalytics.get(`board/${id}`).then(({ data }) => data), { - enabled: id !== undefined && id !== null && id.length > 0, - onError: (e: AxiosError) => { - if (!toast.isActive('board-fetching-error')) - toast({ - id: 'board-fetching-error', - title: t('common.error'), - description: t('crud.error_fetching_obj', { - obj: t('analytics.board'), - e: e?.response?.data?.ErrorDescription, - }), - status: 'error', - duration: 5000, - isClosable: true, - position: 'top-right', - }); - }, - }); -}; - -export const useGetAnalyticsBoardDevices = ({ id }: { id: string }) => { - const { t } = useTranslation(); - const toast = useToast(); - return useQuery( - ['get-board-devices', id], - () => axiosAnalytics.get(`board/${id}/devices`).then(({ data }) => data.devices), + ['get-board', id], + () => axiosAnalytics.get(`board/${id}`).then(({ data }: { data: AnalyticsBoardApiResponse }) => data), { enabled: id !== undefined && id !== null && id.length > 0, onError: (e: AxiosError) => { - if (!toast.isActive('board-fetching-error')) + if (e.response?.status !== 404 && !toast.isActive('board-fetching-error')) toast({ id: 'board-fetching-error', title: t('common.error'), @@ -57,24 +271,35 @@ export const useGetAnalyticsBoardDevices = ({ id }: { id: string }) => { ); }; -const getPartialClients = async (venueId: string, offset: number) => - axiosAnalytics - .get(`wifiClientHistory?macsOnly=true&venue=${venueId}&limit=500&offset=${offset}`) - .then(({ data }) => data.entries as string[]); +export const useGetAnalyticsBoardDevices = ({ id }: { id?: string }) => { + const { t } = useTranslation(); + const toast = useToast(); -export const getAllClients = async (venueId: string) => { - const allClients: string[] = []; - let continueFirmware = true; - let offset = 0; - while (continueFirmware) { - // eslint-disable-next-line no-await-in-loop - const newClients = await getPartialClients(venueId, offset); - if (newClients === null || newClients.length === 0 || newClients.length < 500 || offset >= 50000) - continueFirmware = false; - allClients.push(...newClients); - offset += 500; - } - return allClients; + return useQuery( + ['get-board-devices', id], + () => + axiosAnalytics + .get(`board/${id}/devices`) + .then(({ data }: { data: AnalyticsBoardDevicesApiResponse }) => data.devices), + { + enabled: id !== undefined && id !== null && id.length > 0, + onError: (e: AxiosError) => { + if (e.response?.status !== 404 && !toast.isActive('board-fetching-error')) + toast({ + id: 'board-fetching-error', + title: t('common.error'), + description: t('crud.error_fetching_obj', { + obj: t('analytics.board'), + e: e?.response?.data?.ErrorDescription, + }), + status: 'error', + duration: 5000, + isClosable: true, + position: 'top-right', + }); + }, + }, + ); }; export const useGetAnalyticsClients = ({ venueId }: { venueId: string }) => { @@ -83,7 +308,10 @@ export const useGetAnalyticsClients = ({ venueId }: { venueId: string }) => { return useQuery( ['get-venue-analytics-clients', venueId], - () => axiosAnalytics.get(`wifiClientHistory?macsOnly=true&venue=${venueId}`).then(({ data }) => data.entries), + () => + axiosAnalytics + .get(`wifiClientHistory?macsOnly=true&venue=${venueId}`) + .then(({ data }: { data: { entries: string[] } }) => data.entries), { onError: (e: AxiosError) => { if (!toast.isActive('get-venue-analytics-clients-error')) @@ -107,12 +335,35 @@ export const useGetAnalyticsClients = ({ venueId }: { venueId: string }) => { export const useGetClientLifecycleTableSpecs = () => useQuery( ['get-lifecycles-table-spec'], - () => axiosAnalytics.get(`wifiClientHistory/0?orderSpec=true`).then(({ data }) => data.list), + () => + axiosAnalytics + .get(`wifiClientHistory/0?orderSpec=true`) + .then(({ data }: { data: { list: string[] } }) => data.list), { staleTime: Infinity, }, ); +const getPartialClients = async (venueId: string, offset: number) => + axiosAnalytics + .get(`wifiClientHistory?macsOnly=true&venue=${venueId}&limit=500&offset=${offset}`) + .then(({ data }) => data.entries as string[]); + +export const getAllClients = async (venueId: string) => { + const allClients: string[] = []; + let continueFirmware = true; + let offset = 0; + while (continueFirmware) { + // eslint-disable-next-line no-await-in-loop + const newClients = await getPartialClients(venueId, offset); + if (newClients === null || newClients.length === 0 || newClients.length < 500 || offset >= 50000) + continueFirmware = false; + allClients.push(...newClients); + offset += 500; + } + return allClients; +}; + export const useGetClientLifecycleCount = ({ venueId, mac, @@ -134,7 +385,7 @@ export const useGetClientLifecycleCount = ({ () => axiosAnalytics .get(`wifiClientHistory/${mac}?venue=${venueId}&countOnly=true&fromDate=${fromDate}&endDate=${endDate}`) - .then(({ data }) => data.count), + .then(({ data }: { data: { count: number } }) => data.count), { enabled: mac !== undefined, onError: (e: AxiosError) => { @@ -190,7 +441,7 @@ export const useGetClientLifecycle = ({ (pageInfo?.limit ?? 10) * (pageInfo?.index ?? 1) }${sortString}&fromDate=${fromDate}&endDate=${endDate}`, ) - .then(({ data }) => data.entries), + .then(({ data }: { data: { entries: AnalyticsClientLifecycleApiResponse[] } }) => data.entries), { keepPreviousData: true, enabled: count !== undefined && pageInfo !== undefined, @@ -205,7 +456,7 @@ export const useGetAnalyticsBoardTimepoints = ({ endTime, enabled = true, }: { - id: string; + id?: string; startTime: Date; endTime?: Date; enabled?: boolean; @@ -214,19 +465,21 @@ export const useGetAnalyticsBoardTimepoints = ({ const toast = useToast(); return useQuery( - ['get-board-timepoints', id], + ['get-venue-timepoints', id, startTime.toString(), endTime?.toString()], () => axiosAnalytics .get( - `board/${id}/timepoints?fromDate=${Math.floor(startTime.getTime() / 1000)}${ + `board/${id}/timepoints?maxRecords=1000&fromDate=${Math.floor(startTime.getTime() / 1000)}${ endTime ? `&endDate=${Math.floor(endTime.getTime() / 1000)}` : '' }`, ) - .then(({ data }) => data.points), + .then(({ data }: { data: AnalyticsTimePointsApiResponse }) => data.points), { - enabled: id !== null && enabled, + enabled: id !== undefined && id !== '' && enabled, + keepPreviousData: true, + staleTime: Infinity, onError: (e: AxiosError) => { - if (!toast.isActive('board-fetching-error')) + if (e.response?.status !== 404 && !toast.isActive('board-fetching-error')) toast({ id: 'board-fetching-error', title: t('common.error'), diff --git a/src/pages/VenuePage/VenueChildrenCard/VenueDashboard/index.jsx b/src/pages/VenuePage/VenueChildrenCard/VenueDashboard/index.jsx index 1490e23..1db4749 100644 --- a/src/pages/VenuePage/VenueChildrenCard/VenueDashboard/index.jsx +++ b/src/pages/VenuePage/VenueChildrenCard/VenueDashboard/index.jsx @@ -1,5 +1,17 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { Box, Center, Flex, Heading, Spacer, Spinner, useDisclosure } from '@chakra-ui/react'; +import { + Alert, + AlertDescription, + AlertIcon, + AlertTitle, + Box, + Center, + Flex, + Heading, + Spacer, + Spinner, + useDisclosure, +} from '@chakra-ui/react'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; import VenueAnalyticsHeader from './Header'; @@ -16,7 +28,7 @@ const VenueDashboard = ({ boardId }) => { const { t } = useTranslation(); const { isOpen, onOpen, onClose } = useDisclosure(false); const [tableOptions, setTableOptions] = useState(null); - const { data: devices, isFetching, refetch } = useGetAnalyticsBoardDevices({ id: boardId }); + const { data: devices, isFetching, refetch, error } = useGetAnalyticsBoardDevices({ id: boardId }); const handleRefreshClick = () => { refetch(); @@ -112,6 +124,21 @@ const VenueDashboard = ({ boardId }) => { if (!isOpen) setTableOptions(null); }, [isOpen]); + if (error) + return ( +
+ + + + {t('common.error')} + + {error.response?.status === 404 ? t('analytics.missing_board') : error.response?.data?.ErrorDescription} + + + +
+ ); + return !devices ? (
diff --git a/src/pages/VenuePage/VenueChildrenCard/VenueLiveView/index.jsx b/src/pages/VenuePage/VenueChildrenCard/VenueLiveView/index.jsx index 485092d..6c6f917 100644 --- a/src/pages/VenuePage/VenueChildrenCard/VenueLiveView/index.jsx +++ b/src/pages/VenuePage/VenueChildrenCard/VenueLiveView/index.jsx @@ -1,7 +1,19 @@ import React, { useState } from 'react'; -import { Box, Center, Flex, Spacer, Spinner, useColorModeValue } from '@chakra-ui/react'; +import { + Alert, + AlertDescription, + AlertIcon, + AlertTitle, + Box, + Center, + Flex, + Spacer, + Spinner, + useColorModeValue, +} from '@chakra-ui/react'; import PropTypes from 'prop-types'; import { FullScreen, useFullScreenHandle } from 'react-full-screen'; +import { useTranslation } from 'react-i18next'; import CirclePack from './CirclePack'; import ExpandButton from './ExpandButton'; import CirclePackTimePickers from './TimePickers'; @@ -17,11 +29,32 @@ const propTypes = { }; const VenueLiveView = ({ boardId, venue }) => { + const { t } = useTranslation(); const handle = useFullScreenHandle(); const color = useColorModeValue('gray.50', 'gray.800'); const [startTime, setStartTime] = useState(getHoursAgo(1)); const [endTime, setEndTime] = useState(new Date()); - const { data: timepoints, isFetching, refetch } = useGetAnalyticsBoardTimepoints({ id: boardId, startTime, endTime }); + const { + data: timepoints, + isFetching, + refetch, + error, + } = useGetAnalyticsBoardTimepoints({ id: boardId, startTime, endTime }); + + if (error) + return ( +
+ + + + {t('common.error')} + + {error.response?.status === 404 ? t('analytics.missing_board') : error.response?.data?.ErrorDescription} + + + +
+ ); return !timepoints ? (