[WIFI-12435] [WIFI-12436] Device table added functionality and styling fixes

Signed-off-by: Charles <charles.bourque96@gmail.com>
This commit is contained in:
Charles
2023-04-10 10:51:01 +02:00
parent f1f62efe6f
commit ad5b0ce2a0
24 changed files with 1305 additions and 166 deletions

49
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "ucentral-client",
"version": "2.9.0(23)",
"version": "2.10.0(5)",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "ucentral-client",
"version": "2.9.0(23)",
"version": "2.10.0(5)",
"license": "ISC",
"dependencies": {
"@chakra-ui/icons": "^2.0.11",
@@ -20,6 +20,7 @@
"@googlemaps/typescript-guards": "^2.0.3",
"@react-spring/web": "^9.5.5",
"@tanstack/react-query": "^4.12.0",
"@tanstack/react-table": "^8.7.9",
"@textea/json-viewer": "^2.10.0",
"axios": "^1.1.3",
"buffer": "^6.0.3",
@@ -3496,6 +3497,37 @@
}
}
},
"node_modules/@tanstack/react-table": {
"version": "8.8.5",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.8.5.tgz",
"integrity": "sha512-g/t21E/ICHvaCOJOhsDNr5QaB/6aDQEHFbx/YliwwU/CJThMqG+dS28vnToIBV/5MBgpeXoGRi2waDJVJlZrtg==",
"dependencies": {
"@tanstack/table-core": "8.8.5"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.8.5",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.8.5.tgz",
"integrity": "sha512-Xnwa1qxpgvSW7ozLiexmKp2PIYcLBiY/IizbdGriYCL6OOHuZ9baRhrrH51zjyz+61ly6K59rmt6AI/3RR+97Q==",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@textea/json-viewer": {
"version": "2.10.0",
"license": "MIT",
@@ -11823,6 +11855,19 @@
"use-sync-external-store": "^1.2.0"
}
},
"@tanstack/react-table": {
"version": "8.8.5",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.8.5.tgz",
"integrity": "sha512-g/t21E/ICHvaCOJOhsDNr5QaB/6aDQEHFbx/YliwwU/CJThMqG+dS28vnToIBV/5MBgpeXoGRi2waDJVJlZrtg==",
"requires": {
"@tanstack/table-core": "8.8.5"
}
},
"@tanstack/table-core": {
"version": "8.8.5",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.8.5.tgz",
"integrity": "sha512-Xnwa1qxpgvSW7ozLiexmKp2PIYcLBiY/IizbdGriYCL6OOHuZ9baRhrrH51zjyz+61ly6K59rmt6AI/3RR+97Q=="
},
"@textea/json-viewer": {
"version": "2.10.0",
"requires": {

View File

@@ -1,6 +1,6 @@
{
"name": "ucentral-client",
"version": "2.9.0(23)",
"version": "2.10.0(5)",
"description": "",
"private": true,
"main": "index.tsx",
@@ -51,6 +51,7 @@
"react-i18next": "^11.18.6",
"react-masonry-css": "^1.0.16",
"@tanstack/react-query": "^4.12.0",
"@tanstack/react-table": "^8.7.9",
"react-router-dom": "^6.4.2",
"react-table": "^7.8.0",
"react-virtualized-auto-sizer": "^1.0.7",

23
src/@tanstack.react-table.d.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { BoxProps } from '@chakra-ui/react';
import '@tanstack/react-table';
declare module '@tanstack/table-core' {
interface ColumnMeta<TData extends RowData, TValue> {
stopPropagation?: boolean;
alwaysShow?: boolean;
hasPopover?: boolean;
customMaxWidth?: string;
customMinWidth?: string;
customWidth?: string;
isMonospace?: boolean;
isCentered?: boolean;
columnSelectorOptions?: {
label?: string;
};
headerOptions?: {
tooltip?: string;
};
headerStyleProps?: BoxProps;
}
}

View File

@@ -0,0 +1,62 @@
import * as React from 'react';
import { Td, Tr } from '@chakra-ui/react';
import { Row, flexRender } from '@tanstack/react-table';
export type DataGridCellRowProps<TValue extends object> = {
row: Row<TValue>;
onRowClick: ((row: TValue) => (() => void) | undefined) | undefined;
rowStyle: {
hoveredRowBg: string;
};
};
export const DataGridCellRow = <TValue extends object>({
row,
rowStyle: { hoveredRowBg },
onRowClick,
}: DataGridCellRowProps<TValue>) => {
const onClick = onRowClick ? onRowClick(row.original) : undefined;
return (
<Tr
key={row.id}
_hover={{
backgroundColor: hoveredRowBg,
}}
onClick={onClick}
>
{row.getVisibleCells().map((cell) => (
<Td
px={1}
key={cell.id}
textOverflow="ellipsis"
overflow="hidden"
whiteSpace="nowrap"
minWidth={cell.column.columnDef.meta?.customMinWidth ?? undefined}
maxWidth={cell.column.columnDef.meta?.customMaxWidth ?? undefined}
width={cell.column.columnDef.meta?.customWidth}
textAlign={cell.column.columnDef.meta?.isCentered ? 'center' : undefined}
fontFamily={
cell.column.columnDef.meta?.isMonospace
? 'Inter, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
: undefined
}
onClick={
cell.column.columnDef.meta?.stopPropagation || (cell.column.id === 'actions' && onClick)
? (e) => {
e.stopPropagation();
}
: undefined
}
cursor={
!cell.column.columnDef.meta?.stopPropagation && cell.column.id !== 'actions' && onClick
? 'pointer'
: undefined
}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Td>
))}
</Tr>
);
};

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { Box, Checkbox, IconButton, Menu, MenuButton, MenuItem, MenuList, Tooltip } from '@chakra-ui/react';
import { VisibilityState } from '@tanstack/react-table';
import { FunnelSimple } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import { DataGridColumn } from './useDataGrid';
import { useAuth } from 'contexts/AuthProvider';
export type DataGridColumnPickerProps<TValue extends object> = {
preference: string;
columns: DataGridColumn<TValue>[];
columnVisibility: VisibilityState;
setColumnVisibility: (str: VisibilityState) => void;
};
export const DataGridColumnPicker = <TValue extends object>({
preference,
columns,
columnVisibility,
setColumnVisibility,
}: DataGridColumnPickerProps<TValue>) => {
const { t } = useTranslation();
const { getPref, setPref } = useAuth();
const handleColumnClick = React.useCallback(
(id: string) => {
const newVisibility = { ...columnVisibility };
newVisibility[id] = newVisibility[id] !== undefined ? !newVisibility[id] : false;
const hiddenColumnsArray = Object.entries(newVisibility)
.filter(([, value]) => !value)
.map(([key]) => key);
setPref({ preference, value: hiddenColumnsArray.join(',') });
setColumnVisibility({ ...newVisibility });
},
[columnVisibility],
);
React.useEffect(() => {
const savedPrefs = getPref(preference);
if (savedPrefs) {
const savedHiddenColumns = savedPrefs.split(',');
setColumnVisibility(savedHiddenColumns.reduce((acc, curr) => ({ ...acc, [curr]: false }), {}));
} else {
setColumnVisibility({});
}
}, [preference]);
return (
<Box>
<Menu closeOnSelect={false} isLazy>
<Tooltip label={t('common.columns')} hasArrow>
<MenuButton as={IconButton} icon={<FunnelSimple />} />
</Tooltip>
<MenuList maxH="200px" overflowY="auto">
{columns
.filter((col) => col.id && col.header)
.map((column) => {
const handleClick =
column.id !== undefined ? () => handleColumnClick(column.id as unknown as string) : undefined;
const id = column.id ?? uuid();
let label = column.header?.toString() ?? 'Unrecognized column';
if (column.meta?.columnSelectorOptions?.label) label = column.meta.columnSelectorOptions.label;
return (
<MenuItem
key={id}
as={Checkbox}
isChecked={columnVisibility[id] === undefined || columnVisibility[id]}
onChange={column.meta?.alwaysShow ? undefined : handleClick}
isDisabled={column.meta?.alwaysShow}
>
{label}
</MenuItem>
);
})}
</MenuList>
</Menu>
</Box>
);
};

View File

@@ -0,0 +1,43 @@
import * as React from 'react';
import { Box, Flex, Th, Tooltip, Tr } from '@chakra-ui/react';
import { HeaderGroup, flexRender } from '@tanstack/react-table';
import { DataGridSortIcon } from './SortIcon';
export type DataGridHeaderRowProps<TValue extends object> = {
headerGroup: HeaderGroup<TValue>;
};
export const DataGridHeaderRow = <TValue extends object>({ headerGroup }: DataGridHeaderRowProps<TValue>) => (
<Tr p={0}>
{headerGroup.headers.map((header) => (
<Th
color="gray.400"
key={header.id}
colSpan={header.colSpan}
minWidth={header.column.columnDef.meta?.customMinWidth ?? undefined}
maxWidth={header.column.columnDef.meta?.customMaxWidth ?? undefined}
width={header.column.columnDef.meta?.customWidth}
fontSize="sm"
onClick={header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined}
cursor={header.column.getCanSort() ? 'pointer' : undefined}
>
<Flex display="flex" alignItems="center">
{header.isPlaceholder ? null : (
<Tooltip label={header.column.columnDef.meta?.headerOptions?.tooltip}>
<Box
overflow="hidden"
whiteSpace="nowrap"
alignContent="center"
width="100%"
{...header.column.columnDef.meta?.headerStyleProps}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</Box>
</Tooltip>
)}
<DataGridSortIcon sortInfo={header.column.getIsSorted()} canSort={header.column.getCanSort()} />
</Flex>
</Th>
))}
</Tr>
);

View File

@@ -0,0 +1,124 @@
import * as React from 'react';
import { ArrowLeftIcon, ArrowRightIcon, ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
import {
Tooltip,
Flex,
IconButton,
Text,
Select,
NumberInput,
NumberInputField,
NumberInputStepper,
NumberIncrementStepper,
NumberDecrementStepper,
} from '@chakra-ui/react';
import { Table } from '@tanstack/react-table';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import { useContainerDimensions } from 'hooks/useContainerDimensions';
type Props<T extends object> = {
table: Table<T>;
isDisabled?: boolean;
};
const DataGridControls = <T extends object>({ table, isDisabled }: Props<T>) => {
const { t } = useTranslation();
const { ref, dimensions } = useContainerDimensions({ precision: 100 });
const isCompact = dimensions.width !== 0 && dimensions.width <= 800;
return (
<Flex ref={ref} justifyContent="space-between" m={4} alignItems="center">
<Flex>
<Tooltip label={t('table.first_page')}>
<IconButton
aria-label="Go to first page"
onClick={() => table.setPageIndex(0)}
isDisabled={isDisabled || !table.getCanPreviousPage()}
icon={<ArrowLeftIcon h={3} w={3} />}
mr={4}
/>
</Tooltip>
<Tooltip label={t('table.previous_page')}>
<IconButton
aria-label="Previous page"
onClick={() => table.previousPage()}
isDisabled={isDisabled || !table.getCanPreviousPage()}
icon={<ChevronLeftIcon h={6} w={6} />}
/>
</Tooltip>
</Flex>
<Flex alignItems="center">
{isCompact ? null : (
<>
<Text flexShrink={0} mr={8}>
{t('table.page')}{' '}
<Text fontWeight="bold" as="span">
{table.getState().pagination.pageIndex + 1}
</Text>{' '}
{t('common.of')}{' '}
<Text fontWeight="bold" as="span">
{table.getPageCount()}
</Text>
</Text>
<Text flexShrink={0}>{t('table.go_to_page')}</Text>{' '}
<NumberInput
ml={2}
mr={8}
w={28}
min={1}
max={table.getPageCount()}
onChange={(_, numberValue) => {
const newPage = numberValue ? numberValue - 1 : 0;
table.setPageIndex(newPage);
}}
value={table.getState().pagination.pageIndex + 1}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</>
)}
<Select
w={32}
value={table.getState().pagination.pageSize}
onChange={(e) => {
table.setPageSize(Number(e.target.value));
}}
>
{[10, 20, 30, 40, 50].map((opt) => (
<option key={uuid()} value={opt}>
{t('common.show')} {opt}
</option>
))}
</Select>
</Flex>
<Flex>
<Tooltip label={t('table.next_page')}>
<IconButton
aria-label="Go to next page"
onClick={() => table.nextPage()}
isDisabled={isDisabled || !table.getCanNextPage()}
icon={<ChevronRightIcon h={6} w={6} />}
/>
</Tooltip>
<Tooltip label={t('table.last_page')}>
<IconButton
aria-label="Go to last page"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
isDisabled={isDisabled || !table.getCanNextPage()}
icon={<ArrowRightIcon h={3} w={3} />}
ml={4}
/>
</Tooltip>
</Flex>
</Flex>
);
};
export default DataGridControls;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { Icon } from '@chakra-ui/react';
import { SortDirection } from '@tanstack/react-table';
import { ArrowDown, ArrowUp, Circle } from 'phosphor-react';
export type DataGridSortIconProps = {
sortInfo: false | SortDirection;
canSort: boolean;
};
export const DataGridSortIcon = ({ sortInfo, canSort }: DataGridSortIconProps) => {
if (canSort) {
if (sortInfo) {
return sortInfo === 'desc' ? (
<Icon ml={1} boxSize={3} as={ArrowDown} />
) : (
<Icon ml={1} boxSize={3} as={ArrowUp} />
);
}
return <Icon ml={1} boxSize={3} as={Circle} />;
}
return null;
};

View File

@@ -0,0 +1,189 @@
import React from 'react';
import {
Box,
Center,
Flex,
HStack,
Heading,
LayoutProps,
Spacer,
Spinner,
Table,
TableContainer,
Tbody,
Thead,
useColorModeValue,
} from '@chakra-ui/react';
import { getCoreRowModel, getPaginationRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table';
import { useTranslation } from 'react-i18next';
import { DataGridCellRow } from './CellRow';
import { DataGridColumnPicker } from './DataGridColumnPicker';
import { DataGridHeaderRow } from './HeaderRow';
import DataGridControls from './Input';
import { DataGridColumn, UseDataGridReturn } from './useDataGrid';
import { RefreshButton } from 'components/Buttons/RefreshButton';
import { LoadingOverlay } from 'components/LoadingOverlay';
export type ColumnOptions = {
isSortable?: boolean;
};
export type DataGridOptions<TValue extends object> = {
count?: number;
isFullScreen?: boolean;
isHidingControls?: boolean;
isManual?: boolean;
minimumHeight?: LayoutProps['minH'];
onRowClick?: (row: TValue) => (() => void) | undefined;
refetch?: () => void;
};
export type DataGridProps<TValue extends object> = {
controller: UseDataGridReturn;
columns: DataGridColumn<TValue>[];
header: {
title: string;
objectListed: string;
leftContent?: React.ReactNode;
addButton?: React.ReactNode;
otherButtons?: React.ReactNode;
};
data?: TValue[];
isLoading?: boolean;
options?: DataGridOptions<TValue>;
};
export const DataGrid = <TValue extends object>({
controller,
columns,
header,
data = [],
options = {},
isLoading = false,
}: DataGridProps<TValue>) => {
const { t } = useTranslation();
/*
Table Styling
*/
const textColor = useColorModeValue('gray.700', 'white');
const hoveredRowBg = useColorModeValue('gray.100', 'gray.600');
const minimumHeight: LayoutProps['minH'] = React.useMemo(() => {
if (options.isFullScreen) {
return { base: 'calc(100vh - 360px)', md: 'calc(100vh - 288px)' };
}
return options.minimumHeight ?? '300px';
}, [options.isFullScreen, options.minimumHeight]);
/*
Table Options
*/
const onRowClick = React.useMemo(() => options.onRowClick, [options.onRowClick]);
const pagination = React.useMemo(
() => ({
pageIndex: controller.pageInfo.pageIndex,
pageSize: controller.pageInfo.pageSize,
}),
[controller.pageInfo.pageIndex, controller.pageInfo.pageSize],
);
const pageCount = React.useMemo(() => {
if (options.isManual && options.count) {
return Math.ceil(options.count / pagination.pageSize);
}
return Math.ceil((data?.length ?? 0) / pagination.pageSize);
}, [options.count, options.isManual, data?.length, pagination.pageSize]);
const tableOptions = React.useMemo(
() => ({
pageCount: pageCount > 0 ? pageCount : 1,
initialState: { sorting: controller.sortBy, pagination },
manualPagination: options.isManual,
manualSorting: options.isManual,
autoResetPageIndex: false,
}),
[options.isManual, controller.sortBy, pageCount],
);
const table = useReactTable<TValue>({
// react-table base functions
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
// Table State
data,
columns,
state: {
sorting: controller.sortBy,
columnVisibility: controller.columnVisibility,
pagination,
},
// Change Handlers
onSortingChange: controller.setSortBy,
onPaginationChange: controller.onPaginationChange,
// debugTable: true,
// Table Options
...tableOptions,
});
if (isLoading && data.length === 0) {
return (
<Center>
<Spinner size="xl" />
</Center>
);
}
return (
<Box w="100%">
<Flex mb={2}>
<Heading size="md" my="auto" mr={2}>
{header.title}
</Heading>
{header.leftContent}
<Spacer />
<HStack spacing={2}>
{header.otherButtons}
{header.addButton}
<DataGridColumnPicker
columns={columns}
columnVisibility={controller.columnVisibility}
setColumnVisibility={controller.setColumnVisibility}
preference={`${controller.tableSettingsId}.hiddenColumns`}
/>
{options.refetch ? <RefreshButton onClick={options.refetch} isCompact isFetching={isLoading} /> : null}
</HStack>
</Flex>
<LoadingOverlay isLoading={isLoading}>
<TableContainer minH={minimumHeight}>
<Table size="small" textColor={textColor} w="100%" fontSize="14px">
<Thead>
{table.getHeaderGroups().map((headerGroup) => (
<DataGridHeaderRow<TValue> key={headerGroup.id} headerGroup={headerGroup} />
))}
</Thead>
<Tbody>
{table.getRowModel().rows.map((row) => (
<DataGridCellRow<TValue> key={row.id} row={row} onRowClick={onRowClick} rowStyle={{ hoveredRowBg }} />
))}
</Tbody>
</Table>
{data?.length === 0 ? (
<Center mt={8}>
<Heading size="md">
{header.objectListed ? t('common.no_obj_found', { obj: header.objectListed }) : t('common.empty_list')}
</Heading>
</Center>
) : null}
</TableContainer>
</LoadingOverlay>
{!options.isHidingControls ? <DataGridControls table={table} isDisabled={isLoading} /> : null}
</Box>
);
};

View File

@@ -0,0 +1,82 @@
import * as React from 'react';
import {
ColumnDef,
OnChangeFn,
PaginationState,
SortingColumnDef,
SortingState,
VisibilityState,
} from '@tanstack/react-table';
const getDefaultSettings = (settings?: string) => {
let limit = 10;
let index = 0;
if (settings) {
const savedSizeSetting = localStorage.getItem(settings);
if (savedSizeSetting) {
try {
limit = parseInt(savedSizeSetting, 10);
} catch (e) {
limit = 10;
}
}
const savedPageSetting = localStorage.getItem(`${settings}.page`);
if (savedPageSetting) {
try {
index = parseInt(savedPageSetting, 10);
} catch (e) {
index = 0;
}
}
}
return {
pageSize: limit,
pageIndex: index,
};
};
export type DataGridColumn<T> = ColumnDef<T> & SortingColumnDef<T> & { id: string };
export type UseDataGridReturn = {
tableSettingsId: string;
pageInfo: PaginationState;
columnVisibility: VisibilityState;
setColumnVisibility: React.Dispatch<React.SetStateAction<VisibilityState>>;
sortBy: SortingState;
setSortBy: React.Dispatch<React.SetStateAction<SortingState>>;
onPaginationChange: OnChangeFn<PaginationState>;
};
export type UseDataGridProps = {
tableSettingsId: string;
defaultSortBy?: SortingState;
};
export const useDataGrid = ({ tableSettingsId, defaultSortBy }: UseDataGridProps): UseDataGridReturn => {
const [sortBy, setSortBy] = React.useState<SortingState>(defaultSortBy ?? []);
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
const [pageInfo, setPageInfo] = React.useState<PaginationState>(getDefaultSettings(tableSettingsId));
React.useEffect(() => {
if (tableSettingsId) {
localStorage.setItem(`${tableSettingsId}.page`, String(pageInfo.pageIndex));
if (tableSettingsId) localStorage.setItem(`${tableSettingsId}`, String(pageInfo.pageSize));
}
}, [pageInfo.pageIndex, pageInfo.pageSize]);
return React.useMemo(
() => ({
tableSettingsId,
pageInfo,
sortBy,
setSortBy,
columnVisibility,
setColumnVisibility,
onPaginationChange: setPageInfo,
}),
[pageInfo, columnVisibility, sortBy],
);
};

View File

@@ -1,18 +1,19 @@
import React from 'react';
import { Tooltip } from '@chakra-ui/react';
import { compactDate, formatDaysAgo } from 'helpers/dateFormatting';
import { compactDate, formatDaysAgo, formatDaysAgoCompact } from 'helpers/dateFormatting';
type Props = { date?: number; hidePrefix?: boolean };
type Props = { date?: number; hidePrefix?: boolean; isCompact?: boolean };
const getDaysAgo = ({ date, hidePrefix }: { date?: number; hidePrefix?: boolean }) => {
const getDaysAgo = ({ date, hidePrefix, isCompact }: { date?: number; hidePrefix?: boolean; isCompact?: boolean }) => {
if (!date || date === 0) return '-';
if (isCompact)
return hidePrefix ? formatDaysAgoCompact(date).split(' ').slice(1).join(' ') : formatDaysAgoCompact(date);
return hidePrefix ? formatDaysAgo(date).split(' ').slice(1).join(' ') : formatDaysAgo(date);
};
const FormattedDate = ({ date, hidePrefix }: Props) => (
const FormattedDate = ({ date, hidePrefix, isCompact }: Props) => (
<Tooltip hasArrow placement="top" label={compactDate(date ?? 0)}>
{getDaysAgo({ date, hidePrefix })}
{getDaysAgo({ date, hidePrefix, isCompact })}
</Tooltip>
);

View File

@@ -1,14 +1,16 @@
import React, { useMemo } from 'react';
import { Box, BoxProps } from '@chakra-ui/react';
import { bytesString } from 'helpers/stringHelper';
const DataCell: React.FC<{ bytes?: number }> = ({ bytes }) => {
type Props = { bytes?: number; showZerosAs?: string; boxProps?: BoxProps };
const DataCell = ({ bytes, showZerosAs, boxProps }: Props) => {
const data = useMemo(() => {
if (bytes === undefined) return '-';
if (showZerosAs && bytes === 0) return showZerosAs;
return bytesString(bytes);
}, [bytes]);
return <div>{data}</div>;
return <Box {...boxProps}>{data}</Box>;
};
export default React.memo(DataCell);

View File

@@ -0,0 +1,17 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { secondsDuration } from 'helpers/dateFormatting';
const DurationCell: React.FC<{ seconds?: number }> = ({ seconds }) => {
const { t } = useTranslation();
const data = useMemo(() => {
if (seconds === undefined) return '-';
return secondsDuration(seconds, t);
}, [seconds]);
return <div>{data}</div>;
};
export default React.memo(DurationCell);

View File

@@ -1,13 +1,20 @@
import React, { useMemo } from 'react';
import React from 'react';
import { Box, BoxProps } from '@chakra-ui/react';
const NumberCell = ({ value }: { value?: number }) => {
const data = useMemo(() => {
type Props = {
value?: number;
boxProps?: BoxProps;
showZerosAs?: string;
};
const NumberCell = ({ value, boxProps, showZerosAs }: Props) => {
const getData = () => {
if (value === undefined) return '-';
if (value === 0 && showZerosAs) return showZerosAs;
return value.toLocaleString();
}, [value]);
};
return <div>{data}</div>;
return <Box {...boxProps}>{getData()}</Box>;
};
export default React.memo(NumberCell);

View File

@@ -126,3 +126,40 @@ export const dateForFilename = (dateString: number) => {
date.getDate(),
)}_${twoDigitNumber(date.getHours())}h${twoDigitNumber(date.getMinutes())}m${twoDigitNumber(date.getSeconds())}s`;
};
export const formatDaysAgoCompact = (d1: number, d2: number = new Date().getTime()) => {
try {
const convertedTimestamp = unixToDateString(d1);
const date = new Date(convertedTimestamp).getTime();
const elapsed = date - d2;
for (const key of Object.keys(UNITS)) {
if (
Math.abs(elapsed) > UNITS[key as 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second'] ||
key === 'second'
) {
const result = RTF.format(
Math.round(elapsed / UNITS[key as 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second']),
key as Intl.RelativeTimeFormatUnit,
);
return result
.replace(' years', 'y')
.replace(' year', 'y')
.replace(' months', 'm')
.replace(' month', 'm')
.replace(' days', 'd')
.replace(' day', 'd')
.replace(' hours', 'h')
.replace(' hour', 'h')
.replace(' minutes', 'm')
.replace(' minute', 'm')
.replace(' seconds', 's')
.replace(' second', 's');
}
}
return compactDate(date);
} catch {
return '-';
}
};

View File

@@ -51,23 +51,30 @@ export type DeviceWithStatus = {
entity: string;
firmware: string;
fwUpdatePolicy: string;
hasGPS: boolean;
hasRADIUSSessions: number | boolean;
ipAddress: string;
lastConfigurationChange: number;
lastConfigurationDownload: number;
lastContact: number | string;
lastFWUpdate: number;
load: number;
locale: string;
location: string;
macAddress: string;
manufacturer: string;
memoryUsed: number;
messageCount: number;
modified: number;
notes: Note[];
owner: string;
sanity: number;
started: number;
restrictedDevice: boolean;
rxBytes: number;
serialNumber: string;
subscriber: string;
temperature: number;
txBytes: number;
venue: string;
verifiedCertificate: string;

View File

@@ -55,4 +55,5 @@ export const useGetTag = ({ serialNumber, onError }: { serialNumber?: string; on
useQuery(['tag', serialNumber], () => getTag(serialNumber), {
enabled: serialNumber !== undefined && serialNumber !== '',
onError,
staleTime: 1000 * 60 * 5,
});

View File

@@ -0,0 +1,54 @@
import * as React from 'react';
const roundToNearest = (num: number, precision: number) => {
const factor = 1 / precision;
return Math.round(num * factor) / factor;
};
export type UseContainerDimensionsProps = {
precision?: 10 | 100 | 1000;
};
export const useContainerDimensions = ({ precision }: UseContainerDimensionsProps) => {
const ref = React.useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = React.useState({ width: 0, height: 0 });
React.useEffect(() => {
const getDimensions = () => ({
width: (ref && ref.current?.offsetWidth) || 0,
height: (ref && ref.current?.offsetHeight) || 0,
});
const handleResize = () => {
const { width, height } = getDimensions();
if (!precision) {
if (dimensions.width !== width && dimensions.height !== height) setDimensions({ width, height });
} else {
const newDimensions = { width, height };
newDimensions.width = roundToNearest(newDimensions.width, precision);
newDimensions.height = roundToNearest(newDimensions.height, precision);
if (newDimensions.width !== dimensions.width || newDimensions.height !== dimensions.height) {
setDimensions(newDimensions);
}
}
};
if (ref.current) {
handleResize();
}
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [ref, dimensions]);
return React.useMemo(
() => ({
dimensions,
ref,
}),
[dimensions.height, dimensions.width],
);
};

View File

@@ -28,7 +28,6 @@ const DeviceDetails = ({ serialNumber }: Props) => {
? getDevice.data?.devicePassword
: 'openwifi',
);
const getPassword = () => {
if (!getDevice.data) return '-';
if (isShowingPassword) {

View File

@@ -1,5 +1,15 @@
import * as React from 'react';
import { Box, Button, Flex, FormControl, FormLabel, useDisclosure } from '@chakra-ui/react';
import {
Box,
Button,
Flex,
FormControl,
FormLabel,
Icon,
Tooltip,
useColorModeValue,
useDisclosure,
} from '@chakra-ui/react';
import { Wrapper } from '@googlemaps/react-wrapper';
import { Globe } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
@@ -11,13 +21,15 @@ import { useGetDeviceLastStats } from 'hooks/Network/Statistics';
type Props = {
serialNumber: string;
isCompact?: boolean;
};
const LocationDisplayButton = ({ serialNumber }: Props) => {
const LocationDisplayButton = ({ serialNumber, isCompact }: Props) => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const getGoogleApiKey = useGetSystemSecret({ secret: 'google.maps.apikey' });
const getLastStats = useGetDeviceLastStats({ serialNumber });
const iconColor = useColorModeValue('blue.500', 'blue.200');
const location: google.maps.LatLngLiteral | undefined = React.useMemo(() => {
if (!getLastStats.data?.gps) return undefined;
@@ -38,9 +50,15 @@ const LocationDisplayButton = ({ serialNumber }: Props) => {
return (
<>
{isCompact ? (
<Tooltip label={t('locations.view_gps')}>
<Icon as={Globe} boxSize={6} onClick={onOpen} color={iconColor} cursor="pointer" />
</Tooltip>
) : (
<Button variant="link" onClick={onOpen} rightIcon={<Globe size={20} />} colorScheme="blue">
{t('locations.view_gps')}
</Button>
)}
<Modal isOpen={isOpen} onClose={onClose} title={t('locations.one')}>
<Box w="100%" h="100%">
<Flex mb={4}>

View File

@@ -0,0 +1,19 @@
import * as React from 'react';
import { Center } from '@chakra-ui/react';
import { DeviceWithStatus } from 'hooks/Network/Devices';
import LocationDisplayButton from 'pages/Device/LocationDisplayButton';
type Props = {
device: DeviceWithStatus;
};
const DeviceTableGpsCell = ({ device }: Props) => {
if (!device.hasGPS) return <Center>-</Center>;
return (
<Center>
<LocationDisplayButton serialNumber={device.serialNumber} isCompact />
</Center>
);
};
export default DeviceTableGpsCell;

View File

@@ -0,0 +1,40 @@
import * as React from 'react';
import { Link } from '@chakra-ui/react';
import { DeviceWithStatus } from 'hooks/Network/Devices';
import { useGetProvUi } from 'hooks/Network/Endpoints';
import { useGetTag } from 'hooks/Network/Inventory';
type Props = {
device: DeviceWithStatus;
};
const ProvisioningStatusCell = ({ device }: Props) => {
const getProvUi = useGetProvUi();
const getTag = useGetTag({ serialNumber: device.serialNumber });
const goToProvUi = (dir: string) => `${getProvUi.data}/#/${dir}`;
if (getTag.data?.extendedInfo?.entity?.name) {
return (
<Link isExternal href={goToProvUi(`entity/${getTag.data?.entity}`)}>
{getTag.data?.extendedInfo?.entity?.name}
</Link>
);
}
if (getTag.data?.extendedInfo?.venue?.name) {
return (
<Link isExternal href={goToProvUi(`venue/${getTag.data?.venue}`)}>
{getTag.data?.extendedInfo?.venue?.name}
</Link>
);
}
if (getTag.data?.extendedInfo?.subscriber?.name) {
return (
<Link isExternal href={goToProvUi(`venue/${getTag.data?.subscriber}`)}>
{getTag.data?.extendedInfo?.subscriber?.name}
</Link>
);
}
return <span>-</span>;
};
export default ProvisioningStatusCell;

View File

@@ -0,0 +1,17 @@
import * as React from 'react';
import DurationCell from 'components/TableCells/DurationCell';
import { DeviceWithStatus } from 'hooks/Network/Devices';
type Props = {
device: DeviceWithStatus;
};
const DeviceUptimeCell = ({ device }: Props) => {
if (!device.connected || device.started === 0) return <span>-</span>;
// Get the uptime in seconds from device.started which is UNIX timestamp
const uptime = Math.floor(Date.now() / 1000 - device.started);
return <DurationCell seconds={uptime} />;
};
export default DeviceUptimeCell;

View File

@@ -1,20 +1,29 @@
import * as React from 'react';
import { Box, Heading, Image, Link, Spacer, Tooltip, useDisclosure } from '@chakra-ui/react';
import { LockSimple } from 'phosphor-react';
import { Box, Center, Image, Link, Tag, TagLabel, TagRightIcon, Tooltip, useDisclosure } from '@chakra-ui/react';
import {
CheckCircle,
Heart,
HeartBreak,
LockSimple,
ThermometerCold,
ThermometerHot,
WarningCircle,
} from 'phosphor-react';
import ReactCountryFlag from 'react-country-flag';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Actions from './Actions';
import DeviceListFirmwareButton from './FirmwareButton';
import DeviceTableGpsCell from './GpsCell';
import AP from './icons/AP.png';
import IOT from './icons/IOT.png';
import MESH from './icons/MESH.png';
import SWITCH from './icons/SWITCH.png';
import { RefreshButton } from 'components/Buttons/RefreshButton';
import ProvisioningStatusCell from './ProvisioningStatusCell';
import DeviceUptimeCell from './Uptime';
import { CardBody } from 'components/Containers/Card/CardBody';
import { CardHeader } from 'components/Containers/Card/CardHeader';
import { ColumnPicker } from 'components/DataTables/ColumnPicker';
import { DataTable } from 'components/DataTables/DataTable';
import { DataGrid } from 'components/DataTables/DataGrid';
import { DataGridColumn, useDataGrid } from 'components/DataTables/DataGrid/useDataGrid';
import DeviceSearchBar from 'components/DeviceSearchBar';
import FormattedDate from 'components/InformationDisplays/FormattedDate';
import { ConfigureModal } from 'components/Modals/ConfigureModal';
@@ -30,8 +39,16 @@ import DataCell from 'components/TableCells/DataCell';
import NumberCell from 'components/TableCells/NumberCell';
import { DeviceWithStatus, useGetDeviceCount, useGetDevices } from 'hooks/Network/Devices';
import { FirmwareAgeResponse, useGetFirmwareAges } from 'hooks/Network/Firmware';
import { Column, PageInfo } from 'models/Table';
const fourDigitNumber = (v: number) => {
if (v === 0) {
return '0.00';
}
const str = v.toString();
const fourthChar = str.charAt(3);
if (fourthChar === '.') return `${str.slice(0, 3)}`;
return `${str.slice(0, 4)}`;
};
const ICON_STYLE = { width: '24px', height: '24px', borderRadius: '20px' };
const ICONS = {
@@ -52,8 +69,6 @@ const DeviceListCard = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [serialNumber, setSerialNumber] = React.useState<string>('');
const [hiddenColumns, setHiddenColumns] = React.useState<string[]>([]);
const [pageInfo, setPageInfo] = React.useState<PageInfo | undefined>(undefined);
const scanModalProps = useDisclosure();
const resetModalProps = useDisclosure();
const upgradeModalProps = useDisclosure();
@@ -63,9 +78,13 @@ const DeviceListCard = () => {
const configureModalProps = useDisclosure();
const rebootModalProps = useDisclosure();
const scriptModal = useScriptModal();
const tableController = useDataGrid({ tableSettingsId: 'gateway.devices.table' });
const getCount = useGetDeviceCount({ enabled: true });
const getDevices = useGetDevices({
pageInfo,
pageInfo: {
limit: tableController.pageInfo.pageSize,
index: tableController.pageInfo.pageIndex,
},
enabled: true,
});
const getAges = useGetFirmwareAges({
@@ -171,7 +190,7 @@ const DeviceListCard = () => {
const dataCell = React.useCallback(
(v: number) => (
<Box textAlign="right">
<DataCell bytes={v} />
<DataCell bytes={v} showZerosAs="-" />
</Box>
),
[],
@@ -185,12 +204,27 @@ const DeviceListCard = () => {
),
[],
);
const compactDateCell = React.useCallback(
(v?: number | string, hidePrefix?: boolean) =>
v !== undefined && typeof v === 'number' && v !== 0 ? (
<FormattedDate date={v as number} hidePrefix={hidePrefix} isCompact />
) : (
'-'
),
[],
);
const firmwareCell = React.useCallback(
(device: DeviceWithStatus & { age?: FirmwareAgeResponse }) => (
<DeviceListFirmwareButton device={device} age={device.age} onOpenUpgrade={onOpenUpgradeModal} />
),
[getAges],
);
const provCell = React.useCallback(
(device: DeviceWithStatus) =>
device.subscriber || device.entity || device.venue ? <ProvisioningStatusCell device={device} /> : '-',
[],
);
const uptimeCell = React.useCallback((device: DeviceWithStatus) => <DeviceUptimeCell device={device} />, []);
const localeCell = React.useCallback(
(device: DeviceWithStatus) => (
<Tooltip label={`${device.locale !== '' ? `${device.locale} - ` : ''}${device.ipAddress}`} placement="top">
@@ -198,13 +232,25 @@ const DeviceListCard = () => {
{device.locale !== '' && device.ipAddress !== '' && (
<ReactCountryFlag style={ICON_STYLE} countryCode={device.locale} svg />
)}
{` ${device.ipAddress}`}
{` ${device.ipAddress.length > 0 ? device.ipAddress : '-'}`}
</Box>
</Tooltip>
),
[],
);
const numberCell = React.useCallback((v?: number) => <NumberCell value={v !== undefined ? v : 0} />, []);
const gpsCell = React.useCallback((device: DeviceWithStatus) => <DeviceTableGpsCell device={device} />, []);
const numberCell = React.useCallback(
(v?: number) => (
<NumberCell
value={v !== undefined ? v : 0}
showZerosAs="-"
boxProps={{
textAlign: 'right',
}}
/>
),
[],
);
const actionCell = React.useCallback(
(device: DeviceWithStatus) => (
<Actions
@@ -224,135 +270,358 @@ const DeviceListCard = () => {
[],
);
const columns: Column<DeviceWithStatus>[] = React.useMemo(
(): Column<DeviceWithStatus>[] => [
const sanityCell = React.useCallback((device: DeviceWithStatus) => {
if (!device.connected) return <Center>-</Center>;
let colorScheme = 'red';
if (device.sanity >= 80) colorScheme = 'yellow';
if (device.sanity === 100) colorScheme = 'green';
return (
<Center>
<Tag borderRadius="full" variant="subtle" colorScheme={colorScheme}>
<TagLabel>{device.sanity}%</TagLabel>
<TagRightIcon marginStart="0.1rem" as={colorScheme === 'green' ? Heart : HeartBreak} />
</Tag>
</Center>
);
}, []);
const loadCell = React.useCallback((device: DeviceWithStatus) => {
if (!device.connected) return <Center>-</Center>;
let colorScheme = 'red';
if (device.load <= 20) colorScheme = 'yellow';
if (device.load <= 5) colorScheme = 'green';
return (
<Center>
<Tag borderRadius="full" variant="subtle" colorScheme={colorScheme}>
<TagLabel>{fourDigitNumber(device.load)}%</TagLabel>
<TagRightIcon marginStart="0.1rem" as={colorScheme === 'green' ? CheckCircle : WarningCircle} />
</Tag>
</Center>
);
}, []);
const memoryCell = React.useCallback((device: DeviceWithStatus) => {
if (!device.connected) return <Center>-</Center>;
let colorScheme = 'red';
if (device.memoryUsed <= 85) colorScheme = 'yellow';
if (device.memoryUsed <= 60) colorScheme = 'green';
return (
<Center>
<Tag borderRadius="full" variant="subtle" colorScheme={colorScheme}>
<TagLabel>{fourDigitNumber(device.memoryUsed)}%</TagLabel>
<TagRightIcon marginStart="0.1rem" as={colorScheme === 'green' ? CheckCircle : WarningCircle} />
</Tag>
</Center>
);
}, []);
const temperatureCell = React.useCallback((device: DeviceWithStatus) => {
if (!device.connected || device.temperature === 0) return <Center>-</Center>;
let colorScheme = 'red';
if (device.temperature <= 85) colorScheme = 'yellow';
if (device.temperature <= 75) colorScheme = 'green';
return (
<Center>
<Tag borderRadius="full" variant="subtle" colorScheme={colorScheme}>
<TagLabel>{fourDigitNumber(device.temperature)}°C</TagLabel>
<TagRightIcon marginStart="0.1rem" as={colorScheme === 'green' ? ThermometerCold : ThermometerHot} />
</Tag>
</Center>
);
}, []);
const columns: DataGridColumn<DeviceWithStatus>[] = React.useMemo(
(): DataGridColumn<DeviceWithStatus>[] => [
{
id: 'badge',
Header: '',
Footer: '',
accessor: 'badge',
Cell: (v) => badgeCell(v.cell.row.original),
header: '',
footer: '',
accessorKey: 'badge',
cell: (v) => badgeCell(v.cell.row.original),
enableSorting: false,
meta: {
customWidth: '35px',
alwaysShow: true,
disableSortBy: true,
},
},
{
id: 'serialNumber',
Header: t('inventory.serial_number'),
Footer: '',
accessor: 'serialNumber',
Cell: (v) => serialCell(v.cell.row.original),
header: t('inventory.serial_number'),
footer: '',
accessorKey: 'serialNumber',
cell: (v) => serialCell(v.cell.row.original),
enableSorting: false,
meta: {
alwaysShow: true,
customMaxWidth: '200px',
customWidth: '130px',
customMinWidth: '130px',
disableSortBy: true,
},
},
{
id: 'sanity',
header: t('devices.sanity'),
footer: '',
cell: (v) => sanityCell(v.cell.row.original),
enableSorting: false,
meta: {
headerStyleProps: {
textAlign: 'center',
},
},
},
{
id: 'memory',
header: t('analytics.memory'),
footer: '',
cell: (v) => memoryCell(v.cell.row.original),
enableSorting: false,
meta: {
headerStyleProps: {
textAlign: 'center',
},
},
},
{
id: 'load',
header: 'Load',
footer: '',
cell: (v) => loadCell(v.cell.row.original),
enableSorting: false,
meta: {
headerOptions: {
tooltip: 'CPU Load',
},
headerStyleProps: {
textAlign: 'center',
},
},
},
{
id: 'temperature',
header: 'Temp',
footer: '',
cell: (v) => temperatureCell(v.cell.row.original),
enableSorting: false,
meta: {
headerOptions: {
tooltip: t('analytics.temperature'),
},
columnSelectorOptions: {
label: t('analytics.temperature'),
},
headerStyleProps: {
textAlign: 'center',
},
},
},
{
id: 'firmware',
Header: t('commands.revision'),
Footer: '',
accessor: 'firmware',
Cell: (v) => firmwareCell(v.cell.row.original),
header: t('commands.revision'),
footer: '',
accessorKey: 'firmware',
cell: (v) => firmwareCell(v.cell.row.original),
enableSorting: false,
meta: {
stopPropagation: true,
customWidth: '50px',
disableSortBy: true,
},
},
{
id: 'compatible',
Header: t('common.type'),
Footer: '',
accessor: 'compatible',
customWidth: '50px',
disableSortBy: true,
header: t('common.type'),
footer: '',
accessorKey: 'compatible',
enableSorting: false,
},
{
id: 'IP',
Header: 'IP',
Footer: '',
accessor: 'IP',
Cell: (v) => localeCell(v.cell.row.original),
disableSortBy: true,
header: 'IP',
footer: '',
accessorKey: 'IP',
cell: (v) => localeCell(v.cell.row.original),
enableSorting: false,
meta: {
customMaxWidth: '140px',
customWidth: '130px',
customMinWidth: '130px',
},
},
{
id: 'provisioning',
header: 'Provisioning',
footer: '',
cell: (v) => provCell(v.cell.row.original),
enableSorting: false,
meta: {
stopPropagation: true,
},
},
{
id: 'radius',
header: 'Rad',
footer: '',
accessorKey: 'hasRADIUSSessions',
cell: (v) =>
numberCell(
typeof v.cell.row.original.hasRADIUSSessions === 'number' ? v.cell.row.original.hasRADIUSSessions : 0,
),
enableSorting: false,
meta: {
customWidth: '40px',
customMinWidth: '40px',
columnSelectorOptions: {
label: 'Radius Sessions',
},
headerOptions: {
tooltip: 'Current active radius sessions',
},
headerStyleProps: {
textAlign: 'right',
},
},
},
{
id: 'GPS',
header: 'GPS',
footer: '',
cell: (v) => gpsCell(v.cell.row.original),
enableSorting: false,
meta: {
customWidth: '32px',
stopPropagation: true,
},
},
{
id: 'uptime',
header: t('system.uptime'),
footer: '',
cell: (v) => uptimeCell(v.cell.row.original),
enableSorting: false,
},
{
id: 'lastContact',
Header: t('analytics.last_contact'),
Footer: '',
accessor: 'lastContact',
Cell: (v) => dateCell(v.cell.row.original.lastContact),
disableSortBy: true,
header: t('analytics.last_contact'),
footer: '',
accessorKey: 'lastContact',
cell: (v) => dateCell(v.cell.row.original.lastContact),
enableSorting: false,
},
{
id: 'lastFWUpdate',
Header: t('controller.devices.last_upgrade'),
Footer: '',
accessor: 'lastFWUpdate',
Cell: (v) => dateCell(v.cell.row.original.lastFWUpdate),
disableSortBy: true,
header: t('controller.devices.last_upgrade'),
footer: '',
accessorKey: 'lastFWUpdate',
cell: (v) => dateCell(v.cell.row.original.lastFWUpdate),
enableSorting: false,
},
{
id: 'rxBytes',
Header: 'Rx',
Footer: '',
accessor: 'rxBytes',
Cell: (v) => dataCell(v.cell.row.original.rxBytes),
header: 'Rx',
footer: '',
accessorKey: 'rxBytes',
cell: (v) => dataCell(v.cell.row.original.rxBytes),
enableSorting: false,
meta: {
customWidth: '50px',
disableSortBy: true,
headerStyleProps: {
textAlign: 'right',
},
},
},
{
id: 'txBytes',
Header: 'Tx',
Footer: '',
accessor: 'txBytes',
Cell: (v) => dataCell(v.cell.row.original.txBytes),
customWidth: '50px',
disableSortBy: true,
header: 'Tx',
footer: '',
accessorKey: 'txBytes',
cell: (v) => dataCell(v.cell.row.original.txBytes),
enableSorting: false,
meta: {
customWidth: '40px',
customMinWidth: '40px',
headerStyleProps: {
textAlign: 'right',
},
},
},
{
id: '2G',
Header: '2G',
Footer: '',
accessor: 'associations_2G',
Cell: (v) => numberCell(v.cell.row.original.associations_2G),
customWidth: '50px',
disableSortBy: true,
header: '2G',
footer: '',
accessorKey: 'associations_2G',
cell: (v) => numberCell(v.cell.row.original.associations_2G),
enableSorting: false,
meta: {
customWidth: '40px',
customMinWidth: '40px',
headerStyleProps: {
textAlign: 'right',
},
},
},
{
id: '5G',
Header: '5G',
Footer: '',
accessor: 'associations_5G',
Cell: (v) => numberCell(v.cell.row.original.associations_5G),
customWidth: '50px',
disableSortBy: true,
header: '5G',
footer: '',
accessorKey: 'associations_5G',
cell: (v) => numberCell(v.cell.row.original.associations_5G),
enableSorting: false,
meta: {
customWidth: '40px',
customMinWidth: '40px',
headerStyleProps: {
textAlign: 'right',
},
},
},
{
id: '6G',
Header: '6G',
Footer: '',
accessor: 'associations_6G',
Cell: (v) => numberCell(v.cell.row.original.associations_6G),
customWidth: '50px',
disableSortBy: true,
header: '6G',
footer: '',
accessorKey: 'associations_6G',
cell: (v) => numberCell(v.cell.row.original.associations_6G),
enableSorting: false,
meta: {
customWidth: '40px',
customMinWidth: '40px',
headerStyleProps: {
textAlign: 'right',
},
},
},
{
id: 'certificateExpiryDate',
Header: t('devices.certificate_expiry'),
Footer: '',
accessor: 'certificateExpiryDate',
Cell: (v) => dateCell(v.cell.row.original.certificateExpiryDate, true),
customWidth: '50px',
disableSortBy: true,
header: 'Exp',
footer: '',
accessorKey: 'certificateExpiryDate',
cell: (v) => compactDateCell(v.cell.row.original.certificateExpiryDate, true),
enableSorting: false,
meta: {
columnSelectorOptions: {
label: 'Certificate Expiry',
},
headerOptions: {
tooltip: 'Certificate Expiry Date',
},
},
},
{
id: 'actions',
Header: t('common.actions'),
Footer: '',
accessor: 'actions',
Cell: (v) => actionCell(v.cell.row.original),
header: t('common.actions'),
footer: '',
accessorKey: 'actions',
cell: (v) => actionCell(v.cell.row.original),
enableSorting: false,
meta: {
customWidth: '50px',
alwaysShow: true,
disableSortBy: true,
},
},
],
[t, firmwareCell],
@@ -368,50 +637,27 @@ const DeviceListCard = () => {
return (
<>
<CardHeader px={4} pt={4}>
<Heading size="md" my="auto" mr={2}>
{getCount.data?.count} {t('devices.title')}
</Heading>
<DeviceSearchBar />
<Spacer />
<ColumnPicker
columns={columns as Column<unknown>[]}
hiddenColumns={hiddenColumns}
setHiddenColumns={setHiddenColumns}
preference="gateway.devices.table.hiddenColumns"
/>
<RefreshButton
onClick={() => {
getDevices.refetch();
getCount.refetch();
}}
isCompact
ml={2}
isFetching={getCount.isFetching || getDevices.isFetching}
/>
</CardHeader>
<CardBody p={4}>
<Box overflowX="auto" w="100%">
<DataTable<DeviceWithStatus>
columns={
columns.filter(({ id }) => !hiddenColumns.find((hidden) => hidden === id)) as {
id: string;
Header: string;
Footer: string;
accessor: string;
}[]
}
data={data ?? []}
<DataGrid<DeviceWithStatus>
controller={tableController}
header={{
title: `${getCount.data?.count} ${t('devices.title')}`,
objectListed: t('devices.title'),
leftContent: <DeviceSearchBar />,
}}
columns={columns}
data={data}
isLoading={getCount.isFetching || getDevices.isFetching}
isManual
hiddenColumns={hiddenColumns}
obj={t('devices.title')}
count={getCount.data?.count || 0}
// @ts-ignore
setPageInfo={setPageInfo}
saveSettingsId="gateway.devices.table"
onRowClick={(device) => navigate(`devices/${device.serialNumber}`)}
isRowClickable={() => true}
options={{
count: getCount.data?.count,
isManual: true,
onRowClick: (device) => () => navigate(`devices/${device.serialNumber}`),
refetch: () => {
getDevices.refetch();
getCount.refetch();
},
}}
/>
</Box>
</CardBody>