mirror of
https://github.com/optim-enterprises-bv/OptimCloud-gw-ui.git
synced 2025-11-01 02:37:45 +00:00
[WIFI-12435] [WIFI-12436] Device table added functionality and styling fixes
Signed-off-by: Charles <charles.bourque96@gmail.com>
This commit is contained in:
49
package-lock.json
generated
49
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ucentral-client",
|
"name": "ucentral-client",
|
||||||
"version": "2.9.0(23)",
|
"version": "2.10.0(5)",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ucentral-client",
|
"name": "ucentral-client",
|
||||||
"version": "2.9.0(23)",
|
"version": "2.10.0(5)",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chakra-ui/icons": "^2.0.11",
|
"@chakra-ui/icons": "^2.0.11",
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
"@googlemaps/typescript-guards": "^2.0.3",
|
"@googlemaps/typescript-guards": "^2.0.3",
|
||||||
"@react-spring/web": "^9.5.5",
|
"@react-spring/web": "^9.5.5",
|
||||||
"@tanstack/react-query": "^4.12.0",
|
"@tanstack/react-query": "^4.12.0",
|
||||||
|
"@tanstack/react-table": "^8.7.9",
|
||||||
"@textea/json-viewer": "^2.10.0",
|
"@textea/json-viewer": "^2.10.0",
|
||||||
"axios": "^1.1.3",
|
"axios": "^1.1.3",
|
||||||
"buffer": "^6.0.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": {
|
"node_modules/@textea/json-viewer": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -11823,6 +11855,19 @@
|
|||||||
"use-sync-external-store": "^1.2.0"
|
"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": {
|
"@textea/json-viewer": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"requires": {
|
"requires": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ucentral-client",
|
"name": "ucentral-client",
|
||||||
"version": "2.9.0(23)",
|
"version": "2.10.0(5)",
|
||||||
"description": "",
|
"description": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.tsx",
|
"main": "index.tsx",
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
"react-i18next": "^11.18.6",
|
"react-i18next": "^11.18.6",
|
||||||
"react-masonry-css": "^1.0.16",
|
"react-masonry-css": "^1.0.16",
|
||||||
"@tanstack/react-query": "^4.12.0",
|
"@tanstack/react-query": "^4.12.0",
|
||||||
|
"@tanstack/react-table": "^8.7.9",
|
||||||
"react-router-dom": "^6.4.2",
|
"react-router-dom": "^6.4.2",
|
||||||
"react-table": "^7.8.0",
|
"react-table": "^7.8.0",
|
||||||
"react-virtualized-auto-sizer": "^1.0.7",
|
"react-virtualized-auto-sizer": "^1.0.7",
|
||||||
|
|||||||
23
src/@tanstack.react-table.d.ts
vendored
Normal file
23
src/@tanstack.react-table.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/components/DataTables/DataGrid/CellRow.tsx
Normal file
62
src/components/DataTables/DataGrid/CellRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
82
src/components/DataTables/DataGrid/DataGridColumnPicker.tsx
Normal file
82
src/components/DataTables/DataGrid/DataGridColumnPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
43
src/components/DataTables/DataGrid/HeaderRow.tsx
Normal file
43
src/components/DataTables/DataGrid/HeaderRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
124
src/components/DataTables/DataGrid/Input.tsx
Normal file
124
src/components/DataTables/DataGrid/Input.tsx
Normal 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;
|
||||||
23
src/components/DataTables/DataGrid/SortIcon.tsx
Normal file
23
src/components/DataTables/DataGrid/SortIcon.tsx
Normal 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;
|
||||||
|
};
|
||||||
189
src/components/DataTables/DataGrid/index.tsx
Normal file
189
src/components/DataTables/DataGrid/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
82
src/components/DataTables/DataGrid/useDataGrid.ts
Normal file
82
src/components/DataTables/DataGrid/useDataGrid.ts
Normal 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],
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Tooltip } from '@chakra-ui/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 (!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);
|
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)}>
|
<Tooltip hasArrow placement="top" label={compactDate(date ?? 0)}>
|
||||||
{getDaysAgo({ date, hidePrefix })}
|
{getDaysAgo({ date, hidePrefix, isCompact })}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
import { Box, BoxProps } from '@chakra-ui/react';
|
||||||
import { bytesString } from 'helpers/stringHelper';
|
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(() => {
|
const data = useMemo(() => {
|
||||||
if (bytes === undefined) return '-';
|
if (bytes === undefined) return '-';
|
||||||
|
if (showZerosAs && bytes === 0) return showZerosAs;
|
||||||
return bytesString(bytes);
|
return bytesString(bytes);
|
||||||
}, [bytes]);
|
}, [bytes]);
|
||||||
|
|
||||||
return <div>{data}</div>;
|
return <Box {...boxProps}>{data}</Box>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default React.memo(DataCell);
|
export default React.memo(DataCell);
|
||||||
|
|||||||
17
src/components/TableCells/DurationCell/index.tsx
Normal file
17
src/components/TableCells/DurationCell/index.tsx
Normal 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);
|
||||||
@@ -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 }) => {
|
type Props = {
|
||||||
const data = useMemo(() => {
|
value?: number;
|
||||||
|
boxProps?: BoxProps;
|
||||||
|
showZerosAs?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NumberCell = ({ value, boxProps, showZerosAs }: Props) => {
|
||||||
|
const getData = () => {
|
||||||
if (value === undefined) return '-';
|
if (value === undefined) return '-';
|
||||||
|
if (value === 0 && showZerosAs) return showZerosAs;
|
||||||
return value.toLocaleString();
|
return value.toLocaleString();
|
||||||
}, [value]);
|
};
|
||||||
|
|
||||||
return <div>{data}</div>;
|
return <Box {...boxProps}>{getData()}</Box>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default React.memo(NumberCell);
|
export default React.memo(NumberCell);
|
||||||
|
|||||||
@@ -126,3 +126,40 @@ export const dateForFilename = (dateString: number) => {
|
|||||||
date.getDate(),
|
date.getDate(),
|
||||||
)}_${twoDigitNumber(date.getHours())}h${twoDigitNumber(date.getMinutes())}m${twoDigitNumber(date.getSeconds())}s`;
|
)}_${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 '-';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -51,23 +51,30 @@ export type DeviceWithStatus = {
|
|||||||
entity: string;
|
entity: string;
|
||||||
firmware: string;
|
firmware: string;
|
||||||
fwUpdatePolicy: string;
|
fwUpdatePolicy: string;
|
||||||
|
hasGPS: boolean;
|
||||||
|
hasRADIUSSessions: number | boolean;
|
||||||
ipAddress: string;
|
ipAddress: string;
|
||||||
lastConfigurationChange: number;
|
lastConfigurationChange: number;
|
||||||
lastConfigurationDownload: number;
|
lastConfigurationDownload: number;
|
||||||
lastContact: number | string;
|
lastContact: number | string;
|
||||||
lastFWUpdate: number;
|
lastFWUpdate: number;
|
||||||
|
load: number;
|
||||||
locale: string;
|
locale: string;
|
||||||
location: string;
|
location: string;
|
||||||
macAddress: string;
|
macAddress: string;
|
||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
|
memoryUsed: number;
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
modified: number;
|
modified: number;
|
||||||
notes: Note[];
|
notes: Note[];
|
||||||
owner: string;
|
owner: string;
|
||||||
|
sanity: number;
|
||||||
|
started: number;
|
||||||
restrictedDevice: boolean;
|
restrictedDevice: boolean;
|
||||||
rxBytes: number;
|
rxBytes: number;
|
||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
subscriber: string;
|
subscriber: string;
|
||||||
|
temperature: number;
|
||||||
txBytes: number;
|
txBytes: number;
|
||||||
venue: string;
|
venue: string;
|
||||||
verifiedCertificate: string;
|
verifiedCertificate: string;
|
||||||
|
|||||||
@@ -55,4 +55,5 @@ export const useGetTag = ({ serialNumber, onError }: { serialNumber?: string; on
|
|||||||
useQuery(['tag', serialNumber], () => getTag(serialNumber), {
|
useQuery(['tag', serialNumber], () => getTag(serialNumber), {
|
||||||
enabled: serialNumber !== undefined && serialNumber !== '',
|
enabled: serialNumber !== undefined && serialNumber !== '',
|
||||||
onError,
|
onError,
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
});
|
});
|
||||||
|
|||||||
54
src/hooks/useContainerDimensions.ts
Normal file
54
src/hooks/useContainerDimensions.ts
Normal 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],
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -28,7 +28,6 @@ const DeviceDetails = ({ serialNumber }: Props) => {
|
|||||||
? getDevice.data?.devicePassword
|
? getDevice.data?.devicePassword
|
||||||
: 'openwifi',
|
: 'openwifi',
|
||||||
);
|
);
|
||||||
|
|
||||||
const getPassword = () => {
|
const getPassword = () => {
|
||||||
if (!getDevice.data) return '-';
|
if (!getDevice.data) return '-';
|
||||||
if (isShowingPassword) {
|
if (isShowingPassword) {
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import * as React from 'react';
|
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 { Wrapper } from '@googlemaps/react-wrapper';
|
||||||
import { Globe } from 'phosphor-react';
|
import { Globe } from 'phosphor-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -11,13 +21,15 @@ import { useGetDeviceLastStats } from 'hooks/Network/Statistics';
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
|
isCompact?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LocationDisplayButton = ({ serialNumber }: Props) => {
|
const LocationDisplayButton = ({ serialNumber, isCompact }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
const getGoogleApiKey = useGetSystemSecret({ secret: 'google.maps.apikey' });
|
const getGoogleApiKey = useGetSystemSecret({ secret: 'google.maps.apikey' });
|
||||||
const getLastStats = useGetDeviceLastStats({ serialNumber });
|
const getLastStats = useGetDeviceLastStats({ serialNumber });
|
||||||
|
const iconColor = useColorModeValue('blue.500', 'blue.200');
|
||||||
|
|
||||||
const location: google.maps.LatLngLiteral | undefined = React.useMemo(() => {
|
const location: google.maps.LatLngLiteral | undefined = React.useMemo(() => {
|
||||||
if (!getLastStats.data?.gps) return undefined;
|
if (!getLastStats.data?.gps) return undefined;
|
||||||
@@ -38,9 +50,15 @@ const LocationDisplayButton = ({ serialNumber }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant="link" onClick={onOpen} rightIcon={<Globe size={20} />} colorScheme="blue">
|
{isCompact ? (
|
||||||
{t('locations.view_gps')}
|
<Tooltip label={t('locations.view_gps')}>
|
||||||
</Button>
|
<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')}>
|
<Modal isOpen={isOpen} onClose={onClose} title={t('locations.one')}>
|
||||||
<Box w="100%" h="100%">
|
<Box w="100%" h="100%">
|
||||||
<Flex mb={4}>
|
<Flex mb={4}>
|
||||||
|
|||||||
19
src/pages/Devices/ListCard/GpsCell.tsx
Normal file
19
src/pages/Devices/ListCard/GpsCell.tsx
Normal 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;
|
||||||
40
src/pages/Devices/ListCard/ProvisioningStatusCell.tsx
Normal file
40
src/pages/Devices/ListCard/ProvisioningStatusCell.tsx
Normal 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;
|
||||||
17
src/pages/Devices/ListCard/Uptime.tsx
Normal file
17
src/pages/Devices/ListCard/Uptime.tsx
Normal 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;
|
||||||
@@ -1,20 +1,29 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Box, Heading, Image, Link, Spacer, Tooltip, useDisclosure } from '@chakra-ui/react';
|
import { Box, Center, Image, Link, Tag, TagLabel, TagRightIcon, Tooltip, useDisclosure } from '@chakra-ui/react';
|
||||||
import { LockSimple } from 'phosphor-react';
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
Heart,
|
||||||
|
HeartBreak,
|
||||||
|
LockSimple,
|
||||||
|
ThermometerCold,
|
||||||
|
ThermometerHot,
|
||||||
|
WarningCircle,
|
||||||
|
} from 'phosphor-react';
|
||||||
import ReactCountryFlag from 'react-country-flag';
|
import ReactCountryFlag from 'react-country-flag';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Actions from './Actions';
|
import Actions from './Actions';
|
||||||
import DeviceListFirmwareButton from './FirmwareButton';
|
import DeviceListFirmwareButton from './FirmwareButton';
|
||||||
|
import DeviceTableGpsCell from './GpsCell';
|
||||||
import AP from './icons/AP.png';
|
import AP from './icons/AP.png';
|
||||||
import IOT from './icons/IOT.png';
|
import IOT from './icons/IOT.png';
|
||||||
import MESH from './icons/MESH.png';
|
import MESH from './icons/MESH.png';
|
||||||
import SWITCH from './icons/SWITCH.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 { CardBody } from 'components/Containers/Card/CardBody';
|
||||||
import { CardHeader } from 'components/Containers/Card/CardHeader';
|
import { DataGrid } from 'components/DataTables/DataGrid';
|
||||||
import { ColumnPicker } from 'components/DataTables/ColumnPicker';
|
import { DataGridColumn, useDataGrid } from 'components/DataTables/DataGrid/useDataGrid';
|
||||||
import { DataTable } from 'components/DataTables/DataTable';
|
|
||||||
import DeviceSearchBar from 'components/DeviceSearchBar';
|
import DeviceSearchBar from 'components/DeviceSearchBar';
|
||||||
import FormattedDate from 'components/InformationDisplays/FormattedDate';
|
import FormattedDate from 'components/InformationDisplays/FormattedDate';
|
||||||
import { ConfigureModal } from 'components/Modals/ConfigureModal';
|
import { ConfigureModal } from 'components/Modals/ConfigureModal';
|
||||||
@@ -30,8 +39,16 @@ import DataCell from 'components/TableCells/DataCell';
|
|||||||
import NumberCell from 'components/TableCells/NumberCell';
|
import NumberCell from 'components/TableCells/NumberCell';
|
||||||
import { DeviceWithStatus, useGetDeviceCount, useGetDevices } from 'hooks/Network/Devices';
|
import { DeviceWithStatus, useGetDeviceCount, useGetDevices } from 'hooks/Network/Devices';
|
||||||
import { FirmwareAgeResponse, useGetFirmwareAges } from 'hooks/Network/Firmware';
|
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 ICON_STYLE = { width: '24px', height: '24px', borderRadius: '20px' };
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
@@ -52,8 +69,6 @@ const DeviceListCard = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [serialNumber, setSerialNumber] = React.useState<string>('');
|
const [serialNumber, setSerialNumber] = React.useState<string>('');
|
||||||
const [hiddenColumns, setHiddenColumns] = React.useState<string[]>([]);
|
|
||||||
const [pageInfo, setPageInfo] = React.useState<PageInfo | undefined>(undefined);
|
|
||||||
const scanModalProps = useDisclosure();
|
const scanModalProps = useDisclosure();
|
||||||
const resetModalProps = useDisclosure();
|
const resetModalProps = useDisclosure();
|
||||||
const upgradeModalProps = useDisclosure();
|
const upgradeModalProps = useDisclosure();
|
||||||
@@ -63,9 +78,13 @@ const DeviceListCard = () => {
|
|||||||
const configureModalProps = useDisclosure();
|
const configureModalProps = useDisclosure();
|
||||||
const rebootModalProps = useDisclosure();
|
const rebootModalProps = useDisclosure();
|
||||||
const scriptModal = useScriptModal();
|
const scriptModal = useScriptModal();
|
||||||
|
const tableController = useDataGrid({ tableSettingsId: 'gateway.devices.table' });
|
||||||
const getCount = useGetDeviceCount({ enabled: true });
|
const getCount = useGetDeviceCount({ enabled: true });
|
||||||
const getDevices = useGetDevices({
|
const getDevices = useGetDevices({
|
||||||
pageInfo,
|
pageInfo: {
|
||||||
|
limit: tableController.pageInfo.pageSize,
|
||||||
|
index: tableController.pageInfo.pageIndex,
|
||||||
|
},
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
const getAges = useGetFirmwareAges({
|
const getAges = useGetFirmwareAges({
|
||||||
@@ -171,7 +190,7 @@ const DeviceListCard = () => {
|
|||||||
const dataCell = React.useCallback(
|
const dataCell = React.useCallback(
|
||||||
(v: number) => (
|
(v: number) => (
|
||||||
<Box textAlign="right">
|
<Box textAlign="right">
|
||||||
<DataCell bytes={v} />
|
<DataCell bytes={v} showZerosAs="-" />
|
||||||
</Box>
|
</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(
|
const firmwareCell = React.useCallback(
|
||||||
(device: DeviceWithStatus & { age?: FirmwareAgeResponse }) => (
|
(device: DeviceWithStatus & { age?: FirmwareAgeResponse }) => (
|
||||||
<DeviceListFirmwareButton device={device} age={device.age} onOpenUpgrade={onOpenUpgradeModal} />
|
<DeviceListFirmwareButton device={device} age={device.age} onOpenUpgrade={onOpenUpgradeModal} />
|
||||||
),
|
),
|
||||||
[getAges],
|
[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(
|
const localeCell = React.useCallback(
|
||||||
(device: DeviceWithStatus) => (
|
(device: DeviceWithStatus) => (
|
||||||
<Tooltip label={`${device.locale !== '' ? `${device.locale} - ` : ''}${device.ipAddress}`} placement="top">
|
<Tooltip label={`${device.locale !== '' ? `${device.locale} - ` : ''}${device.ipAddress}`} placement="top">
|
||||||
@@ -198,13 +232,25 @@ const DeviceListCard = () => {
|
|||||||
{device.locale !== '' && device.ipAddress !== '' && (
|
{device.locale !== '' && device.ipAddress !== '' && (
|
||||||
<ReactCountryFlag style={ICON_STYLE} countryCode={device.locale} svg />
|
<ReactCountryFlag style={ICON_STYLE} countryCode={device.locale} svg />
|
||||||
)}
|
)}
|
||||||
{` ${device.ipAddress}`}
|
{` ${device.ipAddress.length > 0 ? device.ipAddress : '-'}`}
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</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(
|
const actionCell = React.useCallback(
|
||||||
(device: DeviceWithStatus) => (
|
(device: DeviceWithStatus) => (
|
||||||
<Actions
|
<Actions
|
||||||
@@ -224,135 +270,358 @@ const DeviceListCard = () => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns: Column<DeviceWithStatus>[] = React.useMemo(
|
const sanityCell = React.useCallback((device: DeviceWithStatus) => {
|
||||||
(): Column<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',
|
id: 'badge',
|
||||||
Header: '',
|
header: '',
|
||||||
Footer: '',
|
footer: '',
|
||||||
accessor: 'badge',
|
accessorKey: 'badge',
|
||||||
Cell: (v) => badgeCell(v.cell.row.original),
|
cell: (v) => badgeCell(v.cell.row.original),
|
||||||
customWidth: '35px',
|
enableSorting: false,
|
||||||
alwaysShow: true,
|
meta: {
|
||||||
disableSortBy: true,
|
customWidth: '35px',
|
||||||
|
alwaysShow: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'serialNumber',
|
id: 'serialNumber',
|
||||||
Header: t('inventory.serial_number'),
|
header: t('inventory.serial_number'),
|
||||||
Footer: '',
|
footer: '',
|
||||||
accessor: 'serialNumber',
|
accessorKey: 'serialNumber',
|
||||||
Cell: (v) => serialCell(v.cell.row.original),
|
cell: (v) => serialCell(v.cell.row.original),
|
||||||
alwaysShow: true,
|
enableSorting: false,
|
||||||
customMaxWidth: '200px',
|
meta: {
|
||||||
customWidth: '130px',
|
alwaysShow: true,
|
||||||
customMinWidth: '130px',
|
customMaxWidth: '200px',
|
||||||
disableSortBy: true,
|
customWidth: '130px',
|
||||||
|
customMinWidth: '130px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
id: 'firmware',
|
||||||
Header: t('commands.revision'),
|
header: t('commands.revision'),
|
||||||
Footer: '',
|
footer: '',
|
||||||
accessor: 'firmware',
|
accessorKey: 'firmware',
|
||||||
Cell: (v) => firmwareCell(v.cell.row.original),
|
cell: (v) => firmwareCell(v.cell.row.original),
|
||||||
stopPropagation: true,
|
enableSorting: false,
|
||||||
customWidth: '50px',
|
meta: {
|
||||||
disableSortBy: true,
|
stopPropagation: true,
|
||||||
|
customWidth: '50px',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'compatible',
|
id: 'compatible',
|
||||||
Header: t('common.type'),
|
header: t('common.type'),
|
||||||
Footer: '',
|
footer: '',
|
||||||
accessor: 'compatible',
|
accessorKey: 'compatible',
|
||||||
customWidth: '50px',
|
enableSorting: false,
|
||||||
disableSortBy: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'IP',
|
id: 'IP',
|
||||||
Header: 'IP',
|
header: 'IP',
|
||||||
Footer: '',
|
footer: '',
|
||||||
accessor: 'IP',
|
accessorKey: 'IP',
|
||||||
Cell: (v) => localeCell(v.cell.row.original),
|
cell: (v) => localeCell(v.cell.row.original),
|
||||||
disableSortBy: true,
|
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',
|
id: 'lastContact',
|
||||||
Header: t('analytics.last_contact'),
|
header: t('analytics.last_contact'),
|
||||||
Footer: '',
|
footer: '',
|
||||||
accessor: 'lastContact',
|
accessorKey: 'lastContact',
|
||||||
Cell: (v) => dateCell(v.cell.row.original.lastContact),
|
cell: (v) => dateCell(v.cell.row.original.lastContact),
|
||||||
disableSortBy: true,
|
enableSorting: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'lastFWUpdate',
|
id: 'lastFWUpdate',
|
||||||
Header: t('controller.devices.last_upgrade'),
|
header: t('controller.devices.last_upgrade'),
|
||||||
Footer: '',
|
footer: '',
|
||||||
accessor: 'lastFWUpdate',
|
accessorKey: 'lastFWUpdate',
|
||||||
Cell: (v) => dateCell(v.cell.row.original.lastFWUpdate),
|
cell: (v) => dateCell(v.cell.row.original.lastFWUpdate),
|
||||||
disableSortBy: true,
|
enableSorting: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rxBytes',
|
id: 'rxBytes',
|
||||||
Header: 'Rx',
|
header: 'Rx',
|
||||||
Footer: '',
|
footer: '',
|
||||||
accessor: 'rxBytes',
|
accessorKey: 'rxBytes',
|
||||||
Cell: (v) => dataCell(v.cell.row.original.rxBytes),
|
cell: (v) => dataCell(v.cell.row.original.rxBytes),
|
||||||
customWidth: '50px',
|
enableSorting: false,
|
||||||
disableSortBy: true,
|
meta: {
|
||||||
|
customWidth: '50px',
|
||||||
|
headerStyleProps: {
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'txBytes',
|
id: 'txBytes',
|
||||||
Header: 'Tx',
|
header: 'Tx',
|
||||||
Footer: '',
|
footer: '',
|
||||||
accessor: 'txBytes',
|
accessorKey: 'txBytes',
|
||||||
Cell: (v) => dataCell(v.cell.row.original.txBytes),
|
cell: (v) => dataCell(v.cell.row.original.txBytes),
|
||||||
customWidth: '50px',
|
enableSorting: false,
|
||||||
disableSortBy: true,
|
meta: {
|
||||||
|
customWidth: '40px',
|
||||||
|
customMinWidth: '40px',
|
||||||
|
headerStyleProps: {
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2G',
|
id: '2G',
|
||||||
Header: '2G',
|
header: '2G',
|
||||||
Footer: '',
|
footer: '',
|
||||||
accessor: 'associations_2G',
|
accessorKey: 'associations_2G',
|
||||||
Cell: (v) => numberCell(v.cell.row.original.associations_2G),
|
cell: (v) => numberCell(v.cell.row.original.associations_2G),
|
||||||
customWidth: '50px',
|
enableSorting: false,
|
||||||
disableSortBy: true,
|
meta: {
|
||||||
|
customWidth: '40px',
|
||||||
|
customMinWidth: '40px',
|
||||||
|
headerStyleProps: {
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '5G',
|
id: '5G',
|
||||||
Header: '5G',
|
header: '5G',
|
||||||
Footer: '',
|
footer: '',
|
||||||
accessor: 'associations_5G',
|
accessorKey: 'associations_5G',
|
||||||
Cell: (v) => numberCell(v.cell.row.original.associations_5G),
|
cell: (v) => numberCell(v.cell.row.original.associations_5G),
|
||||||
customWidth: '50px',
|
enableSorting: false,
|
||||||
disableSortBy: true,
|
meta: {
|
||||||
|
customWidth: '40px',
|
||||||
|
customMinWidth: '40px',
|
||||||
|
headerStyleProps: {
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '6G',
|
id: '6G',
|
||||||
Header: '6G',
|
header: '6G',
|
||||||
Footer: '',
|
footer: '',
|
||||||
accessor: 'associations_6G',
|
accessorKey: 'associations_6G',
|
||||||
Cell: (v) => numberCell(v.cell.row.original.associations_6G),
|
cell: (v) => numberCell(v.cell.row.original.associations_6G),
|
||||||
customWidth: '50px',
|
enableSorting: false,
|
||||||
disableSortBy: true,
|
meta: {
|
||||||
|
customWidth: '40px',
|
||||||
|
customMinWidth: '40px',
|
||||||
|
headerStyleProps: {
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'certificateExpiryDate',
|
id: 'certificateExpiryDate',
|
||||||
Header: t('devices.certificate_expiry'),
|
header: 'Exp',
|
||||||
Footer: '',
|
footer: '',
|
||||||
accessor: 'certificateExpiryDate',
|
accessorKey: 'certificateExpiryDate',
|
||||||
Cell: (v) => dateCell(v.cell.row.original.certificateExpiryDate, true),
|
cell: (v) => compactDateCell(v.cell.row.original.certificateExpiryDate, true),
|
||||||
customWidth: '50px',
|
enableSorting: false,
|
||||||
disableSortBy: true,
|
meta: {
|
||||||
|
columnSelectorOptions: {
|
||||||
|
label: 'Certificate Expiry',
|
||||||
|
},
|
||||||
|
headerOptions: {
|
||||||
|
tooltip: 'Certificate Expiry Date',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
Header: t('common.actions'),
|
header: t('common.actions'),
|
||||||
Footer: '',
|
footer: '',
|
||||||
accessor: 'actions',
|
accessorKey: 'actions',
|
||||||
Cell: (v) => actionCell(v.cell.row.original),
|
cell: (v) => actionCell(v.cell.row.original),
|
||||||
customWidth: '50px',
|
enableSorting: false,
|
||||||
alwaysShow: true,
|
meta: {
|
||||||
disableSortBy: true,
|
customWidth: '50px',
|
||||||
|
alwaysShow: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, firmwareCell],
|
[t, firmwareCell],
|
||||||
@@ -368,50 +637,27 @@ const DeviceListCard = () => {
|
|||||||
|
|
||||||
return (
|
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}>
|
<CardBody p={4}>
|
||||||
<Box overflowX="auto" w="100%">
|
<Box overflowX="auto" w="100%">
|
||||||
<DataTable<DeviceWithStatus>
|
<DataGrid<DeviceWithStatus>
|
||||||
columns={
|
controller={tableController}
|
||||||
columns.filter(({ id }) => !hiddenColumns.find((hidden) => hidden === id)) as {
|
header={{
|
||||||
id: string;
|
title: `${getCount.data?.count} ${t('devices.title')}`,
|
||||||
Header: string;
|
objectListed: t('devices.title'),
|
||||||
Footer: string;
|
leftContent: <DeviceSearchBar />,
|
||||||
accessor: string;
|
}}
|
||||||
}[]
|
columns={columns}
|
||||||
}
|
data={data}
|
||||||
data={data ?? []}
|
|
||||||
isLoading={getCount.isFetching || getDevices.isFetching}
|
isLoading={getCount.isFetching || getDevices.isFetching}
|
||||||
isManual
|
options={{
|
||||||
hiddenColumns={hiddenColumns}
|
count: getCount.data?.count,
|
||||||
obj={t('devices.title')}
|
isManual: true,
|
||||||
count={getCount.data?.count || 0}
|
onRowClick: (device) => () => navigate(`devices/${device.serialNumber}`),
|
||||||
// @ts-ignore
|
refetch: () => {
|
||||||
setPageInfo={setPageInfo}
|
getDevices.refetch();
|
||||||
saveSettingsId="gateway.devices.table"
|
getCount.refetch();
|
||||||
onRowClick={(device) => navigate(`devices/${device.serialNumber}`)}
|
},
|
||||||
isRowClickable={() => true}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
|||||||
Reference in New Issue
Block a user