[WIFI-13136] Display WiFi scan new station count and channel utilization values

Signed-off-by: Charles <charles.bourque96@gmail.com>
This commit is contained in:
Charles
2023-11-08 12:19:40 +02:00
parent 418f4ce576
commit 179900fab0
12 changed files with 234 additions and 109 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "ucentral-client", "name": "ucentral-client",
"version": "2.11.0(13)", "version": "2.11.0(15)",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ucentral-client", "name": "ucentral-client",
"version": "2.11.0(13)", "version": "2.11.0(15)",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@chakra-ui/anatomy": "^2.1.1", "@chakra-ui/anatomy": "^2.1.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "ucentral-client", "name": "ucentral-client",
"version": "2.11.0(13)", "version": "2.11.0(15)",
"description": "", "description": "",
"private": true, "private": true,
"main": "index.tsx", "main": "index.tsx",

View File

@@ -4,18 +4,22 @@ import '@tanstack/react-table';
declare module '@tanstack/table-core' { declare module '@tanstack/table-core' {
interface ColumnMeta<TData extends RowData, TValue> { interface ColumnMeta<TData extends RowData, TValue> {
ref?: React.MutableRefObject<HTMLTableCellElement | null>;
customMinWidth?: string;
anchored?: boolean;
stopPropagation?: boolean; stopPropagation?: boolean;
alwaysShow?: boolean; alwaysShow?: boolean;
anchored?: boolean;
hasPopover?: boolean; hasPopover?: boolean;
customMaxWidth?: string; customMaxWidth?: string;
customMinWidth?: string;
customWidth?: string; customWidth?: string;
isMonospace?: boolean; isMonospace?: boolean;
isCentered?: boolean; isCentered?: boolean;
columnSelectorOptions?: { columnSelectorOptions?: {
label?: string; label?: string;
}; };
rowContentOptions?: {
style?: React.CSSProperties;
};
headerOptions?: { headerOptions?: {
tooltip?: string; tooltip?: string;
}; };

View File

@@ -24,7 +24,6 @@ export const DataGridCellRow = <TValue extends object>({
backgroundColor: hoveredRowBg, backgroundColor: hoveredRowBg,
}} }}
onClick={onClick} onClick={onClick}
borderRight="1px solid gray"
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<Td <Td
@@ -55,6 +54,7 @@ export const DataGridCellRow = <TValue extends object>({
: undefined : undefined
} }
border="0.5px solid gray" border="0.5px solid gray"
style={cell.column.columnDef.meta?.rowContentOptions?.style}
> >
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</Td> </Td>

View File

@@ -8,7 +8,7 @@ export type DataGridHeaderRowProps<TValue extends object> = {
}; };
export const DataGridHeaderRow = <TValue extends object>({ headerGroup }: DataGridHeaderRowProps<TValue>) => ( export const DataGridHeaderRow = <TValue extends object>({ headerGroup }: DataGridHeaderRowProps<TValue>) => (
<Tr p={0} borderRight="1px solid gray"> <Tr p={0}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<Th <Th
color="gray.400" color="gray.400"

View File

@@ -40,13 +40,16 @@ export type DataGridOptions<TValue extends object> = {
onRowClick?: (row: TValue) => (() => void) | undefined; onRowClick?: (row: TValue) => (() => void) | undefined;
refetch?: () => void; refetch?: () => void;
showAsCard?: boolean; showAsCard?: boolean;
hideTablePreferences?: boolean;
hideTableTitleRow?: boolean;
}; };
export type DataGridProps<TValue extends object> = { export type DataGridProps<TValue extends object> = {
innerTableKey?: string | number;
controller: UseDataGridReturn; controller: UseDataGridReturn;
columns: DataGridColumn<TValue>[]; columns: DataGridColumn<TValue>[];
header: { header: {
title: string; title: string | React.ReactNode;
objectListed: string; objectListed: string;
leftContent?: React.ReactNode; leftContent?: React.ReactNode;
addButton?: React.ReactNode; addButton?: React.ReactNode;
@@ -58,6 +61,7 @@ export type DataGridProps<TValue extends object> = {
}; };
export const DataGrid = <TValue extends object>({ export const DataGrid = <TValue extends object>({
innerTableKey,
controller, controller,
columns, columns,
header, header,
@@ -149,6 +153,20 @@ export const DataGrid = <TValue extends object>({
...tableOptions, ...tableOptions,
}); });
// If this is a manual DataTable, with a page index that is higher than 0 and higher than the max possible page, we send to index 0
React.useEffect(() => {
if (
options.isManual &&
!isLoading &&
data &&
pagination.pageIndex > 0 &&
options.count !== undefined &&
Math.ceil(options.count / pagination.pageSize) - 1 < pagination.pageIndex
) {
controller.onPaginationChange({ pageIndex: 0, pageSize: pagination.pageSize });
}
}, [options.count, isLoading, pagination, data]);
if (isLoading && !options.showAsCard && data.length === 0) { if (isLoading && !options.showAsCard && data.length === 0) {
return ( return (
<Center> <Center>
@@ -160,25 +178,29 @@ export const DataGrid = <TValue extends object>({
return options.showAsCard ? ( return options.showAsCard ? (
<Card> <Card>
<CardHeader> <CardHeader>
<Heading size="md" my="auto" mr={2}> {typeof header.title === 'string' ? (
{header.title} <Heading size="md" my="auto" mr={2}>
</Heading> {header.title}
</Heading>
) : (
header.title
)}
{header.leftContent} {header.leftContent}
<Spacer /> <Spacer />
<HStack spacing={2}> <HStack spacing={2}>
{header.otherButtons} {header.otherButtons}
{header.addButton} {header.addButton}
{ {options.hideTablePreferences ? null : (
// @ts-ignore // @ts-ignore
<TableSettingsModal<TValue> controller={controller} columns={columns} /> <TableSettingsModal<TValue> controller={controller} columns={columns} />
} )}
{options.refetch ? <RefreshButton onClick={options.refetch} isCompact isFetching={isLoading} /> : null} {options.refetch ? <RefreshButton onClick={options.refetch} isCompact isFetching={isLoading} /> : null}
</HStack> </HStack>
</CardHeader> </CardHeader>
<CardBody display="flex" flexDirection="column"> <CardBody display="flex" flexDirection="column">
<LoadingOverlay isLoading={isLoading}> <LoadingOverlay isLoading={isLoading}>
<TableContainer minH={minimumHeight}> <TableContainer minH={minimumHeight}>
<Table size="small" variant="simple" textColor={textColor} w="100%" fontSize="14px"> <Table size="small" variant="simple" textColor={textColor} w="100%" fontSize="14px" key={innerTableKey}>
<Thead> <Thead>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<DataGridHeaderRow<TValue> key={headerGroup.id} headerGroup={headerGroup} /> <DataGridHeaderRow<TValue> key={headerGroup.id} headerGroup={headerGroup} />
@@ -206,7 +228,7 @@ export const DataGrid = <TValue extends object>({
</Card> </Card>
) : ( ) : (
<Box w="100%"> <Box w="100%">
<Flex mb={2}> <Flex mb={2} hidden={options.hideTableTitleRow}>
<Heading size="md" my="auto" mr={2}> <Heading size="md" my="auto" mr={2}>
{header.title} {header.title}
</Heading> </Heading>
@@ -215,16 +237,16 @@ export const DataGrid = <TValue extends object>({
<HStack spacing={2}> <HStack spacing={2}>
{header.otherButtons} {header.otherButtons}
{header.addButton} {header.addButton}
{ {options.hideTablePreferences ? null : (
// @ts-ignore // @ts-ignore
<TableSettingsModal<TValue> controller={controller} columns={columns} /> <TableSettingsModal<TValue> controller={controller} columns={columns} />
} )}
{options.refetch ? <RefreshButton onClick={options.refetch} isCompact isFetching={isLoading} /> : null} {options.refetch ? <RefreshButton onClick={options.refetch} isCompact isFetching={isLoading} /> : null}
</HStack> </HStack>
</Flex> </Flex>
<LoadingOverlay isLoading={isLoading}> <LoadingOverlay isLoading={isLoading}>
<TableContainer minH={minimumHeight}> <TableContainer minH={minimumHeight}>
<Table size="small" variant="simple" textColor={textColor} w="100%" fontSize="14px"> <Table size="small" variant="simple" textColor={textColor} w="100%" fontSize="14px" key={innerTableKey}>
<Thead> <Thead>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<DataGridHeaderRow<TValue> key={headerGroup.id} headerGroup={headerGroup} /> <DataGridHeaderRow<TValue> key={headerGroup.id} headerGroup={headerGroup} />

View File

@@ -104,18 +104,43 @@ const GlobalSearchBar = () => {
.then(() => callback([])); .then(() => callback([]));
} }
if (v.match('^[a-fA-F0-9-*]+$')) { if (v.match('^[a-fA-F0-9-*]+$')) {
let result: { label: string; value: string; type: 'serial' }[] = [];
let tryAgain = true;
await store await store
.searchSerialNumber(v) .searchSerialNumber(v)
.then((res) => { .then((res) => {
callback( result = res.map((r) => ({
res.map((r) => ({ label: r,
value: r,
type: 'serial',
}));
tryAgain = false;
})
.catch(() => {
result = [];
});
if (tryAgain) {
// Wait 1 second and try again
await new Promise((resolve) => setTimeout(resolve, 1000));
await store
.searchSerialNumber(v)
.then((res) => {
result = res.map((r) => ({
label: r, label: r,
value: r, value: r,
type: 'serial', type: 'serial',
})), }));
); tryAgain = false;
}) })
.catch(() => []); .catch(() => {
result = [];
});
}
callback(result);
} }
return callback([]); return callback([]);
}, },

View File

@@ -1,18 +1,5 @@
import React from 'react'; import React from 'react';
import { import { Box, Button, Center, Heading, IconButton, Spacer, useColorMode } from '@chakra-ui/react';
Box,
Button,
Heading,
IconButton,
Spacer,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
useColorMode,
} from '@chakra-ui/react';
import { JsonViewer } from '@textea/json-viewer'; import { JsonViewer } from '@textea/json-viewer';
import { ArrowLeft } from '@phosphor-icons/react'; import { ArrowLeft } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -20,21 +7,124 @@ import { v4 as uuid } from 'uuid';
import { Card } from 'components/Containers/Card'; import { Card } from 'components/Containers/Card';
import { CardBody } from 'components/Containers/Card/CardBody'; import { CardBody } from 'components/Containers/Card/CardBody';
import { CardHeader } from 'components/Containers/Card/CardHeader'; import { CardHeader } from 'components/Containers/Card/CardHeader';
import { ScanChannel } from 'models/Device'; import { DeviceScanResult, ScanChannel } from 'models/Device';
import { DataGrid } from 'components/DataTables/DataGrid';
import { DataGridColumn, useDataGrid } from 'components/DataTables/DataGrid/useDataGrid';
interface Props { interface Props {
channelInfo: ScanChannel; channelInfo: ScanChannel;
} }
const ResultCard: React.FC<Props> = ({ channelInfo: { channel, devices } }) => {
const ueCell = (ies: DeviceScanResult['ies'], setIes: (ies: DeviceScanResult['ies']) => void) => (
<Button size="sm" colorScheme="blue" onClick={() => setIes(ies)} w="100%">
{ies.length}
</Button>
);
const centerIfUndefinedCell = (v?: string | number, suffix?: string) =>
v !== undefined ? `${v}${suffix ? `${suffix}` : ''}` : <Center>-</Center>;
const ResultCard = ({ channelInfo: { channel, devices } }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
const [ies, setIes] = React.useState<{ content: unknown; name: string; type: number }[] | undefined>(); const [ies, setIes] = React.useState<{ content: unknown; name: string; type: number }[] | undefined>();
const tableController = useDataGrid({
tableSettingsId: 'wifiscan.devices.table',
defaultOrder: ['ssid', 'signal', 'actions'],
defaultSortBy: [
{
desc: false,
id: 'ssid',
},
],
});
const columns: DataGridColumn<DeviceScanResult>[] = React.useMemo(
(): DataGridColumn<DeviceScanResult>[] => [
{
id: 'ssid',
header: 'SSID',
footer: '',
accessorKey: 'ssid',
meta: {
anchored: true,
alwaysShow: true,
},
},
{
id: 'signal',
header: 'Signal',
footer: '',
accessorKey: 'signal',
cell: (v) => `${v.cell.row.original.signal} db`,
meta: {
anchored: true,
customWidth: '80px',
alwaysShow: true,
rowContentOptions: {
style: {
textAlign: 'right',
},
},
},
},
{
id: 'station',
header: 'UEs',
accessorKey: 'sta_count',
cell: (v) => centerIfUndefinedCell(v.cell.row.original.sta_count),
meta: {
anchored: true,
customWidth: '40px',
alwaysShow: true,
rowContentOptions: {
style: {
textAlign: 'right',
},
},
},
},
{
id: 'utilization',
header: 'Ch. Util.',
accessorKey: 'ch_util',
cell: (v) => centerIfUndefinedCell(v.cell.row.original.ch_util, '%'),
meta: {
anchored: true,
customWidth: '60px',
alwaysShow: true,
headerOptions: {
tooltip: 'Channel Utilization (%)',
},
rowContentOptions: {
style: {
textAlign: 'right',
},
},
},
},
{
id: 'ies',
header: 'Ies',
footer: '',
accessorKey: 'actions',
cell: (v) => ueCell(v.cell.row.original.ies ?? [], setIes),
meta: {
customWidth: '50px',
isCentered: true,
alwaysShow: true,
},
},
],
[t],
);
return ( return (
<Card variant="widget"> <Card>
<CardHeader display="flex"> <CardHeader display="flex">
<Heading size="md" my="auto"> <Heading size="md" my="auto">
{t('commands.channel')} #{channel} ({devices.length} {t('devices.title')}) {t('commands.channel')} #{channel} ({devices.length}{' '}
{devices.length === 1 ? t('devices.one') : t('devices.title')})
</Heading> </Heading>
<Spacer /> <Spacer />
{ies && ( {ies && (
@@ -49,52 +139,43 @@ const ResultCard: React.FC<Props> = ({ channelInfo: { channel, devices } }) => {
)} )}
</CardHeader> </CardHeader>
<CardBody> <CardBody>
<Box h="400px" w="100%" overflowY="auto" overflowX="auto" px={0}> {ies ? (
{ies ? ( <Box w="800px">
<Box w="800px"> {ies.map(({ content, name, type }) => (
{ies.map(({ content, name, type }) => ( <Box key={uuid()} my={2}>
<Box key={uuid()} my={2}> <Heading size="sm" mb={2} textDecor="underline">
<Heading size="sm" mb={2} textDecor="underline"> {name} ({type})
{name} ({type}) </Heading>
</Heading> <JsonViewer
<JsonViewer rootName={false}
rootName={false} displayDataTypes={false}
displayDataTypes={false} enableClipboard
enableClipboard theme={colorMode === 'light' ? undefined : 'dark'}
theme={colorMode === 'light' ? undefined : 'dark'} value={content as object}
value={content as object} style={{ background: 'unset', display: 'unset' }}
style={{ background: 'unset', display: 'unset' }} />
/> </Box>
</Box> ))}
))} </Box>
</Box> ) : (
) : ( <DataGrid<DeviceScanResult>
<Table variant="simple" px={0}> controller={tableController}
<Thead> header={{
<Tr> title: '',
<Th>SSID</Th> objectListed: t('devices.title'),
<Th width="110px" isNumeric> }}
{t('commands.signal')} columns={columns}
</Th> data={devices}
<Th w="10px">IEs</Th> options={{
</Tr> count: devices.length,
</Thead> onRowClick: (device) => () => setIes(device.ies ?? []),
<Tbody> hideTablePreferences: true,
{devices.map((dev) => ( isHidingControls: true,
<Tr key={uuid()}> minimumHeight: '0px',
<Td>{dev.ssid}</Td> hideTableTitleRow: true,
<Td width="110px">{dev.signal} db</Td> }}
<Td w="10px"> />
<Button size="sm" colorScheme="blue" onClick={() => setIes(dev.ies ?? [])}> )}
{dev.ies?.length ?? 0}
</Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Box>
</CardBody> </CardBody>
</Card> </Card>
); );

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo } from 'react'; import React, { useEffect, useMemo } from 'react';
import { Alert, Heading, SimpleGrid } from '@chakra-ui/react'; import { Alert, Heading, VStack } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import ResultCard from './ResultCard'; import ResultCard from './ResultCard';
@@ -11,7 +11,7 @@ interface Props {
setCsvData: (data: DeviceScanResult[]) => void; setCsvData: (data: DeviceScanResult[]) => void;
} }
const WifiScanResultDisplay: React.FC<Props> = ({ results, setCsvData }) => { const WifiScanResultDisplay = ({ results, setCsvData }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const scanResults = useMemo(() => { const scanResults = useMemo(() => {
@@ -54,18 +54,18 @@ const WifiScanResultDisplay: React.FC<Props> = ({ results, setCsvData }) => {
return ( return (
<> <>
{results.errorCode === 1 && ( {results.errorCode === 1 && (
<Heading size="sm"> <Heading size="md">
<Alert colorScheme="red">{t('commands.wifiscan_error_1')}</Alert> <Alert colorScheme="red">{t('commands.wifiscan_error_1')}</Alert>
</Heading> </Heading>
)} )}
<Heading size="sm"> <Heading size="md" mb={2}>
{t('commands.execution_time')}: {Math.floor(results.executionTime / 1000)}s {t('commands.execution_time')}: {Math.floor(results.executionTime / 1000)}s
</Heading> </Heading>
<SimpleGrid minChildWidth="360px" spacing={2}> <VStack spacing={4} align="stretch">
{scanResults?.scanList.map((channel) => ( {scanResults?.scanList.map((channel) => (
<ResultCard key={uuid()} channelInfo={channel} /> <ResultCard key={uuid()} channelInfo={channel} />
))} ))}
</SimpleGrid> </VStack>
</> </>
); );
}; };

View File

@@ -123,6 +123,7 @@ export const Navbar = ({
top="15px" top="15px"
border={scrolled ? '0.5px solid' : undefined} border={scrolled ? '0.5px solid' : undefined}
w={isCompact ? '100%' : 'calc(100% - 254px)'} w={isCompact ? '100%' : 'calc(100% - 254px)'}
zIndex={1}
> >
<Flex <Flex
w="100%" w="100%"

View File

@@ -114,12 +114,16 @@ interface BssidResult {
bssid: string; bssid: string;
capability: number; capability: number;
channel: number; channel: number;
/** Channel Utilization percentage (ex.: 28 -> 28% channel utilization) */
ch_util?: number;
frequency: number; frequency: number;
ht_oper: string; ht_oper: string;
ies: { content: unknown; name: string; type: number }[]; ies: { content: unknown; name: string; type: number }[];
last_seen: number; last_seen: number;
ssid: string; ssid: string;
signal: number; signal: number;
/** Station count */
sta_count?: number;
tsf: number; tsf: number;
meshid?: string; meshid?: string;
vht_oper: string; vht_oper: string;
@@ -144,20 +148,8 @@ export interface WifiScanResult {
}; };
} }
export interface DeviceScanResult { export type DeviceScanResult = BssidResult;
bssid: string;
capability: number;
channel: number;
frequency: number;
ht_oper: string;
ies: { content: unknown; name: string; type: number }[];
last_seen: number;
ssid: string;
signal: number | string;
tsf: number;
meshid?: string;
vht_oper: string;
}
export interface ScanChannel { export interface ScanChannel {
channel: number; channel: number;
devices: DeviceScanResult[]; devices: DeviceScanResult[];

View File

@@ -162,7 +162,7 @@ const WifiAnalysisCard = ({ serialNumber }: Props) => {
<SliderTrack> <SliderTrack>
<SliderFilledTrack /> <SliderFilledTrack />
</SliderTrack> </SliderTrack>
<SliderThumb /> <SliderThumb zIndex={0} />
</Slider> </Slider>
)} )}
<Box /> <Box />