[WIFI-12492] Entity and venue page rework

Signed-off-by: Charles <charles.bourque96@gmail.com>
This commit is contained in:
Charles
2023-04-12 12:29:38 +02:00
parent e196d4b9ef
commit e22ac04459
148 changed files with 4884 additions and 3810 deletions

View File

@@ -3,3 +3,7 @@ build
dist
node_modules
.github
docker-entrypoint.d
helm
.dockerignore
Dockerfile

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "wlan-cloud-owprov-ui",
"version": "2.9.0(18)",
"version": "2.10.0(2)",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "wlan-cloud-owprov-ui",
"version": "2.9.0(18)",
"version": "2.10.0(2)",
"license": "ISC",
"dependencies": {
"@chakra-ui/icons": "^2.0.11",

View File

@@ -1,6 +1,6 @@
{
"name": "wlan-cloud-owprov-ui",
"version": "2.9.0(18)",
"version": "2.10.0(2)",
"description": "",
"main": "index.tsx",
"scripts": {

View File

@@ -1,8 +1,7 @@
import React from 'react';
import { Box, useStyleConfig } from '@chakra-ui/react';
import { ThemeProps } from 'models/Theme';
import { Box, LayoutProps, SpaceProps, useStyleConfig } from '@chakra-ui/react';
interface Props extends ThemeProps {
interface Props extends LayoutProps, SpaceProps {
variant?: string;
children: React.ReactNode;
}
@@ -11,13 +10,7 @@ const defaultProps = {
variant: undefined,
};
const CardHeader = (
{
variant,
children,
...rest
}: Props
) => {
const CardHeader = ({ variant, children, ...rest }: Props) => {
// @ts-ignore
const styles = useStyleConfig('CardHeader', { variant });
// Pass the computed styles into the `__css` prop

View File

@@ -97,7 +97,7 @@ const LocationPickerCreator = ({ locationName, createLocationName, editing, isMo
{ value: 'CREATE_NEW', label: getCreateLabel() },
...getOptions(),
]}
w={256}
w="unset"
/>
{location === 'CREATE_NEW' && newLocation && !isModal && <Form name={createLocationName} />}
{location === 'CREATE_NEW' && isModal && (

View File

@@ -4,27 +4,25 @@ import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import CreateVenueForm from './Form';
import CloseButton from 'components/Buttons/CloseButton';
import CreateButton from 'components/Buttons/CreateButton';
import SaveButton from 'components/Buttons/SaveButton';
import ConfirmCloseAlert from 'components/Modals/Actions/ConfirmCloseAlert';
import ModalHeader from 'components/Modals/ModalHeader';
import useFormRef from 'hooks/useFormRef';
const propTypes = {
isDisabled: PropTypes.bool,
parentId: PropTypes.string,
entityId: PropTypes.string,
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
};
const defaultProps = {
isDisabled: false,
parentId: '',
entityId: '',
};
const CreateVenueModal = ({ parentId, entityId, isDisabled }) => {
const CreateVenueModal = ({ isOpen, onClose, parentId, entityId }) => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: showConfirm, onOpen: openConfirm, onClose: closeConfirm } = useDisclosure();
const { form, formRef } = useFormRef();
@@ -36,8 +34,6 @@ const CreateVenueModal = ({ parentId, entityId, isDisabled }) => {
};
return (
<>
<CreateButton onClick={onOpen} isDisabled={isDisabled} />
<Modal onClose={closeModal} isOpen={isOpen} size="xl">
<ModalOverlay />
<ModalContent maxWidth={{ sm: '600px', md: '700px', lg: '800px', xl: '50%' }}>
@@ -49,6 +45,7 @@ const CreateVenueModal = ({ parentId, entityId, isDisabled }) => {
onClick={form.submitForm}
isLoading={form.isSubmitting}
isDisabled={!form.isValid || !form.dirty}
isCompact
/>
<CloseButton ml={2} onClick={closeModal} />
</>
@@ -66,7 +63,6 @@ const CreateVenueModal = ({ parentId, entityId, isDisabled }) => {
</ModalContent>
<ConfirmCloseAlert isOpen={showConfirm} confirm={closeCancelAndForm} cancel={closeConfirm} />
</Modal>
</>
);
};

View File

@@ -1,248 +1,16 @@
import { useToast } from '@chakra-ui/react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
AnalyticsBoardApiResponse,
AnalyticsBoardDevicesApiResponse,
AnalyticsClientLifecycleApiResponse,
AnalyticsTimePointsApiResponse,
} from 'models/Analytics';
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();
@@ -344,26 +112,6 @@ export const useGetClientLifecycleTableSpecs = () =>
},
);
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,
@@ -497,9 +245,31 @@ export const useGetAnalyticsBoardTimepoints = ({
);
};
export const useCreateAnalyticsBoard = () => useMutation((newBoard) => axiosAnalytics.post('board/0', newBoard));
export const useCreateAnalyticsBoard = () =>
useMutation((newBoard: unknown) => axiosAnalytics.post('board/0', newBoard));
export const useUpdateAnalyticsBoard = () =>
useMutation((newBoard: { id: string }) => axiosAnalytics.put(`board/${newBoard.id}`, newBoard));
export const useUpdateAnalyticsBoard = ({ id }: { id: string }) => {
const queryClient = useQueryClient();
export const useDeleteAnalyticsBoard = () => useMutation((id) => axiosAnalytics.delete(`board/${id}`, {}));
return useMutation(
(newBoard: {
name: string;
venueList: [
{
id: string;
name: string;
retention: number;
interval: number;
monitorSubVenues: boolean;
},
];
}) => axiosAnalytics.put(`board/${id}`, newBoard),
{
onSuccess: () => {
queryClient.invalidateQueries(['get-board', id]);
},
},
);
};
export const useDeleteAnalyticsBoard = () => useMutation((id: string) => axiosAnalytics.delete(`board/${id}`, {}));

View File

@@ -1,9 +1,9 @@
import { useToast } from '@chakra-ui/react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import useDefaultPage from 'hooks/useDefaultPage';
import { Entity } from '../../models/Entity';
import useDefaultPage from '../useDefaultPage';
import { AxiosError } from 'models/Axios';
import { Entity } from 'models/Entity';
import { axiosProv, axiosSec } from 'utils/axiosInstances';
export const useGetEntityTree = () => {
@@ -151,7 +151,19 @@ export const useCreateEntity = (isRoot = false) =>
axiosProv.post(`entity/${isRoot ? '0000-0000-0000' : 0}`, newEnt).then(({ data }) => data as Entity),
);
export const useUpdateEntity = ({ id }: { id: string }) =>
useMutation((newEnt) => axiosProv.put(`entity/${id}`, newEnt).then(({ data }) => data as Entity));
export const useUpdateEntity = ({ id }: { id: string }) => {
const queryClient = useQueryClient();
return useMutation(
(newEnt: Partial<Entity>) => axiosProv.put(`entity/${id}`, newEnt).then(({ data }) => data as Entity),
{
onSuccess: (data) => {
queryClient.invalidateQueries(['get-entity-tree']);
queryClient.invalidateQueries(['get-entities']);
queryClient.setQueryData(['get-entity', id], data);
},
},
);
};
export const useDeleteEntity = () => useMutation((id: string) => axiosProv.delete(`entity/${id}`));

View File

@@ -177,4 +177,4 @@ export const useGetResource = ({ id, enabled }: { id: string; enabled: boolean }
export const useCreateResource = () => useMutation((newResource: unknown) => axiosProv.post('variable/0', newResource));
export const useUpdateResource = (id: string) =>
useMutation((resource: unknown) => axiosProv.put(`variable/${id}`, resource));
export const useDeleteResource = () => useMutation((id) => axiosProv.delete(`variable/${id}`, {}));
export const useDeleteResource = () => useMutation((id: string) => axiosProv.delete(`variable/${id}`, {}));

View File

@@ -4,30 +4,9 @@ import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import useDefaultPage from '../useDefaultPage';
import { AxiosError } from 'models/Axios';
import { DeviceRules } from 'models/Basic';
import { Note } from 'models/Note';
import { VenueApiResponse } from 'models/Venue';
import { axiosProv } from 'utils/axiosInstances';
export interface VenueApiResponse {
id: string;
name: string;
description: string;
parent: string;
devices: string[];
children: string[];
contacts: string[];
entity: string;
boards: string[];
created: number;
modified: number;
configurations: string[];
notes: Note[];
variables: string[];
location: string;
sourceIP: string[];
deviceRules: DeviceRules;
}
const getVenuesBatch = async (limit: number, offset: number) =>
axiosProv
.get(`venue?withExtendedInfo=true&offset=${offset}&limit=${limit}`)

View File

@@ -2,7 +2,8 @@
display: -webkit-box;
display: -ms-flexbox;
display: flex;
margin-left: -30px;
margin-left: -20px;
margin-bottom: -20px;
width: auto;
}
.my-masonry-grid_column {

View File

@@ -1,18 +0,0 @@
import React, { ReactNode } from 'react';
import { Box, useStyleConfig } from '@chakra-ui/react';
interface Props {
children: ReactNode;
}
const PanelContainer = (
{
children
}: Props
) => {
const styles = useStyleConfig('PanelContainer');
// Pass the computed styles into the `__css` prop
return <Box __css={styles}>{children}</Box>;
};
export default PanelContainer;

View File

@@ -1,18 +0,0 @@
import React, { ReactNode } from 'react';
import { Box, useStyleConfig } from '@chakra-ui/react';
interface Props {
children: ReactNode;
}
const PanelContent = (
{
children
}: Props
) => {
const styles = useStyleConfig('PanelContent');
return <Box __css={styles}>{children}</Box>;
};
export default PanelContent;

View File

@@ -1,23 +0,0 @@
import React from 'react';
import { Box, LayoutProps, useStyleConfig } from '@chakra-ui/react';
interface Props extends LayoutProps {
children: React.ReactNode;
}
const MainPanel = (
{
children,
...props
}: Props
) => {
const styles = useStyleConfig('MainPanel');
return (
<Box __css={styles} {...props}>
{children}
</Box>
);
};
export default MainPanel;

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { ChevronDownIcon, HamburgerIcon, MoonIcon, SunIcon } from '@chakra-ui/icons';
import { HamburgerIcon, MoonIcon, SunIcon } from '@chakra-ui/icons';
import {
Box,
Flex,
@@ -12,73 +12,55 @@ import {
MenuList,
Heading,
HStack,
VStack,
Text,
IconButton,
Tooltip,
useBreakpoint,
Portal,
} from '@chakra-ui/react';
import { ArrowCircleLeft, MapTrifold } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import LanguageSwitcher from 'components/LanguageSwitcher';
import { useNavigate } from 'react-router-dom';
import { useAuth } from 'contexts/AuthProvider';
import routes from 'router/routes';
import { uppercaseFirstLetter } from 'utils/stringHelper';
interface Props {
secondary: boolean;
isSidebarOpen: boolean;
export type NavbarProps = {
toggleSidebar: () => void;
}
const Navbar = ({ secondary, toggleSidebar, isSidebarOpen }: Props) => {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const [scrolled, setScrolled] = useState(false);
const { colorMode, toggleColorMode } = useColorMode();
const { logout, user, avatar } = useAuth();
const getActiveRoute = () => {
const route = routes.find(
(r) => r.path === location.pathname || location.pathname.split('/')[1] === r.path.split('/')[1],
);
if (route) return route.navName ?? route.name;
return '';
activeRoute?: string;
languageSwitcher?: React.ReactNode;
};
// Style variables
let navbarPosition: 'absolute' | 'fixed' = 'absolute';
let navbarFilter = 'none';
let navbarBackdrop = 'blur(21px)';
let navbarShadow = 'none';
let navbarBg = 'none';
let navbarBorder = 'transparent';
let secondaryMargin = '0px';
export const Navbar = ({ toggleSidebar, activeRoute, languageSwitcher }: NavbarProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const [scrolled, setScrolled] = useState(false);
const breakpoint = useBreakpoint();
const { colorMode, toggleColorMode } = useColorMode();
const { logout, user, avatar } = useAuth();
// Values if scrolled
const scrolledNavbarShadow = useColorModeValue('0px 7px 23px rgba(0, 0, 0, 0.05)', 'none');
const scrolledNavbarBg = useColorModeValue(
const isCompact = breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md';
const boxShadow = useColorModeValue('0px 7px 23px rgba(0, 0, 0, 0.05)', 'none');
const bg = useColorModeValue(
'linear-gradient(112.83deg, rgba(255, 255, 255, 0.82) 0%, rgba(255, 255, 255, 0.8) 110.84%)',
'linear-gradient(112.83deg, rgba(255, 255, 255, 0.21) 0%, rgba(255, 255, 255, 0) 110.84%)',
);
const scrolledNavbarBorder = useColorModeValue('#FFFFFF', 'rgba(255, 255, 255, 0.31)');
const scrolledNavbarFilter = useColorModeValue('none', 'drop-shadow(0px 7px 23px rgba(0, 0, 0, 0.05))');
if (scrolled === true) {
navbarPosition = 'fixed';
navbarShadow = scrolledNavbarShadow;
navbarBg = scrolledNavbarBg;
navbarBorder = scrolledNavbarBorder;
navbarFilter = scrolledNavbarFilter;
}
if (secondary) {
navbarBackdrop = 'none';
navbarPosition = 'absolute';
secondaryMargin = '22px';
}
const borderColor = useColorModeValue('#FFFFFF', 'rgba(255, 255, 255, 0.31)');
const filter = useColorModeValue('none', 'drop-shadow(0px 7px 23px rgba(0, 0, 0, 0.05))');
const scrollDependentStyles = scrolled
? ({
position: 'fixed',
boxShadow,
bg,
borderColor,
filter,
} as const)
: ({
position: 'absolute',
filter: 'none',
boxShadow: 'none',
bg: 'none',
borderColor: 'transparent',
} as const);
const goBack = () => navigate(-1);
@@ -96,13 +78,10 @@ const Navbar = ({ secondary, toggleSidebar, isSidebarOpen }: Props) => {
window.addEventListener('scroll', changeNavbar);
return (
<Portal>
<Flex
position={navbarPosition}
boxShadow={navbarShadow}
bg={navbarBg}
borderColor={navbarBorder}
filter={navbarFilter}
backdropFilter={navbarBackdrop}
{...scrollDependentStyles}
backdropFilter="blur(21px)"
borderWidth="1.5px"
borderStyle="solid"
transitionDelay="0s, 0s, 0s, 0s"
@@ -110,28 +89,20 @@ const Navbar = ({ secondary, toggleSidebar, isSidebarOpen }: Props) => {
transition-property="box-shadow, background-color, filter, border"
transitionTimingFunction="linear, linear, linear, linear"
alignItems="center"
borderRadius="16px"
display="flex"
borderRadius="15px"
minH="75px"
justifyContent="center"
lineHeight="25.6px"
mx="auto"
mt={secondaryMargin}
pb="8px"
right={{ base: '0px', sm: '0px' }}
pl="30px"
right={{ base: '0px', sm: '0px', lg: '20px' }}
ps="12px"
pt="8px"
top="18px"
w={{
base: '100%',
sm: isSidebarOpen ? 'calc(100vw - 70px - 196px)' : '100%',
md: isSidebarOpen ? 'calc(100vw - 70px - 196px)' : '100%',
}}
top="15px"
w={isCompact ? '100%' : 'calc(100vw - 256px)'}
>
<Flex w="100%" flexDirection="row" alignItems="center">
<HamburgerIcon w="24px" h="24px" onClick={toggleSidebar} mr={10} mt={1} />
<Heading>{t(getActiveRoute())}</Heading>
{isCompact && <HamburgerIcon w="24px" h="24px" onClick={toggleSidebar} mr={10} mt={1} />}
<Heading>{activeRoute}</Heading>
<Tooltip label={t('common.go_back')}>
<IconButton
mt={2}
@@ -139,6 +110,7 @@ const Navbar = ({ secondary, toggleSidebar, isSidebarOpen }: Props) => {
colorScheme="blue"
aria-label={t('common.go_back')}
onClick={goBack}
size="sm"
icon={<ArrowCircleLeft width={20} height={20} />}
/>
</Tooltip>
@@ -160,22 +132,16 @@ const Navbar = ({ secondary, toggleSidebar, isSidebarOpen }: Props) => {
onClick={toggleColorMode}
/>
</Tooltip>
<LanguageSwitcher />
<HStack spacing={{ base: '0', md: '6' }} ml={6} mr={4}>
<Flex alignItems="center">
{languageSwitcher}
<HStack spacing={{ base: '0', md: '6' }} ml={1} mr={4}>
<Menu>
<MenuButton py={2} transition="all 0.3s" _focus={{ boxShadow: 'none' }}>
<HStack>
<VStack display={{ base: 'none', md: 'flex' }} alignItems="flex-start" spacing={0} height={12}>
<Text fontWeight="bold">{user?.name}</Text>
<Text fontSize="sm">{`${uppercaseFirstLetter(user?.userRole)}`}</Text>
</VStack>
<Avatar src={avatar} name={user?.name} />
<Box display={{ base: 'none', md: 'flex' }}>
<ChevronDownIcon />
</Box>
{!isCompact && <Text fontWeight="bold">{user?.name}</Text>}
<Avatar h="40px" w="40px" fontSize="0.8rem" lineHeight="2rem" src={avatar} name={user?.name} />
</HStack>
</MenuButton>
<Portal>
<MenuList
bg={useColorModeValue('white', 'gray.900')}
borderColor={useColorModeValue('gray.200', 'gray.700')}
@@ -185,14 +151,13 @@ const Navbar = ({ secondary, toggleSidebar, isSidebarOpen }: Props) => {
</MenuItem>
<MenuItem onClick={logout}>{t('common.logout')}</MenuItem>
</MenuList>
</Portal>
</Menu>
</Flex>
</HStack>
</Flex>
</Box>
</Flex>
</Flex>
</Portal>
);
};
export default Navbar;

View File

@@ -0,0 +1,48 @@
import * as React from 'react';
import { Box, Center, Flex, Spinner, useBreakpoint } from '@chakra-ui/react';
import { useAuth } from 'contexts/AuthProvider';
export type PageContainerProps = {
waitForUser: boolean;
children: React.ReactNode;
};
export const PageContainer = ({ waitForUser, children }: PageContainerProps) => {
const { isUserLoaded } = useAuth();
const breakpoint = useBreakpoint('xl');
const isCompact = breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md';
return (
<Box
w={isCompact ? 'calc(100%)' : 'calc(100% - 210px)'}
float="right"
position="relative"
transition="all 0.33s cubic-bezier(0.685, 0.0473, 0.346, 1)"
transitionDelay=".2s, .2s, .35s"
transitionProperty="top, bottom, width"
transitionTimingFunction="linear, linear, ease"
px="15px"
pb="15px"
>
<Box minH="calc(100vh - 123px)" pt="105px" pl="10px" pr="5px" pb="0px">
<Flex flexDirection="column">
<React.Suspense
fallback={
<Center mt="100px">
<Spinner size="xl" />
</Center>
}
>
{waitForUser && !isUserLoaded ? (
<Center mt="100px">
<Spinner size="xl" />
</Center>
) : (
children
)}
</React.Suspense>
</Flex>
</Box>
</Box>
);
};

View File

@@ -1,21 +0,0 @@
import React from 'react';
import { v4 as uuid } from 'uuid';
import EntityNavButton from './EntityNavButton';
import NavLinkButton from './NavLinkButton';
import { Route } from 'models/Routes';
const createLinks = (
routes: Route[],
activeRoute: (path: string, otherRoute: string | undefined) => string,
role: string,
toggleSidebar = () => {},
) =>
routes.map((route) =>
route.isEntity ? (
<EntityNavButton key={uuid()} activeRoute={activeRoute} role={role} route={route} toggleSidebar={toggleSidebar} />
) : (
<NavLinkButton key={uuid()} activeRoute={activeRoute} role={role} route={route} />
),
);
export default createLinks;

View File

@@ -9,20 +9,12 @@ import { Route } from 'models/Routes';
const variantChange = '0.2s linear';
interface Props {
activeRoute: (path: string, otherRoute: string | undefined) => string;
isActive: boolean;
route: Route;
role: string;
toggleSidebar: () => void;
}
const EntityNavButton = (
{
activeRoute,
route,
role,
toggleSidebar
}: Props
) => {
const EntityNavButton = ({ isActive, route, toggleSidebar }: Props) => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const activeArrowColor = useColorModeValue('var(--chakra-colors-gray-700)', 'white');
@@ -33,17 +25,15 @@ const EntityNavButton = (
return (
<EntityPopover isOpen={isOpen} onClose={onClose} toggleSidebar={toggleSidebar}>
{activeRoute(route.path, '/venue/:id') === 'active' ? (
{isActive ? (
<Button
onClick={onOpen}
hidden={route.hidden || !route.authorized.includes(role)}
boxSize="initial"
justifyContent="flex-start"
alignItems="center"
boxShadow="none"
bg="transparent"
transition={variantChange}
mb="12px"
mx="auto"
ps="10px"
py="12px"
@@ -71,12 +61,10 @@ const EntityNavButton = (
) : (
<Button
onClick={onOpen}
hidden={route.hidden || !route.authorized.includes(role)}
boxSize="initial"
justifyContent="flex-start"
alignItems="center"
bg="transparent"
mb="12px"
mx="auto"
py="12px"
ps="10px"

View File

@@ -22,7 +22,7 @@ import { TreeStructure, Buildings, X } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { v4 as uuid } from 'uuid';
import useGetEntityTree from 'hooks/Network/EntityTree';
import { useGetEntityTree } from 'hooks/Network/Entity';
interface Tree {
uuid: string;
@@ -110,14 +110,7 @@ interface Props {
children: React.ReactNode;
toggleSidebar: () => void;
}
const EntityPopover = (
{
isOpen,
onClose,
children,
toggleSidebar
}: Props
) => {
const EntityPopover = ({ isOpen, onClose, children, toggleSidebar }: Props) => {
const { t } = useTranslation();
const navigate = useNavigate();
const breakpoint = useBreakpoint();
@@ -126,9 +119,9 @@ const EntityPopover = (
const initRef = React.useRef<HTMLButtonElement>();
const goTo = useCallback(
(id, type) => {
(id: string, type: string) => {
navigate(`/${type}/${id}`);
if (breakpoint === 'base') toggleSidebar();
if (breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md') toggleSidebar();
},
[breakpoint],
);
@@ -145,7 +138,7 @@ const EntityPopover = (
return (
<Popover
offset={[140, -100]}
offset={[0, -100]}
isLazy
returnFocusOnClose={false}
isOpen={isOpen}
@@ -154,8 +147,11 @@ const EntityPopover = (
closeOnBlur={closeOnBlur}
initialFocusRef={initRef as React.RefObject<FocusableElement>}
>
{
// @ts-ignore
<PopoverAnchor>{children}</PopoverAnchor>
{breakpoint === 'base' ? (
}
{breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md' ? (
<PopoverContent maxW={{ base: 'calc(60vw)' }}>
<PopoverHeader fontWeight="semibold" display="flex" alignItems="center">
<Heading size="md">{t('entities.title')}</Heading>

View File

@@ -1,3 +1,4 @@
/* eslint-disable import/prefer-default-export */
import React from 'react';
import { Button, Flex, Text, useColorModeValue } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
@@ -8,83 +9,66 @@ import { Route } from 'models/Routes';
const variantChange = '0.2s linear';
interface Props {
activeRoute: (path: string, otherRoute: string | undefined) => string;
route: Route;
role: string;
}
const commonStyle = {
boxSize: 'initial',
justifyContent: 'flex-start',
alignItems: 'center',
transition: variantChange,
bg: 'transparent',
ps: '6px',
py: '12px',
pe: '4px',
w: '100%',
borderRadius: '15px',
_active: {
bg: 'inherit',
transform: 'none',
borderColor: 'transparent',
},
_focus: {
boxShadow: '0px 7px 11px rgba(0, 0, 0, 0.04)',
},
} as const;
const NavLinkButton = (
{
activeRoute,
route,
role
}: Props
) => {
type Props = {
isActive: boolean;
route: Route;
toggleSidebar: () => void;
};
export const NavLinkButton = ({ isActive, route, toggleSidebar }: Props) => {
const { t } = useTranslation();
const activeTextColor = useColorModeValue('gray.700', 'white');
const inactiveTextColor = useColorModeValue('gray.600', 'gray.200');
const inactiveIconColor = useColorModeValue('gray.100', 'gray.600');
if (route.navButton) {
return route.navButton(isActive, toggleSidebar, route) as JSX.Element;
}
return (
<NavLink to={route.path} key={uuid()}>
{activeRoute(route.path, undefined) === 'active' ? (
<Button
hidden={route.hidden || !route.authorized.includes(role)}
boxSize="initial"
justifyContent="flex-start"
alignItems="center"
boxShadow="none"
bg="transparent"
transition={variantChange}
mb="12px"
mx="auto"
ps="10px"
py="12px"
borderRadius="15px"
w="100%"
_active={{
bg: 'inherit',
transform: 'none',
borderColor: 'transparent',
}}
_focus={{
boxShadow: '0px 7px 11px rgba(0, 0, 0, 0.04)',
}}
>
<NavLink to={route.path.replace(':id', '0')} key={uuid()} style={{ width: '100%' }}>
{isActive ? (
<Button {...commonStyle} boxShadow="none">
<Flex>
<IconBox bg="blue.300" color="white" h="42px" w="42px" me="12px" transition={variantChange}>
<IconBox bg="blue.300" color="white" h="38px" w="38px" me="6px" transition={variantChange}>
{route.icon(true)}
</IconBox>
<Text color={activeTextColor} my="auto" fontSize="lg">
<Text color={activeTextColor} my="auto" fontSize="md">
{t(route.name)}
</Text>
</Flex>
</Button>
) : (
<Button
hidden={route.hidden || !route.authorized.includes(role)}
boxSize="initial"
justifyContent="flex-start"
alignItems="center"
bg="transparent"
mb="12px"
mx="auto"
py="12px"
ps="10px"
borderRadius="15px"
w="100%"
_active={{
bg: 'inherit',
transform: 'none',
borderColor: 'transparent',
}}
{...commonStyle}
ps="6px"
_focus={{
boxShadow: 'none',
}}
>
<Flex>
<IconBox bg={inactiveIconColor} color="blue.300" h="34px" w="34px" me="12px" transition={variantChange}>
<IconBox bg={inactiveIconColor} color="blue.300" h="34px" w="34px" me="6px" transition={variantChange}>
{route.icon(false)}
</IconBox>
<Text color={inactiveTextColor} my="auto" fontSize="sm">
@@ -96,5 +80,3 @@ const NavLinkButton = (
</NavLink>
);
};
export default React.memo(NavLinkButton);

View File

@@ -1,4 +1,4 @@
import React, { LegacyRef, useRef } from 'react';
import React from 'react';
import {
Box,
Drawer,
@@ -8,55 +8,81 @@ import {
DrawerOverlay,
Flex,
useColorModeValue,
useColorMode,
Text,
Spacer,
useBreakpoint,
VStack,
} from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import createLinks from './CreateLinks';
import darkLogo from 'assets/Logo_Dark_Mode.svg';
import lightLogo from 'assets/Logo_Light_Mode.svg';
import { v4 as uuid } from 'uuid';
import { NavLinkButton } from './NavLinkButton';
import { useAuth } from 'contexts/AuthProvider';
import { Route } from 'models/Routes';
const variantChange = '0.2s linear';
interface Props {
export type SidebarProps = {
routes: Route[];
isOpen: boolean;
toggle: () => void;
}
logo: React.ReactNode;
version: string;
children?: React.ReactNode;
topNav?: (isRouteActive: (str: string, str2: string) => boolean, toggleSidebar: () => void) => React.ReactNode;
};
const Sidebar = ({ routes, isOpen, toggle }: Props) => {
export const Sidebar = ({ routes, isOpen, toggle, logo, version, topNav, children }: SidebarProps) => {
const { t } = useTranslation();
const { user } = useAuth();
const location = useLocation();
const mainPanel = useRef<unknown>();
const { colorMode } = useColorMode();
const navbarShadow = useColorModeValue('0px 7px 23px rgba(0, 0, 0, 0.05)', 'none');
const breakpoint = useBreakpoint();
const activeRoute = (routeName: string, otherRoute: string | undefined) => {
const isRouteActive = (routeName: string, otherRoute?: string) => {
if (otherRoute)
return location.pathname.split('/')[1] === routeName.split('/')[1] ||
return (
location.pathname.split('/')[1] === routeName.split('/')[1] ||
location.pathname.split('/')[1] === otherRoute.split('/')[1]
? 'active'
: '';
);
return location.pathname === routeName ? 'active' : '';
return location.pathname === routeName.replace(':id', '0');
};
const isCompact = breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md';
const brand = (
<Box pt="25px" mb="12px">
<img src={colorMode === 'light' ? lightLogo : darkLogo} alt="OpenWifi" width="180px" height="100px" />
<Box pt="25px" mb="15px" px="12px">
{logo}
</Box>
);
const sidebarContent = React.useMemo(
() => (
<>
<VStack spacing={2} alignItems="start" w="100%" px={4}>
{topNav ? topNav(isRouteActive, toggle) : null}
{routes
.filter(({ hidden, authorized }) => !hidden && authorized.includes(user?.userRole ?? ''))
.map((route) => (
<NavLinkButton key={uuid()} isActive={isRouteActive(route.path)} route={route} toggleSidebar={toggle} />
))}
</VStack>
<Spacer />
<Box mb={2}>{children}</Box>
<Box>
<Text color="gray.400">
{t('footer.version')} {version}
</Text>
</Box>
</>
),
[user?.userRole, location],
);
return (
<>
<Drawer isOpen={breakpoint === 'base' && isOpen} onClose={toggle} placement="left">
<Drawer isOpen={isCompact && isOpen} onClose={toggle} placement="left">
<DrawerOverlay />
<DrawerContent
w="250px"
@@ -69,52 +95,33 @@ const Sidebar = ({ routes, isOpen, toggle }: Props) => {
}}
borderRadius="16px"
>
<DrawerCloseButton _focus={{ boxShadow: 'none' }} _hover={{ boxShadow: 'none' }} />
<DrawerCloseButton />
<DrawerBody maxW="250px" px="1rem">
<Box maxW="100%" h="90vh">
<Box>{brand}</Box>
<Flex direction="column" mb="40px" h="calc(100vh - 200px)" alignItems="center">
<Box overflowY="auto">{createLinks(routes, activeRoute, user?.userRole ?? '', toggle)}</Box>
<Spacer />
<Box>
<Text color="gray.400">
{t('footer.version')} {__APP_VERSION__}
</Text>
</Box>
{brand}
<Flex direction="column" mb="40px" h="calc(100vh - 200px)" alignItems="center" overflowY="auto">
{sidebarContent}
</Flex>
</Box>
</DrawerBody>
</DrawerContent>
</Drawer>
<Box ref={mainPanel as LegacyRef<HTMLDivElement> | undefined}>
<Box hidden={!isOpen} position="fixed">
<Box>
<Box hidden={isCompact} position="fixed">
<Box
shadow={navbarShadow}
bg={useColorModeValue('white', 'gray.700')}
transition={variantChange}
w="200px"
maxW="200px"
ms={{
sm: '16px',
}}
my={{
sm: '16px',
}}
h="calc(100vh - 32px)"
ps="20px"
pe="20px"
m="16px 0px 16px 16px"
my="16px"
ml="16px"
borderRadius="16px"
>
<Box>{brand}</Box>
<Flex direction="column" mb="40px" h="calc(100vh - 180px)" alignItems="center">
<Box overflowY="auto">{createLinks(routes, activeRoute, user?.userRole ?? '', toggle)}</Box>
<Spacer />
<Box>
<Text color="gray.400">
{t('footer.version')} {__APP_VERSION__}
</Text>
</Box>
{brand}
<Flex direction="column" h="calc(100vh - 160px)" alignItems="center" overflowY="auto">
{sidebarContent}
</Flex>
</Box>
</Box>
@@ -122,5 +129,3 @@ const Sidebar = ({ routes, isOpen, toggle }: Props) => {
</>
);
};
export default Sidebar;

View File

@@ -1,56 +1,66 @@
import React, { Suspense } from 'react';
import { Flex, Portal, Spinner, useBoolean, useBreakpoint } from '@chakra-ui/react';
import { Route, Routes } from 'react-router-dom';
import React from 'react';
import { useBoolean, useBreakpoint, useColorMode } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { Route, Routes, useLocation } from 'react-router-dom';
import { v4 as uuid } from 'uuid';
import PanelContainer from './Containers/PanelContainer';
import PanelContent from './Containers/PanelContent';
import MainPanel from './MainPanel';
import Navbar from './Navbar';
import Sidebar from './Sidebar';
import CreateRootModal from 'components/Modals/Entity/CreateRootModal';
import { Route as RouteProps } from 'models/Routes';
import { Navbar } from './Navbar';
import { PageContainer } from './PageContainer';
import { Sidebar } from './Sidebar';
import darkLogo from 'assets/Logo_Dark_Mode.svg';
import lightLogo from 'assets/Logo_Light_Mode.svg';
import LanguageSwitcher from 'components/LanguageSwitcher';
import { Route as RouteType } from 'models/Routes';
import NotFoundPage from 'pages/NotFound';
import routes from 'router/routes';
const Layout = () => {
const { t } = useTranslation();
const { colorMode } = useColorMode();
const location = useLocation();
const breakpoint = useBreakpoint('xl');
const [isSidebarOpen, { toggle: toggleSidebar }] = useBoolean(breakpoint !== 'base' && breakpoint !== 'sm');
document.documentElement.dir = 'ltr';
const getRoutes = (r: RouteProps[]) =>
const activeRoute = React.useMemo(() => {
const route = routes.find(
(r) => r.path === location.pathname || location.pathname.split('/')[1] === r.path.split('/')[1],
);
if (route) return route.navName ? t(route.navName) : t(route.name);
return '';
}, [t, location.pathname]);
const getRoutes = (r: RouteType[]) =>
// @ts-ignore
r.map((route: RouteProps) => <Route path={route.path} element={<route.component />} key={uuid()} />);
r.map((route: RouteType) => <Route path={route.path} element={<route.component />} key={uuid()} />);
return (
<>
<Sidebar routes={routes} isOpen={isSidebarOpen} toggle={toggleSidebar} />
<Portal>
<Navbar secondary={false} toggleSidebar={toggleSidebar} isSidebarOpen={isSidebarOpen} />
</Portal>
<MainPanel
w={{
base: '100%',
sm: isSidebarOpen ? 'calc(100% - 220px)' : '100%',
md: isSidebarOpen ? 'calc(100% - 220px)' : '100%',
<Sidebar
routes={routes}
isOpen={isSidebarOpen}
toggle={toggleSidebar}
version={__APP_VERSION__}
logo={
<img
src={colorMode === 'light' ? lightLogo : darkLogo}
alt="OpenWifi"
width="180px"
height="100px"
style={{
marginLeft: 'auto',
marginRight: 'auto',
}}
>
<CreateRootModal />
<PanelContent>
<PanelContainer>
<Suspense
fallback={
<Flex flexDirection="column" pt="75px">
<Spinner />
</Flex>
/>
}
>
/>
<Navbar toggleSidebar={toggleSidebar} languageSwitcher={<LanguageSwitcher />} activeRoute={activeRoute} />
<PageContainer waitForUser>
<Routes>
{[...getRoutes(routes as RouteProps[]), <Route path="*" element={<NotFoundPage />} key={uuid()} />]}
{[...getRoutes(routes as RouteType[]), <Route path="*" element={<NotFoundPage />} key={uuid()} />]}
</Routes>
</Suspense>
</PanelContainer>
</PanelContent>
</MainPanel>
</PageContainer>
</>
);
};

238
src/models/Analytics.ts Normal file
View File

@@ -0,0 +1,238 @@
import { Note } from './Note';
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[][];
};

View File

@@ -1,3 +1,6 @@
import { DeviceRules } from './Basic';
import { Note } from './Note';
export interface Entity {
id: string;
name: string;
@@ -6,4 +9,14 @@ export interface Entity {
venues: string[];
contacts: string[];
entity: string;
created: number;
modified: number;
description: string;
deviceRules: DeviceRules;
sourceIP: string[];
notes: Note[];
children: string[];
configurations: string[];
locations: string[];
variables: string[];
}

View File

@@ -1,13 +1,14 @@
import { ReactNode } from 'react';
export interface Route {
export type Route = {
authorized: string[];
path: string;
name: string;
navName?: string;
icon: (active: boolean) => ReactNode;
navButton?: (isActive: boolean, toggleSidebar: () => void, route: Route) => React.ReactNode;
isEntity?: boolean;
component: unknown;
hidden?: boolean;
isCustom?: boolean;
}
};

View File

@@ -1,9 +1,22 @@
export interface Venue {
import { DeviceRules } from './Basic';
import { Note } from './Note';
export interface VenueApiResponse {
id: string;
name: string;
description: string;
parent: string;
devices: string[];
venues: string[];
children: string[];
contacts: string[];
entity: string;
boards: string[];
created: number;
modified: number;
configurations: string[];
notes: Note[];
variables: string[];
location: string;
sourceIP: string[];
deviceRules: DeviceRules;
}

View File

@@ -1,18 +1,11 @@
import React from 'react';
import { Flex } from '@chakra-ui/react';
import { useParams } from 'react-router-dom';
import ConfigurationCard from './ConfigurationCard';
import { useAuth } from 'contexts/AuthProvider';
const ConfigurationPage = () => {
const { isUserLoaded } = useAuth();
const { id } = useParams();
return (
<Flex flexDirection="column" pt="75px">
{isUserLoaded && id !== '' && <ConfigurationCard id={id} />}
</Flex>
);
return id !== '' ? <ConfigurationCard id={id} /> : null;
};
export default ConfigurationPage;

View File

@@ -55,8 +55,9 @@ const CreateEntityForm = ({ isOpen, onClose, formRef, parentId }) => {
validationSchema={EntitySchema(t)}
onSubmit={(formData, { setSubmitting, resetForm }) =>
create.mutateAsync(createParameters(formData), {
onSuccess: ({ data }) => {
onSuccess: (data) => {
queryClient.invalidateQueries(['get-entity-tree']);
queryClient.invalidateQueries(['get-entity', parentId]);
setSubmitting(false);
resetForm();
toast({

View File

@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import CreateEntityForm from './Form';
import CloseButton from 'components/Buttons/CloseButton';
import CreateButton from 'components/Buttons/CreateButton';
import SaveButton from 'components/Buttons/SaveButton';
import ConfirmCloseAlert from 'components/Modals/Actions/ConfirmCloseAlert';
import ModalHeader from 'components/Modals/ModalHeader';
@@ -12,16 +11,16 @@ import useFormRef from 'hooks/useFormRef';
const propTypes = {
parentId: PropTypes.string.isRequired,
isDisabled: PropTypes.bool,
isOpen: PropTypes.bool,
onClose: PropTypes.func.isRequired,
};
const defaultProps = {
isDisabled: false,
isOpen: false,
};
const CreateEntityModal = ({ parentId, isDisabled }) => {
const CreateEntityModal = ({ parentId, onClose, isOpen }) => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: showConfirm, onOpen: openConfirm, onClose: closeConfirm } = useDisclosure();
const { form, formRef } = useFormRef();
@@ -33,8 +32,6 @@ const CreateEntityModal = ({ parentId, isDisabled }) => {
};
return (
<>
<CreateButton onClick={onOpen} isDisabled={isDisabled} />
<Modal onClose={closeModal} isOpen={isOpen} size="xl">
<ModalOverlay />
<ModalContent maxWidth={{ sm: '600px', md: '700px', lg: '800px', xl: '50%' }}>
@@ -57,7 +54,6 @@ const CreateEntityModal = ({ parentId, isDisabled }) => {
</ModalContent>
<ConfirmCloseAlert isOpen={showConfirm} confirm={closeCancelAndForm} cancel={closeConfirm} />
</Modal>
</>
);
};

View File

@@ -14,24 +14,19 @@ import {
useToast,
} from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import DeleteButton from 'components/Buttons/DeleteButton';
import { EntityShape } from 'constants/propShapes';
import { useDeleteEntity } from 'hooks/Network/Entity';
import { AxiosError } from 'models/Axios';
import { Entity } from 'models/Entity';
const propTypes = {
entity: PropTypes.shape(EntityShape),
isDisabled: PropTypes.bool,
type Props = {
entity?: Entity;
isDisabled: boolean;
};
const defaultProps = {
entity: { name: '', id: '' },
isDisabled: false,
};
const DeleteEntityPopover = ({ entity, isDisabled }) => {
const DeleteEntityPopover = ({ entity, isDisabled }: Props) => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();
@@ -40,7 +35,7 @@ const DeleteEntityPopover = ({ entity, isDisabled }) => {
const deleteEntity = useDeleteEntity();
const handleDeleteClick = () =>
deleteEntity.mutateAsync(entity.id, {
deleteEntity.mutateAsync(entity?.id ?? '', {
onSuccess: () => {
queryClient.invalidateQueries(['get-entity-tree']);
onClose();
@@ -48,14 +43,14 @@ const DeleteEntityPopover = ({ entity, isDisabled }) => {
id: `entity-delete-success`,
title: t('common.success'),
description: t('crud.success_delete_obj', {
obj: entity.name,
obj: entity?.name,
}),
status: 'success',
duration: 5000,
isClosable: true,
position: 'top-right',
});
navigate(`/entity/${entity.parent}`);
navigate(`/entity/${entity?.parent}`);
},
onError: (e) => {
if (!toast.isActive('entity-fetching-error'))
@@ -63,8 +58,8 @@ const DeleteEntityPopover = ({ entity, isDisabled }) => {
id: 'entity-delete-error',
title: t('common.error'),
description: t('crud.error_delete_obj', {
obj: entity.name,
e: e?.response?.data?.ErrorDescription,
obj: entity?.name,
e: (e as AxiosError)?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
@@ -74,12 +69,12 @@ const DeleteEntityPopover = ({ entity, isDisabled }) => {
},
});
if (entity.children?.length > 0 || entity.venues?.length > 0) {
if (entity && (entity.children?.length > 0 || entity.venues?.length > 0)) {
return (
<Popover isOpen={isOpen} onOpen={onOpen} onClose={onClose}>
<PopoverAnchor>
<span>
<DeleteButton onClick={onOpen} isDisabled={isDisabled} ml={2} />
<DeleteButton onClick={onOpen} isDisabled={isDisabled} isCompact />
</span>
</PopoverAnchor>
<PopoverContent>
@@ -103,7 +98,7 @@ const DeleteEntityPopover = ({ entity, isDisabled }) => {
<Popover isOpen={isOpen} onOpen={onOpen} onClose={onClose}>
<PopoverAnchor>
<span>
<DeleteButton onClick={onOpen} isDisabled={isDisabled} ml={2} />
<DeleteButton onClick={onOpen} isDisabled={isDisabled} isCompact />
</span>
</PopoverAnchor>
<PopoverContent>
@@ -126,6 +121,4 @@ const DeleteEntityPopover = ({ entity, isDisabled }) => {
);
};
DeleteEntityPopover.propTypes = propTypes;
DeleteEntityPopover.defaultProps = defaultProps;
export default DeleteEntityPopover;

View File

@@ -1,160 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useToast, Tabs, TabList, TabPanels, TabPanel, Tab, SimpleGrid, Box } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { Formik, Field, Form } from 'formik';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import DeviceRulesField from 'components/CustomFields/DeviceRulesField';
import IpDetectionModalField from 'components/CustomFields/IpDetectionModalField';
import NotesTable from 'components/CustomFields/NotesTable';
import FormattedDate from 'components/FormattedDate';
import StringField from 'components/FormFields/StringField';
import { EntitySchema } from 'constants/formSchemas';
import { EntityShape } from 'constants/propShapes';
import { useUpdateEntity } from 'hooks/Network/Entity';
const propTypes = {
editing: PropTypes.bool.isRequired,
entity: PropTypes.shape(EntityShape).isRequired,
formRef: PropTypes.instanceOf(Object).isRequired,
stopEditing: PropTypes.func.isRequired,
};
const EditEntityForm = ({ editing, entity, formRef, stopEditing }) => {
const { t } = useTranslation();
const toast = useToast();
const [formKey, setFormKey] = useState(uuid());
const queryClient = useQueryClient();
const updateEntity = useUpdateEntity({ id: entity.id });
useEffect(() => {
setFormKey(uuid());
}, [editing]);
return (
<Formik
innerRef={formRef}
enableReinitialize
key={formKey}
initialValues={{ ...entity, rrm: entity.rrm !== '' ? entity.rrm : 'inherit' }}
validationSchema={EntitySchema(t)}
onSubmit={({ name, description, sourceIP, notes, deviceRules }, { setSubmitting, resetForm }) =>
updateEntity.mutateAsync(
{
name,
description,
deviceRules,
sourceIP,
notes: notes.filter((note) => note.isNew),
},
{
onSuccess: ({ data }) => {
setSubmitting(false);
toast({
id: 'entity-update-success',
title: t('common.success'),
description: t('crud.success_update_obj', {
obj: t('entities.one'),
}),
status: 'success',
duration: 5000,
isClosable: true,
position: 'top-right',
});
queryClient.setQueryData(['get-entity', entity.id], data);
queryClient.invalidateQueries(['get-entity-tree']);
resetForm();
stopEditing();
},
onError: (e) => {
toast({
id: uuid(),
title: t('common.error'),
description: t('crud.error_update_obj', {
obj: t('entities.one'),
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
setSubmitting(false);
},
},
)
}
>
{({ errors, touched, setFieldValue }) => (
<Tabs variant="enclosed" w="100%">
<TabList>
<Tab>{t('common.main')}</Tab>
<Tab>{t('common.notes')}</Tab>
</TabList>
<TabPanels>
<TabPanel>
<Form>
<SimpleGrid minChildWidth="300px" spacing="20px">
<StringField
name="name"
label={t('common.name')}
errors={errors}
touched={touched}
isDisabled={!editing}
isRequired
/>
<StringField
name="description"
label={t('common.description')}
errors={errors}
touched={touched}
isDisabled={!editing}
/>
<DeviceRulesField isDisabled={!editing} />
<IpDetectionModalField
name="sourceIP"
setFieldValue={setFieldValue}
errors={errors}
isDisabled={!editing}
/>
<StringField
name="created"
label={t('common.created')}
errors={errors}
touched={touched}
element={
<Box pl={1} pt={2}>
<FormattedDate date={entity.created} />
</Box>
}
/>
<StringField
name="modified"
label={t('common.modified')}
errors={errors}
touched={touched}
element={
<Box pl={1} pt={2}>
<FormattedDate date={entity.modified} />
</Box>
}
/>
</SimpleGrid>
</Form>
</TabPanel>
<TabPanel>
<Field name="notes">
{({ field }) => <NotesTable notes={field.value} setNotes={setFieldValue} isDisabled={!editing} />}
</Field>
</TabPanel>
</TabPanels>
</Tabs>
)}
</Formik>
);
};
EditEntityForm.propTypes = propTypes;
export default EditEntityForm;

View File

@@ -1,67 +0,0 @@
import React from 'react';
import { Box, Center, Heading, Spacer, Spinner, useBoolean } from '@chakra-ui/react';
import PropTypes from 'prop-types';
import DeleteEntityPopover from './DeleteEntityPopover';
import EditEntityForm from './Form';
import RefreshButton from 'components/Buttons/RefreshButton';
import SaveButton from 'components/Buttons/SaveButton';
import ToggleEditButton from 'components/Buttons/ToggleEditButton';
import Card from 'components/Card';
import CardBody from 'components/Card/CardBody';
import CardHeader from 'components/Card/CardHeader';
import LoadingOverlay from 'components/LoadingOverlay';
import { useGetEntity } from 'hooks/Network/Entity';
import useFormRef from 'hooks/useFormRef';
const propTypes = {
id: PropTypes.string.isRequired,
};
const EntityCard = ({ id }) => {
const [editing, setEditing] = useBoolean();
const { data: entity, refetch, isFetching } = useGetEntity({ id });
const { form, formRef } = useFormRef();
return (
<Card mb={4}>
<CardHeader mb="10px" display="flex">
<Box pt={1}>
<Heading size="md">{entity?.name}</Heading>
</Box>
<Spacer />
<Box>
<SaveButton
onClick={form.submitForm}
isLoading={form.isSubmitting}
isDisabled={!editing || !form.isValid || !form.dirty}
ml={2}
/>
<ToggleEditButton
toggleEdit={setEditing.toggle}
isEditing={editing}
isDisabled={isFetching}
isDirty={formRef.dirty}
ml={2}
/>
<DeleteEntityPopover isDisabled={editing || isFetching} entity={entity} />
<RefreshButton onClick={refetch} isFetching={isFetching} isDisabled={editing} ml={2} />
</Box>
</CardHeader>
<CardBody>
{!entity && isFetching ? (
<Center w="100%">
<Spinner size="xl" />
</Center>
) : (
<LoadingOverlay isLoading={isFetching}>
<EditEntityForm editing={editing} entity={entity} stopEditing={setEditing.off} formRef={formRef} />
</LoadingOverlay>
)}
</CardBody>
</Card>
);
};
EntityCard.propTypes = propTypes;
export default React.memo(EntityCard);

View File

@@ -1,34 +0,0 @@
import React from 'react';
import { Flex, IconButton, Tooltip } from '@chakra-ui/react';
import { MagnifyingGlass } from 'phosphor-react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
const propTypes = {
cell: PropTypes.shape({
original: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}).isRequired,
}).isRequired,
};
const Actions = ({ cell: { original: entity } }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const handleGoToPage = () => navigate(`/entity/${entity.id}`);
return (
<Flex>
<Tooltip hasArrow label={t('common.view_details')} placement="top">
<IconButton ml={2} colorScheme="blue" icon={<MagnifyingGlass size={20} />} size="sm" onClick={handleGoToPage} />
</Tooltip>
</Flex>
);
};
Actions.propTypes = propTypes;
export default Actions;

View File

@@ -1,27 +0,0 @@
import React, { useCallback } from 'react';
import { Box } from '@chakra-ui/react';
import PropTypes from 'prop-types';
import { v4 as uuid } from 'uuid';
import Actions from './Actions';
import EntityTable from 'components/Tables/EntityTable';
import { EntityShape } from 'constants/propShapes';
import CreateEntityModal from 'pages/EntityPage/CreateEntityModal';
const propTypes = {
entity: PropTypes.shape(EntityShape).isRequired,
};
const EntityChildrenTableWrapper = ({ entity }) => {
const actions = useCallback((cell) => <Actions key={uuid()} cell={cell.row} />, []);
return (
<>
<Box textAlign="right" mb={2}>
<CreateEntityModal parentId={entity?.id ?? ''} />
</Box>
<EntityTable select={entity.children} actions={actions} />
</>
);
};
EntityChildrenTableWrapper.propTypes = propTypes;
export default EntityChildrenTableWrapper;

View File

@@ -1,38 +0,0 @@
import React from 'react';
import { Flex, IconButton, Tooltip } from '@chakra-ui/react';
import { MagnifyingGlass } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import DeleteConfigurationButton from 'components/Tables/ConfigurationTable/DeleteConfigurationButton';
import { Configuration } from 'models/Configuration';
type Props = {
cell: {
original: Configuration;
};
};
const Actions = ({ cell: { original: configuration } }: Props) => {
const { t } = useTranslation();
const navigate = useNavigate();
const handleGoToPage = () => navigate(`/configuration/${configuration.id}`);
return (
<Flex>
<DeleteConfigurationButton configuration={configuration} />
<Tooltip hasArrow label={t('common.view_details')} placement="top">
<IconButton
aria-label={t('venues.go_to_page')}
ml={2}
colorScheme="blue"
icon={<MagnifyingGlass size={20} />}
size="sm"
onClick={handleGoToPage}
/>
</Tooltip>
</Flex>
);
};
export default Actions;

View File

@@ -1,52 +0,0 @@
import React, { useCallback, useState } from 'react';
import { Box, useDisclosure } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import PropTypes from 'prop-types';
import { v4 as uuid } from 'uuid';
import Actions from './Actions';
import ConfigurationInUseModal from 'components/Modals/Configuration/ConfigurationInUseModal';
import ConfigurationsTable from 'components/Tables/ConfigurationTable';
import ConfigurationViewAffectedModal from 'components/Tables/ConfigurationTable/ConfigurationViewAffectedModal';
import CreateConfigurationModal from 'components/Tables/ConfigurationTable/CreateConfigurationModal';
import { EntityShape } from 'constants/propShapes';
const propTypes = {
entity: PropTypes.shape(EntityShape).isRequired,
};
const EntityConfigurationsTableWrapper = ({ entity }) => {
const [config, setConfig] = useState(null);
const queryClient = useQueryClient();
const { isOpen: isInUseOpen, onOpen: openInUse, onClose: closeInUse } = useDisclosure();
const { isOpen: isAffectedOpen, onOpen: openAffected, onClose: closeAffected } = useDisclosure();
const openInUseModal = (newConf) => {
setConfig(newConf);
openInUse();
};
const openAffectedModal = (newConf) => {
setConfig(newConf);
openAffected();
};
const actions = useCallback(
(cell) => (
<Actions key={uuid()} cell={cell.row} openInUseModal={openInUseModal} openAffectedModal={openAffectedModal} />
),
[],
);
const refresh = () => queryClient.invalidateQueries(['get-entity', entity.id]);
return (
<>
<Box textAlign="right">
<CreateConfigurationModal entityId={`entity:${entity.id}`} refresh={refresh} />
</Box>
<ConfigurationsTable select={entity.configurations} actions={actions} />
<ConfigurationInUseModal isOpen={isInUseOpen} onClose={closeInUse} config={config} />
<ConfigurationViewAffectedModal isOpen={isAffectedOpen} onClose={closeAffected} config={config} />
</>
);
};
EntityConfigurationsTableWrapper.propTypes = propTypes;
export default EntityConfigurationsTableWrapper;

View File

@@ -1,57 +0,0 @@
import React, { useCallback, useState } from 'react';
import { Box, useDisclosure } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import PropTypes from 'prop-types';
import { v4 as uuid } from 'uuid';
import Actions from './Actions';
import ContactTable from 'components/Tables/ContactTable';
import CreateContactModal from 'components/Tables/ContactTable/CreateContactModal';
import EditContactModal from 'components/Tables/ContactTable/EditContactModal';
import { EntityShape } from 'constants/propShapes';
const propTypes = {
entity: PropTypes.shape(EntityShape).isRequired,
};
const EntityContactTableWrapper = ({ entity }) => {
const queryClient = useQueryClient();
const [contact, setContact] = useState(null);
const [refreshId, setRefreshId] = useState(0);
const { isOpen: isEditOpen, onOpen: openEdit, onClose: closeEdit } = useDisclosure();
const openEditModal = (newContact) => {
setContact(newContact);
openEdit();
};
const refreshEntity = () => queryClient.invalidateQueries(['get-entity', entity.id]);
const refetchLocations = () => {
setRefreshId(refreshId + 1);
refreshEntity();
};
const actions = useCallback(
(cell) => <Actions key={uuid()} cell={cell.row} refreshEntity={refreshEntity} openEditModal={openEditModal} />,
[refreshId],
);
return (
<>
<Box textAlign="right" mb={2}>
<CreateContactModal refresh={refreshEntity} entityId={entity.id} />
</Box>
<ContactTable
select={entity.contacts}
actions={actions}
refreshId={refreshId}
ignoredColumns={['entity']}
openDetailsModal={openEditModal}
/>
<EditContactModal isOpen={isEditOpen} onClose={closeEdit} contact={contact} refresh={refetchLocations} />
</>
);
};
EntityContactTableWrapper.propTypes = propTypes;
export default EntityContactTableWrapper;

View File

@@ -1,57 +0,0 @@
import React, { useCallback, useState } from 'react';
import { Box, useDisclosure } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import PropTypes from 'prop-types';
import { v4 as uuid } from 'uuid';
import Actions from './Actions';
import LocationTable from 'components/Tables/LocationTable';
import CreateLocationModal from 'components/Tables/LocationTable/CreateLocationModal';
import EditLocationModal from 'components/Tables/LocationTable/EditLocationModal';
import { EntityShape } from 'constants/propShapes';
const propTypes = {
entity: PropTypes.shape(EntityShape).isRequired,
};
const EntityLocationTableWrapper = ({ entity }) => {
const queryClient = useQueryClient();
const [location, setLocation] = useState(null);
const [refreshId, setRefreshId] = useState(0);
const { isOpen: isEditOpen, onOpen: openEdit, onClose: closeEdit } = useDisclosure();
const openEditModal = (newLoc) => {
setLocation(newLoc);
openEdit();
};
const refreshEntity = () => queryClient.invalidateQueries(['get-entity', entity.id]);
const refetchLocations = () => {
setRefreshId(refreshId + 1);
refreshEntity();
};
const actions = useCallback(
(cell) => <Actions key={uuid()} cell={cell.row} refreshEntity={refreshEntity} openEditModal={openEditModal} />,
[refreshId],
);
return (
<>
<Box textAlign="right" mb={2}>
<CreateLocationModal refresh={refreshEntity} entityId={entity.id} />
</Box>
<LocationTable
select={entity.locations}
actions={actions}
refreshId={refreshId}
ignoredColumns={['entity']}
openDetailsModal={openEditModal}
/>
<EditLocationModal isOpen={isEditOpen} onClose={closeEdit} location={location} refresh={refetchLocations} />
</>
);
};
EntityLocationTableWrapper.propTypes = propTypes;
export default EntityLocationTableWrapper;

View File

@@ -1,60 +0,0 @@
import React, { useCallback, useState } from 'react';
import { Box, useDisclosure } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import PropTypes from 'prop-types';
import { v4 as uuid } from 'uuid';
import Actions from './Actions';
import CreateResourceModal from 'components/Modals/Resources/CreateModal';
import EditResourceModal from 'components/Modals/Resources/EditModal';
import ResourceTable from 'components/Tables/ResourceTable';
import { EntityShape } from 'constants/propShapes';
const propTypes = {
entity: PropTypes.shape(EntityShape).isRequired,
};
const EntityResourcesTableWrapper = ({ entity }) => {
const queryClient = useQueryClient();
const [resource, setResource] = useState(null);
const [refreshId, setRefreshId] = useState(0);
const { isOpen: isEditOpen, onOpen: openEdit, onClose: closeEdit } = useDisclosure();
const openEditModal = (newResource) => {
setResource(newResource);
openEdit();
};
const openDetailsModalFromTable = (openedResource) => {
setResource(openedResource);
openEdit();
};
const refreshEntity = () => queryClient.invalidateQueries(['get-entity', entity.id]);
const refreshTable = () => {
setRefreshId(refreshId + 1);
};
const actions = useCallback(
(cell) => <Actions key={uuid()} cell={cell.row} refreshTable={refreshEntity} openEditModal={openEditModal} />,
[refreshId],
);
return (
<>
<Box textAlign="right" mb={2}>
<CreateResourceModal refresh={refreshEntity} entityId={entity.id} />
</Box>
<ResourceTable
select={entity.variables}
actions={actions}
refreshId={refreshId}
ignoredColumns={['entity']}
openDetailsModal={openDetailsModalFromTable}
/>
<EditResourceModal isOpen={isEditOpen} onClose={closeEdit} resource={resource} refresh={refreshTable} />
</>
);
};
EntityResourcesTableWrapper.propTypes = propTypes;
export default EntityResourcesTableWrapper;

View File

@@ -1,34 +0,0 @@
import React from 'react';
import { Flex, IconButton, Tooltip } from '@chakra-ui/react';
import { MagnifyingGlass } from 'phosphor-react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
const propTypes = {
cell: PropTypes.shape({
original: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}).isRequired,
}).isRequired,
};
const Actions = ({ cell: { original: venue } }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const handleGoToPage = () => navigate(`/venue/${venue.id}`);
return (
<Flex>
<Tooltip hasArrow label={t('common.view_details')} placement="top">
<IconButton ml={2} colorScheme="blue" icon={<MagnifyingGlass size={20} />} size="sm" onClick={handleGoToPage} />
</Tooltip>
</Flex>
);
};
Actions.propTypes = propTypes;
export default Actions;

View File

@@ -1,39 +0,0 @@
import React, { useCallback } from 'react';
import { Alert, Box, Center, Heading } from '@chakra-ui/react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import Actions from './Actions';
import VenueTable from 'components/Tables/VenueTable';
import CreateVenueModal from 'components/Tables/VenueTable/CreateVenueModal';
import { EntityShape } from 'constants/propShapes';
const propTypes = {
entity: PropTypes.shape(EntityShape).isRequired,
};
const EntityVenueTableWrapper = ({ entity }) => {
const { t } = useTranslation();
const actions = useCallback((cell) => <Actions key={uuid()} cell={cell.row} />, []);
if (entity?.id === '0000-0000-0000') {
return (
<Center minHeight="334px">
<Alert colorScheme="red" size="xl">
<Heading size="md">{t('entities.venues_under_root')}</Heading>
</Alert>
</Center>
);
}
return (
<>
<Box textAlign="right" mb={2}>
<CreateVenueModal entityId={entity.id} />
</Box>
<VenueTable select={entity.venues} actions={actions} />
</>
);
};
EntityVenueTableWrapper.propTypes = propTypes;
export default EntityVenueTableWrapper;

View File

@@ -1,92 +0,0 @@
import React from 'react';
import { Alert, Center, Heading, Spinner, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import EntityChildrenTableWrapper from './EntityChildrenTableWrapper';
import EntityConfigurationsTableWrapper from './EntityConfigurationsTableWrapper';
import EntityContactTableWrapper from './EntityContactTableWrapper ';
import EntityDeviceTableWrapper from './EntityDeviceTableWrapper';
import EntityLocationTableWrapper from './EntityLocationTableWrapper ';
import EntityResourcesTableWrapper from './EntityResourcesTableWrapper';
import EntityVenueTableWrapper from './EntityVenueTableWrapper';
import Card from 'components/Card';
import CardBody from 'components/Card/CardBody';
import LoadingOverlay from 'components/LoadingOverlay';
import { useGetEntity } from 'hooks/Network/Entity';
const getDefaultIndex = (id: string) => {
localStorage.getItem(`entity.${id}.lastActiveIndex`);
const index = parseInt(localStorage.getItem(`entity.${id}.lastActiveIndex`) || '0', 10);
if (index >= 0 && index <= 6) return index;
return 0;
};
const EntityChildrenCard = ({ id }: { id: string }) => {
const { t } = useTranslation();
const { data: entity, isFetching } = useGetEntity({ id });
const [tabIndex, setTabIndex] = React.useState(getDefaultIndex(id));
const onTabChange = (index: number) => {
setTabIndex(index);
localStorage.setItem(`entity.${id}.lastActiveIndex`, index.toString());
};
return (
<Card>
<CardBody>
<Tabs isLazy variant="enclosed" w="100%" index={tabIndex} onChange={onTabChange}>
<TabList>
<Tab>{t('entities.sub_other')}</Tab>
<Tab>{t('venues.sub_other')}</Tab>
<Tab>{t('configurations.title')}</Tab>
<Tab>{t('inventory.title')}</Tab>
<Tab>{t('locations.other')}</Tab>
<Tab>{t('contacts.other')}</Tab>
<Tab>{t('resources.title')}</Tab>
</TabList>
{!entity || isFetching ? (
<Center w="100%">
<Spinner size="xl" />
</Center>
) : (
<LoadingOverlay isLoading={isFetching}>
<TabPanels>
<TabPanel overflowX="auto">
<EntityChildrenTableWrapper entity={entity} />
</TabPanel>
<TabPanel overflowX="auto">
<EntityVenueTableWrapper entity={entity} />
</TabPanel>
<TabPanel overflowX="auto">
<EntityConfigurationsTableWrapper entity={entity} />
</TabPanel>
<TabPanel overflowX="auto">
{id === '0000-0000-0000' ? (
<Center minHeight="334px">
<Alert colorScheme="red" size="xl">
<Heading size="md">{t('entities.devices_under_root')}</Heading>
</Alert>
</Center>
) : (
<EntityDeviceTableWrapper entity={entity} />
)}
</TabPanel>
<TabPanel overflowX="auto">
<EntityLocationTableWrapper entity={entity} />
</TabPanel>
<TabPanel overflowX="auto">
<EntityContactTableWrapper entity={entity} />
</TabPanel>
<TabPanel overflowX="auto">
<EntityResourcesTableWrapper entity={entity} />
</TabPanel>
</TabPanels>
</LoadingOverlay>
)}
</Tabs>
</CardBody>
</Card>
);
};
export default EntityChildrenCard;

View File

@@ -0,0 +1,77 @@
import * as React from 'react';
import {
Button,
IconButton,
Menu,
MenuButton,
MenuDivider,
MenuItem,
MenuList,
Tooltip,
useBreakpoint,
useDisclosure,
} from '@chakra-ui/react';
import { TreeStructure } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import CreateEntityModal from './CreateEntityModal';
import { useGetEntity, useGetSelectEntities } from 'hooks/Network/Entity';
import { Entity } from 'models/Entity';
type Props = {
id: string;
};
const EntityDropdown = ({ id }: Props) => {
const { t } = useTranslation();
const breakpoint = useBreakpoint();
const navigate = useNavigate();
const getEntity = useGetEntity({ id });
const getChildren = useGetSelectEntities({ select: getEntity.data?.children ?? [] });
const { isOpen, onOpen, onClose } = useDisclosure();
const goToEntity = (entityId: string) => () => navigate(`/entity/${entityId}`);
const isCompact = breakpoint === 'base' || breakpoint === 'sm';
return (
<>
<Menu>
<Tooltip label={`${t('entities.sub_other')} (${getEntity.data?.children.length ?? 0})`}>
{isCompact ? (
<MenuButton
as={IconButton}
icon={<TreeStructure size={20} />}
aria-label={`${t('entities.sub_other')} (${getEntity.data?.children.length ?? 0})`}
colorScheme="pink"
isDisabled={!getEntity.data}
mx={2}
/>
) : (
<MenuButton
as={Button}
aria-label={`${t('entities.sub_other')} (${getEntity.data?.children.length ?? 0})`}
colorScheme="pink"
isDisabled={!getEntity.data}
mx={2}
>{`${t('entities.sub_other')} (${getEntity.data?.children.length ?? 0})`}</MenuButton>
)}
</Tooltip>
<MenuList>
<MenuItem onClick={onOpen}>{t('common.create')}</MenuItem>
<MenuDivider />
{getChildren.data
?.sort((a: Entity, b: Entity) => a.name.localeCompare(b.name))
.map(({ id: entityId, name }: Entity) => (
<MenuItem key={entityId} onClick={goToEntity(entityId)}>
{name}
</MenuItem>
)) ?? []}
</MenuList>
</Menu>
<CreateEntityModal isOpen={isOpen} onClose={onClose} parentId={getEntity.data?.id ?? ''} />
</>
);
};
export default EntityDropdown;

View File

@@ -0,0 +1,40 @@
import * as React from 'react';
import { HStack, Heading, Icon, Spacer } from '@chakra-ui/react';
import { TreeStructure } from 'phosphor-react';
import DeleteEntityPopover from './DeleteEntityPopover';
import EntityDropdown from './EntityDropdown';
import VenueDropdown from './VenueDropdown';
import RefreshButton from 'components/Buttons/RefreshButton';
import Card from 'components/Card';
import CardHeader from 'components/Card/CardHeader';
import { useGetEntity } from 'hooks/Network/Entity';
type Props = {
id: string;
};
const EntityPageHeader = ({ id }: Props) => {
const getEntity = useGetEntity({ id });
return (
<Card mb={4} p={2}>
<CardHeader display="flex">
<HStack spacing={2}>
<Icon my="auto" as={TreeStructure} color="inherit" boxSize="24px" mr={2} />
<Heading my="auto" size="md">
{getEntity.data?.name}
</Heading>
<EntityDropdown id={id} />
<VenueDropdown id={id} />
</HStack>
<Spacer />
<HStack spacing={2}>
<DeleteEntityPopover entity={getEntity.data} isDisabled={getEntity.isFetching || !getEntity.data} />
<RefreshButton onClick={getEntity.refetch} isFetching={getEntity.isFetching} isCompact />
</HStack>
</CardHeader>
</Card>
);
};
export default EntityPageHeader;

View File

@@ -0,0 +1,58 @@
import * as React from 'react';
import { Box, HStack, IconButton, Spacer, Tooltip } from '@chakra-ui/react';
import { MagnifyingGlass } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import CardBody from 'components/Card/CardBody';
import CardHeader from 'components/Card/CardHeader';
import ConfigurationsTable from 'components/Tables/ConfigurationTable';
import CreateConfigurationModal from 'components/Tables/ConfigurationTable/CreateConfigurationModal';
import DeleteConfigurationButton from 'components/Tables/ConfigurationTable/DeleteConfigurationButton';
import { useGetEntity } from 'hooks/Network/Entity';
import { Configuration } from 'models/Configuration';
type Props = {
id: string;
};
const EntityConfigurations = ({ id }: Props) => {
const { t } = useTranslation();
const getEntity = useGetEntity({ id });
const navigate = useNavigate();
const handleGoToPage = (configId: string) => () => navigate(`/configuration/${configId}`);
const actions = React.useCallback(
(cell: { row: { original: Configuration } }) => (
<HStack spacing={2}>
<DeleteConfigurationButton configuration={cell.row.original} />
<Tooltip hasArrow label={t('common.view_details')} placement="top">
<IconButton
aria-label={t('common.view_details')}
colorScheme="blue"
icon={<MagnifyingGlass size={20} />}
size="sm"
onClick={handleGoToPage(cell.row.original.id)}
/>
</Tooltip>
</HStack>
),
[t],
);
return (
<>
<CardHeader px={2} pt={2}>
<Spacer />
<CreateConfigurationModal entityId={`entity:${id}`} refresh={getEntity.refetch} />
</CardHeader>
<CardBody p={4}>
<Box w="100%" overflowX="auto">
<ConfigurationsTable select={getEntity.data?.configurations ?? []} actions={actions} />
</Box>
</CardBody>
</>
);
};
export default EntityConfigurations;

View File

@@ -0,0 +1,67 @@
import * as React from 'react';
import { Box, Spacer, useDisclosure } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import EntityResourceActions from './ResourceActions';
import CardBody from 'components/Card/CardBody';
import CardHeader from 'components/Card/CardHeader';
import CreateResourceModal from 'components/Modals/Resources/CreateModal';
import EditResourceModal from 'components/Modals/Resources/EditModal';
import ResourcesTable from 'components/Tables/ResourceTable';
import { useGetEntity } from 'hooks/Network/Entity';
import { Resource } from 'models/Resource';
type Props = {
id: string;
};
const EntityResources = ({ id }: Props) => {
const queryClient = useQueryClient();
const [resource, setResource] = React.useState<Resource>();
const { isOpen: isEditOpen, onOpen: openEdit, onClose: closeEdit } = useDisclosure();
const getEntity = useGetEntity({ id });
const refreshTable = () => {
queryClient.invalidateQueries(['get-resources-with-select']);
};
const openEditModal = (openedResource: Resource) => () => {
setResource(openedResource);
openEdit();
};
const openDetailsModalFromTable = (openedResource: Resource) => {
setResource(openedResource);
openEdit();
};
const actions = React.useCallback(
(cell: { row: { original: Resource } }) => (
<EntityResourceActions
resource={cell.row.original}
refreshTable={getEntity.refetch}
openEditModal={openEditModal(cell.row.original)}
/>
),
[],
);
return (
<>
<CardHeader px={2} pt={2}>
<Spacer />
<CreateResourceModal refresh={getEntity.refetch} entityId={getEntity.data?.id ?? ''} />
</CardHeader>
<CardBody p={4}>
<Box w="100%" overflowX="auto">
<ResourcesTable
select={getEntity.data?.variables ?? []}
actions={actions}
openDetailsModal={openDetailsModalFromTable}
/>
</Box>
</CardBody>
<EditResourceModal isOpen={isEditOpen} onClose={closeEdit} resource={resource} refresh={refreshTable} />
</>
);
};
export default EntityResources;

View File

@@ -18,23 +18,19 @@ import {
useToast,
} from '@chakra-ui/react';
import { MagnifyingGlass, Trash } from 'phosphor-react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import { useDeleteResource } from 'hooks/Network/Resources';
import { AxiosError } from 'models/Axios';
import { Resource } from 'models/Resource';
const propTypes = {
cell: PropTypes.shape({
original: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}).isRequired,
}).isRequired,
refreshTable: PropTypes.func.isRequired,
openEditModal: PropTypes.func.isRequired,
type Props = {
resource: Resource;
refreshTable: () => void;
openEditModal: (resource: Resource) => void;
};
const Actions = ({ cell: { original: resource }, refreshTable, openEditModal }) => {
const EntityResourceActions = ({ resource, refreshTable, openEditModal }: Props) => {
const { t } = useTranslation();
const toast = useToast();
const { isOpen, onOpen, onClose } = useDisclosure();
@@ -63,7 +59,7 @@ const Actions = ({ cell: { original: resource }, refreshTable, openEditModal })
title: t('common.error'),
description: t('crud.error_delete_obj', {
obj: resource.name,
e: e?.response?.data?.ErrorDescription,
e: (e as AxiosError)?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
@@ -80,7 +76,7 @@ const Actions = ({ cell: { original: resource }, refreshTable, openEditModal })
<Tooltip hasArrow label={t('crud.delete')} placement="top" isDisabled={isOpen}>
<Box>
<PopoverTrigger>
<IconButton colorScheme="red" icon={<Trash size={20} />} size="sm" />
<IconButton aria-label={t('crud.delete')} colorScheme="red" icon={<Trash size={20} />} size="sm" />
</PopoverTrigger>
</Box>
</Tooltip>
@@ -105,6 +101,7 @@ const Actions = ({ cell: { original: resource }, refreshTable, openEditModal })
</Popover>
<Tooltip hasArrow label={t('common.view_details')} placement="top">
<IconButton
aria-label={t('common.view_details')}
ml={2}
colorScheme="blue"
icon={<MagnifyingGlass size={20} />}
@@ -116,6 +113,4 @@ const Actions = ({ cell: { original: resource }, refreshTable, openEditModal })
);
};
Actions.propTypes = propTypes;
export default Actions;
export default EntityResourceActions;

View File

@@ -0,0 +1,56 @@
import * as React from 'react';
import { Box, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import EntityConfigurations from './EntityConfigurations';
import EntityResources from './EntityResources';
import Card from 'components/Card';
import CardHeader from 'components/Card/CardHeader';
type Props = {
id: string;
};
const ConfigurationCard = ({ id }: Props) => {
const { t } = useTranslation();
return (
<Card p={0}>
<Tabs variant="enclosed" isLazy>
<TabList>
<CardHeader>
<Tab>{t('configurations.title')}</Tab>
<Tab>{t('resources.title')}</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"
>
<EntityConfigurations id={id} />
</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"
>
<EntityResources id={id} />
</Box>
</TabPanel>
</TabPanels>
</Tabs>
</Card>
);
};
export default ConfigurationCard;

View File

@@ -0,0 +1,38 @@
import * as React from 'react';
import { Heading, Spacer } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import CreateEntityModal from '../CreateEntityModal';
import EntityChildrenActions from './EntityChildrenActions';
import Card from 'components/Card';
import CardHeader from 'components/Card/CardHeader';
import EntityTable from 'components/Tables/EntityTable';
import { useGetEntity } from 'hooks/Network/Entity';
import { Entity } from 'models/Entity';
type Props = {
id: string;
};
const EntityChildren = ({ id }: Props) => {
const { t } = useTranslation();
const getEntity = useGetEntity({ id });
const actions = React.useCallback(
(cell: { row: { original: Entity } }) => <EntityChildrenActions entity={cell.row.original} isVenue />,
[],
);
return (
<Card>
<CardHeader>
<Heading size="md" my="auto">
{t('entities.sub_other')}
</Heading>
<Spacer />
<CreateEntityModal parentId={getEntity.data?.id ?? ''} />
</CardHeader>
<EntityTable select={getEntity.data?.children ?? []} actions={actions} />
</Card>
);
};
export default EntityChildren;

View File

@@ -3,24 +3,21 @@ import { Flex, IconButton, Tooltip } from '@chakra-ui/react';
import { MagnifyingGlass } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import DeleteConfigurationButton from 'components/Tables/ConfigurationTable/DeleteConfigurationButton';
import { Configuration } from 'models/Configuration';
import { Entity } from 'models/Entity';
type Props = {
cell: {
original: Configuration;
};
entity: Entity;
isVenue: boolean;
};
const Actions = ({ cell: { original: configuration } }: Props) => {
const EntityChildrenActions = ({ entity, isVenue }: Props) => {
const { t } = useTranslation();
const navigate = useNavigate();
const handleGoToPage = () => navigate(`/configuration/${configuration.id}`);
const handleGoToPage = () => navigate(isVenue ? `/venue/${entity.id}` : `/entity/${entity.id}`);
return (
<Flex>
<DeleteConfigurationButton configuration={configuration} />
<Tooltip hasArrow label={t('common.view_details')} placement="top">
<IconButton
aria-label={t('common.view_details')}
@@ -35,4 +32,4 @@ const Actions = ({ cell: { original: configuration } }: Props) => {
);
};
export default Actions;
export default EntityChildrenActions;

View File

@@ -0,0 +1,161 @@
import * as React from 'react';
import {
Box,
Center,
FormControl,
FormLabel,
Grid,
GridItem,
HStack,
Heading,
SimpleGrid,
Spacer,
Spinner,
useBoolean,
useToast,
} from '@chakra-ui/react';
import { Form, Formik } from 'formik';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import SaveButton from 'components/Buttons/SaveButton';
import ToggleEditButton from 'components/Buttons/ToggleEditButton';
import Card from 'components/Card';
import CardBody from 'components/Card/CardBody';
import CardHeader from 'components/Card/CardHeader';
import DeviceRulesField from 'components/CustomFields/DeviceRulesField';
import IpDetectionModalField from 'components/CustomFields/IpDetectionModalField';
import FormattedDate from 'components/FormattedDate';
import StringField from 'components/FormFields/StringField';
import { EntitySchema } from 'constants/formSchemas';
import { useGetEntity, useUpdateEntity } from 'hooks/Network/Entity';
import useFormRef from 'hooks/useFormRef';
import { AxiosError } from 'models/Axios';
import { Entity } from 'models/Entity';
type Props = {
id: string;
};
const EntityDetails = ({ id }: Props) => {
const { t } = useTranslation();
const toast = useToast();
const [formKey, setFormKey] = React.useState(uuid());
const getEntity = useGetEntity({ id });
const [editing, setEditing] = useBoolean();
const { form, formRef } = useFormRef<Entity>();
const updateEntity = useUpdateEntity({ id });
React.useEffect(() => {
setFormKey(uuid());
}, [editing]);
return (
<Card>
<CardHeader>
<Heading my="auto" size="md">
{t('common.details')}
</Heading>
<Spacer />
<HStack spacing={2}>
<SaveButton
onClick={form.submitForm}
isLoading={form.isSubmitting}
isCompact
isDisabled={!editing || !form.isValid || !form.dirty}
hidden={!editing}
/>
<ToggleEditButton
toggleEdit={setEditing.toggle}
isEditing={editing}
isDisabled={getEntity.isFetching}
isDirty={form.dirty}
isCompact
/>
</HStack>
</CardHeader>
<CardBody>
<Box w="100%">
{getEntity.data ? (
<Formik
innerRef={formRef}
enableReinitialize
key={formKey}
initialValues={getEntity.data}
validationSchema={EntitySchema(t)}
onSubmit={({ name, description, sourceIP, deviceRules }, { setSubmitting, resetForm }) =>
updateEntity.mutateAsync(
{
name,
description,
deviceRules,
sourceIP,
},
{
onSuccess: () => {
setSubmitting(false);
toast({
id: 'entity-update-success',
title: t('common.success'),
description: t('crud.success_update_obj', {
obj: t('entities.one'),
}),
status: 'success',
duration: 5000,
isClosable: true,
position: 'top-right',
});
resetForm();
setEditing.off();
},
onError: (e) => {
toast({
id: uuid(),
title: t('common.error'),
description: t('crud.error_update_obj', {
obj: t('entities.one'),
e: (e as AxiosError)?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
setSubmitting(false);
},
},
)
}
>
<Form>
<Grid templateRows="repeat(1, 1fr)" templateColumns="repeat(3, 1fr)" gap={4}>
<GridItem colSpan={1}>
<StringField name="name" label={t('common.name')} isDisabled={!editing} isRequired />
</GridItem>
<GridItem colSpan={2}>
<StringField name="description" label={t('common.description')} isDisabled={!editing} />
</GridItem>
</Grid>
<SimpleGrid minChildWidth="200px" spacing={4} mt={2}>
<IpDetectionModalField name="sourceIP" isDisabled={!editing} />
<DeviceRulesField isDisabled={!editing} />
<FormControl>
<FormLabel>{t('common.modified')}</FormLabel>
<Box pt={1.5}>
<FormattedDate date={getEntity.data?.modified} />
</Box>
</FormControl>
</SimpleGrid>
</Form>
</Formik>
) : (
<Center my={6}>
<Spinner size="xl" />
</Center>
)}
</Box>
</CardBody>
</Card>
);
};
export default EntityDetails;

View File

@@ -19,26 +19,21 @@ import {
} from '@chakra-ui/react';
import { useMutation } from '@tanstack/react-query';
import { MagnifyingGlass, Trash } from 'phosphor-react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import { AxiosError } from 'models/Axios';
import { ContactObj } from 'models/Contact';
import { axiosProv } from 'utils/axiosInstances';
const deleteApi = async (id) => axiosProv.delete(`/contact/${id}`).then(() => true);
const deleteApi = async (id: string) => axiosProv.delete(`/contact/${id}`).then(() => true);
const propTypes = {
cell: PropTypes.shape({
original: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
entity: PropTypes.string.isRequired,
}).isRequired,
}).isRequired,
refreshEntity: PropTypes.func.isRequired,
openEditModal: PropTypes.func.isRequired,
type Props = {
contact: ContactObj;
refreshEntity: () => void;
openEditModal: (contact: ContactObj) => void;
};
const Actions = ({ cell: { original: contact }, refreshEntity, openEditModal }) => {
const ContactActions = ({ contact, refreshEntity, openEditModal }: Props) => {
const { t } = useTranslation();
const toast = useToast();
const { isOpen, onOpen, onClose } = useDisclosure();
@@ -64,7 +59,7 @@ const Actions = ({ cell: { original: contact }, refreshEntity, openEditModal })
title: t('common.error'),
description: t('crud.error_delete_obj', {
obj: contact.name,
e: e?.response?.data?.ErrorDescription,
e: (e as AxiosError)?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
@@ -83,7 +78,7 @@ const Actions = ({ cell: { original: contact }, refreshEntity, openEditModal })
<Tooltip hasArrow label={t('crud.delete')} placement="top" isDisabled={isOpen}>
<Box>
<PopoverTrigger>
<IconButton colorScheme="red" icon={<Trash size={20} />} size="sm" />
<IconButton aria-label={t('crud.delete')} colorScheme="red" icon={<Trash size={20} />} size="sm" />
</PopoverTrigger>
</Box>
</Tooltip>
@@ -107,12 +102,17 @@ const Actions = ({ cell: { original: contact }, refreshEntity, openEditModal })
</PopoverContent>
</Popover>
<Tooltip hasArrow label={t('common.view_details')} placement="top">
<IconButton ml={2} colorScheme="blue" icon={<MagnifyingGlass size={20} />} size="sm" onClick={handleOpenEdit} />
<IconButton
aria-label={t('common.view_details')}
ml={2}
colorScheme="blue"
icon={<MagnifyingGlass size={20} />}
size="sm"
onClick={handleOpenEdit}
/>
</Tooltip>
</Flex>
);
};
Actions.propTypes = propTypes;
export default Actions;
export default ContactActions;

View File

@@ -0,0 +1,60 @@
import * as React from 'react';
import { Box, Spacer, useDisclosure } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import ContactActions from './ContactActions';
import CardBody from 'components/Card/CardBody';
import CardHeader from 'components/Card/CardHeader';
import ContactTable from 'components/Tables/ContactTable';
import CreateContactModal from 'components/Tables/ContactTable/CreateContactModal';
import EditContactModal from 'components/Tables/ContactTable/EditContactModal';
import { useGetEntity } from 'hooks/Network/Entity';
import { ContactObj } from 'models/Contact';
type Props = {
id: string;
};
const EntityContacts = ({ id }: Props) => {
const queryClient = useQueryClient();
const getEntity = useGetEntity({ id });
const [contact, setContact] = React.useState<ContactObj>();
const { isOpen: isEditOpen, onOpen: openEdit, onClose: closeEdit } = useDisclosure();
const openEditModal = (newContact: ContactObj) => {
setContact(newContact);
openEdit();
};
const refetchContacts = () => {
queryClient.invalidateQueries(['get-contacts-select']);
};
const actions = React.useCallback(
(cell: { row: { original: ContactObj } }) => (
<ContactActions contact={cell.row.original} refreshEntity={getEntity.refetch} openEditModal={openEditModal} />
),
[],
);
return (
<>
<CardHeader px={2} pt={2}>
<Spacer />
<CreateContactModal refresh={getEntity.refetch} entityId={getEntity.data?.id ?? ''} />
</CardHeader>
<CardBody p={4}>
<Box w="100%" overflowX="auto">
<ContactTable
select={getEntity.data?.contacts ?? []}
actions={actions}
ignoredColumns={['email', 'entity']}
openDetailsModal={openEditModal}
/>
</Box>
</CardBody>
<EditContactModal isOpen={isEditOpen} onClose={closeEdit} contact={contact} refresh={refetchContacts} />
</>
);
};
export default EntityContacts;

View File

@@ -0,0 +1,60 @@
import * as React from 'react';
import { Box, Spacer, useDisclosure } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import LocationActions from './LocationActions';
import CardBody from 'components/Card/CardBody';
import CardHeader from 'components/Card/CardHeader';
import LocationTable from 'components/Tables/LocationTable';
import CreateLocationModal from 'components/Tables/LocationTable/CreateLocationModal';
import EditLocationModal from 'components/Tables/LocationTable/EditLocationModal';
import { useGetEntity } from 'hooks/Network/Entity';
import { Location } from 'models/Location';
type Props = {
id: string;
};
const EntityLocations = ({ id }: Props) => {
const queryClient = useQueryClient();
const getEntity = useGetEntity({ id });
const [location, setLocation] = React.useState<Location>();
const { isOpen: isEditOpen, onOpen: openEdit, onClose: closeEdit } = useDisclosure();
const openEditModal = (newLoc: Location) => {
setLocation(newLoc);
openEdit();
};
const refetchLocations = () => {
queryClient.invalidateQueries(['get-locations-select']);
};
const actions = React.useCallback(
(cell: { row: { original: Location } }) => (
<LocationActions location={cell.row.original} refreshEntity={getEntity.refetch} openEditModal={openEditModal} />
),
[],
);
return (
<>
<CardHeader px={2} pt={2}>
<Spacer />
<CreateLocationModal refresh={getEntity.refetch} entityId={getEntity.data?.id ?? ''} />
</CardHeader>
<CardBody p={4}>
<Box w="100%" overflowX="auto">
<LocationTable
select={getEntity.data?.locations ?? []}
actions={actions}
ignoredColumns={['entity']}
openDetailsModal={openEditModal}
/>
</Box>
</CardBody>
<EditLocationModal isOpen={isEditOpen} onClose={closeEdit} location={location} refresh={refetchLocations} />
</>
);
};
export default EntityLocations;

View File

@@ -19,26 +19,21 @@ import {
} from '@chakra-ui/react';
import { useMutation } from '@tanstack/react-query';
import { MagnifyingGlass, Trash } from 'phosphor-react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import { AxiosError } from 'models/Axios';
import { Location } from 'models/Location';
import { axiosProv } from 'utils/axiosInstances';
const deleteApi = async (id) => axiosProv.delete(`/location/${id}`).then(() => true);
const deleteApi = async (id: string) => axiosProv.delete(`/location/${id}`).then(() => true);
const propTypes = {
cell: PropTypes.shape({
original: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
entity: PropTypes.string.isRequired,
}).isRequired,
}).isRequired,
refreshEntity: PropTypes.func.isRequired,
openEditModal: PropTypes.func.isRequired,
type Props = {
location: Location;
refreshEntity: () => void;
openEditModal: (location: Location) => void;
};
const Actions = ({ cell: { original: location }, refreshEntity, openEditModal }) => {
const LocationActions = ({ location, refreshEntity, openEditModal }: Props) => {
const { t } = useTranslation();
const toast = useToast();
const { isOpen, onOpen, onClose } = useDisclosure();
@@ -64,7 +59,7 @@ const Actions = ({ cell: { original: location }, refreshEntity, openEditModal })
title: t('common.error'),
description: t('crud.error_delete_obj', {
obj: location.name,
e: e?.response?.data?.ErrorDescription,
e: (e as AxiosError)?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
@@ -83,7 +78,7 @@ const Actions = ({ cell: { original: location }, refreshEntity, openEditModal })
<Tooltip hasArrow label={t('crud.delete')} placement="top" isDisabled={isOpen}>
<Box>
<PopoverTrigger>
<IconButton colorScheme="red" icon={<Trash size={20} />} size="sm" />
<IconButton aria-label={t('crud.delete')} colorScheme="red" icon={<Trash size={20} />} size="sm" />
</PopoverTrigger>
</Box>
</Tooltip>
@@ -107,12 +102,17 @@ const Actions = ({ cell: { original: location }, refreshEntity, openEditModal })
</PopoverContent>
</Popover>
<Tooltip hasArrow label={t('common.view_details')} placement="top">
<IconButton ml={2} colorScheme="blue" icon={<MagnifyingGlass size={20} />} size="sm" onClick={handleOpenEdit} />
<IconButton
aria-label={t('common.view_details')}
ml={2}
colorScheme="blue"
icon={<MagnifyingGlass size={20} />}
size="sm"
onClick={handleOpenEdit}
/>
</Tooltip>
</Flex>
);
};
Actions.propTypes = propTypes;
export default Actions;
export default LocationActions;

View File

@@ -0,0 +1,56 @@
import * as React from 'react';
import { Box, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import EntityContacts from './EntityContacts';
import EntityLocations from './EntityLocations';
import Card from 'components/Card';
import CardHeader from 'components/Card/CardHeader';
type Props = {
id: string;
};
const EntityLocationContactsCard = ({ id }: Props) => {
const { t } = useTranslation();
return (
<Card p={0}>
<Tabs variant="enclosed" isLazy>
<TabList>
<CardHeader>
<Tab>{t('locations.title')}</Tab>
<Tab>{t('contacts.other')}</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"
>
<EntityLocations id={id} />
</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"
>
<EntityContacts id={id} />
</Box>
</TabPanel>
</TabPanels>
</Tabs>
</Card>
);
};
export default EntityLocationContactsCard;

View File

@@ -0,0 +1,172 @@
import * as React from 'react';
import {
Box,
Button,
Center,
Heading,
IconButton,
Popover,
PopoverArrow,
PopoverBody,
PopoverCloseButton,
PopoverContent,
PopoverHeader,
PopoverTrigger,
Spacer,
Text,
Textarea,
useBreakpoint,
useToast,
} from '@chakra-ui/react';
import { Plus } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import Card from 'components/Card';
import CardBody from 'components/Card/CardBody';
import CardHeader from 'components/Card/CardHeader';
import DataTable from 'components/DataTable';
import FormattedDate from 'components/FormattedDate';
import { useGetEntity, useUpdateEntity } from 'hooks/Network/Entity';
import { Note } from 'models/Note';
import { Column } from 'models/Table';
const EntityNotes = ({ id }: { id: string }) => {
const { t } = useTranslation();
const getEntity = useGetEntity({ id });
const [newNote, setNewNote] = React.useState('');
const updateEntity = useUpdateEntity({ id });
const toast = useToast();
const breakpoint = useBreakpoint();
const onNoteChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setNewNote(e.target.value);
}, []);
const onNoteSubmit = React.useCallback(
(onClose: () => void) => () => {
updateEntity.mutateAsync(
{
id,
notes: [{ note: newNote, created: 0 }],
},
{
onSuccess: () => {
toast({
id: 'entity-update-success',
title: t('common.success'),
description: t('entities.update_success'),
status: 'success',
duration: 5000,
isClosable: true,
position: 'top-right',
});
onClose();
setNewNote('');
},
},
);
},
[newNote],
);
const notes = React.useMemo(
() => getEntity.data?.notes?.sort(({ created: a }, { created: b }) => b - a) ?? [],
[getEntity.data, getEntity.data?.notes],
);
const dateCell = React.useCallback((created: number) => <FormattedDate date={created} />, []);
const noteCell = React.useCallback(
(note: string) => (
<Text w="100%" overflowWrap="break-word" whiteSpace="pre-wrap">
{note}
</Text>
),
[],
);
const columns: Column<Note>[] = React.useMemo(
() => [
{
id: 'created',
Header: t('common.date'),
Footer: '',
accessor: 'created',
Cell: ({ cell }: { cell: { row: { original: { created: number } } } }) => dateCell(cell.row.original.created),
customWidth: '150px',
},
{
id: 'note',
Header: t('common.note'),
Cell: ({ cell }: { cell: { row: { original: { note: string } } } }) => noteCell(cell.row.original.note),
Footer: '',
accessor: 'note',
},
{
id: 'by',
Header: t('common.by'),
Footer: '',
accessor: 'createdBy',
customWidth: '200px',
},
],
[dateCell],
);
return (
<Card p={4}>
<CardHeader mb={2}>
<Heading size="md" my="auto">
{t('common.notes')}
</Heading>
<Spacer />
<Popover trigger="click" placement="auto">
{({ onClose }) => (
<>
<PopoverTrigger>
<IconButton aria-label={`${t('crud.add')} ${t('common.note')}`} icon={<Plus size={20} />} />
</PopoverTrigger>
<PopoverContent w={breakpoint === 'base' ? 'calc(80vw)' : '500px'}>
<PopoverArrow />
<PopoverCloseButton alignContent="center" mt={1} />
<PopoverHeader display="flex">{t('profile.add_new_note')}</PopoverHeader>
<PopoverBody>
<Box>
<Textarea h="100px" placeholder="Your new note" value={newNote} onChange={onNoteChange} />
</Box>
<Center mt={2}>
<Button
colorScheme="blue"
isDisabled={newNote.length === 0}
onClick={onNoteSubmit(onClose)}
isLoading={updateEntity.isLoading}
>
{t('crud.add')}
</Button>
</Center>
</PopoverBody>
</PopoverContent>
</>
)}
</Popover>
</CardHeader>
<CardBody display="block">
<Box overflow="auto" h="300px">
<DataTable
columns={columns as Column<object>[]}
data={notes}
obj={t('common.notes')}
sortBy={[
{
id: 'created',
desc: true,
},
]}
minHeight="200px"
hideControls
showAllRows
/>
</Box>
</CardBody>
</Card>
);
};
export default EntityNotes;

View File

@@ -32,20 +32,17 @@ interface Props {
onOpenUpgradeModal: (serialNumber: string) => void;
}
const Actions = (
{
const EntityInventoryActions = ({
cell: { original: tag },
refreshEntity,
openEditModal,
onOpenScan,
onOpenFactoryReset,
onOpenUpgradeModal
}: Props
) => {
onOpenUpgradeModal,
}: Props) => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const { data: gwUi } = useGetGatewayUi();
const { mutateAsync: deleteConfig, isLoading: isDeleting } = useDeleteTag({
name: tag.name,
refreshTable: refreshEntity,
@@ -94,7 +91,7 @@ const Actions = (
/>
<Tooltip hasArrow label={t('common.view_details')} placement="top">
<IconButton
aria-label="View Details"
aria-label="Open Edit"
ml={2}
colorScheme="blue"
icon={<MagnifyingGlass size={20} />}
@@ -104,7 +101,7 @@ const Actions = (
</Tooltip>
<Tooltip hasArrow label={t('common.view_in_gateway')} placement="top">
<IconButton
aria-label="View in Gateway"
aria-label="Go to gateway"
ml={2}
colorScheme="blue"
icon={<ArrowSquareOut size={20} />}
@@ -116,4 +113,4 @@ const Actions = (
);
};
export default Actions;
export default EntityInventoryActions;

View File

@@ -1,8 +1,10 @@
import React, { useCallback, useState } from 'react';
import { Box, useDisclosure } from '@chakra-ui/react';
import * as React from 'react';
import { Box, Heading, Spacer, useDisclosure } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { v4 as uuid } from 'uuid';
import Actions from './Actions';
import { useTranslation } from 'react-i18next';
import EntityInventoryActions from './Actions';
import Card from 'components/Card';
import CardHeader from 'components/Card/CardHeader';
import FactoryResetModal from 'components/Modals/SubscriberDevice/FactoryResetModal';
import FirmwareUpgradeModal from 'components/Modals/SubscriberDevice/FirmwareUpgradeModal';
import WifiScanModal from 'components/Modals/SubscriberDevice/WifiScanModal';
@@ -11,19 +13,20 @@ import ConfigurationPushModal from 'components/Tables/InventoryTable/Configurati
import CreateTagModal from 'components/Tables/InventoryTable/CreateTagModal';
import EditTagModal from 'components/Tables/InventoryTable/EditTagModal';
import ImportDeviceCsvModal from 'components/Tables/InventoryTable/ImportDeviceCsvModal';
import { useGetEntity } from 'hooks/Network/Entity';
import { usePushConfig } from 'hooks/Network/Inventory';
import { Device } from 'models/Device';
import { Entity } from 'models/Entity';
interface Props {
entity: Entity;
}
type Props = {
id: string;
};
const EntityDeviceTableWrapper = ({ entity }: Props) => {
const EntityInventoryCard = ({ id }: Props) => {
const { t } = useTranslation();
const getEntity = useGetEntity({ id });
const queryClient = useQueryClient();
const [tag, setTag] = useState<Device | undefined>(undefined);
const [serialNumber, setSerialNumber] = useState<string>('');
const [refreshId, setRefreshId] = useState(0);
const [tag, setTag] = React.useState<Device | undefined>(undefined);
const [serialNumber, setSerialNumber] = React.useState<string>('');
const { isOpen: isEditOpen, onOpen: openEdit, onClose: closeEdit } = useDisclosure();
const { isOpen: isPushOpen, onOpen: openPush, onClose: closePush } = useDisclosure();
const scanModalProps = useDisclosure();
@@ -48,38 +51,46 @@ const EntityDeviceTableWrapper = ({ entity }: Props) => {
openEdit();
};
const refreshEntity = () => queryClient.invalidateQueries(['get-entity', entity.id]);
const refetchTags = () => setRefreshId(refreshId + 1);
const actions = useCallback(
(cell) => (
<Actions
key={uuid()}
const actions = React.useCallback(
(cell: { row: { original: Device } }) => (
<EntityInventoryActions
cell={cell.row}
refreshEntity={refreshEntity}
refreshEntity={getEntity.refetch}
openEditModal={openEditModal}
onOpenScan={onOpenScan}
onOpenFactoryReset={onOpenFactoryReset}
onOpenUpgradeModal={onOpenUpgradeModal}
/>
),
[refreshId],
[],
);
const refetchTags = React.useCallback(() => {
queryClient.invalidateQueries(['get-inventory-with-select']);
}, []);
return (
<>
<Box textAlign="right" mb={2}>
<ImportDeviceCsvModal refresh={refreshEntity} parent={{ entity: entity.id }} deviceClass="entity" />
<CreateTagModal refresh={refreshEntity} entityId={`entity:${entity.id}`} deviceClass="entity" />
</Box>
<Card>
<CardHeader>
<Heading size="md" my="auto">
{t('inventory.title')}
</Heading>
<Spacer />
<ImportDeviceCsvModal
refresh={getEntity.refetch}
parent={{ entity: getEntity.data?.id }}
deviceClass="entity"
/>
<CreateTagModal refresh={getEntity.refetch} entityId={`entity:${getEntity.data?.id}`} deviceClass="entity" />
</CardHeader>
<Box overflowX="auto">
<InventoryTable
tagSelect={entity.devices}
ignoredColumns={['entity', 'venue']}
refreshId={refreshId}
tagSelect={getEntity.data?.devices ?? []}
ignoredColumns={['entity', 'venue', 'description']}
actions={actions}
openDetailsModal={openEditModal}
/>
</Box>
<EditTagModal
isOpen={isEditOpen}
onClose={closeEdit}
@@ -94,8 +105,8 @@ const EntityDeviceTableWrapper = ({ entity }: Props) => {
<WifiScanModal modalProps={scanModalProps} serialNumber={serialNumber} />
<FirmwareUpgradeModal modalProps={upgradeModalProps} serialNumber={serialNumber} />
<FactoryResetModal modalProps={resetModalProps} serialNumber={serialNumber} />
</>
</Card>
);
};
export default EntityDeviceTableWrapper;
export default EntityInventoryCard;

View File

@@ -0,0 +1,31 @@
import * as React from 'react';
import Masonry from 'react-masonry-css';
import ConfigurationCard from './ConfigurationCard';
import EntityDetails from './EntityDetails';
import EntityLocationContactsCard from './EntityLocationContactsCard';
import EntityNotes from './EntityNotes';
import EntityInventoryCard from './InventoryCard';
type Props = {
id: string;
};
const EntityPageLayout = ({ id }: Props) => (
<Masonry
breakpointCols={{
default: 3,
2200: 2,
1100: 1,
}}
className="my-masonry-grid"
columnClassName="my-masonry-grid_column"
>
<EntityDetails id={id} />
<EntityInventoryCard id={id} />
<ConfigurationCard id={id} />
<EntityLocationContactsCard id={id} />
<EntityNotes id={id} />
</Masonry>
);
export default EntityPageLayout;

View File

@@ -0,0 +1,81 @@
import * as React from 'react';
import {
Button,
IconButton,
Menu,
MenuButton,
MenuDivider,
MenuItem,
MenuList,
Tooltip,
useBreakpoint,
useDisclosure,
} from '@chakra-ui/react';
import { Buildings } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import CreateVenueModal from 'components/Tables/VenueTable/CreateVenueModal';
import { useGetEntity } from 'hooks/Network/Entity';
import { useGetSelectVenues } from 'hooks/Network/Venues';
import { Entity } from 'models/Entity';
type Props = {
id: string;
};
const VenueDropdown = ({ id }: Props) => {
const { t } = useTranslation();
const breakpoint = useBreakpoint();
const navigate = useNavigate();
const getEntity = useGetEntity({ id });
const getChildren = useGetSelectVenues({ select: getEntity.data?.venues ?? [] });
const { isOpen, onOpen, onClose } = useDisclosure();
const goToVenue = (venueId: string) => () => navigate(`/venue/${venueId}`);
const amount = getEntity.data?.venues.length ?? 0;
const isCompact = breakpoint === 'base' || breakpoint === 'sm';
return (
<>
<Menu>
<Tooltip label={`${t('venues.sub_other')} (${amount})`}>
{isCompact ? (
<MenuButton
as={IconButton}
icon={<Buildings size={20} />}
aria-label={`${t('venues.sub_other')} (${amount})`}
colorScheme="purple"
isDisabled={!getEntity.data}
ml={2}
/>
) : (
<MenuButton
as={Button}
aria-label={`${t('venues.sub_other')} (${amount})`}
colorScheme="purple"
isDisabled={!getEntity.data}
>{`${t('venues.sub_other')} (${amount})`}</MenuButton>
)}
</Tooltip>
<MenuList>
<MenuItem onClick={onOpen} isDisabled={id === '0000-0000-0000'}>
{id === '0000-0000-0000' ? t('entities.venues_under_root') : t('common.create')}
</MenuItem>
<MenuDivider hidden={amount === 0} />
{getChildren.data
?.sort((a: Entity, b: Entity) => a.name.localeCompare(b.name))
.map(({ id: venueId, name }: Entity) => (
<MenuItem key={venueId} onClick={goToVenue(venueId)}>
{name}
</MenuItem>
)) ?? []}
</MenuList>
</Menu>
<CreateVenueModal isOpen={isOpen} onClose={onClose} entityId={getEntity.data?.id ?? ''} />
</>
);
};
export default VenueDropdown;

View File

@@ -1,12 +1,9 @@
import React from 'react';
import { Flex } from '@chakra-ui/react';
import { useParams } from 'react-router-dom';
import EntityCard from './EntityCard';
import EntityChildrenCard from './EntityChildrenCard';
import { useAuth } from 'contexts/AuthProvider';
import EntityPageHeader from './EntityHeader';
import EntityPageLayout from './Layout';
const EntityPage = ({ idToUse }: { idToUse?: string }) => {
const { isUserLoaded } = useAuth();
const { id } = useParams();
const entityIdToUse = React.useMemo(() => {
@@ -20,16 +17,12 @@ const EntityPage = ({ idToUse }: { idToUse?: string }) => {
return undefined;
}, [idToUse, id]);
return (
<Flex flexDirection="column" pt="75px">
{isUserLoaded && entityIdToUse && (
return entityIdToUse ? (
<>
<EntityCard id={entityIdToUse} />
<EntityChildrenCard id={entityIdToUse} />
<EntityPageHeader id={entityIdToUse} />
<EntityPageLayout id={entityIdToUse} />
</>
)}
</Flex>
);
) : null;
};
export default EntityPage;

View File

@@ -1,16 +1,6 @@
import React from 'react';
import { Flex } from '@chakra-ui/react';
import Table from './Table';
import { useAuth } from 'contexts/AuthProvider';
const InventoryPage = () => {
const { isUserLoaded } = useAuth();
return (
<Flex flexDirection="column" pt="75px">
{isUserLoaded && <Table />}
</Flex>
);
};
const InventoryPage = () => <Table />;
export default InventoryPage;

View File

@@ -1,16 +1,6 @@
import React from 'react';
import { Flex } from '@chakra-ui/react';
import MapCard from './MapCard';
import { useAuth } from 'contexts/AuthProvider';
const MapPage = () => {
const { isUserLoaded } = useAuth();
return (
<Flex flexDirection="column" pt="75px">
{isUserLoaded && <MapCard />}
</Flex>
);
};
const MapPage = () => <MapCard />;
export default MapPage;

View File

@@ -1,15 +1,11 @@
import React from 'react';
import { Flex, Heading } from '@chakra-ui/react';
import { Heading } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
const NotFoundPage = () => {
const { t } = useTranslation();
return (
<Flex flexDirection="column" pt="75px">
<Heading size="lg">{t('common.not_found')}</Heading>
</Flex>
);
return <Heading size="lg">{t('common.not_found')}</Heading>;
};
export default NotFoundPage;

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Box, Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
import { Box, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import FmsLogsCard from './FmsLogs';
import GeneralLogsCard from './GeneralLogs';
@@ -7,7 +7,6 @@ import LogsCard from './Notifications';
import SecLogsCard from './SecLogs';
import Card from 'components/Card';
import CardHeader from 'components/Card/CardHeader';
import { useAuth } from 'contexts/AuthProvider';
const INDEX_PARAM = 'notifications-tab-index';
@@ -22,7 +21,6 @@ const getDefaultTabIndex = () => {
const NotificationsPage = () => {
const { t } = useTranslation();
const { isUserLoaded } = useAuth();
const [tabIndex, setTabIndex] = React.useState(getDefaultTabIndex());
const handleTabChange = (index: number) => {
@@ -31,8 +29,6 @@ const NotificationsPage = () => {
};
return (
<Flex flexDirection="column" pt="75px">
{isUserLoaded && (
<Card p={0}>
<Tabs index={tabIndex} onChange={handleTabChange} variant="enclosed" isLazy>
<TabList>
@@ -97,8 +93,6 @@ const NotificationsPage = () => {
</TabPanels>
</Tabs>
</Card>
)}
</Flex>
);
};

View File

@@ -1,12 +1,9 @@
import React from 'react';
import { Flex } from '@chakra-ui/react';
import { useParams } from 'react-router-dom';
import OperatorChildrenCard from './ChildrenCard';
import DetailsCard from './DetailsCard';
import { useAuth } from 'contexts/AuthProvider';
const OperatorPage = ({ idToUse }: { idToUse?: string }) => {
const { isUserLoaded } = useAuth();
const { id } = useParams();
const entityIdToUse = React.useMemo(() => {
@@ -20,16 +17,12 @@ const OperatorPage = ({ idToUse }: { idToUse?: string }) => {
return undefined;
}, [idToUse, id]);
return (
<Flex flexDirection="column" pt="75px">
{isUserLoaded && entityIdToUse !== undefined && (
return entityIdToUse !== undefined ? (
<>
<DetailsCard id={entityIdToUse} />
<OperatorChildrenCard id={entityIdToUse} />
</>
)}
</Flex>
);
) : null;
};
export default OperatorPage;

View File

@@ -1,16 +1,6 @@
import React from 'react';
import { Flex } from '@chakra-ui/react';
import OperatorsTable from './Table';
import { useAuth } from 'contexts/AuthProvider';
const OperatorsPage = () => {
const { isUserLoaded } = useAuth();
return (
<Flex flexDirection="column" pt="75px">
{isUserLoaded && <OperatorsTable />}
</Flex>
);
};
const OperatorsPage = () => <OperatorsTable />;
export default OperatorsPage;

View File

@@ -1,22 +1,6 @@
import * as React from 'react';
import { Center, Flex, Spinner } from '@chakra-ui/react';
import ProfileLayout from './Layout';
import { useAuth } from 'contexts/AuthProvider';
const ProfilePage = () => {
const context = useAuth();
return (
<Flex flexDirection="column" pt="75px">
{!context.isUserLoaded ? (
<Center mt={40}>
<Spinner size="xl" />
</Center>
) : (
<ProfileLayout />
)}
</Flex>
);
};
const ProfilePage = () => <ProfileLayout />;
export default ProfilePage;

View File

@@ -1,24 +1,17 @@
import React from 'react';
import { Flex } from '@chakra-ui/react';
import { useParams } from 'react-router-dom';
import SubscriberCard from './SubscriberCard';
import SubscriberChildrenCard from './SubscriberChildrenCard';
import { useAuth } from 'contexts/AuthProvider';
const SubscriberPage = () => {
const { isUserLoaded } = useAuth();
const { id } = useParams();
return (
<Flex flexDirection="column" pt="75px">
{isUserLoaded && id !== '' && (
return id !== '' ? (
<>
<SubscriberCard id={id ?? ''} />
<SubscriberChildrenCard id={id ?? ''} />
</>
)}
</Flex>
);
) : null;
};
export default SubscriberPage;

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Box, Flex, SimpleGrid, Spacer, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
import { Box, SimpleGrid, Spacer, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import RefreshButton from '../../components/Buttons/RefreshButton';
@@ -25,7 +25,7 @@ type Props = {
const SystemPage = ({ isOnlySec }: Props) => {
const { t } = useTranslation();
const { token, user, isUserLoaded } = useAuth();
const { token, user } = useAuth();
const { data: endpoints, refetch, isFetching } = useGetEndpoints({ onSuccess: () => {} });
const [tabIndex, setTabIndex] = React.useState(getDefaultTabIndex());
const handleTabChange = (index: number) => {
@@ -56,10 +56,7 @@ const SystemPage = ({ isOnlySec }: Props) => {
.map((endpoint) => <SystemTile key={uuid()} endpoint={endpoint} token={token} />);
}, [endpoints, token]);
if (!isUserLoaded) return null;
return (
<Flex flexDirection="column" pt="75px">
<Card p={0}>
<Tabs index={tabIndex} onChange={handleTabChange} variant="enclosed" isLazy>
<TabList>
@@ -104,7 +101,6 @@ const SystemPage = ({ isOnlySec }: Props) => {
</TabPanels>
</Tabs>
</Card>
</Flex>
);
};

View File

@@ -1,16 +1,6 @@
import React from 'react';
import { Flex } from '@chakra-ui/react';
import UserTable from './Table';
import { useAuth } from 'contexts/AuthProvider';
const UsersPage = () => {
const { isUserLoaded } = useAuth();
return (
<Flex flexDirection="column" pt="75px">
{isUserLoaded && <UserTable />}
</Flex>
);
};
const UsersPage = () => <UserTable />;
export default UsersPage;

View File

@@ -0,0 +1,194 @@
import * as React from 'react';
import {
Box,
Button,
Flex,
Heading,
HStack,
IconButton,
Popover,
PopoverArrow,
PopoverBody,
PopoverContent,
PopoverHeader,
PopoverTrigger,
Spacer,
Tooltip,
useBreakpoint,
} from '@chakra-ui/react';
import { Clock, Prohibit } from 'phosphor-react';
import ReactDatePicker from 'react-datepicker';
import { useTranslation } from 'react-i18next';
import CloseButton from 'components/Buttons/CloseButton';
import SaveButton from 'components/Buttons/SaveButton';
const CustomInputButton = React.forwardRef(
({ value, onClick }: { value: string; onClick: () => void }, ref: React.LegacyRef<HTMLButtonElement>) => (
<Button colorScheme="gray" size="sm" onClick={onClick} ref={ref} mt={1}>
{value}
</Button>
),
);
const getStart = () => {
const date = new Date();
date.setHours(date.getHours() - 1);
return date;
};
type Props = {
defaults?: { start: Date; end: Date };
setTime: (start: Date, end: Date) => void;
onClear: () => void;
};
const AnalyticsDatePickers = ({ defaults, setTime, onClear }: Props) => {
const { t } = useTranslation();
const [start, setStart] = React.useState<Date>(defaults?.start ?? getStart());
const [end, setEnd] = React.useState<Date>(defaults?.end ?? new Date());
const breakpoint = useBreakpoint();
const onStartChange = (newDate: Date) => {
setStart(newDate);
};
const onEndChange = (newDate: Date) => {
setEnd(newDate);
};
const clear = (onClose: () => void) => () => {
onClear();
onClose();
};
const onSave = (onClose: () => void) => () => {
onClose();
setTime(start, end);
};
const width = (isOpen: boolean) => {
if (isOpen) {
return breakpoint === 'base' ? '360px' : '460px';
}
return undefined;
};
React.useEffect(() => {
setStart(defaults?.start ?? getStart());
setEnd(defaults?.end ?? new Date());
}, [defaults]);
return (
<Popover>
{({ isOpen, onClose }) => (
<>
<PopoverTrigger>
<Box>
<Tooltip label={t('controller.crud.choose_time')}>
<IconButton
aria-label={t('controller.crud.choose_time')}
icon={<Clock size={20} />}
mx={2}
colorScheme="blue"
/>
</Tooltip>
</Box>
</PopoverTrigger>
<PopoverContent w={width(isOpen)}>
<PopoverArrow />
<PopoverHeader display="flex">
<Heading size="sm" my="auto">
{t('controller.crud.choose_time')}
</Heading>
<Spacer />
<HStack>
<Tooltip label={t('controller.crud.clear_time')}>
<IconButton
colorScheme="red"
aria-label={t('controller.crud.clear_time')}
onClick={clear(onClose)}
icon={<Prohibit size={20} />}
/>
</Tooltip>
<SaveButton onClick={onSave(onClose)} isCompact />
<CloseButton onClick={onClose} />
</HStack>
</PopoverHeader>
<PopoverBody>
{breakpoint === 'base' ? (
<Box>
<Flex>
<Heading size="sm" my="auto" mr={2}>
{t('system.start')}:{' '}
</Heading>
<Box w="170px">
<ReactDatePicker
selected={start}
onChange={onStartChange}
timeInputLabel={`${t('common.time')}: `}
dateFormat="dd/MM/yyyy hh:mm aa"
timeFormat="p"
showTimeSelect
// @ts-ignore
customInput={<CustomInputButton />}
/>
</Box>
</Flex>
<Flex>
<Heading size="sm" my="auto" mr={4}>
{t('common.end')}:{' '}
</Heading>
<Box w="170px">
<ReactDatePicker
selected={end}
onChange={onEndChange}
timeInputLabel={`${t('common.time')}: `}
dateFormat="dd/MM/yyyy hh:mm aa"
timeFormat="p"
showTimeSelect
// @ts-ignore
customInput={<CustomInputButton />}
/>
</Box>
</Flex>
</Box>
) : (
<Flex>
<Heading size="sm" my="auto" mr={2}>
{t('system.start')}:{' '}
</Heading>
<Box w="170px">
<ReactDatePicker
selected={start}
onChange={onStartChange}
timeInputLabel={`${t('common.time')}: `}
dateFormat="dd/MM/yyyy hh:mm aa"
timeFormat="p"
showTimeSelect
// @ts-ignore
customInput={<CustomInputButton />}
/>
</Box>
<Heading size="sm" my="auto" mr={2}>
{t('common.end')}:{' '}
</Heading>
<Box w="170px">
<ReactDatePicker
selected={end}
onChange={onEndChange}
timeInputLabel={`${t('common.time')}: `}
dateFormat="dd/MM/yyyy hh:mm aa"
timeFormat="p"
showTimeSelect
// @ts-ignore
customInput={<CustomInputButton />}
/>
</Box>
</Flex>
)}
</PopoverBody>
</PopoverContent>
</>
)}
</Popover>
);
};
export default AnalyticsDatePickers;

View File

@@ -18,24 +18,33 @@ import {
Thead,
Tr,
} from '@chakra-ui/react';
import { animated } from '@react-spring/web';
import { ComputedDatum } from '@nivo/circle-packing';
import { Interpolation, SpringValue, animated } from '@react-spring/web';
import { WifiHigh } from 'phosphor-react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { AssociationCircle } from '../utils';
import { useCircleGraph } from 'contexts/CircleGraphProvider';
import { bytesString, formatNumberToScientificBasedOnMax } from 'utils/stringHelper';
const propTypes = {
node: PropTypes.instanceOf(Object).isRequired,
handleClicks: PropTypes.shape({
onClick: PropTypes.func.isRequired,
}).isRequired,
style: PropTypes.instanceOf(Object).isRequired,
const AssociationCirclePack = ({
node,
style,
handleClicks,
}: {
node: ComputedDatum<AssociationCircle>;
style: {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
};
const AssociationCircle = ({ node, style, handleClicks }) => {
handleClicks: {
onClick: (e: React.MouseEvent<SVGCircleElement>) => void;
};
}) => {
const { t } = useTranslation();
const { popoverRef } = useCircleGraph();
const context = useCircleGraph();
return (
<Popover isLazy trigger="hover" placement="auto">
@@ -53,7 +62,7 @@ const AssociationCircle = ({ node, style, handleClicks }) => {
onClick={handleClicks.onClick}
/>
</PopoverTrigger>
<Portal containerRef={popoverRef}>
<Portal containerRef={context?.popoverRef}>
<PopoverContent>
<PopoverArrow />
<PopoverCloseButton alignContent="center" mt={1} />
@@ -117,5 +126,4 @@ const AssociationCircle = ({ node, style, handleClicks }) => {
);
};
AssociationCircle.propTypes = propTypes;
export default React.memo(AssociationCircle);
export default AssociationCirclePack;

View File

@@ -19,27 +19,36 @@ import {
Flex,
Tag as TagDisplay,
} from '@chakra-ui/react';
import { animated } from '@react-spring/web';
import { ComputedDatum } from '@nivo/circle-packing';
import { Interpolation, SpringValue, animated } from '@react-spring/web';
import { ArrowSquareOut, Tag } from 'phosphor-react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { DeviceCircleInfo } from '../utils';
import FormattedDate from 'components/FormattedDate';
import { useCircleGraph } from 'contexts/CircleGraphProvider';
import { useGetGatewayUi } from 'hooks/Network/Endpoints';
import { bytesString } from 'utils/stringHelper';
const propTypes = {
node: PropTypes.instanceOf(Object).isRequired,
handleClicks: PropTypes.shape({
onClick: PropTypes.func.isRequired,
}).isRequired,
style: PropTypes.instanceOf(Object).isRequired,
const DeviceCirclePack = ({
node,
style,
handleClicks,
}: {
node: ComputedDatum<DeviceCircleInfo>;
style: {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
};
const DeviceCircle = ({ node, style, handleClicks }) => {
handleClicks: {
onClick: (e: React.MouseEvent<SVGCircleElement>) => void;
};
}) => {
const { t } = useTranslation();
const { data: gwUi } = useGetGatewayUi();
const { popoverRef } = useCircleGraph();
const context = useCircleGraph();
const handleOpenInGateway = useMemo(
() => () => window.open(`${gwUi}/#/devices/${node.data.details.deviceInfo.serialNumber}`, '_blank'),
@@ -62,7 +71,7 @@ const DeviceCircle = ({ node, style, handleClicks }) => {
onClick={handleClicks.onClick}
/>
</PopoverTrigger>
<Portal containerRef={popoverRef}>
<Portal containerRef={context?.popoverRef}>
<PopoverContent w="580px">
<PopoverArrow />
<PopoverCloseButton alignContent="center" mt={1} />
@@ -71,6 +80,7 @@ const DeviceCircle = ({ node, style, handleClicks }) => {
<Text ml={2}>{node?.data?.name.split('/')[0]}</Text>
<Tooltip hasArrow label={t('common.view_in_gateway')} placement="top">
<IconButton
aria-label={t('common.view_in_gateway')}
ml={2}
colorScheme="blue"
icon={<ArrowSquareOut size={20} />}
@@ -91,13 +101,13 @@ const DeviceCircle = ({ node, style, handleClicks }) => {
: node.data.details.deviceInfo.deviceType}
</Td>
<Td w="150px">TX {t('analytics.delta')}</Td>
<Td>{bytesString(node.data.details.tx_bytes_delta)}</Td>
<Td>{bytesString(node.data.details.apData.tx_bytes_delta)}</Td>
</Tr>
<Tr>
<Td w="130px">{t('analytics.firmware')}</Td>
<Td>{node.data.details.deviceInfo.lastFirmware?.split('/')[1] ?? t('common.unknown')}</Td>
<Td w="150px">RX {t('analytics.delta')}</Td>
<Td>{bytesString(node.data.details.rx_bytes_delta)}</Td>
<Td>{bytesString(node.data.details.apData.rx_bytes_delta)}</Td>
</Tr>
<Tr>
<Td w="130px">SSIDs</Td>
@@ -139,5 +149,4 @@ const DeviceCircle = ({ node, style, handleClicks }) => {
);
};
DeviceCircle.propTypes = propTypes;
export default React.memo(DeviceCircle);
export default DeviceCirclePack;

View File

@@ -16,23 +16,32 @@ import {
Box,
Tag,
} from '@chakra-ui/react';
import { animated } from '@react-spring/web';
import { ComputedDatum } from '@nivo/circle-packing';
import { Interpolation, SpringValue, animated } from '@react-spring/web';
import { Radio } from 'phosphor-react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { RadioCircle } from '../utils';
import { useCircleGraph } from 'contexts/CircleGraphProvider';
const propTypes = {
node: PropTypes.instanceOf(Object).isRequired,
handleClicks: PropTypes.shape({
onClick: PropTypes.func.isRequired,
}).isRequired,
style: PropTypes.instanceOf(Object).isRequired,
const RadioCirclePack = ({
node,
style,
handleClicks,
}: {
node: ComputedDatum<RadioCircle>;
style: {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
};
const RadioCircle = ({ node, style, handleClicks }) => {
handleClicks: {
onClick: (e: React.MouseEvent<SVGCircleElement>) => void;
};
}) => {
const { t } = useTranslation();
const { popoverRef } = useCircleGraph();
const context = useCircleGraph();
return (
<Popover isLazy trigger="hover" placement="auto">
@@ -50,7 +59,7 @@ const RadioCircle = ({ node, style, handleClicks }) => {
onClick={handleClicks.onClick}
/>
</PopoverTrigger>
<Portal containerRef={popoverRef}>
<Portal containerRef={context?.popoverRef}>
<PopoverContent>
<PopoverArrow />
<PopoverCloseButton alignContent="center" mt={1} />
@@ -76,7 +85,7 @@ const RadioCircle = ({ node, style, handleClicks }) => {
<Td w="100px">{t('analytics.airtime')}</Td>
<Td>
<Tag ml={-2} colorScheme={node.data.details.tagColor} size="md">
<b>{node.data.details.transmitPct.toFixed(2)}%</b>
<b>{node.data.details.transmit_pct.toFixed(2)}%</b>
</Tag>
</Td>
</Tr>
@@ -106,5 +115,4 @@ const RadioCircle = ({ node, style, handleClicks }) => {
);
};
RadioCircle.propTypes = propTypes;
export default React.memo(RadioCircle);
export default RadioCirclePack;

View File

@@ -19,24 +19,33 @@ import {
Box,
Tag,
} from '@chakra-ui/react';
import { animated } from '@react-spring/web';
import { ComputedDatum } from '@nivo/circle-packing';
import { Interpolation, SpringValue, animated } from '@react-spring/web';
import { Broadcast } from 'phosphor-react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { SsidCircle } from '../utils';
import { useCircleGraph } from 'contexts/CircleGraphProvider';
import { bytesString } from 'utils/stringHelper';
const propTypes = {
node: PropTypes.instanceOf(Object).isRequired,
handleClicks: PropTypes.shape({
onClick: PropTypes.func.isRequired,
}).isRequired,
style: PropTypes.instanceOf(Object).isRequired,
const SsidCirclePack = ({
node,
style,
handleClicks,
}: {
node: ComputedDatum<SsidCircle>;
style: {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
};
const SsidCircle = ({ node, style, handleClicks }) => {
handleClicks: {
onClick: (e: React.MouseEvent<SVGCircleElement>) => void;
};
}) => {
const { t } = useTranslation();
const { popoverRef } = useCircleGraph();
const context = useCircleGraph();
return (
<Popover isLazy trigger="hover" placement="auto">
@@ -55,7 +64,7 @@ const SsidCircle = ({ node, style, handleClicks }) => {
onClick={handleClicks.onClick}
/>
</PopoverTrigger>
<Portal containerRef={popoverRef}>
<Portal containerRef={context?.popoverRef}>
<PopoverContent w="400px">
<PopoverArrow />
<PopoverCloseButton alignContent="center" mt={1} />
@@ -122,5 +131,4 @@ const SsidCircle = ({ node, style, handleClicks }) => {
);
};
SsidCircle.propTypes = propTypes;
export default React.memo(SsidCircle);
export default SsidCirclePack;

View File

@@ -12,23 +12,32 @@ import {
Tag,
Text,
} from '@chakra-ui/react';
import { animated } from '@react-spring/web';
import { ComputedDatum } from '@nivo/circle-packing';
import { Interpolation, SpringValue, animated } from '@react-spring/web';
import { Buildings } from 'phosphor-react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { CirclePackRoot } from '../utils';
import { useCircleGraph } from 'contexts/CircleGraphProvider';
const propTypes = {
node: PropTypes.instanceOf(Object).isRequired,
handleClicks: PropTypes.shape({
onClick: PropTypes.func.isRequired,
}).isRequired,
style: PropTypes.instanceOf(Object).isRequired,
const VenueCirclePack = ({
node,
style,
handleClicks,
}: {
node: ComputedDatum<CirclePackRoot>;
style: {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
};
const VenueCircle = ({ node, style, handleClicks }) => {
handleClicks: {
onClick: (e: React.MouseEvent<SVGCircleElement>) => void;
};
}) => {
const { t } = useTranslation();
const { popoverRef } = useCircleGraph();
const context = useCircleGraph();
return (
<Popover isLazy trigger="hover" placement="auto">
@@ -46,7 +55,7 @@ const VenueCircle = ({ node, style, handleClicks }) => {
onClick={handleClicks.onClick}
/>
</PopoverTrigger>
<Portal containerRef={popoverRef}>
<Portal containerRef={context?.popoverRef}>
<PopoverContent>
<PopoverArrow />
<PopoverCloseButton alignContent="center" mt={1} />
@@ -72,5 +81,4 @@ const VenueCircle = ({ node, style, handleClicks }) => {
);
};
VenueCircle.propTypes = propTypes;
export default React.memo(VenueCircle);
export default VenueCirclePack;

View File

@@ -0,0 +1,110 @@
import React, { useMemo } from 'react';
import { ComputedDatum, CircleComponent as CircleComponentT, CircleProps } from '@nivo/circle-packing';
import { Interpolation, SpringValue } from '@react-spring/web';
import { AssociationCircle, CirclePackRoot, DeviceCircleInfo, RadioCircle, SsidCircle } from '../utils';
import AssociationCirclePack from './AssociationCirclePack';
import DeviceCirclePack from './DeviceCirclePack';
import RadioCirclePack from './RadioCirclePack';
import SsidCirclePack from './SsidCirclePack';
import VenueCirclePack from './VenueCirclePack';
const CircleComponent: CircleComponentT<
CirclePackRoot | SsidCircle | RadioCircle | AssociationCircle | DeviceCircleInfo
> = ({
node,
style,
onClick,
}: CircleProps<CirclePackRoot | SsidCircle | RadioCircle | AssociationCircle | DeviceCircleInfo>) => {
const handleClicks = useMemo(
() => ({
onClick: (e: React.MouseEvent<SVGCircleElement>) => {
if (onClick) onClick(node, e);
},
}),
[onClick, node],
);
if (node.data.type === 'association')
return (
<AssociationCirclePack
node={node as ComputedDatum<AssociationCircle>}
style={
style as unknown as {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
}
}
handleClicks={handleClicks}
/>
);
if (node.data.type === 'ssid')
return (
<SsidCirclePack
node={node as ComputedDatum<SsidCircle>}
style={
style as unknown as {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
}
}
handleClicks={handleClicks}
/>
);
if (node.data.type === 'radio')
return (
<RadioCirclePack
node={node as ComputedDatum<RadioCircle>}
style={
style as unknown as {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
}
}
handleClicks={handleClicks}
/>
);
if (node.data.type === 'device')
return (
<DeviceCirclePack
node={node as ComputedDatum<DeviceCircleInfo>}
style={
style as unknown as {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
}
}
handleClicks={handleClicks}
/>
);
if (node.data.type === 'venue')
return (
<VenueCirclePack
node={node as ComputedDatum<CirclePackRoot>}
style={
style as unknown as {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
}
}
handleClicks={handleClicks}
/>
);
return null;
};
export default CircleComponent;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { LabelComponent, LabelProps } from '@nivo/circle-packing';
import { animated } from '@react-spring/web';
import { CirclePackRoot } from './utils';
const CircleLabel: LabelComponent<CirclePackRoot> = ({ label, node, style }: LabelProps<CirclePackRoot>) => (
<animated.text
key={node.id}
x={style.x}
y={style.y}
textAnchor="middle"
dominantBaseline="central"
style={{
fill: style.textColor,
opacity: style.opacity,
pointerEvents: 'none',
}}
>
{typeof label === 'string' ? label.split('/')[0] : label}
</animated.text>
);
export default CircleLabel;

View File

@@ -1,16 +1,15 @@
import React, { useMemo, useState } from 'react';
import { Box, Center, Heading, Slider, SliderFilledTrack, SliderThumb, SliderTrack, Tooltip } from '@chakra-ui/react';
import { Clock } from 'phosphor-react';
import PropTypes from 'prop-types';
import { AnalyticsTimePointApiResponse } from 'models/Analytics';
import { compactDate } from 'utils/dateFormatting';
const propTypes = {
index: PropTypes.number.isRequired,
setIndex: PropTypes.func.isRequired,
points: PropTypes.instanceOf(Object).isRequired,
type Props = {
index: number;
setIndex: (index: number) => void;
points: AnalyticsTimePointApiResponse[][];
};
const CirclePackSlider = ({ index, setIndex, points }) => {
const CirclePackSlider = ({ index, setIndex, points }: Props) => {
const [showTooltip, setShowTooltip] = useState(false);
const onMouseEnter = () => setShowTooltip(true);
@@ -19,11 +18,13 @@ const CirclePackSlider = ({ index, setIndex, points }) => {
const stepsDetails = useMemo(
() => ({
steps: points.length,
allTimestamps: points.map((point) => (point.length === 0 ? '-' : compactDate(point[0].timestamp))),
allTimestamps: points.map((point) => (!point[0] ? '-' : compactDate(point[0].timestamp))),
}),
[points],
);
const currTimestamp = points[index]?.[0]?.timestamp;
return (
<>
<Slider
@@ -46,11 +47,10 @@ const CirclePackSlider = ({ index, setIndex, points }) => {
</Tooltip>
</Slider>
<Center>
<Heading size="lg">{points[index] && points[index][0] ? compactDate(points[index][0].timestamp) : ''}</Heading>
<Heading size="lg">{currTimestamp ? compactDate(currTimestamp) : ''}</Heading>
</Center>
</>
);
};
CirclePackSlider.propTypes = propTypes;
export default React.memo(CirclePackSlider);

View File

@@ -0,0 +1,89 @@
import * as React from 'react';
import { Box, Center, Heading, useColorMode } from '@chakra-ui/react';
import { MouseHandler, ResponsiveCirclePacking } from '@nivo/circle-packing';
import { FullScreenHandle } from 'react-full-screen';
import { useTranslation } from 'react-i18next';
import CircleComponent from './CircleComponent';
import CircleLabel from './CircleLabel';
import CirclePackSlider from './Slider';
import { useCirclePackTheme } from './useCirclePackTheme';
import { CirclePackRoot, parseAnalyticsTimepointsToCirclePackData } from './utils';
import { useCircleGraph } from 'contexts/CircleGraphProvider';
import { AnalyticsTimePointApiResponse } from 'models/Analytics';
import { VenueApiResponse } from 'models/Venue';
type Props = {
data: AnalyticsTimePointApiResponse[][];
venue: VenueApiResponse;
handle: FullScreenHandle;
};
const LiveViewCirclePack = ({ data, venue, handle }: Props) => {
const { t } = useTranslation();
const context = useCircleGraph();
const { colorMode } = useColorMode();
const theme = useCirclePackTheme();
const [pointIndex, setPointIndex] = React.useState(Math.max(data.length - 1, 0));
const [zoomedId, setZoomedId] = React.useState<string | null>(null);
const parsedData = React.useMemo(() => {
const dataIndex = data[pointIndex] || [];
if (dataIndex) {
try {
return parseAnalyticsTimepointsToCirclePackData(dataIndex, venue, colorMode);
} catch (e) {
return undefined;
}
}
return undefined;
}, [data, pointIndex, colorMode]);
const handleNodeClick: MouseHandler<CirclePackRoot> = React.useCallback(
(node) => {
setZoomedId(zoomedId === node.id ? null : node.id);
},
[zoomedId],
);
React.useEffect(() => {
setPointIndex(data.length - 1);
}, [data]);
return (
<Box px={10} h="100%">
{data.length > 0 && <CirclePackSlider index={pointIndex} setIndex={setPointIndex} points={data} />}
<Box w="100%" h={handle?.active ? 'calc(100vh - 200px)' : '600px'} ref={context?.popoverRef}>
{!parsedData ? (
<Center>
<Heading size="lg">{t('common.no_records_found')}</Heading>
</Center>
) : (
<ResponsiveCirclePacking
margin={theme.MARGINS}
padding={36}
defs={theme.shapeDefs}
animate={false}
fill={theme.getFill}
id="name"
value="scale"
data={parsedData}
enableLabels
labelsSkipRadius={42}
labelsFilter={theme.getLabelsFilter}
labelTextColor={theme.LABEL_TEXT_COLORS}
labelComponent={CircleLabel}
// onMouseEnter={null}
// tooltip={null}
circleComponent={CircleComponent}
zoomedId={zoomedId}
theme={theme.THEME}
onClick={handleNodeClick}
/>
)}
</Box>
</Box>
);
};
export default LiveViewCirclePack;

View File

@@ -0,0 +1,101 @@
import * as React from 'react';
import { useColorMode } from '@chakra-ui/react';
import { patternLinesDef } from '@nivo/core';
import { AssociationCircle, CirclePackRoot, RadioCircle, SsidCircle } from './utils';
const THEME = {
labels: {
text: {
background: 'black',
},
background: 'black',
},
};
const LABEL_TEXT_COLORS = {
from: 'color',
modifiers: [['darker', 4]],
};
const MARGINS = { top: 20, right: 20, bottom: 20, left: 20 };
const getFill = [
{
match: (d: { data: CirclePackRoot | SsidCircle | RadioCircle | AssociationCircle }) =>
d.data.type === 'association' && typeof d.data.details.rssi === 'number' && d.data.details.rssi >= -45,
id: 'assoc_success',
},
{
match: (d: { data: CirclePackRoot | SsidCircle | RadioCircle | AssociationCircle }) =>
d.data.type === 'association' && typeof d.data.details.rssi === 'number' && d.data.details.rssi >= -60,
id: 'assoc_warning',
},
{
match: (d: { data: CirclePackRoot | SsidCircle | RadioCircle | AssociationCircle }) =>
d.data.type === 'association' && typeof d.data.details.rssi === 'number' && d.data.details.rssi < -60,
id: 'assoc_danger',
},
];
const getLabelsFilter = (label: { node: { height: number } }) => label.node.height === 0;
export const useCirclePackTheme = () => {
const { colorMode } = useColorMode();
const shapeDefs = React.useMemo(
() => [
patternLinesDef(
'assoc_success',
colorMode === 'light'
? {
rotation: -45,
color: 'var(--chakra-colors-success-400)',
background: 'var(--chakra-colors-success-600)',
}
: {
rotation: -45,
color: 'var(--chakra-colors-success-400)',
background: 'var(--chakra-colors-success-600)',
},
),
patternLinesDef(
'assoc_warning',
colorMode === 'light'
? {
rotation: -45,
color: 'var(--chakra-colors-warning-100)',
background: 'var(--chakra-colors-warning-400)',
}
: {
rotation: -45,
color: 'var(--chakra-colors-warning-100)',
background: 'var(--chakra-colors-warning-400)',
},
),
patternLinesDef(
'assoc_danger',
colorMode === 'light'
? {
rotation: -45,
color: 'var(--chakra-colors-danger-200)',
background: 'var(--chakra-colors-danger-400)',
}
: {
rotation: -45,
color: 'var(--chakra-colors-danger-200)',
background: 'var(--chakra-colors-danger-400)',
},
),
],
[colorMode],
);
return {
shapeDefs,
THEME,
LABEL_TEXT_COLORS,
MARGINS,
getFill,
getLabelsFilter,
};
};

View File

@@ -0,0 +1,259 @@
import { v4 as uuid } from 'uuid';
import {
AnalyticsApData,
AnalyticsAssociationData,
AnalyticsBoardDevice,
AnalyticsRadioData,
AnalyticsSsidData,
AnalyticsTimePointApiResponse,
} from 'models/Analytics';
import { VenueApiResponse } from 'models/Venue';
import { getScaledArray } from 'utils/arrayHelpers';
import { errorColor, getBlendedColor, successColor, warningColor } from 'utils/colors';
import { parseDbm } from 'utils/stringHelper';
type ChangeTypeOfKeys<T extends object, Keys extends keyof T, NewType> = {
// Loop to every key. We gonna check if the key
// is assignable to Keys. If yes, change the type.
// Else, retain the type.
[key in keyof T]: key extends Keys ? NewType : T[key];
};
type CircleColor = 'green' | 'yellow' | 'red';
export type AssociationCircle = {
name: string;
type: 'association';
details: ChangeTypeOfKeys<AnalyticsAssociationData, 'rssi', number | '-'> & {
color: string;
tagColor: CircleColor;
};
scale: number;
totalBw: number;
};
export type SsidCircle = {
name: string;
type: 'ssid';
details: {
avgRssi: '-' | number;
color: string;
tagColor: CircleColor;
} & AnalyticsSsidData;
children: AssociationCircle[];
scale: number;
};
export type RadioCircle = {
name: string;
type: 'radio';
details: {
color: string;
tagColor: CircleColor;
} & AnalyticsRadioData;
children: SsidCircle[];
};
export type DeviceCircleInfo = {
name: string;
type: 'device';
details: {
deviceInfo: AnalyticsBoardDevice;
ssidData: AnalyticsSsidData[];
apData: AnalyticsApData;
color: string;
tagColor: CircleColor;
};
scale: number;
children: RadioCircle[];
};
export type CirclePackRoot = {
name: string;
type: 'venue';
details: {
avgHealth: number;
color: string;
tagColor: CircleColor;
};
children: DeviceCircleInfo[];
scale: number;
};
export const parseAnalyticsTimepointsToCirclePackData = (
data: AnalyticsTimePointApiResponse[],
venue: VenueApiResponse,
colorMode: 'light' | 'dark',
) => {
if (data.length === 0) return undefined;
const root: CirclePackRoot = {
name: venue.name,
details: {
avgHealth: 0,
color: 'green',
tagColor: 'green',
},
type: 'venue',
children: [],
scale: 1,
};
let globalVenueHealth = 0;
const globalBandwidth: number[] = [];
for (const device of data) {
globalVenueHealth += device.device_info.health;
const deviceCircleInfo: DeviceCircleInfo = {
name: `${device.device_info.serialNumber}/device/${uuid()}`,
type: 'device',
details: {
deviceInfo: device.device_info,
ssidData: device.ssid_data,
apData: device.ap_data,
color: 'green',
tagColor: 'green',
},
scale: 1,
children: [],
};
if (device.device_info.health >= 90) {
deviceCircleInfo.details.color = successColor(colorMode);
deviceCircleInfo.details.tagColor = 'green';
} else if (device.device_info.health >= 70) {
deviceCircleInfo.details.color = warningColor(colorMode);
deviceCircleInfo.details.tagColor = 'yellow';
} else {
deviceCircleInfo.details.color = errorColor(colorMode);
deviceCircleInfo.details.tagColor = 'red';
}
const radioChannelIndex: { [key: string]: number } = {};
for (const [i, radio] of device.radio_data.entries()) {
radioChannelIndex[radio.band] = i;
let tagColor: CircleColor = 'green';
if (radio.transmit_pct > 30) tagColor = 'yellow';
else if (radio.transmit_pct > 50) tagColor = 'red';
deviceCircleInfo.children.push({
name: `${radio.band}/radio/${uuid()}`,
type: 'radio',
details: {
...radio,
color: getBlendedColor('#0ba057', '#FD3049', radio.transmit_pct / 100),
tagColor,
},
children: [],
});
}
for (const ssid of device.ssid_data) {
const ssidInfo: SsidCircle = {
name: `${ssid.ssid}/ssid/${uuid()}`,
type: 'ssid',
details: {
...ssid,
avgRssi: '-',
color: 'green',
tagColor: 'green',
},
children: [],
scale: 1,
};
let totalSsidRssi = 0;
for (const association of ssid.associations) {
const bw = association.tx_bytes_bw + association.rx_bytes_bw;
globalBandwidth.push(bw);
const associationInfo: AssociationCircle = {
name: `${association.station}/assoc/${uuid()}`,
type: 'association',
details: {
...association,
rssi: parseDbm(association.rssi) as '-' | number,
color: 'green',
tagColor: 'green',
},
scale: 1,
totalBw: bw,
};
if (association.rssi >= -45) {
associationInfo.details.color = successColor(colorMode);
associationInfo.details.tagColor = 'green';
} else if (association.rssi >= -60) {
associationInfo.details.color = warningColor(colorMode);
associationInfo.details.tagColor = 'yellow';
} else {
associationInfo.details.color = errorColor(colorMode);
associationInfo.details.tagColor = 'red';
}
totalSsidRssi += association.rssi;
ssidInfo.children.push(associationInfo);
}
const index = radioChannelIndex[ssid.band];
if (index !== undefined) {
ssidInfo.details.avgRssi =
ssid.associations.length === 0
? '-'
: parseDbm(Math.floor(totalSsidRssi / Math.max(ssid.associations.length, 1)));
if (typeof ssidInfo.details.avgRssi === 'number') {
if (ssid.associations.length === 0 || ssidInfo.details.avgRssi >= -45) {
ssidInfo.details.color = successColor(colorMode);
ssidInfo.details.tagColor = 'green';
} else if (ssidInfo.details.avgRssi >= -60) {
ssidInfo.details.color = warningColor(colorMode);
ssidInfo.details.tagColor = 'yellow';
}
} else {
ssidInfo.details.color = errorColor(colorMode);
ssidInfo.details.tagColor = 'red';
}
deviceCircleInfo.children[index]?.children.push(ssidInfo);
}
}
root.details.avgHealth = Math.floor(globalVenueHealth / Math.max(data.length, 1));
if (root.details.avgHealth >= 90) {
root.details.color = successColor(colorMode);
root.details.tagColor = 'green';
} else if (root.details.avgHealth >= 70) {
root.details.color = warningColor(colorMode);
root.details.tagColor = 'yellow';
} else {
root.details.color = errorColor(colorMode);
root.details.tagColor = 'red';
}
root.children.push(deviceCircleInfo);
}
if (globalBandwidth.length > 0) {
const scaledArray = getScaledArray(globalBandwidth, 1, 30);
const bandwidthObj: { [key: number]: number } = {};
for (const [i, bw] of globalBandwidth.entries()) {
bandwidthObj[bw] = scaledArray[i];
}
for (const [deviceIndex, dev] of root.children.entries()) {
for (const [radioIndex, radio] of dev.children.entries()) {
for (const [ssidIndex, ssid] of radio.children.entries()) {
for (const [assocIndex, assoc] of ssid.children.entries()) {
if (root.children[deviceIndex]?.children[radioIndex]?.children[ssidIndex]?.children[assocIndex]?.scale)
// @ts-ignore
root.children[deviceIndex].children[radioIndex].children[ssidIndex].children[assocIndex].scale =
bandwidthObj[assoc.totalBw];
}
}
}
}
}
return root;
};

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { IconButton, Tooltip } from '@chakra-ui/react';
import { ArrowsIn, ArrowsOut } from 'phosphor-react';
import { FullScreenHandle } from 'react-full-screen';
import { useTranslation } from 'react-i18next';
type Props = {
isDisabled: boolean;
handle: FullScreenHandle;
};
const FullScreenLiveViewButton = ({ isDisabled, handle }: Props) => {
const { t } = useTranslation();
const handleClick = () => (handle.active ? handle.exit() : handle.enter());
const icon = () => (handle.active ? <ArrowsIn size={20} /> : <ArrowsOut size={20} />);
return (
<Tooltip label={handle.active ? t('common.exit_fullscreen') : t('common.fullscreen')}>
<IconButton
aria-label={handle.active ? t('common.exit_fullscreen') : t('common.fullscreen')}
type="button"
onClick={handleClick}
icon={icon()}
isDisabled={isDisabled}
colorScheme="teal"
mr={2}
/>
</Tooltip>
);
};
export default FullScreenLiveViewButton;

View File

@@ -0,0 +1,49 @@
import React from 'react';
import {
Heading,
IconButton,
Popover,
PopoverArrow,
PopoverBody,
PopoverCloseButton,
PopoverContent,
PopoverHeader,
PopoverTrigger,
Tooltip,
} from '@chakra-ui/react';
import { Question } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
const CirclePackInfoButton = () => {
const { t } = useTranslation();
return (
<Popover>
<PopoverTrigger>
<Tooltip label={t('configurations.explanation')}>
<IconButton aria-label={t('configurations.explanation')} icon={<Question size={20} />} colorScheme="blue" />
</Tooltip>
</PopoverTrigger>
<PopoverContent w="440px">
<PopoverArrow />
<PopoverCloseButton alignContent="center" mt={1} />
<PopoverHeader display="flex">{t('analytics.live_view_help')}</PopoverHeader>
<PopoverBody>
<Heading size="sm">{t('analytics.live_view_explanation_one')}</Heading>
<Heading size="sm" mt={4}>
{t('analytics.live_view_explanation_two')}
</Heading>
<Heading size="sm">{t('analytics.live_view_explanation_three')}</Heading>
<Heading size="sm" mt={4}>
{t('analytics.live_view_explanation_four')}
</Heading>
<Heading size="sm" mt={4}>
{t('analytics.live_view_explanation_five')}
</Heading>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default CirclePackInfoButton;

View File

@@ -0,0 +1,60 @@
import * as React from 'react';
import { Box, Flex, HStack, Spacer, useColorModeValue } from '@chakra-ui/react';
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import AnalyticsDatePickers from '../DatePickers';
import LiveViewCirclePack from './CirclePack';
import FullScreenLiveViewButton from './FullScreenLiveViewButton';
import CirclePackInfoButton from './InfoButton';
import { UseLiveViewReturn } from './useLiveView';
import RefreshButton from 'components/Buttons/RefreshButton';
import LoadingOverlay from 'components/LoadingOverlay';
import { CircleGraphProvider } from 'contexts/CircleGraphProvider';
import { AnalyticsTimePointApiResponse } from 'models/Analytics';
import { VenueApiResponse } from 'models/Venue';
import { getHoursAgo } from 'utils/dateFormatting';
type Props = {
data: AnalyticsTimePointApiResponse[][];
venue: VenueApiResponse;
isFetching?: boolean;
onChangeTime: UseLiveViewReturn['onChangeTime'];
onClearTime: UseLiveViewReturn['onClearTime'];
refreshData: () => void;
};
const LiveViewLayout = ({ data, venue, isFetching, onChangeTime, onClearTime, refreshData }: Props) => {
const color = useColorModeValue('gray.50', 'gray.800');
const handle = useFullScreenHandle();
return (
<LoadingOverlay isLoading={!!isFetching}>
<Box bgColor={handle?.active ? color : undefined} h="100%" p={0}>
<FullScreen handle={handle}>
<Box bgColor={handle?.active ? color : undefined} h="100%" p={0}>
<Flex mb={2} pt={2} px={2} mt={handle?.active ? 4 : undefined} mr={handle?.active ? 4 : undefined}>
<Spacer />
<HStack>
<CirclePackInfoButton />
<AnalyticsDatePickers
defaults={{
start: getHoursAgo(5),
end: new Date(),
}}
setTime={(start: Date, end: Date) => onChangeTime({ start, end })}
onClear={onClearTime}
/>
<FullScreenLiveViewButton isDisabled={isFetching || !data} handle={handle} />
<RefreshButton onClick={refreshData} isFetching={isFetching} isCompact />
</HStack>
</Flex>
<CircleGraphProvider>
<LiveViewCirclePack data={data} handle={handle} venue={venue} />
</CircleGraphProvider>
</Box>
</FullScreen>
</Box>
</LoadingOverlay>
);
};
export default React.memo(LiveViewLayout);

View File

@@ -0,0 +1,64 @@
import * as React from 'react';
import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, BoxProps, Center, Flex, Spinner } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import LiveViewLayout from './LiveViewLayout';
import { useLiveView } from './useLiveView';
import { VenueApiResponse } from 'models/Venue';
type Props = {
boardId: string;
venue: VenueApiResponse;
containerStyle?: BoxProps;
};
const LiveView = ({ boardId, venue, containerStyle }: Props) => {
const { t } = useTranslation();
const liveView = useLiveView({ boardId });
const contents = React.useMemo(() => {
if (liveView.getTimepoints.error) {
return (
<Flex justifyContent="center" alignItems="center" height="100%">
<Center>
<Alert status="error" w="unset" borderRadius="15px">
<AlertIcon />
<Box>
<AlertTitle>{t('common.error')}</AlertTitle>
<AlertDescription>
{liveView.getTimepoints.error.response?.status === 404
? t('analytics.missing_board')
: liveView.getTimepoints.error.response?.data?.ErrorDescription}
</AlertDescription>
</Box>
</Alert>
</Center>
</Flex>
);
}
if (liveView.getTimepoints.isLoading || !liveView.getTimepoints.data) {
return (
<Flex justifyContent="center" alignItems="center" height="100%">
<Center>
<Spinner size="xl" />
</Center>
</Flex>
);
}
return (
<LiveViewLayout
data={liveView.getTimepoints.data}
isFetching={liveView.getTimepoints.isFetching}
venue={venue}
onChangeTime={liveView.onChangeTime}
onClearTime={liveView.onClearTime}
refreshData={liveView.getTimepoints.refetch}
/>
);
}, [liveView.getTimepoints]);
return <Box {...containerStyle}>{contents}</Box>;
};
export default React.memo(LiveView);

View File

@@ -0,0 +1,42 @@
import * as React from 'react';
import { useGetAnalyticsBoardTimepoints } from 'hooks/Network/Analytics';
import { getHoursAgo } from 'utils/dateFormatting';
export type UseLiveViewProps = {
boardId: string;
};
export type UseLiveViewReturn = {
time: { start: Date; end: Date };
onChangeTime: (newTime: { start: Date; end: Date }) => void;
onClearTime: () => void;
getTimepoints: ReturnType<typeof useGetAnalyticsBoardTimepoints>;
};
export const useLiveView = ({ boardId }: UseLiveViewProps) => {
const [time, setTime] = React.useState({
start: getHoursAgo(5),
end: new Date(),
});
const onChangeTime = (newTime: { start: Date; end: Date }) => setTime({ ...newTime });
const onClearTime = () => {
setTime({
start: getHoursAgo(5),
end: new Date(),
});
};
const getTimepoints = useGetAnalyticsBoardTimepoints({ id: boardId, startTime: time.start, endTime: time.end });
return React.useMemo(
() =>
({
time,
onChangeTime,
onClearTime,
getTimepoints,
} as UseLiveViewReturn),
[getTimepoints, time],
);
};

View File

@@ -0,0 +1,186 @@
import * as React from 'react';
import { Box, VStack, useDisclosure, useToast } from '@chakra-ui/react';
import { Form, Formik } from 'formik';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import * as Yup from 'yup';
import SaveButton from 'components/Buttons/SaveButton';
import NumberField from 'components/FormFields/NumberField';
import StringField from 'components/FormFields/StringField';
import ToggleField from 'components/FormFields/ToggleField';
import ConfirmCloseAlert from 'components/Modals/Actions/ConfirmCloseAlert';
import { Modal } from 'components/Modals/Modal';
import { testObjectName } from 'constants/formTests';
import { useCreateAnalyticsBoard } from 'hooks/Network/Analytics';
import { useGetVenue, useUpdateVenue } from 'hooks/Network/Venues';
import useFormRef from 'hooks/useFormRef';
import { AxiosError } from 'models/Axios';
type FormValues = {
name: string;
interval: number;
retention: number;
monitorSubVenues: boolean;
};
type Props = {
id: string;
isOpen: boolean;
onClose: () => void;
};
const StartAnalyticsModal = ({ id, isOpen, onClose }: Props) => {
const { t } = useTranslation();
const toast = useToast();
const getVenue = useGetVenue({ id });
const createAnalytics = useCreateAnalyticsBoard();
const updateVenue = useUpdateVenue({ id });
const [formKey, setFormKey] = React.useState(uuid());
const { isOpen: isConfirmOpen, onOpen: openConfirm, onClose: closeConfirm } = useDisclosure();
const { form, formRef } = useFormRef<FormValues>();
const closeModal = () => (form.dirty ? openConfirm() : onClose());
const Schema = Yup.object()
.shape({
name: Yup.string().required(t('form.required')).test('len', t('common.name_error'), testObjectName),
interval: Yup.number().required(t('form.required')).moreThan(0).integer(),
retention: Yup.number().required(t('form.required')).moreThan(0).integer(),
})
.nullable()
.default(undefined);
React.useEffect(() => {
setFormKey(uuid());
}, [isOpen]);
return (
<>
<Modal
title={t('analytics.create_board')}
isOpen={isOpen}
onClose={closeModal}
topRightButtons={
<SaveButton onClick={form.submitForm} isLoading={form.isSubmitting} isDisabled={!form.isValid} />
}
options={{
modalSize: 'sm',
}}
>
<Box>
<Formik
innerRef={formRef}
enableReinitialize
key={formKey}
initialValues={
{
name: getVenue.data?.name ?? '',
interval: 60,
retention: 3600 * 24 * 7,
monitorSubVenues: true,
} as FormValues
}
validationSchema={Schema}
onSubmit={({ name, interval, retention, monitorSubVenues }, { setSubmitting, resetForm }) => {
createAnalytics.mutateAsync(
{
name,
venueList: [
{
id,
name,
retention,
interval,
monitorSubVenues,
},
],
},
{
onSuccess: ({ data: boardData }) => {
updateVenue.mutateAsync(
{
params: {
boards: [boardData.id],
},
},
{
onSuccess: () => {
setSubmitting(false);
toast({
id: 'venue-update-success',
title: t('common.success'),
description: t('crud.success_update_obj', {
obj: t('venues.one'),
}),
status: 'success',
duration: 5000,
isClosable: true,
position: 'top-right',
});
resetForm();
onClose();
},
onError: (e) => {
toast({
id: uuid(),
title: t('common.error'),
description: t('crud.error_update_obj', {
obj: t('venues.one'),
e: (e as AxiosError)?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
setSubmitting(false);
},
},
);
},
onError: (e) => {
toast({
id: uuid(),
title: t('common.error'),
description: t('crud.error_create_obj', {
obj: t('analytics.board'),
e: (e as AxiosError)?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
setSubmitting(false);
},
},
);
}}
>
<Form>
<VStack spacing={2} alignItems="left">
<StringField name="name" label={t('common.name')} isRequired />
<Box maxW="200px">
<NumberField name="interval" label={t('analytics.interval')} isRequired unit={t('common.seconds')} />
</Box>
<Box maxW="200px">
<NumberField
name="retention"
label={t('analytics.retention')}
isRequired
unit={t('common.days')}
conversionFactor={3600 * 24}
/>
</Box>
<ToggleField name="monitorSubVenues" label={t('analytics.analyze_sub_venues')} />
</VStack>
</Form>
</Formik>
</Box>
</Modal>
<ConfirmCloseAlert isOpen={isConfirmOpen} confirm={onClose} cancel={closeConfirm} />
</>
);
};
export default StartAnalyticsModal;

View File

@@ -0,0 +1,114 @@
import * as React from 'react';
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
IconButton,
Tooltip,
useDisclosure,
useToast,
} from '@chakra-ui/react';
import { Stop } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { useDeleteAnalyticsBoard } from 'hooks/Network/Analytics';
import { useUpdateVenue } from 'hooks/Network/Venues';
import { AxiosError } from 'models/Axios';
type Props = {
boardId: string;
venueId: string;
};
const StopMonitoringButton = ({ boardId, venueId }: Props) => {
const { t } = useTranslation();
const toast = useToast();
const { isOpen, onOpen, onClose } = useDisclosure();
const stopMonitoring = useDeleteAnalyticsBoard();
const updateVenue = useUpdateVenue({ id: venueId });
const cancelRef = React.useRef<HTMLButtonElement>(null);
const handleStop = () => {
updateVenue.mutate(
{ params: { boards: [] } },
{
onSuccess: () => {
stopMonitoring.mutate(boardId, {
onSuccess: () => {
toast({
title: t('common.success'),
description: t('analytics.stop_monitoring_success'),
status: 'success',
duration: 5000,
isClosable: true,
});
onClose();
},
onError: (e) => {
toast({
title: t('common.error'),
description: (e as AxiosError)?.response?.data?.ErrorDescription,
status: 'error',
duration: 5000,
isClosable: true,
});
},
});
},
onError: (e) => {
toast({
title: t('common.error'),
description: (e as AxiosError)?.response?.data?.ErrorDescription,
status: 'error',
duration: 5000,
isClosable: true,
});
},
},
);
};
return (
<>
<Tooltip label={t('analytics.stop_monitoring')}>
<IconButton
aria-label={t('analytics.stop_monitoring')}
icon={<Stop size={20} />}
colorScheme="red"
borderRadius={0}
onClick={onOpen}
h="41px"
/>
</Tooltip>
<AlertDialog isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose} isCentered>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{t('analytics.stop_monitoring')}
</AlertDialogHeader>
<AlertDialogBody>{t('analytics.stop_monitoring_warning')}</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
colorScheme="red"
onClick={handleStop}
ml={2}
isLoading={stopMonitoring.isLoading || updateVenue.isLoading}
>
{t('analytics.stop_monitoring')}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</>
);
};
export default StopMonitoringButton;

View File

@@ -3,15 +3,10 @@ import { Heading } from '@chakra-ui/react';
import { ActionMeta, InputActionMeta, Select, SingleValue } from 'chakra-react-select';
import { useTranslation } from 'react-i18next';
const MacSearchBar = (
{
const MacSearchBar: React.FC<{ macs?: string[]; setMac: React.Dispatch<React.SetStateAction<string | undefined>> }> = ({
macs,
setMac
}: {
macs?: string[]
setMac: React.Dispatch<React.SetStateAction<string | undefined>>
}
) => {
setMac,
}) => {
const { t } = useTranslation();
const [inputValue, setInputValue] = useState('');

View File

@@ -19,25 +19,14 @@ import {
import useControlledTable from 'hooks/useControlledTable';
import { Column, SortInfo } from 'models/Table';
const ClientLifecyleTable = (
{
venueId,
mac,
fromDate,
endDate,
refreshId,
timePickers,
searchBar
}: {
venueId: string
mac?: string
fromDate: number
endDate: number
refreshId: number
timePickers: React.ReactNode
searchBar: React.ReactNode
}
) => {
const ClientLifecyleTable: React.FC<{
venueId: string;
mac?: string;
fromDate: number;
endDate: number;
timePickers: React.ReactNode;
searchBar: React.ReactNode;
}> = ({ venueId, mac, fromDate, endDate, timePickers, searchBar }) => {
const { t } = useTranslation();
const [sortInfo, setSortInfo] = useState<SortInfo>([{ id: 'timestamp', sort: 'dsc' }]);
const {
@@ -48,8 +37,8 @@ const ClientLifecyleTable = (
} = useControlledTable({
useCount: useGetClientLifecycleCount as (props: unknown) => UseQueryResult,
useGet: useGetClientLifecycle as (props: unknown) => UseQueryResult,
countParams: { venueId, mac, sortInfo, fromDate, endDate, refreshId },
getParams: { venueId, mac, sortInfo, fromDate, endDate, refreshId },
countParams: { venueId, mac, sortInfo, fromDate, endDate },
getParams: { venueId, mac, sortInfo, fromDate, endDate },
});
const [hiddenColumns, setHiddenColumns] = useState<string[]>([]);
const { data: tableSpecs } = useGetClientLifecycleTableSpecs();
@@ -66,8 +55,8 @@ const ClientLifecyleTable = (
const dbCell = useCallback((cell, key) => <DecibelCell db={cell.row.values[key]} key={uuid()} />, []);
const numberCell = useCallback((cell, key) => <NumberCell value={cell.row.values[key]} key={uuid()} />, []);
const columns: Column[] = useMemo((): Column[] => {
const cols: Column[] = [
const columns: Column<unknown>[] = useMemo((): Column<unknown>[] => {
const cols: Column<unknown>[] = [
{
id: 'timestamp',
Header: t('common.timestamp'),
@@ -346,14 +335,17 @@ const ClientLifecyleTable = (
return (
<>
<Box my="10px" display="flex">
<Box w="300px">{searchBar}</Box>
<Box display="flex" pt={2}>
<Box w="200px" ml={2}>
{searchBar}
</Box>
<Spacer />
<ColumnPicker
columns={columns}
hiddenColumns={hiddenColumns}
setHiddenColumns={setHiddenColumns}
preference="provisioning.clientLifecycle.hiddenColumns"
isCompact
/>
{timePickers}
</Box>

View File

@@ -0,0 +1,86 @@
import React, { useState } from 'react';
import { Box } from '@chakra-ui/react';
import ClientLifecycleDatePickers from '../DatePickers';
import MacSearchBar from './MacSearchBar';
import ClientLifecyleTable from './Table';
import LoadingOverlay from 'components/LoadingOverlay';
import { axiosAnalytics } from 'utils/axiosInstances';
import { getHoursAgo } from 'utils/dateFormatting';
const getPartialClients = async (venueId: string, offset: number) =>
axiosAnalytics
.get(`wifiClientHistory?macsOnly=true&venue=${venueId}&limit=500&offset=${offset}`)
.then(({ data }) => data.entries as string[]);
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;
};
interface Props {
venueId: string;
}
const VenueClientLifecycle: React.FC<Props> = ({ venueId }) => {
const [macs, setMacs] = useState<string[] | undefined>();
const [mac, setMac] = useState<string | undefined>();
const [time, setTime] = React.useState<{ start: Date; end: Date }>({
start: getHoursAgo(5 * 24),
end: new Date(),
});
const onChange = (start: Date, end: Date) => {
setTime({ start, end });
};
const onClear = () => {
setTime({
start: getHoursAgo(5 * 24),
end: new Date(),
});
};
const getMacs = React.useCallback(async () => {
try {
const newMacs = await getAllClients(venueId);
return newMacs;
} catch (e) {
return undefined;
}
}, [venueId]);
React.useEffect(() => {
getMacs().then((res) => setMacs(res));
}, [getMacs]);
return (
<LoadingOverlay isLoading={!macs}>
<Box minHeight="200px">
<ClientLifecyleTable
fromDate={Math.floor(time.start.getTime() / 1000)}
endDate={Math.floor(time.end.getTime() / 1000)}
venueId={venueId}
mac={mac}
timePickers={
<ClientLifecycleDatePickers
defaults={{ start: getHoursAgo(5 * 24), end: new Date() }}
setTime={onChange}
onClear={onClear}
/>
}
searchBar={<MacSearchBar macs={macs} setMac={setMac} />}
/>
</Box>
</LoadingOverlay>
);
};
export default VenueClientLifecycle;

View File

@@ -57,7 +57,6 @@ const DeviceTypeStat = ({ data, handleModalClick }) => {
},
],
})}
mb={4}
/>
);
};

View File

@@ -57,7 +57,6 @@ const FirmwareStat = ({ data, handleModalClick }) => {
},
],
})}
mb={4}
/>
);
};

View File

@@ -36,7 +36,6 @@ const HealthStat = ({ data, handleModalClick }) => {
],
})}
color={getHealthColor()}
mb={4}
/>
);
};

View File

@@ -35,7 +35,6 @@ const MemoryStat = ({ data, handleModalClick }) => {
},
],
})}
mb={4}
color={getMemoryColor()}
/>
);

Some files were not shown because too many files have changed in this diff Show More