UI: NodeList added Pie Chart and extracted Table Data

This commit is contained in:
Luis-Hebendanz
2023-08-12 00:01:09 +02:00
parent 3d3dcc800b
commit de9cac534b
8 changed files with 1608 additions and 276 deletions

View File

@@ -27,47 +27,53 @@ import Stack from '@mui/material/Stack/Stack';
import ModeIcon from '@mui/icons-material/Mode';
import ClearIcon from '@mui/icons-material/Clear';
import Fade from '@mui/material/Fade/Fade';
import NodePieChart, { PieData } from './NodePieChart';
import Grid2 from '@mui/material/Unstable_Grid2'; // Grid version 2
import { Card, CardContent, Container, FormGroup, useTheme } from '@mui/material';
import hexRgb from 'hex-rgb';
import useMediaQuery from '@mui/material/useMediaQuery';
interface Data {
export interface TableData {
name: string;
id: string;
status: boolean;
status: NodeStatus;
last_seen: number;
}
function createData(
name: string,
id: string,
status: boolean,
last_seen: number,
): Data {
if (status && last_seen > 0) {
console.error("Last seen should be 0 if status is true");
}
return {
name,
id,
status,
last_seen: last_seen,
};
export enum NodeStatus {
Online,
Offline,
Pending,
}
const rows = [
createData('Matchbox', "42:0:f21:6916:e333:c47e:4b5c:e74c", true, 0),
createData('Ahorn', "42:0:3c46:b51c:b34d:b7e1:3b02:8d24", true, 0),
createData('Yellow', "42:0:3c46:98ac:9c80:4f25:50e3:1d8f", false, 16.0),
createData('Rauter', "42:0:61ea:b777:61ea:803:f885:3523", false, 6.0),
createData('Porree', "42:0:e644:4499:d034:895e:34c8:6f9a", false, 13),
createData('Helsinki', "42:0:3c46:fd4a:acf9:e971:6036:8047", true, 0),
createData('Kelle', "42:0:3c46:362d:a9aa:4996:c78e:839a", true, 0),
createData('Shodan', "42:0:3c46:6745:adf4:a844:26c4:bf91", true, 0.0),
createData('Qubasa', "42:0:3c46:123e:bbea:3529:db39:6764", false, 7.0),
createData('Green', "42:0:a46e:5af:632c:d2fe:a71d:cde0", false, 2),
createData('Gum', "42:0:e644:238d:3e46:c884:6ec5:16c", false, 0),
createData('Xu', "42:0:ca48:c2c2:19fb:a0e9:95b9:794f", true, 0),
createData('Zaatar', "42:0:3c46:156e:10b6:3bd6:6e82:b2cd", true, 0),
interface HeadCell {
disablePadding: boolean;
id: keyof TableData;
label: string;
alignRight: boolean;
}
const headCells: readonly HeadCell[] = [
{
id: 'name',
alignRight: false,
disablePadding: false,
label: 'DISPLAY NAME & ID',
},
{
id: 'status',
alignRight: false,
disablePadding: false,
label: 'STATUS',
},
{
id: 'last_seen',
alignRight: false,
disablePadding: false,
label: 'LAST SEEN',
},
];
function descendingComparator<T>(a: T, b: T, orderBy: keyof T) {
@@ -110,36 +116,10 @@ function stableSort<T>(array: readonly T[], comparator: (a: T, b: T) => number)
return stabilizedThis.map((el) => el[0]);
}
interface HeadCell {
disablePadding: boolean;
id: keyof Data;
label: string;
alignRight: boolean;
}
const headCells: readonly HeadCell[] = [
{
id: 'name',
alignRight: false,
disablePadding: false,
label: 'Display Name & ID',
},
{
id: 'status',
alignRight: false,
disablePadding: false,
label: 'Status',
},
{
id: 'last_seen',
alignRight: false,
disablePadding: false,
label: 'Last Seen',
},
];
interface EnhancedTableProps {
onRequestSort: (event: React.MouseEvent<unknown>, property: keyof Data) => void;
onRequestSort: (event: React.MouseEvent<unknown>, property: keyof TableData) => void;
order: Order;
orderBy: string;
rowCount: number;
@@ -149,7 +129,7 @@ function EnhancedTableHead(props: EnhancedTableProps) {
const { order, orderBy, onRequestSort } =
props;
const createSortHandler =
(property: keyof Data) => (event: React.MouseEvent<unknown>) => {
(property: keyof TableData) => (event: React.MouseEvent<unknown>) => {
onRequestSort(event, property);
};
@@ -182,10 +162,161 @@ function EnhancedTableHead(props: EnhancedTableProps) {
);
}
interface EnhancedTableToolbarProps {
selected: string | undefined;
tableData: TableData[];
onClear: () => void;
}
function EnhancedTableToolbar(props: EnhancedTableToolbarProps) {
const { selected, onClear, tableData } = props;
const theme = useTheme();
const matches = useMediaQuery(theme.breakpoints.down('lg'));
const isSelected = selected != undefined;
const [debug, setDebug] = React.useState<boolean>(false);
const debugSx = debug ? {
'--Grid-borderWidth': '1px',
borderTop: 'var(--Grid-borderWidth) solid',
borderLeft: 'var(--Grid-borderWidth) solid',
borderColor: 'divider',
'& > div': {
borderRight: 'var(--Grid-borderWidth) solid',
borderBottom: 'var(--Grid-borderWidth) solid',
borderColor: 'divider',
}
} : {};
const pieData = React.useMemo(() => {
const online = tableData.filter((row) => row.status === NodeStatus.Online).length;
const offline = tableData.filter((row) => row.status === NodeStatus.Offline).length;
const pending = tableData.filter((row) => row.status === NodeStatus.Pending).length;
return [
{ name: 'Online', value: online, color: '#2E7D32' },
{ name: 'Offline', value: offline, color: '#db3927' },
{ name: 'Pending', value: pending, color: '#FFBB28' },
];
}, [tableData]);
const cardData = React.useMemo(() => {
let copy = pieData.filter((pieItem) => pieItem.value > 0);
const elem = { name: 'Total', value: copy.reduce((a, b) => a + b.value, 0), color: '#000000' };
copy.push(elem);
return copy;
}, [pieData]);
const cardStack = (
<Stack
sx={{ ...debugSx, paddingTop: 6 }}
height={350}
id="cardBox"
display="flex"
flexDirection="column"
justifyContent="flex-start"
flexWrap="wrap">
{cardData.map((pieItem) => (
<Card key={pieItem.name} sx={{ marginBottom: 2, marginRight: 2, width: 110, height: 110, backgroundColor: hexRgb(pieItem.color, { format: 'css', alpha: 0.18 }) }}>
<CardContent >
<Typography variant="h4" component="div" gutterBottom={true} textAlign="center">
{pieItem.value}
</Typography>
<Typography sx={{ mb: 1.5 }} color="text.secondary" textAlign="center">
{pieItem.name}
</Typography>
</CardContent>
</Card>
))}
</Stack>
);
const selectedToolbar = (
<Toolbar
sx={{
pl: { sm: 2 },
pr: { xs: 1, sm: 1 },
bgcolor: (theme) =>
alpha(theme.palette.primary.main, theme.palette.action.activatedOpacity),
}}>
<Tooltip title="Clear">
<IconButton onClick={onClear}>
<ClearIcon />
</IconButton>
</Tooltip>
<Typography
sx={{ flex: '1 1 100%' }}
color="inherit"
style={{ fontSize: 18, marginBottom: 3, marginLeft: 3 }}
component="div"
>
{selected} selected
</Typography>
<Tooltip title="Edit">
<IconButton>
<ModeIcon />
</IconButton>
</Tooltip>
</Toolbar >
);
const unselectedToolbar = (
<Toolbar
sx={{
pl: { sm: 2 },
pr: { xs: 1, sm: 1 },
}}
>
<Box sx={{ flex: '1 1 100%' }} ></Box>
<Tooltip title="Filter list">
<IconButton>
<FilterListIcon />
</IconButton>
</Tooltip>
</Toolbar >
);
return (
<Grid2 container spacing={1} sx={debugSx}>
<Grid2 xs={6}>
<Typography
sx={{ marginLeft: 3, marginTop: 1 }}
variant="h6"
id="tableTitle"
component="div"
>
NODES
</Typography>
</Grid2>
{/* Debug Controls */}
<Grid2 xs={6} justifyContent="right" display="flex">
<FormGroup>
<FormControlLabel control={<Switch onChange={() => { setDebug(!debug) }} checked={debug} />} label="Debug" />
</FormGroup>
</Grid2>
{/* Pie Chart Grid */}
<Grid2 lg={6} sm={12} display="flex" justifyContent="center" alignItems="center">
<Box height={350} width={400}>
<NodePieChart data={pieData} showLabels={matches} />
</Box>
</Grid2>
{/* Card Stack Grid */}
<Grid2 lg={6} display="flex" sx={{ display: { lg: 'flex', sm: 'none' } }} >
{cardStack}
</Grid2>
{/*Toolbar Grid */}
<Grid2 xs={12}>
{isSelected ? selectedToolbar : unselectedToolbar}
</Grid2>
</Grid2>
);
}
function renderLastSeen(last_seen: number) {
return (
@@ -208,98 +339,47 @@ function renderName(name: string, id: string) {
);
}
function renderStatus(status: boolean) {
if (status) {
return (
<Stack direction="row" alignItems="center" gap={1}>
<CircleIcon color="success" style={{ fontSize: 15 }} />
<Typography component="div" align="left" variant="body1">
Online
</Typography>
</Stack>
);
function renderStatus(status: NodeStatus) {
switch (status) {
case NodeStatus.Online:
return (
<Stack direction="row" alignItems="center" gap={1}>
<CircleIcon color="success" style={{ fontSize: 15 }} />
<Typography component="div" align="left" variant="body1">
Online
</Typography>
</Stack>
);
case NodeStatus.Offline:
return (
<Stack direction="row" alignItems="center" gap={1}>
<CircleIcon color="error" style={{ fontSize: 15 }} />
<Typography component="div" align="left" variant="body1">
Offline
</Typography>
</Stack>
);
case NodeStatus.Pending:
return (
<Stack direction="row" alignItems="center" gap={1}>
<CircleIcon color="warning" style={{ fontSize: 15 }} />
<Typography component="div" align="left" variant="body1">
Pending
</Typography>
</Stack>
);
}
return (
<Stack direction="row" alignItems="center" gap={1}>
<CircleIcon color="error" style={{ fontSize: 15 }} />
<Typography component="div" align="left" variant="body1">
Offline
</Typography>
</Stack>
);
}
function EnhancedTableToolbar(props: EnhancedTableToolbarProps) {
const { selected, onClear } = props;
const somethingSelected = selected !== undefined;
const handleSomethingSelected = () => {
if (somethingSelected) {
return (
<Toolbar
sx={{
pl: { sm: 2 },
pr: { xs: 1, sm: 1 },
bgcolor: (theme) =>
alpha(theme.palette.primary.main, theme.palette.action.activatedOpacity),
}}>
<Tooltip title="Clear">
<IconButton onClick={onClear}>
<ClearIcon />
</IconButton>
</Tooltip>
<Typography
sx={{ flex: '1 1 100%' }}
color="inherit"
style={{ fontSize: 18, marginBottom: 2.5, marginLeft: 3 }}
component="div"
>
{selected} selected
</Typography>
<Tooltip title="Edit">
<IconButton>
<ModeIcon />
</IconButton>
</Tooltip>
</Toolbar >
);
} else {
return (
<Toolbar
sx={{
pl: { sm: 2 },
pr: { xs: 1, sm: 1 }
}}>
<Typography
sx={{ flex: '1 1 100%' }}
variant="h6"
id="tableTitle"
component="div"
>
Nodes
</Typography>
<Tooltip title="Filter list">
<IconButton>
<FilterListIcon />
</IconButton>
</Tooltip>
</Toolbar >
);
}
};
return handleSomethingSelected();
export interface NodeTableProps {
tableData: TableData[]
}
export default function EnhancedTable() {
export default function NodeTable(props: NodeTableProps) {
let {tableData} = props;
const [order, setOrder] = React.useState<Order>('asc');
const [orderBy, setOrderBy] = React.useState<keyof Data>('status');
const [orderBy, setOrderBy] = React.useState<keyof TableData>('status');
const [selected, setSelected] = React.useState<string | undefined>(undefined);
const [page, setPage] = React.useState(0);
const [dense, setDense] = React.useState(false);
@@ -307,7 +387,7 @@ export default function EnhancedTable() {
const handleRequestSort = (
event: React.MouseEvent<unknown>,
property: keyof Data,
property: keyof TableData,
) => {
const isAsc = orderBy === property && order === 'asc';
setOrder(isAsc ? 'desc' : 'asc');
@@ -315,7 +395,8 @@ export default function EnhancedTable() {
};
const handleClick = (event: React.MouseEvent<unknown>, name: string) => {
if (selected === name) {
// Speed optimization. We compare string pointers here instead of the string content.
if (selected == name) {
setSelected(undefined);
} else {
setSelected(name);
@@ -331,106 +412,93 @@ export default function EnhancedTable() {
setPage(0);
};
const handleChangeDense = (event: React.ChangeEvent<HTMLInputElement>) => {
setDense(event.target.checked);
};
// TODO: Make a number to increase comparison speed and ui performance
const isSelected = (name: string) => name === selected;
// Speed optimization. We compare string pointers here instead of the string content.
const isSelected = (name: string) => name == selected;
// Avoid a layout jump when reaching the last page with empty rows.
const emptyRows =
page > 0 ? Math.max(0, (1 + page) * rowsPerPage - rows.length) : 0;
page > 0 ? Math.max(0, (1 + page) * rowsPerPage - tableData.length) : 0;
const visibleRows = React.useMemo(
() =>
stableSort(rows, getComparator(order, orderBy)).slice(
stableSort(tableData, getComparator(order, orderBy)).slice(
page * rowsPerPage,
page * rowsPerPage + rowsPerPage,
),
[order, orderBy, page, rowsPerPage],
[order, orderBy, page, rowsPerPage, tableData],
);
return (
<Box sx={{ width: '100%' }}>
<Paper sx={{ width: '100%', mb: 2 }} id="test">
<EnhancedTableToolbar selected={selected} onClear={() => setSelected(undefined)} />
<TableContainer>
<Table
sx={{ minWidth: 750 }}
aria-labelledby="tableTitle"
size={dense ? 'small' : 'medium'}
>
<EnhancedTableHead
order={order}
orderBy={orderBy}
onRequestSort={handleRequestSort}
rowCount={rows.length}
/>
<TableBody>
{visibleRows.map((row, index) => {
const isItemSelected = isSelected(row.name);
const labelId = `enhanced-table-checkbox-${index}`;
<Paper elevation={1} sx={{ margin: 5 }}>
<Box sx={{ width: '100%' }}>
<Paper sx={{ width: '100%', mb: 2 }}>
<EnhancedTableToolbar tableData={tableData} selected={selected} onClear={() => setSelected(undefined)} />
<TableContainer>
<Table
sx={{ minWidth: 750 }}
aria-labelledby="tableTitle"
size={dense ? 'small' : 'medium'}
>
<EnhancedTableHead
order={order}
orderBy={orderBy}
onRequestSort={handleRequestSort}
rowCount={tableData.length}
/>
<TableBody>
{visibleRows.map((row, index) => {
const isItemSelected = isSelected(row.name);
const labelId = `enhanced-table-checkbox-${index}`;
return (
<TableRow
hover
onClick={(event) => handleClick(event, row.name)}
role="checkbox"
aria-checked={isItemSelected}
tabIndex={-1}
key={row.name}
selected={isItemSelected}
sx={{ cursor: 'pointer' }}
>
{/* <TableCell padding="checkbox">
<Checkbox
color="primary"
checked={isItemSelected}
inputProps={{
'aria-labelledby': labelId,
}}
/>
</TableCell> */}
<TableCell
component="th"
id={labelId}
scope="row"
return (
<TableRow
hover
onClick={(event) => handleClick(event, row.name)}
role="checkbox"
aria-checked={isItemSelected}
tabIndex={-1}
key={row.name}
selected={isItemSelected}
sx={{ cursor: 'pointer' }}
>
{renderName(row.name, row.id)}
</TableCell>
<TableCell align="right">{renderStatus(row.status)}</TableCell>
<TableCell align="right">{renderLastSeen(row.last_seen)}</TableCell>
<TableCell
component="th"
id={labelId}
scope="row"
>
{renderName(row.name, row.id)}
</TableCell>
<TableCell align="right">{renderStatus(row.status)}</TableCell>
<TableCell align="right">{renderLastSeen(row.last_seen)}</TableCell>
</TableRow>
);
})}
{emptyRows > 0 && (
<TableRow
style={{
height: (dense ? 33 : 53) * emptyRows,
}}
>
<TableCell colSpan={6} />
</TableRow>
);
})}
{emptyRows > 0 && (
<TableRow
style={{
height: (dense ? 33 : 53) * emptyRows,
}}
>
<TableCell colSpan={6} />
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{/* TODO: This creates the error Warning: Prop `id` did not match. Server: ":RspmmcqH1:" Client: ":R3j6qpj9H1:" */}
<TablePagination
rowsPerPageOptions={[5, 10, 25]}
component="div"
count={rows.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</Paper>
<FormControlLabel
control={<Switch checked={dense} onChange={handleChangeDense} />}
label="Dense padding"
/>
</Box>
)}
</TableBody>
</Table>
</TableContainer>
{/* TODO: This creates the error Warning: Prop `id` did not match. Server: ":RspmmcqH1:" Client: ":R3j6qpj9H1:" */}
<TablePagination
rowsPerPageOptions={[5, 10, 25]}
component="div"
count={tableData.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</Paper>
</Box>
</Paper>
);
}

View File

@@ -0,0 +1,45 @@
import React, { PureComponent } from 'react';
import { PieChart, Pie, Sector, Cell, ResponsiveContainer, Legend } from 'recharts';
import { useTheme } from '@mui/material/styles';
import { Box, Color } from '@mui/material';
export interface PieData {
name: string;
value: number;
color: string;
};
interface Props {
data: PieData[];
showLabels?: boolean;
};
export default function NodePieChart(props: Props ) {
const theme = useTheme();
const {data, showLabels} = props;
return (
<Box height={350}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
innerRadius={85}
outerRadius={120}
fill={theme.palette.primary.main}
dataKey="value"
nameKey="name"
label={showLabels}
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Legend verticalAlign="bottom" />
</PieChart>
</ResponsiveContainer>
</Box>
);
};

View File

@@ -1,36 +0,0 @@
export default function PieData() {
return [
{
"id": "scala",
"label": "scala",
"value": 317,
"color": "hsl(3, 70%, 50%)"
},
{
"id": "rust",
"label": "rust",
"value": 489,
"color": "hsl(113, 70%, 50%)"
},
{
"id": "css",
"label": "css",
"value": 456,
"color": "hsl(17, 70%, 50%)"
},
{
"id": "elixir",
"label": "elixir",
"value": 343,
"color": "hsl(232, 70%, 50%)"
},
{
"id": "haskell",
"label": "haskell",
"value": 167,
"color": "hsl(292, 70%, 50%)"
}
];
}

View File

@@ -1,17 +1,47 @@
"use client"
import { StrictMode } from "react";
import NodeList from "./NodeList";
import NodeList, { NodeStatus, TableData } from "./NodeList";
import Box from "@mui/material/Box";
function createData(
name: string,
id: string,
status: NodeStatus,
last_seen: number,
): TableData {
return {
name,
id,
status,
last_seen: last_seen,
};
}
const tableData = [
createData('Matchbox', "42:0:f21:6916:e333:c47e:4b5c:e74c", NodeStatus.Pending, 0),
createData('Ahorn', "42:0:3c46:b51c:b34d:b7e1:3b02:8d24", NodeStatus.Online, 0),
createData('Yellow', "42:0:3c46:98ac:9c80:4f25:50e3:1d8f", NodeStatus.Offline, 16.0),
createData('Rauter', "42:0:61ea:b777:61ea:803:f885:3523", NodeStatus.Offline, 6.0),
createData('Porree', "42:0:e644:4499:d034:895e:34c8:6f9a", NodeStatus.Offline, 13),
createData('Helsinki', "42:0:3c46:fd4a:acf9:e971:6036:8047", NodeStatus.Online, 0),
createData('Kelle', "42:0:3c46:362d:a9aa:4996:c78e:839a", NodeStatus.Online, 0),
createData('Shodan', "42:0:3c46:6745:adf4:a844:26c4:bf91", NodeStatus.Online, 0.0),
createData('Qubasa', "42:0:3c46:123e:bbea:3529:db39:6764", NodeStatus.Offline, 7.0),
createData('Green', "42:0:a46e:5af:632c:d2fe:a71d:cde0", NodeStatus.Offline, 2),
createData('Gum', "42:0:e644:238d:3e46:c884:6ec5:16c", NodeStatus.Offline, 0),
createData('Xu', "42:0:ca48:c2c2:19fb:a0e9:95b9:794f", NodeStatus.Online, 0),
createData('Zaatar', "42:0:3c46:156e:10b6:3bd6:6e82:b2cd", NodeStatus.Online, 0),
];
export default function Page() {
return (
<Box>
<NodeList />
<Box sx={{ backgroundColor: "#e9ecf5", height: "100%", width: "100%" }} display="inline-block" id="rootBox">
<NodeList tableData={tableData} />
</Box>
);
}

View File

@@ -1,13 +1,16 @@
import { createTheme } from "@mui/material/styles";
export const darkTheme = createTheme({
palette: {
mode: "dark",
},
});
export const lightTheme = createTheme({
palette: {
mode: "light",
},
});