diff --git a/package.json b/package.json
index 7a4600027..a5b63a38e 100644
--- a/package.json
+++ b/package.json
@@ -12,12 +12,12 @@
"@tailwindcss/line-clamp": "^0.4.0",
"axios": "^0.21.1",
"boring-avatars": "^1.7.0",
- "chart.js": "^2.8.0",
+ "chart.js": "^3.8.0",
"classnames": "^2.3.1",
"codemirror": "^5.57.0",
"date-fns": "^2.16.1",
"debounce": "^1.2.0",
- "deepmerge": "^4.2.2",
+ "deepmerge-ts": "^4.2.1",
"easy-peasy": "^4.0.1",
"events": "^3.0.0",
"formik": "^2.2.6",
@@ -28,6 +28,7 @@
"qrcode.react": "^1.0.1",
"query-string": "^6.7.0",
"react": "^16.14.0",
+ "react-chartjs-2": "^4.2.0",
"react-copy-to-clipboard": "^5.0.2",
"react-dom": "npm:@hot-loader/react-dom",
"react-fast-compare": "^3.2.0",
diff --git a/resources/scripts/components/server/console/ChartBlock.tsx b/resources/scripts/components/server/console/ChartBlock.tsx
new file mode 100644
index 000000000..4e451cb21
--- /dev/null
+++ b/resources/scripts/components/server/console/ChartBlock.tsx
@@ -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) => (
+
+
+
+ {title}
+
+ {legend &&
+
+ {legend}
+
+ }
+
+
+ {children}
+
+
+);
diff --git a/resources/scripts/components/server/console/ServerDetailsBlock.tsx b/resources/scripts/components/server/console/ServerDetailsBlock.tsx
index a8599de98..9008a3e71 100644
--- a/resources/scripts/components/server/console/ServerDetailsBlock.tsx
+++ b/resources/scripts/components/server/console/ServerDetailsBlock.tsx
@@ -85,9 +85,9 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
({
- 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();
-
- 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>({ 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 (
<>
-
- {status !== 'offline' ?
-
- :
-
- Server is offline.
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
}
-
-
- {status !== 'offline' ?
-
-
- {status !== 'offline' ?
-
-
- {status !== 'offline' ?
-
+ >
+
+
>
);
};
diff --git a/resources/scripts/components/server/console/chart.ts b/resources/scripts/components/server/console/chart.ts
new file mode 100644
index 000000000..3be1ac4c7
--- /dev/null
+++ b/resources/scripts/components/server/console/chart.ts
@@ -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> | 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> | 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 };
diff --git a/resources/scripts/components/server/console/style.module.css b/resources/scripts/components/server/console/style.module.css
index 24c5743bb..0c99bba04 100644
--- a/resources/scripts/components/server/console/style.module.css
+++ b/resources/scripts/components/server/console/style.module.css
@@ -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;
+}
diff --git a/resources/scripts/helpers.ts b/resources/scripts/helpers.ts
index 9b31aaddc..aff09591a 100644
--- a/resources/scripts/helpers.ts
+++ b/resources/scripts/helpers.ts
@@ -1,8 +1,8 @@
export const megabytesToBytes = (mb: number) => Math.floor(mb * 1024 * 1024);
export function bytesToHuman (bytes: number): string {
- if (bytes === 0) {
- return '0 kB';
+ if (bytes < 1) {
+ return '0 Bytes';
}
const i = Math.floor(Math.log(bytes) / Math.log(1024));
@@ -75,3 +75,9 @@ export const isEmptyObject = (o: {}): boolean =>
// eslint-disable-next-line @typescript-eslint/ban-types
export const getObjectKeys = (o: T): (keyof T)[] => Object.keys(o) as (keyof typeof o)[];
+
+export const toRGBA = (hex: string, alpha = 1): string => {
+ const [ r, g, b ] = hex.match(/\w\w/g)!.map(v => parseInt(v, 16));
+
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+};
diff --git a/yarn.lock b/yarn.lock
index 5f8587c4c..2fd21feec 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2617,25 +2617,10 @@ chalk@^4.1.2:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
-chart.js@^2.8.0:
- version "2.8.0"
- resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.8.0.tgz#b703b10d0f4ec5079eaefdcd6ca32dc8f826e0e9"
- dependencies:
- chartjs-color "^2.1.0"
- moment "^2.10.2"
-
-chartjs-color-string@^0.6.0:
- version "0.6.0"
- resolved "https://registry.yarnpkg.com/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz#1df096621c0e70720a64f4135ea171d051402f71"
- dependencies:
- color-name "^1.0.0"
-
-chartjs-color@^2.1.0:
- version "2.3.0"
- resolved "https://registry.yarnpkg.com/chartjs-color/-/chartjs-color-2.3.0.tgz#0e7e1e8dba37eae8415fd3db38bf572007dd958f"
- dependencies:
- chartjs-color-string "^0.6.0"
- color-convert "^0.5.3"
+chart.js@^3.8.0:
+ version "3.8.0"
+ resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.8.0.tgz#c6c14c457b9dc3ce7f1514a59e9b262afd6f1a94"
+ integrity sha512-cr8xhrXjLIXVLOBZPkBZVF6NDeiVIrPLHcMhnON7UufudL+CNeRrD+wpYanswlm8NpudMdrt3CHoLMQMxJhHRg==
check-types@^8.0.3:
version "8.0.3"
@@ -2775,10 +2760,6 @@ collection-visit@^1.0.0:
map-visit "^1.0.0"
object-visit "^1.0.0"
-color-convert@^0.5.3:
- version "0.5.3"
- resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-0.5.3.tgz#bdb6c69ce660fadffe0b0007cc447e1b9f7282bd"
-
color-convert@^1.9.0, color-convert@^1.9.1:
version "1.9.2"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.2.tgz#49881b8fba67df12a96bdf3f56c0aab9e7913147"
@@ -3224,6 +3205,11 @@ deep-is@^0.1.3:
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=
+deepmerge-ts@^4.2.1:
+ version "4.2.1"
+ resolved "https://registry.yarnpkg.com/deepmerge-ts/-/deepmerge-ts-4.2.1.tgz#104fe27c91abde4597bad1dcfd00a7a8be22d532"
+ integrity sha512-xzJLiUo4z1dD2nggSfaMvHo5qWLoy/JVa9rKuktC6FrQQEBI8Qnj7KwuCYZhqBoGOOpGqs6+3MR2ZhSMcTr4BA==
+
deepmerge@^2.1.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170"
@@ -5756,10 +5742,6 @@ modern-normalize@^1.1.0:
resolved "https://registry.yarnpkg.com/modern-normalize/-/modern-normalize-1.1.0.tgz#da8e80140d9221426bd4f725c6e11283d34f90b7"
integrity sha512-2lMlY1Yc1+CUy0gw4H95uNN7vjbpoED7NNRSBHE25nWfLBdmMzFCsPshlzbxHz+gYMcBEUN8V4pU16prcdPSgA==
-moment@^2.10.2:
- version "2.24.0"
- resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
-
move-concurrently@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
@@ -7023,6 +7005,11 @@ react-async-script@^1.1.1:
hoist-non-react-statics "^3.3.0"
prop-types "^15.5.0"
+react-chartjs-2@^4.2.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/react-chartjs-2/-/react-chartjs-2-4.2.0.tgz#bc5693a8b161f125301cf28ab0fe980d7dce54aa"
+ integrity sha512-9Vm9Sg9XAKiR579/FnBkesofjW9goaaFLfS7XlGTzUJlWFZGSE6A/pBI6+i/bP3pobKZoFcWJdFnjShytToqXw==
+
react-copy-to-clipboard@^5.0.2:
version "5.0.2"
resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.2.tgz#d82a437e081e68dfca3761fbd57dbf2abdda1316"