Add support for showing usage graphs on the console page
This commit is contained in:
parent
c66d2cd123
commit
29834a33f8
7 changed files with 292 additions and 52 deletions
23
resources/scripts/components/elements/TitledGreyBox.tsx
Normal file
23
resources/scripts/components/elements/TitledGreyBox.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import React from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { IconProp } from '@fortawesome/fontawesome-svg-core';
|
||||
|
||||
interface Props {
|
||||
icon?: IconProp;
|
||||
title: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default ({ icon, title, children, className }: Props) => (
|
||||
<div className={`rounded shadow-md bg-neutral-700 ${className}`}>
|
||||
<div className={'bg-neutral-900 rounded-t p-3 border-b border-black'}>
|
||||
<p className={'text-sm uppercase'}>
|
||||
{icon && <FontAwesomeIcon icon={icon} className={'mr-2 text-neutral-300'}/>}{title}
|
||||
</p>
|
||||
</div>
|
||||
<div className={'p-3'}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
|
@ -4,19 +4,16 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||
import { faServer } from '@fortawesome/free-solid-svg-icons/faServer';
|
||||
import { faCircle } from '@fortawesome/free-solid-svg-icons/faCircle';
|
||||
import classNames from 'classnames';
|
||||
import styled from 'styled-components';
|
||||
import { faMemory } from '@fortawesome/free-solid-svg-icons/faMemory';
|
||||
import { faMicrochip } from '@fortawesome/free-solid-svg-icons/faMicrochip';
|
||||
import { bytesToHuman } from '@/helpers';
|
||||
import SuspenseSpinner from '@/components/elements/SuspenseSpinner';
|
||||
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
||||
|
||||
type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
|
||||
|
||||
const GreyBox = styled.div`
|
||||
${tw`mt-4 shadow-md bg-neutral-700 rounded p-3 flex text-xs`}
|
||||
`;
|
||||
|
||||
const ChunkedConsole = lazy(() => import('@/components/server/Console'));
|
||||
const ChunkedStatGraphs = lazy(() => import('@/components/server/StatGraphs'));
|
||||
|
||||
const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void }) => {
|
||||
const [ clicked, setClicked ] = useState(false);
|
||||
|
@ -80,46 +77,39 @@ export default () => {
|
|||
|
||||
return (
|
||||
<div className={'my-10 flex'}>
|
||||
<div className={'flex-1 ml-4'}>
|
||||
<div className={'rounded shadow-md bg-neutral-700'}>
|
||||
<div className={'bg-neutral-900 rounded-t p-3 border-b border-black'}>
|
||||
<p className={'text-sm uppercase'}>
|
||||
<FontAwesomeIcon icon={faServer} className={'mr-1 text-neutral-300'}/> {server.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className={'p-3'}>
|
||||
<p className={'text-xs uppercase'}>
|
||||
<FontAwesomeIcon
|
||||
icon={faCircle}
|
||||
fixedWidth={true}
|
||||
className={classNames('mr-1', {
|
||||
'text-red-500': status === 'offline',
|
||||
'text-yellow-500': [ 'running', 'offline' ].indexOf(status) < 0,
|
||||
'text-green-500': status === 'running',
|
||||
})}
|
||||
/>
|
||||
{status}
|
||||
</p>
|
||||
<p className={'text-xs mt-2'}>
|
||||
<FontAwesomeIcon
|
||||
icon={faMemory}
|
||||
fixedWidth={true}
|
||||
className={'mr-1'}
|
||||
/>
|
||||
{bytesToHuman(memory)}
|
||||
<span className={'text-neutral-500'}>/ {server.limits.memory} MB</span>
|
||||
</p>
|
||||
<p className={'text-xs mt-2'}>
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrochip}
|
||||
fixedWidth={true}
|
||||
className={'mr-1'}
|
||||
/>
|
||||
{cpu.toFixed(2)} %
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<GreyBox className={'justify-center'}>
|
||||
<div className={'w-1/4 ml-4'}>
|
||||
<TitledGreyBox title={server.name} icon={faServer}>
|
||||
<p className={'text-xs uppercase'}>
|
||||
<FontAwesomeIcon
|
||||
icon={faCircle}
|
||||
fixedWidth={true}
|
||||
className={classNames('mr-1', {
|
||||
'text-red-500': status === 'offline',
|
||||
'text-yellow-500': [ 'running', 'offline' ].indexOf(status) < 0,
|
||||
'text-green-500': status === 'running',
|
||||
})}
|
||||
/>
|
||||
{status}
|
||||
</p>
|
||||
<p className={'text-xs mt-2'}>
|
||||
<FontAwesomeIcon
|
||||
icon={faMemory}
|
||||
fixedWidth={true}
|
||||
className={'mr-1'}
|
||||
/>
|
||||
{bytesToHuman(memory)}
|
||||
<span className={'text-neutral-500'}>/ {server.limits.memory} MB</span>
|
||||
</p>
|
||||
<p className={'text-xs mt-2'}>
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrochip}
|
||||
fixedWidth={true}
|
||||
className={'mr-1'}
|
||||
/>
|
||||
{cpu.toFixed(2)} %
|
||||
</p>
|
||||
</TitledGreyBox>
|
||||
<div className={'grey-box justify-center'}>
|
||||
<button
|
||||
className={'btn btn-secondary btn-xs mr-2'}
|
||||
disabled={status !== 'offline'}
|
||||
|
@ -140,13 +130,14 @@ export default () => {
|
|||
Restart
|
||||
</button>
|
||||
<StopOrKillButton onPress={action => sendPowerCommand(action)}/>
|
||||
</GreyBox>
|
||||
</div>
|
||||
<SuspenseSpinner>
|
||||
<div className={'mx-4 w-3/4 mr-4'}>
|
||||
<ChunkedConsole/>
|
||||
</div>
|
||||
</SuspenseSpinner>
|
||||
</div>
|
||||
<div className={'flex-1 mx-4 mr-4'}>
|
||||
<SuspenseSpinner>
|
||||
<ChunkedConsole/>
|
||||
{status !== 'offline' && <ChunkedStatGraphs/>}
|
||||
</SuspenseSpinner>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
171
resources/scripts/components/server/StatGraphs.tsx
Normal file
171
resources/scripts/components/server/StatGraphs.tsx
Normal file
|
@ -0,0 +1,171 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Chart, { ChartConfiguration } from 'chart.js';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { bytesToMegabytes } from '@/helpers';
|
||||
import merge from 'lodash-es/merge';
|
||||
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
||||
import { faMemory } from '@fortawesome/free-solid-svg-icons/faMemory';
|
||||
import { faMicrochip } from '@fortawesome/free-solid-svg-icons/faMicrochip';
|
||||
|
||||
const chartDefaults: ChartConfiguration = {
|
||||
type: 'line',
|
||||
options: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltips: {
|
||||
enabled: false,
|
||||
},
|
||||
animation: {
|
||||
duration: 0,
|
||||
},
|
||||
hover: {
|
||||
animationDuration: 0,
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 0,
|
||||
},
|
||||
line: {
|
||||
tension: 0.1,
|
||||
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: {
|
||||
fontSize: 10,
|
||||
fontFamily: '"IBM Plex Mono", monospace',
|
||||
fontColor: 'rgb(229, 232, 235)',
|
||||
min: 0,
|
||||
beginAtZero: true,
|
||||
maxTicksLimit: 5,
|
||||
},
|
||||
} ],
|
||||
},
|
||||
responsiveAnimationDuration: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const createDefaultChart = (ctx: CanvasRenderingContext2D, options?: ChartConfiguration): Chart => new Chart(ctx, {
|
||||
...merge({}, chartDefaults, options),
|
||||
data: {
|
||||
labels: Array(20).fill(''),
|
||||
datasets: [
|
||||
{
|
||||
fill: true,
|
||||
data: Array(20).fill(0),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export default () => {
|
||||
const { limits } = ServerContext.useStoreState(state => state.server.data!);
|
||||
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
|
||||
|
||||
const [ memory, setMemory ] = useState<Chart>();
|
||||
const [ cpu, setCpu ] = useState<Chart>();
|
||||
|
||||
const memoryRef = useCallback<(node: HTMLCanvasElement | null) => void>(node => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMemory(createDefaultChart(node.getContext('2d')!, {
|
||||
options: {
|
||||
scales: {
|
||||
yAxes: [ {
|
||||
ticks: {
|
||||
callback: (value) => `${value}Mb `,
|
||||
suggestedMax: limits.memory,
|
||||
},
|
||||
} ],
|
||||
},
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const cpuRef = useCallback<(node: HTMLCanvasElement | null) => void>(node => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCpu(createDefaultChart(node.getContext('2d')!, {
|
||||
options: {
|
||||
scales: {
|
||||
yAxes: [ {
|
||||
ticks: {
|
||||
callback: (value) => `${value}% `,
|
||||
},
|
||||
} ],
|
||||
},
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const statsListener = (data: string) => {
|
||||
let stats: any = {};
|
||||
try {
|
||||
stats = JSON.parse(data);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (memory && memory.data.datasets) {
|
||||
const data = memory.data.datasets[0].data!;
|
||||
|
||||
data.push(bytesToMegabytes(stats.memory_bytes));
|
||||
data.shift();
|
||||
|
||||
memory.update();
|
||||
}
|
||||
|
||||
if (cpu && cpu.data.datasets) {
|
||||
const data = cpu.data.datasets[0].data!;
|
||||
|
||||
data.push(stats.cpu_absolute);
|
||||
data.shift();
|
||||
|
||||
cpu.update();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected || !instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
instance.addListener('stats', statsListener);
|
||||
|
||||
return () => {
|
||||
instance.removeListener('stats', statsListener);
|
||||
};
|
||||
}, [ connected, memory, cpu ]);
|
||||
|
||||
return (
|
||||
<div className={'flex mt-4'}>
|
||||
<TitledGreyBox title={'Memory usage'} icon={faMemory} className={'flex-1 mr-2'}>
|
||||
<canvas id={'memory_chart'} ref={memoryRef} aria-label={'Server Memory Usage Graph'} role={'img'}/>
|
||||
</TitledGreyBox>
|
||||
<TitledGreyBox title={'CPU usage'} icon={faMicrochip} className={'flex-1 ml-2'}>
|
||||
<canvas id={'cpu_chart'} ref={cpuRef} aria-label={'Server CPU Usage Graph'} role={'img'}/>
|
||||
</TitledGreyBox>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -4,7 +4,8 @@ export function bytesToHuman (bytes: number): string {
|
|||
}
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1000));
|
||||
|
||||
// @ts-ignore
|
||||
return `${(bytes / Math.pow(1000, i)).toFixed(2) * 1} ${['Bytes', 'kB', 'MB', 'GB', 'TB'][i]}`;
|
||||
}
|
||||
|
||||
export const bytesToMegabytes = (bytes: number) => Math.floor(bytes / 1000 / 1000);
|
||||
|
|
|
@ -19,3 +19,7 @@ code.clean {
|
|||
@apply .rounded-full .bg-neutral-500 .p-3;
|
||||
}
|
||||
}
|
||||
|
||||
.grey-box {
|
||||
@apply .mt-4 .shadow-md .bg-neutral-700 .rounded .p-3 .flex .text-xs;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue