[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",
"version": "2.11.0(13)",
"version": "2.11.0(15)",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ucentral-client",
"version": "2.11.0(13)",
"version": "2.11.0(15)",
"license": "ISC",
"dependencies": {
"@chakra-ui/anatomy": "^2.1.1",

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,13 +40,16 @@ export type DataGridOptions<TValue extends object> = {
onRowClick?: (row: TValue) => (() => void) | undefined;
refetch?: () => void;
showAsCard?: boolean;
hideTablePreferences?: boolean;
hideTableTitleRow?: boolean;
};
export type DataGridProps<TValue extends object> = {
innerTableKey?: string | number;
controller: UseDataGridReturn;
columns: DataGridColumn<TValue>[];
header: {
title: string;
title: string | React.ReactNode;
objectListed: string;
leftContent?: React.ReactNode;
addButton?: React.ReactNode;
@@ -58,6 +61,7 @@ export type DataGridProps<TValue extends object> = {
};
export const DataGrid = <TValue extends object>({
innerTableKey,
controller,
columns,
header,
@@ -149,6 +153,20 @@ export const DataGrid = <TValue extends object>({
...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) {
return (
<Center>
@@ -160,25 +178,29 @@ export const DataGrid = <TValue extends object>({
return options.showAsCard ? (
<Card>
<CardHeader>
<Heading size="md" my="auto" mr={2}>
{header.title}
</Heading>
{typeof header.title === 'string' ? (
<Heading size="md" my="auto" mr={2}>
{header.title}
</Heading>
) : (
header.title
)}
{header.leftContent}
<Spacer />
<HStack spacing={2}>
{header.otherButtons}
{header.addButton}
{
{options.hideTablePreferences ? null : (
// @ts-ignore
<TableSettingsModal<TValue> controller={controller} columns={columns} />
}
)}
{options.refetch ? <RefreshButton onClick={options.refetch} isCompact isFetching={isLoading} /> : null}
</HStack>
</CardHeader>
<CardBody display="flex" flexDirection="column">
<LoadingOverlay isLoading={isLoading}>
<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>
{table.getHeaderGroups().map((headerGroup) => (
<DataGridHeaderRow<TValue> key={headerGroup.id} headerGroup={headerGroup} />
@@ -206,7 +228,7 @@ export const DataGrid = <TValue extends object>({
</Card>
) : (
<Box w="100%">
<Flex mb={2}>
<Flex mb={2} hidden={options.hideTableTitleRow}>
<Heading size="md" my="auto" mr={2}>
{header.title}
</Heading>
@@ -215,16 +237,16 @@ export const DataGrid = <TValue extends object>({
<HStack spacing={2}>
{header.otherButtons}
{header.addButton}
{
{options.hideTablePreferences ? null : (
// @ts-ignore
<TableSettingsModal<TValue> controller={controller} columns={columns} />
}
)}
{options.refetch ? <RefreshButton onClick={options.refetch} isCompact isFetching={isLoading} /> : null}
</HStack>
</Flex>
<LoadingOverlay isLoading={isLoading}>
<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>
{table.getHeaderGroups().map((headerGroup) => (
<DataGridHeaderRow<TValue> key={headerGroup.id} headerGroup={headerGroup} />

View File

@@ -104,18 +104,43 @@ const GlobalSearchBar = () => {
.then(() => callback([]));
}
if (v.match('^[a-fA-F0-9-*]+$')) {
let result: { label: string; value: string; type: 'serial' }[] = [];
let tryAgain = true;
await store
.searchSerialNumber(v)
.then((res) => {
callback(
res.map((r) => ({
result = 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,
value: r,
type: 'serial',
})),
);
})
.catch(() => []);
}));
tryAgain = false;
})
.catch(() => {
result = [];
});
}
callback(result);
}
return callback([]);
},

View File

@@ -1,18 +1,5 @@
import React from 'react';
import {
Box,
Button,
Heading,
IconButton,
Spacer,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
useColorMode,
} from '@chakra-ui/react';
import { Box, Button, Center, Heading, IconButton, Spacer, useColorMode } from '@chakra-ui/react';
import { JsonViewer } from '@textea/json-viewer';
import { ArrowLeft } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
@@ -20,21 +7,124 @@ import { v4 as uuid } from 'uuid';
import { Card } from 'components/Containers/Card';
import { CardBody } from 'components/Containers/Card/CardBody';
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 {
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 { colorMode } = useColorMode();
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 (
<Card variant="widget">
<Card>
<CardHeader display="flex">
<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>
<Spacer />
{ies && (
@@ -49,52 +139,43 @@ const ResultCard: React.FC<Props> = ({ channelInfo: { channel, devices } }) => {
)}
</CardHeader>
<CardBody>
<Box h="400px" w="100%" overflowY="auto" overflowX="auto" px={0}>
{ies ? (
<Box w="800px">
{ies.map(({ content, name, type }) => (
<Box key={uuid()} my={2}>
<Heading size="sm" mb={2} textDecor="underline">
{name} ({type})
</Heading>
<JsonViewer
rootName={false}
displayDataTypes={false}
enableClipboard
theme={colorMode === 'light' ? undefined : 'dark'}
value={content as object}
style={{ background: 'unset', display: 'unset' }}
/>
</Box>
))}
</Box>
) : (
<Table variant="simple" px={0}>
<Thead>
<Tr>
<Th>SSID</Th>
<Th width="110px" isNumeric>
{t('commands.signal')}
</Th>
<Th w="10px">IEs</Th>
</Tr>
</Thead>
<Tbody>
{devices.map((dev) => (
<Tr key={uuid()}>
<Td>{dev.ssid}</Td>
<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>
{ies ? (
<Box w="800px">
{ies.map(({ content, name, type }) => (
<Box key={uuid()} my={2}>
<Heading size="sm" mb={2} textDecor="underline">
{name} ({type})
</Heading>
<JsonViewer
rootName={false}
displayDataTypes={false}
enableClipboard
theme={colorMode === 'light' ? undefined : 'dark'}
value={content as object}
style={{ background: 'unset', display: 'unset' }}
/>
</Box>
))}
</Box>
) : (
<DataGrid<DeviceScanResult>
controller={tableController}
header={{
title: '',
objectListed: t('devices.title'),
}}
columns={columns}
data={devices}
options={{
count: devices.length,
onRowClick: (device) => () => setIes(device.ies ?? []),
hideTablePreferences: true,
isHidingControls: true,
minimumHeight: '0px',
hideTableTitleRow: true,
}}
/>
)}
</CardBody>
</Card>
);

View File

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

View File

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

View File

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

View File

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