mirror of
https://github.com/Telecominfraproject/wlan-cloud-owprov-ui.git
synced 2025-10-28 17:22:20 +00:00
[WIFI-12492] Entity and venue page rework
Signed-off-by: Charles <charles.bourque96@gmail.com>
This commit is contained in:
@@ -3,3 +3,7 @@ build
|
||||
dist
|
||||
node_modules
|
||||
.github
|
||||
docker-entrypoint.d
|
||||
helm
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wlan-cloud-owprov-ui",
|
||||
"version": "2.9.0(18)",
|
||||
"version": "2.10.0(2)",
|
||||
"description": "",
|
||||
"main": "index.tsx",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,37 +34,35 @@ 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%' }}>
|
||||
<ModalHeader
|
||||
title={t('crud.create_object', { obj: t('venues.sub_one') })}
|
||||
right={
|
||||
<>
|
||||
<SaveButton
|
||||
onClick={form.submitForm}
|
||||
isLoading={form.isSubmitting}
|
||||
isDisabled={!form.isValid || !form.dirty}
|
||||
/>
|
||||
<CloseButton ml={2} onClick={closeModal} />
|
||||
</>
|
||||
}
|
||||
<Modal onClose={closeModal} isOpen={isOpen} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxWidth={{ sm: '600px', md: '700px', lg: '800px', xl: '50%' }}>
|
||||
<ModalHeader
|
||||
title={t('crud.create_object', { obj: t('venues.sub_one') })}
|
||||
right={
|
||||
<>
|
||||
<SaveButton
|
||||
onClick={form.submitForm}
|
||||
isLoading={form.isSubmitting}
|
||||
isDisabled={!form.isValid || !form.dirty}
|
||||
isCompact
|
||||
/>
|
||||
<CloseButton ml={2} onClick={closeModal} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ModalBody>
|
||||
<CreateVenueForm
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
formRef={formRef}
|
||||
parentId={parentId}
|
||||
entityId={entityId}
|
||||
/>
|
||||
<ModalBody>
|
||||
<CreateVenueForm
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
formRef={formRef}
|
||||
parentId={parentId}
|
||||
entityId={entityId}
|
||||
/>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
<ConfirmCloseAlert isOpen={showConfirm} confirm={closeCancelAndForm} cancel={closeConfirm} />
|
||||
</Modal>
|
||||
</>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
<ConfirmCloseAlert isOpen={showConfirm} confirm={closeCancelAndForm} cancel={closeConfirm} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}`, {}));
|
||||
|
||||
@@ -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}`));
|
||||
|
||||
@@ -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}`, {}));
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
activeRoute?: string;
|
||||
languageSwitcher?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Navbar = ({ secondary, toggleSidebar, isSidebarOpen }: Props) => {
|
||||
export const Navbar = ({ toggleSidebar, activeRoute, languageSwitcher }: NavbarProps) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const breakpoint = useBreakpoint();
|
||||
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;
|
||||
const isCompact = breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md';
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
// 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';
|
||||
|
||||
// Values if scrolled
|
||||
const scrolledNavbarShadow = useColorModeValue('0px 7px 23px rgba(0, 0, 0, 0.05)', 'none');
|
||||
const scrolledNavbarBg = useColorModeValue(
|
||||
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,103 +78,86 @@ const Navbar = ({ secondary, toggleSidebar, isSidebarOpen }: Props) => {
|
||||
window.addEventListener('scroll', changeNavbar);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
position={navbarPosition}
|
||||
boxShadow={navbarShadow}
|
||||
bg={navbarBg}
|
||||
borderColor={navbarBorder}
|
||||
filter={navbarFilter}
|
||||
backdropFilter={navbarBackdrop}
|
||||
borderWidth="1.5px"
|
||||
borderStyle="solid"
|
||||
transitionDelay="0s, 0s, 0s, 0s"
|
||||
transitionDuration=" 0.25s, 0.25s, 0.25s, 0s"
|
||||
transition-property="box-shadow, background-color, filter, border"
|
||||
transitionTimingFunction="linear, linear, linear, linear"
|
||||
alignItems="center"
|
||||
borderRadius="16px"
|
||||
display="flex"
|
||||
minH="75px"
|
||||
justifyContent="center"
|
||||
lineHeight="25.6px"
|
||||
mx="auto"
|
||||
mt={secondaryMargin}
|
||||
pb="8px"
|
||||
right={{ base: '0px', sm: '0px' }}
|
||||
pl="30px"
|
||||
ps="12px"
|
||||
pt="8px"
|
||||
top="18px"
|
||||
w={{
|
||||
base: '100%',
|
||||
sm: isSidebarOpen ? 'calc(100vw - 70px - 196px)' : '100%',
|
||||
md: isSidebarOpen ? 'calc(100vw - 70px - 196px)' : '100%',
|
||||
}}
|
||||
>
|
||||
<Flex w="100%" flexDirection="row" alignItems="center">
|
||||
<HamburgerIcon w="24px" h="24px" onClick={toggleSidebar} mr={10} mt={1} />
|
||||
<Heading>{t(getActiveRoute())}</Heading>
|
||||
<Tooltip label={t('common.go_back')}>
|
||||
<IconButton
|
||||
mt={2}
|
||||
ml={4}
|
||||
colorScheme="blue"
|
||||
aria-label={t('common.go_back')}
|
||||
onClick={goBack}
|
||||
icon={<ArrowCircleLeft width={20} height={20} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Box ms="auto" w={{ base: 'unset' }}>
|
||||
<Flex alignItems="center" flexDirection="row">
|
||||
<Tooltip hasArrow label={t('common.go_to_map')}>
|
||||
<IconButton
|
||||
aria-label={t('common.go_to_map')}
|
||||
variant="ghost"
|
||||
icon={<MapTrifold size={24} />}
|
||||
onClick={goToMap}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip hasArrow label={t('common.theme')}>
|
||||
<IconButton
|
||||
aria-label={t('common.theme')}
|
||||
variant="ghost"
|
||||
icon={colorMode === 'light' ? <MoonIcon h="20px" w="20px" /> : <SunIcon h="20px" w="20px" />}
|
||||
onClick={toggleColorMode}
|
||||
/>
|
||||
</Tooltip>
|
||||
<LanguageSwitcher />
|
||||
<HStack spacing={{ base: '0', md: '6' }} ml={6} mr={4}>
|
||||
<Flex alignItems="center">
|
||||
<Portal>
|
||||
<Flex
|
||||
{...scrollDependentStyles}
|
||||
backdropFilter="blur(21px)"
|
||||
borderWidth="1.5px"
|
||||
borderStyle="solid"
|
||||
transitionDelay="0s, 0s, 0s, 0s"
|
||||
transitionDuration=" 0.25s, 0.25s, 0.25s, 0s"
|
||||
transition-property="box-shadow, background-color, filter, border"
|
||||
transitionTimingFunction="linear, linear, linear, linear"
|
||||
alignItems="center"
|
||||
borderRadius="15px"
|
||||
minH="75px"
|
||||
justifyContent="center"
|
||||
lineHeight="25.6px"
|
||||
pb="8px"
|
||||
right={{ base: '0px', sm: '0px', lg: '20px' }}
|
||||
ps="12px"
|
||||
pt="8px"
|
||||
top="15px"
|
||||
w={isCompact ? '100%' : 'calc(100vw - 256px)'}
|
||||
>
|
||||
<Flex w="100%" flexDirection="row" alignItems="center">
|
||||
{isCompact && <HamburgerIcon w="24px" h="24px" onClick={toggleSidebar} mr={10} mt={1} />}
|
||||
<Heading>{activeRoute}</Heading>
|
||||
<Tooltip label={t('common.go_back')}>
|
||||
<IconButton
|
||||
mt={2}
|
||||
ml={4}
|
||||
colorScheme="blue"
|
||||
aria-label={t('common.go_back')}
|
||||
onClick={goBack}
|
||||
size="sm"
|
||||
icon={<ArrowCircleLeft width={20} height={20} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Box ms="auto" w={{ base: 'unset' }}>
|
||||
<Flex alignItems="center" flexDirection="row">
|
||||
<Tooltip hasArrow label={t('common.go_to_map')}>
|
||||
<IconButton
|
||||
aria-label={t('common.go_to_map')}
|
||||
variant="ghost"
|
||||
icon={<MapTrifold size={24} />}
|
||||
onClick={goToMap}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip hasArrow label={t('common.theme')}>
|
||||
<IconButton
|
||||
aria-label={t('common.theme')}
|
||||
variant="ghost"
|
||||
icon={colorMode === 'light' ? <MoonIcon h="20px" w="20px" /> : <SunIcon h="20px" w="20px" />}
|
||||
onClick={toggleColorMode}
|
||||
/>
|
||||
</Tooltip>
|
||||
{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>
|
||||
<MenuList
|
||||
bg={useColorModeValue('white', 'gray.900')}
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
>
|
||||
<MenuItem onClick={goToProfile} w="100%">
|
||||
{t('account.title')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={logout}>{t('common.logout')}</MenuItem>
|
||||
</MenuList>
|
||||
<Portal>
|
||||
<MenuList
|
||||
bg={useColorModeValue('white', 'gray.900')}
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
>
|
||||
<MenuItem onClick={goToProfile} w="100%">
|
||||
{t('account.title')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={logout}>{t('common.logout')}</MenuItem>
|
||||
</MenuList>
|
||||
</Portal>
|
||||
</Menu>
|
||||
</Flex>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
|
||||
48
src/layout/PageContainer/index.tsx
Normal file
48
src/layout/PageContainer/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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>}
|
||||
>
|
||||
<PopoverAnchor>{children}</PopoverAnchor>
|
||||
{breakpoint === 'base' ? (
|
||||
{
|
||||
// @ts-ignore
|
||||
<PopoverAnchor>{children}</PopoverAnchor>
|
||||
}
|
||||
{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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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%',
|
||||
}}
|
||||
>
|
||||
<CreateRootModal />
|
||||
<PanelContent>
|
||||
<PanelContainer>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Flex flexDirection="column" pt="75px">
|
||||
<Spinner />
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<Routes>
|
||||
{[...getRoutes(routes as RouteProps[]), <Route path="*" element={<NotFoundPage />} key={uuid()} />]}
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</PanelContainer>
|
||||
</PanelContent>
|
||||
</MainPanel>
|
||||
<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',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Navbar toggleSidebar={toggleSidebar} languageSwitcher={<LanguageSwitcher />} activeRoute={activeRoute} />
|
||||
<PageContainer waitForUser>
|
||||
<Routes>
|
||||
{[...getRoutes(routes as RouteType[]), <Route path="*" element={<NotFoundPage />} key={uuid()} />]}
|
||||
</Routes>
|
||||
</PageContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
238
src/models/Analytics.ts
Normal file
238
src/models/Analytics.ts
Normal 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[][];
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,31 +32,28 @@ 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%' }}>
|
||||
<ModalHeader
|
||||
title={t('crud.create_object', { obj: t('entities.sub_one') })}
|
||||
right={
|
||||
<>
|
||||
<SaveButton
|
||||
onClick={form.submitForm}
|
||||
isLoading={form.isSubmitting}
|
||||
isDisabled={!form.isValid || !form.dirty}
|
||||
/>
|
||||
<CloseButton ml={2} onClick={closeModal} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ModalBody>
|
||||
<CreateEntityForm isOpen={isOpen} onClose={onClose} formRef={formRef} parentId={parentId} />
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
<ConfirmCloseAlert isOpen={showConfirm} confirm={closeCancelAndForm} cancel={closeConfirm} />
|
||||
</Modal>
|
||||
</>
|
||||
<Modal onClose={closeModal} isOpen={isOpen} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxWidth={{ sm: '600px', md: '700px', lg: '800px', xl: '50%' }}>
|
||||
<ModalHeader
|
||||
title={t('crud.create_object', { obj: t('entities.sub_one') })}
|
||||
right={
|
||||
<>
|
||||
<SaveButton
|
||||
onClick={form.submitForm}
|
||||
isLoading={form.isSubmitting}
|
||||
isDisabled={!form.isValid || !form.dirty}
|
||||
/>
|
||||
<CloseButton ml={2} onClick={closeModal} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ModalBody>
|
||||
<CreateEntityForm isOpen={isOpen} onClose={onClose} formRef={formRef} parentId={parentId} />
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
<ConfirmCloseAlert isOpen={showConfirm} confirm={closeCancelAndForm} cancel={closeConfirm} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
77
src/pages/EntityPage/EntityDropdown.tsx
Normal file
77
src/pages/EntityPage/EntityDropdown.tsx
Normal 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;
|
||||
40
src/pages/EntityPage/EntityHeader.tsx
Normal file
40
src/pages/EntityPage/EntityHeader.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
56
src/pages/EntityPage/Layout/ConfigurationCard/index.tsx
Normal file
56
src/pages/EntityPage/Layout/ConfigurationCard/index.tsx
Normal 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;
|
||||
38
src/pages/EntityPage/Layout/EntityChildren.tsx
Normal file
38
src/pages/EntityPage/Layout/EntityChildren.tsx
Normal 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;
|
||||
@@ -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;
|
||||
161
src/pages/EntityPage/Layout/EntityDetails.tsx
Normal file
161
src/pages/EntityPage/Layout/EntityDetails.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
172
src/pages/EntityPage/Layout/EntityNotes.tsx
Normal file
172
src/pages/EntityPage/Layout/EntityNotes.tsx
Normal 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;
|
||||
@@ -32,20 +32,17 @@ interface Props {
|
||||
onOpenUpgradeModal: (serialNumber: string) => void;
|
||||
}
|
||||
|
||||
const Actions = (
|
||||
{
|
||||
cell: { original: tag },
|
||||
refreshEntity,
|
||||
openEditModal,
|
||||
onOpenScan,
|
||||
onOpenFactoryReset,
|
||||
onOpenUpgradeModal
|
||||
}: Props
|
||||
) => {
|
||||
const EntityInventoryActions = ({
|
||||
cell: { original: tag },
|
||||
refreshEntity,
|
||||
openEditModal,
|
||||
onOpenScan,
|
||||
onOpenFactoryReset,
|
||||
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;
|
||||
@@ -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" />
|
||||
<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={getEntity.data?.devices ?? []}
|
||||
ignoredColumns={['entity', 'venue', 'description']}
|
||||
actions={actions}
|
||||
openDetailsModal={openEditModal}
|
||||
/>
|
||||
</Box>
|
||||
<InventoryTable
|
||||
tagSelect={entity.devices}
|
||||
ignoredColumns={['entity', 'venue']}
|
||||
refreshId={refreshId}
|
||||
actions={actions}
|
||||
openDetailsModal={openEditModal}
|
||||
/>
|
||||
<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;
|
||||
31
src/pages/EntityPage/Layout/index.tsx
Normal file
31
src/pages/EntityPage/Layout/index.tsx
Normal 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;
|
||||
81
src/pages/EntityPage/VenueDropdown.tsx
Normal file
81
src/pages/EntityPage/VenueDropdown.tsx
Normal 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;
|
||||
@@ -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 && (
|
||||
<>
|
||||
<EntityCard id={entityIdToUse} />
|
||||
<EntityChildrenCard id={entityIdToUse} />
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
return entityIdToUse ? (
|
||||
<>
|
||||
<EntityPageHeader id={entityIdToUse} />
|
||||
<EntityPageLayout id={entityIdToUse} />
|
||||
</>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default EntityPage;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,74 +29,70 @@ const NotificationsPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" pt="75px">
|
||||
{isUserLoaded && (
|
||||
<Card p={0}>
|
||||
<Tabs index={tabIndex} onChange={handleTabChange} variant="enclosed" isLazy>
|
||||
<TabList>
|
||||
<CardHeader>
|
||||
<Tab>
|
||||
{t('venues.one')} {t('notification.other')}
|
||||
</Tab>
|
||||
<Tab>Provisioning</Tab>
|
||||
<Tab>{t('logs.security')}</Tab>
|
||||
<Tab>{t('logs.firmware')}</Tab>
|
||||
</CardHeader>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<LogsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<GeneralLogsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<SecLogsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<FmsLogsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Card>
|
||||
)}
|
||||
</Flex>
|
||||
<Card p={0}>
|
||||
<Tabs index={tabIndex} onChange={handleTabChange} variant="enclosed" isLazy>
|
||||
<TabList>
|
||||
<CardHeader>
|
||||
<Tab>
|
||||
{t('venues.one')} {t('notification.other')}
|
||||
</Tab>
|
||||
<Tab>Provisioning</Tab>
|
||||
<Tab>{t('logs.security')}</Tab>
|
||||
<Tab>{t('logs.firmware')}</Tab>
|
||||
</CardHeader>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<LogsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<GeneralLogsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<SecLogsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<FmsLogsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
<DetailsCard id={entityIdToUse} />
|
||||
<OperatorChildrenCard id={entityIdToUse} />
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
return entityIdToUse !== undefined ? (
|
||||
<>
|
||||
<DetailsCard id={entityIdToUse} />
|
||||
<OperatorChildrenCard id={entityIdToUse} />
|
||||
</>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default OperatorPage;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 !== '' && (
|
||||
<>
|
||||
<SubscriberCard id={id ?? ''} />
|
||||
<SubscriberChildrenCard id={id ?? ''} />
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
return id !== '' ? (
|
||||
<>
|
||||
<SubscriberCard id={id ?? ''} />
|
||||
<SubscriberChildrenCard id={id ?? ''} />
|
||||
</>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default SubscriberPage;
|
||||
|
||||
@@ -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,55 +56,51 @@ 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>
|
||||
<CardHeader>
|
||||
<Tab>{t('system.services')}</Tab>
|
||||
<Tab hidden={!isRoot}>{t('system.configuration')}</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"
|
||||
>
|
||||
{!isOnlySec && (
|
||||
<CardHeader px={4} pt={4}>
|
||||
<Spacer />
|
||||
<RefreshButton onClick={refetch} isFetching={isFetching} />
|
||||
</CardHeader>
|
||||
)}
|
||||
<SimpleGrid minChildWidth="500px" spacing="20px" p={4}>
|
||||
{endpointsList}
|
||||
</SimpleGrid>
|
||||
</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"
|
||||
>
|
||||
<SystemSecretsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</Flex>
|
||||
<Card p={0}>
|
||||
<Tabs index={tabIndex} onChange={handleTabChange} variant="enclosed" isLazy>
|
||||
<TabList>
|
||||
<CardHeader>
|
||||
<Tab>{t('system.services')}</Tab>
|
||||
<Tab hidden={!isRoot}>{t('system.configuration')}</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"
|
||||
>
|
||||
{!isOnlySec && (
|
||||
<CardHeader px={4} pt={4}>
|
||||
<Spacer />
|
||||
<RefreshButton onClick={refetch} isFetching={isFetching} />
|
||||
</CardHeader>
|
||||
)}
|
||||
<SimpleGrid minChildWidth="500px" spacing="20px" p={4}>
|
||||
{endpointsList}
|
||||
</SimpleGrid>
|
||||
</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"
|
||||
>
|
||||
<SystemSecretsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
194
src/pages/VenuePage/Layout/AnalyticsCard/DatePickers.tsx
Normal file
194
src/pages/VenuePage/Layout/AnalyticsCard/DatePickers.tsx
Normal 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;
|
||||
@@ -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 AssociationCircle = ({ node, style, handleClicks }) => {
|
||||
const AssociationCirclePack = ({
|
||||
node,
|
||||
style,
|
||||
handleClicks,
|
||||
}: {
|
||||
node: ComputedDatum<AssociationCircle>;
|
||||
style: {
|
||||
x: SpringValue<number>;
|
||||
y: SpringValue<number>;
|
||||
radius: Interpolation<number>;
|
||||
textColor: SpringValue<string>;
|
||||
opacity: SpringValue<number>;
|
||||
};
|
||||
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;
|
||||
@@ -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 DeviceCircle = ({ node, style, handleClicks }) => {
|
||||
const DeviceCirclePack = ({
|
||||
node,
|
||||
style,
|
||||
handleClicks,
|
||||
}: {
|
||||
node: ComputedDatum<DeviceCircleInfo>;
|
||||
style: {
|
||||
x: SpringValue<number>;
|
||||
y: SpringValue<number>;
|
||||
radius: Interpolation<number>;
|
||||
textColor: SpringValue<string>;
|
||||
opacity: SpringValue<number>;
|
||||
};
|
||||
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;
|
||||
@@ -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 RadioCircle = ({ node, style, handleClicks }) => {
|
||||
const RadioCirclePack = ({
|
||||
node,
|
||||
style,
|
||||
handleClicks,
|
||||
}: {
|
||||
node: ComputedDatum<RadioCircle>;
|
||||
style: {
|
||||
x: SpringValue<number>;
|
||||
y: SpringValue<number>;
|
||||
radius: Interpolation<number>;
|
||||
textColor: SpringValue<string>;
|
||||
opacity: SpringValue<number>;
|
||||
};
|
||||
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;
|
||||
@@ -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 SsidCircle = ({ node, style, handleClicks }) => {
|
||||
const SsidCirclePack = ({
|
||||
node,
|
||||
style,
|
||||
handleClicks,
|
||||
}: {
|
||||
node: ComputedDatum<SsidCircle>;
|
||||
style: {
|
||||
x: SpringValue<number>;
|
||||
y: SpringValue<number>;
|
||||
radius: Interpolation<number>;
|
||||
textColor: SpringValue<string>;
|
||||
opacity: SpringValue<number>;
|
||||
};
|
||||
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;
|
||||
@@ -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 VenueCircle = ({ node, style, handleClicks }) => {
|
||||
const VenueCirclePack = ({
|
||||
node,
|
||||
style,
|
||||
handleClicks,
|
||||
}: {
|
||||
node: ComputedDatum<CirclePackRoot>;
|
||||
style: {
|
||||
x: SpringValue<number>;
|
||||
y: SpringValue<number>;
|
||||
radius: Interpolation<number>;
|
||||
textColor: SpringValue<string>;
|
||||
opacity: SpringValue<number>;
|
||||
};
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
64
src/pages/VenuePage/Layout/AnalyticsCard/LiveView/index.tsx
Normal file
64
src/pages/VenuePage/Layout/AnalyticsCard/LiveView/index.tsx
Normal 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);
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
186
src/pages/VenuePage/Layout/AnalyticsCard/StartAnalyticsModal.tsx
Normal file
186
src/pages/VenuePage/Layout/AnalyticsCard/StartAnalyticsModal.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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 = (
|
||||
{
|
||||
macs,
|
||||
setMac
|
||||
}: {
|
||||
macs?: string[]
|
||||
setMac: React.Dispatch<React.SetStateAction<string | undefined>>
|
||||
}
|
||||
) => {
|
||||
const MacSearchBar: React.FC<{ macs?: string[]; setMac: React.Dispatch<React.SetStateAction<string | undefined>> }> = ({
|
||||
macs,
|
||||
setMac,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -57,7 +57,6 @@ const DeviceTypeStat = ({ data, handleModalClick }) => {
|
||||
},
|
||||
],
|
||||
})}
|
||||
mb={4}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -57,7 +57,6 @@ const FirmwareStat = ({ data, handleModalClick }) => {
|
||||
},
|
||||
],
|
||||
})}
|
||||
mb={4}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -36,7 +36,6 @@ const HealthStat = ({ data, handleModalClick }) => {
|
||||
],
|
||||
})}
|
||||
color={getHealthColor()}
|
||||
mb={4}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user