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' ? - - : -

- Server is offline. -

- } -
- - {status !== 'offline' ? - - : -

- Server is offline. -

- } -
- - {status !== 'offline' ? - - : -

- Server is 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"