Upgrade charts to ChartJS 3 and improve UI for them

This commit is contained in:
DaneEveritt 2022-06-25 20:49:25 -04:00
parent 980f828edd
commit 182507ff0e
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
8 changed files with 269 additions and 179 deletions

View file

@ -0,0 +1,27 @@
import React from 'react';
import classNames from 'classnames';
import styles from '@/components/server/console/style.module.css';
interface ChartBlockProps {
title: string;
legend?: React.ReactNode;
children: React.ReactNode;
}
export default ({ title, legend, children }: ChartBlockProps) => (
<div className={classNames(styles.chart_container, 'group')}>
<div className={'flex items-center justify-between px-4 py-2'}>
<h3 className={'font-header transition-colors duration-100 group-hover:text-gray-50'}>
{title}
</h3>
{legend &&
<p className={'text-sm flex items-center'}>
{legend}
</p>
}
</div>
<div className={'z-10 ml-2'}>
{children}
</div>
</div>
);

View file

@ -85,9 +85,9 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
</StatBlock>
<StatBlock
icon={faMicrochip}
title={'CPU'}
title={'CPU Load'}
color={getBackgroundColor(stats.cpu, limits.cpu)}
description={limits.memory
description={limits.cpu
? `This server is allowed to use up to ${limits.cpu}% of the host's available CPU resources.`
: 'No CPU limit has been configured for this server.'
}

View file

@ -1,170 +1,94 @@
import React, { useCallback, useRef, useState } from 'react';
import Chart, { ChartConfiguration } from 'chart.js';
import React, { useEffect, useRef } from 'react';
import { ServerContext } from '@/state/server';
import merge from 'deepmerge';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import { faEthernet, faMemory, faMicrochip } from '@fortawesome/free-solid-svg-icons';
import tw from 'twin.macro';
import { SocketEvent } from '@/components/server/events';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
const chartDefaults = (ticks?: Chart.TickOptions): ChartConfiguration => ({
type: 'line',
options: {
legend: {
display: false,
},
tooltips: {
enabled: false,
},
animation: {
duration: 0,
},
elements: {
point: {
radius: 0,
},
line: {
tension: 0.3,
backgroundColor: 'rgba(15, 178, 184, 0.45)',
borderColor: '#32D0D9',
},
},
scales: {
xAxes: [ {
ticks: {
display: false,
},
gridLines: {
display: false,
},
} ],
yAxes: [ {
gridLines: {
drawTicks: false,
color: 'rgba(229, 232, 235, 0.15)',
zeroLineColor: 'rgba(15, 178, 184, 0.45)',
zeroLineWidth: 3,
},
ticks: merge(ticks || {}, {
fontSize: 10,
fontFamily: '"IBM Plex Mono", monospace',
fontColor: 'rgb(229, 232, 235)',
min: 0,
beginAtZero: true,
maxTicksLimit: 5,
}),
} ],
},
},
data: {
labels: Array(20).fill(''),
datasets: [
{
fill: true,
data: Array(20).fill(0),
},
],
},
});
type ChartState = [ (node: HTMLCanvasElement | null) => void, Chart | undefined ];
/**
* Creates an element ref and a chart instance.
*/
const useChart = (options?: Chart.TickOptions): ChartState => {
const [ chart, setChart ] = useState<Chart>();
const ref = useCallback<(node: HTMLCanvasElement | null) => void>(node => {
if (!node) return;
const chart = new Chart(node.getContext('2d')!, chartDefaults(options));
setChart(chart);
}, []);
return [ ref, chart ];
};
const updateChartDataset = (chart: Chart | null | undefined, value: Chart.ChartPoint & number): void => {
if (!chart || !chart.data?.datasets) return;
const data = chart.data.datasets[0].data!;
data.push(value);
data.shift();
chart.update({ lazy: true });
};
import { Line } from 'react-chartjs-2';
import { useChart, useChartTickLabel } from '@/components/server/console/chart';
import { bytesToHuman, toRGBA } from '@/helpers';
import { CloudDownloadIcon, CloudUploadIcon } from '@heroicons/react/solid';
import { theme } from 'twin.macro';
import ChartBlock from '@/components/server/console/ChartBlock';
import Tooltip from '@/components/elements/tooltip/Tooltip';
export default () => {
const status = ServerContext.useStoreState(state => state.status.value);
const limits = ServerContext.useStoreState(state => state.server.data!.limits);
const previous = useRef<Record<'tx' | 'rx', number>>({ tx: -1, rx: -1 });
const [ cpuRef, cpu ] = useChart({ callback: (value) => `${value}% `, suggestedMax: limits.cpu });
const [ memoryRef, memory ] = useChart({ callback: (value) => `${value}Mb `, suggestedMax: limits.memory });
const [ txRef, tx ] = useChart({ callback: (value) => `${value}Kb/s ` });
const [ rxRef, rx ] = useChart({ callback: (value) => `${value}Kb/s ` });
const cpu = useChartTickLabel('CPU', limits.cpu, '%');
const memory = useChartTickLabel('Memory', limits.memory, 'MB');
const network = useChart('Network', {
sets: 2,
options: {
scales: {
y: {
ticks: {
callback (value) {
return bytesToHuman(typeof value === 'string' ? parseInt(value, 10) : value);
},
},
},
},
},
callback (opts, index) {
return {
...opts,
label: !index ? 'Network In' : 'Network Out',
borderColor: !index ? theme('colors.cyan.400') : theme('colors.yellow.400'),
backgroundColor: toRGBA(!index ? theme('colors.cyan.700') : theme('colors.yellow.700'), 0.5),
};
},
});
useEffect(() => {
if (status === 'offline') {
cpu.clear();
memory.clear();
network.clear();
}
}, [ status ]);
useWebsocketEvent(SocketEvent.STATS, (data: string) => {
let stats: any = {};
let values: any = {};
try {
stats = JSON.parse(data);
values = JSON.parse(data);
} catch (e) {
return;
}
updateChartDataset(cpu, stats.cpu_absolute);
updateChartDataset(memory, Math.floor(stats.memory_bytes / 1024 / 1024));
updateChartDataset(tx, previous.current.tx < 0 ? 0 : Math.max(0, stats.network.tx_bytes - previous.current.tx) / 1024);
updateChartDataset(rx, previous.current.rx < 0 ? 0 : Math.max(0, stats.network.rx_bytes - previous.current.rx) / 1024);
cpu.push(values.cpu_absolute);
memory.push(Math.floor(values.memory_bytes / 1024 / 1024));
network.push([
previous.current.tx < 0 ? 0 : Math.max(0, values.network.tx_bytes - previous.current.tx),
previous.current.rx < 0 ? 0 : Math.max(0, values.network.rx_bytes - previous.current.rx),
]);
previous.current = { tx: stats.network.tx_bytes, rx: stats.network.rx_bytes };
previous.current = { tx: values.network.tx_bytes, rx: values.network.rx_bytes };
});
return (
<>
<TitledGreyBox title={'Memory usage'} icon={faMemory}>
{status !== 'offline' ?
<canvas
id={'memory_chart'}
ref={memoryRef}
aria-label={'Server Memory Usage Graph'}
role={'img'}
/>
:
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
<ChartBlock title={'CPU Load'}>
<Line {...cpu.props}/>
</ChartBlock>
<ChartBlock title={'Memory'}>
<Line {...memory.props}/>
</ChartBlock>
<ChartBlock
title={'Network'}
legend={
<>
<Tooltip arrow content={'Inbound'}>
<CloudDownloadIcon className={'mr-2 w-4 h-4 text-yellow-400'}/>
</Tooltip>
<Tooltip arrow content={'Outbound'}>
<CloudUploadIcon className={'w-4 h-4 text-cyan-400'}/>
</Tooltip>
</>
}
</TitledGreyBox>
<TitledGreyBox title={'CPU usage'} icon={faMicrochip}>
{status !== 'offline' ?
<canvas id={'cpu_chart'} ref={cpuRef} aria-label={'Server CPU Usage Graph'} role={'img'}/>
:
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
}
</TitledGreyBox>
<TitledGreyBox title={'Inbound Data'} icon={faEthernet}>
{status !== 'offline' ?
<canvas id={'rx_chart'} ref={rxRef} aria-label={'Server Inbound Data'} role={'img'}/>
:
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
}
</TitledGreyBox>
<TitledGreyBox title={'Outbound Data'} icon={faEthernet}>
{status !== 'offline' ?
<canvas id={'tx_chart'} ref={txRef} aria-label={'Server Outbound Data'} role={'img'}/>
:
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
}
</TitledGreyBox>
>
<Line {...network.props}/>
</ChartBlock>
</>
);
};

View file

@ -0,0 +1,141 @@
import {
Chart as ChartJS,
ChartData,
ChartDataset,
ChartOptions,
Filler,
LinearScale,
LineElement,
PointElement,
} from 'chart.js';
import { DeepPartial } from 'ts-essentials';
import { useState } from 'react';
import { deepmerge, deepmergeCustom } from 'deepmerge-ts';
import { theme } from 'twin.macro';
import { toRGBA } from '@/helpers';
ChartJS.register(LineElement, PointElement, Filler, LinearScale);
const options: ChartOptions<'line'> = {
responsive: true,
animation: false,
plugins: {
legend: { display: false },
title: { display: false },
tooltip: { enabled: false },
},
layout: {
padding: 0,
},
scales: {
x: {
min: 0,
max: 19,
type: 'linear',
grid: {
display: false,
drawBorder: false,
},
ticks: {
display: false,
},
},
y: {
min: 0,
type: 'linear',
grid: {
display: true,
color: theme('colors.gray.700'),
drawBorder: false,
},
ticks: {
display: true,
count: 3,
color: theme('colors.gray.200'),
font: {
family: theme('fontFamily.sans'),
size: 11,
weight: '400',
},
},
},
},
elements: {
point: {
radius: 0,
},
line: {
tension: 0.3,
},
},
};
function getOptions (opts?: DeepPartial<ChartOptions<'line'>> | undefined): ChartOptions<'line'> {
return deepmerge(options, opts || {});
}
type ChartDatasetCallback = (value: ChartDataset<'line'>, index: number) => ChartDataset<'line'>;
function getEmptyData (label: string, sets = 1, callback?: ChartDatasetCallback | undefined): ChartData<'line'> {
const next = callback || (value => value);
return {
labels: Array(20).fill(0).map((_, index) => index),
datasets: Array(sets).fill(0).map((_, index) => next({
fill: true,
label,
data: Array(20).fill(0),
borderColor: theme('colors.cyan.400'),
backgroundColor: toRGBA(theme('colors.cyan.700'), 0.5),
}, index)),
};
}
const merge = deepmergeCustom({ mergeArrays: false });
interface UseChartOptions {
sets: number;
options?: DeepPartial<ChartOptions<'line'>> | number | undefined;
callback?: ChartDatasetCallback | undefined;
}
function useChart (label: string, opts?: UseChartOptions) {
const options = getOptions(typeof opts?.options === 'number' ? { scales: { y: { min: 0, suggestedMax: opts.options } } } : opts?.options);
const [ data, setData ] = useState(getEmptyData(label, opts?.sets || 1, opts?.callback));
const push = (items: number | null | ((number | null)[])) => setData(state => merge(state, {
datasets: (Array.isArray(items) ? items : [ items ]).map((item, index) => ({
...state.datasets[index],
data: state.datasets[index].data.slice(1).concat(item),
})),
}));
const clear = () => setData(state => merge(state, {
datasets: state.datasets.map(value => ({
...value,
data: Array(20).fill(0),
})),
}));
return { props: { data, options }, push, clear };
}
function useChartTickLabel (label: string, max: number, tickLabel: string) {
return useChart(label, {
sets: 1,
options: {
scales: {
y: {
suggestedMax: max,
ticks: {
callback (value) {
return `${value}${tickLabel}`;
},
},
},
},
},
});
}
export { useChart, useChartTickLabel, getOptions, getEmptyData };

View file

@ -57,3 +57,7 @@
@apply active:border-cyan-500 focus:border-cyan-500;
}
}
.chart_container {
@apply bg-gray-600 rounded shadow-lg pt-2 border-b-4 border-gray-700 relative;
}