Some mobile improvements for the UI; make console fill space better
This commit is contained in:
parent
faff263f17
commit
54c619e6ba
9 changed files with 127 additions and 93 deletions
214
resources/scripts/components/server/console/Console.tsx
Normal file
214
resources/scripts/components/server/console/Console.tsx
Normal file
|
@ -0,0 +1,214 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ITerminalOptions, Terminal } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { SearchAddon } from 'xterm-addon-search';
|
||||
import { SearchBarAddon } from 'xterm-addon-search-bar';
|
||||
import { WebLinksAddon } from 'xterm-addon-web-links';
|
||||
import { ScrollDownHelperAddon } from '@/plugins/XtermScrollDownHelperAddon';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { usePermissions } from '@/plugins/usePermissions';
|
||||
import { theme as th } from 'twin.macro';
|
||||
import 'xterm/css/xterm.css';
|
||||
import useEventListener from '@/plugins/useEventListener';
|
||||
import { debounce } from 'debounce';
|
||||
import { usePersistedState } from '@/plugins/usePersistedState';
|
||||
import { SocketEvent, SocketRequest } from '@/components/server/events';
|
||||
import classNames from 'classnames';
|
||||
import styles from './style.module.css';
|
||||
import { ChevronDoubleRightIcon } from '@heroicons/react/solid';
|
||||
|
||||
const theme = {
|
||||
background: th`colors.black`.toString(),
|
||||
cursor: 'transparent',
|
||||
black: th`colors.black`.toString(),
|
||||
red: '#E54B4B',
|
||||
green: '#9ECE58',
|
||||
yellow: '#FAED70',
|
||||
blue: '#396FE2',
|
||||
magenta: '#BB80B3',
|
||||
cyan: '#2DDAFD',
|
||||
white: '#d0d0d0',
|
||||
brightBlack: 'rgba(255, 255, 255, 0.2)',
|
||||
brightRed: '#FF5370',
|
||||
brightGreen: '#C3E88D',
|
||||
brightYellow: '#FFCB6B',
|
||||
brightBlue: '#82AAFF',
|
||||
brightMagenta: '#C792EA',
|
||||
brightCyan: '#89DDFF',
|
||||
brightWhite: '#ffffff',
|
||||
selection: '#FAF089',
|
||||
};
|
||||
|
||||
const terminalProps: ITerminalOptions = {
|
||||
disableStdin: true,
|
||||
cursorStyle: 'underline',
|
||||
allowTransparency: true,
|
||||
fontSize: 12,
|
||||
fontFamily: 'Menlo, Monaco, Consolas, monospace',
|
||||
rows: 30,
|
||||
theme: theme,
|
||||
};
|
||||
|
||||
export default () => {
|
||||
const TERMINAL_PRELUDE = '\u001b[1m\u001b[33mcontainer@pterodactyl~ \u001b[0m';
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const terminal = useMemo(() => new Terminal({ ...terminalProps }), []);
|
||||
const fitAddon = new FitAddon();
|
||||
const searchAddon = new SearchAddon();
|
||||
const searchBar = new SearchBarAddon({ searchAddon });
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
const scrollDownHelperAddon = new ScrollDownHelperAddon();
|
||||
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
|
||||
const [ canSendCommands ] = usePermissions([ 'control.console' ]);
|
||||
const serverId = ServerContext.useStoreState(state => state.server.data!.id);
|
||||
const isTransferring = ServerContext.useStoreState(state => state.server.data!.isTransferring);
|
||||
const [ history, setHistory ] = usePersistedState<string[]>(`${serverId}:command_history`, []);
|
||||
const [ historyIndex, setHistoryIndex ] = useState(-1);
|
||||
|
||||
const handleConsoleOutput = (line: string, prelude = false) => terminal.writeln(
|
||||
(prelude ? TERMINAL_PRELUDE : '') + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m',
|
||||
);
|
||||
|
||||
const handleTransferStatus = (status: string) => {
|
||||
switch (status) {
|
||||
// Sent by either the source or target node if a failure occurs.
|
||||
case 'failure':
|
||||
terminal.writeln(TERMINAL_PRELUDE + 'Transfer has failed.\u001b[0m');
|
||||
return;
|
||||
|
||||
// Sent by the source node whenever the server was archived successfully.
|
||||
case 'archive':
|
||||
terminal.writeln(TERMINAL_PRELUDE + 'Server has been archived successfully, attempting connection to target node..\u001b[0m');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDaemonErrorOutput = (line: string) => terminal.writeln(
|
||||
TERMINAL_PRELUDE + '\u001b[1m\u001b[41m' + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m',
|
||||
);
|
||||
|
||||
const handlePowerChangeEvent = (state: string) => terminal.writeln(
|
||||
TERMINAL_PRELUDE + 'Server marked as ' + state + '...\u001b[0m',
|
||||
);
|
||||
|
||||
const handleCommandKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
const newIndex = Math.min(historyIndex + 1, history!.length - 1);
|
||||
|
||||
setHistoryIndex(newIndex);
|
||||
e.currentTarget.value = history![newIndex] || '';
|
||||
|
||||
// By default up arrow will also bring the cursor to the start of the line,
|
||||
// so we'll preventDefault to keep it at the end.
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
const newIndex = Math.max(historyIndex - 1, -1);
|
||||
|
||||
setHistoryIndex(newIndex);
|
||||
e.currentTarget.value = history![newIndex] || '';
|
||||
}
|
||||
|
||||
const command = e.currentTarget.value;
|
||||
if (e.key === 'Enter' && command.length > 0) {
|
||||
setHistory(prevHistory => [ command, ...prevHistory! ].slice(0, 32));
|
||||
setHistoryIndex(-1);
|
||||
|
||||
instance && instance.send('send command', command);
|
||||
e.currentTarget.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (connected && ref.current && !terminal.element) {
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(searchAddon);
|
||||
terminal.loadAddon(searchBar);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
terminal.loadAddon(scrollDownHelperAddon);
|
||||
|
||||
terminal.open(ref.current);
|
||||
fitAddon.fit();
|
||||
|
||||
// Add support for capturing keys
|
||||
terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
||||
document.execCommand('copy');
|
||||
return false;
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||
e.preventDefault();
|
||||
searchBar.show();
|
||||
return false;
|
||||
} else if (e.key === 'Escape') {
|
||||
searchBar.hidden();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}, [ terminal, connected ]);
|
||||
|
||||
useEventListener('resize', debounce(() => {
|
||||
if (terminal.element) {
|
||||
fitAddon.fit();
|
||||
}
|
||||
}, 100));
|
||||
|
||||
useEffect(() => {
|
||||
const listeners: Record<string, (s: string) => void> = {
|
||||
[SocketEvent.STATUS]: handlePowerChangeEvent,
|
||||
[SocketEvent.CONSOLE_OUTPUT]: handleConsoleOutput,
|
||||
[SocketEvent.INSTALL_OUTPUT]: handleConsoleOutput,
|
||||
[SocketEvent.TRANSFER_LOGS]: handleConsoleOutput,
|
||||
[SocketEvent.TRANSFER_STATUS]: handleTransferStatus,
|
||||
[SocketEvent.DAEMON_MESSAGE]: line => handleConsoleOutput(line, true),
|
||||
[SocketEvent.DAEMON_ERROR]: handleDaemonErrorOutput,
|
||||
};
|
||||
|
||||
if (connected && instance) {
|
||||
// Do not clear the console if the server is being transferred.
|
||||
if (!isTransferring) {
|
||||
terminal.clear();
|
||||
}
|
||||
|
||||
Object.keys(listeners).forEach((key: string) => {
|
||||
instance.addListener(key, listeners[key]);
|
||||
});
|
||||
instance.send(SocketRequest.SEND_LOGS);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (instance) {
|
||||
Object.keys(listeners).forEach((key: string) => {
|
||||
instance.removeListener(key, listeners[key]);
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [ connected, instance ]);
|
||||
|
||||
return (
|
||||
<div className={styles.terminal}>
|
||||
<SpinnerOverlay visible={!connected} size={'large'}/>
|
||||
<div className={classNames(styles.container, styles.overflows_container, { 'rounded-b': !canSendCommands })}>
|
||||
<div id={styles.terminal} ref={ref}/>
|
||||
</div>
|
||||
{canSendCommands &&
|
||||
<div className={classNames('relative', styles.overflows_container)}>
|
||||
<input
|
||||
className={classNames('peer', styles.command_input)}
|
||||
type={'text'}
|
||||
placeholder={'Type a command...'}
|
||||
aria-label={'Console command input.'}
|
||||
disabled={!instance || !connected}
|
||||
onKeyDown={handleCommandKeyDown}
|
||||
autoCorrect={'off'}
|
||||
autoCapitalize={'none'}
|
||||
/>
|
||||
<div className={classNames('text-gray-100 peer-focus:text-gray-50 peer-focus:animate-pulse', styles.command_icon)}>
|
||||
<ChevronDoubleRightIcon className={'w-4 h-4'}/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
|||
import { Button } from '@/components/elements/button/index';
|
||||
import Can from '@/components/elements/Can';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { PowerAction } from '@/components/server/ServerConsole';
|
||||
import { PowerAction } from '@/components/server/console/ServerConsoleContainer';
|
||||
import { Dialog } from '@/components/elements/dialog';
|
||||
|
||||
interface PowerButtonProps {
|
||||
|
@ -41,7 +41,7 @@ export default ({ className }: PowerButtonProps) => {
|
|||
</Dialog.Confirm>
|
||||
<Can action={'control.start'}>
|
||||
<Button
|
||||
className={'w-24'}
|
||||
className={'w-full sm:w-24'}
|
||||
disabled={status !== 'offline'}
|
||||
onClick={onButtonClick.bind(this, 'start')}
|
||||
>
|
||||
|
@ -50,7 +50,7 @@ export default ({ className }: PowerButtonProps) => {
|
|||
</Can>
|
||||
<Can action={'control.restart'}>
|
||||
<Button.Text
|
||||
className={'w-24'}
|
||||
className={'w-full sm:w-24'}
|
||||
variant={Button.Variants.Secondary}
|
||||
disabled={!status}
|
||||
onClick={onButtonClick.bind(this, 'restart')}
|
||||
|
@ -60,7 +60,7 @@ export default ({ className }: PowerButtonProps) => {
|
|||
</Can>
|
||||
<Can action={'control.stop'}>
|
||||
<Button.Danger
|
||||
className={'w-24'}
|
||||
className={'w-full sm:w-24'}
|
||||
variant={killable ? undefined : Button.Variants.Secondary}
|
||||
disabled={status === 'offline'}
|
||||
onClick={onButtonClick.bind(this, killable ? 'kill' : 'stop')}
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
import React, { memo } from 'react';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import Can from '@/components/elements/Can';
|
||||
import ContentContainer from '@/components/elements/ContentContainer';
|
||||
import tw from 'twin.macro';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
import isEqual from 'react-fast-compare';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import Features from '@feature/Features';
|
||||
import Console from '@/components/server/console/Console';
|
||||
import StatGraphs from '@/components/server/console/StatGraphs';
|
||||
import PowerButtons from '@/components/server/console/PowerButtons';
|
||||
import ServerDetailsBlock from '@/components/server/console/ServerDetailsBlock';
|
||||
|
||||
export type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
|
||||
|
||||
const ServerConsoleContainer = () => {
|
||||
const name = ServerContext.useStoreState(state => state.server.data!.name);
|
||||
const description = ServerContext.useStoreState(state => state.server.data!.description);
|
||||
const isInstalling = ServerContext.useStoreState(state => state.server.data!.isInstalling);
|
||||
const isTransferring = ServerContext.useStoreState(state => state.server.data!.isTransferring);
|
||||
const eggFeatures = ServerContext.useStoreState(state => state.server.data!.eggFeatures, isEqual);
|
||||
|
||||
return (
|
||||
<ServerContentBlock title={'Console'} className={'flex flex-col gap-2 sm:gap-4'}>
|
||||
<div className={'flex gap-4 items-end'}>
|
||||
<div className={'hidden sm:block flex-1'}>
|
||||
<h1 className={'font-header text-2xl text-gray-50 leading-relaxed line-clamp-1'}>{name}</h1>
|
||||
<p className={'text-sm line-clamp-2'}>{description}</p>
|
||||
</div>
|
||||
<div className={'flex-1'}>
|
||||
<Can action={[ 'control.start', 'control.stop', 'control.restart' ]} matchAny>
|
||||
<PowerButtons className={'flex sm:justify-end space-x-2'}/>
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'grid grid-cols-4 gap-2 sm:gap-4'}>
|
||||
<ServerDetailsBlock className={'col-span-4 lg:col-span-1 order-last lg:order-none'}/>
|
||||
<div className={'col-span-4 lg:col-span-3'}>
|
||||
<Spinner.Suspense>
|
||||
<Console/>
|
||||
</Spinner.Suspense>
|
||||
</div>
|
||||
{isInstalling ?
|
||||
<div css={tw`mt-4 rounded bg-yellow-500 p-3`}>
|
||||
<ContentContainer>
|
||||
<p css={tw`text-sm text-yellow-900`}>
|
||||
This server is currently running its installation process and most actions are
|
||||
unavailable.
|
||||
</p>
|
||||
</ContentContainer>
|
||||
</div>
|
||||
:
|
||||
isTransferring ?
|
||||
<div css={tw`mt-4 rounded bg-yellow-500 p-3`}>
|
||||
<ContentContainer>
|
||||
<p css={tw`text-sm text-yellow-900`}>
|
||||
This server is currently being transferred to another node and all actions
|
||||
are unavailable.
|
||||
</p>
|
||||
</ContentContainer>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
<div className={'grid grid-cols-1 md:grid-cols-3 gap-2 sm:gap-4'}>
|
||||
<Spinner.Suspense>
|
||||
<StatGraphs/>
|
||||
</Spinner.Suspense>
|
||||
</div>
|
||||
<Features enabled={eggFeatures}/>
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ServerConsoleContainer, isEqual);
|
|
@ -0,0 +1,160 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
faClock,
|
||||
faCloudDownloadAlt,
|
||||
faCloudUploadAlt,
|
||||
faHdd,
|
||||
faMemory,
|
||||
faMicrochip,
|
||||
faWifi,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { bytesToHuman, formatIp, megabytesToHuman } from '@/helpers';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { SocketEvent, SocketRequest } from '@/components/server/events';
|
||||
import UptimeDuration from '@/components/server/UptimeDuration';
|
||||
import StatBlock from '@/components/server/console/StatBlock';
|
||||
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
|
||||
import classNames from 'classnames';
|
||||
|
||||
type Stats = Record<'memory' | 'cpu' | 'disk' | 'uptime' | 'rx' | 'tx', number>;
|
||||
|
||||
const getBackgroundColor = (value: number, max: number | null): string | undefined => {
|
||||
const delta = !max ? 0 : (value / max);
|
||||
|
||||
if (delta > 0.8) {
|
||||
if (delta > 0.9) {
|
||||
return 'bg-red-500';
|
||||
}
|
||||
return 'bg-yellow-500';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const ServerDetailsBlock = ({ className }: { className?: string }) => {
|
||||
const [ stats, setStats ] = useState<Stats>({ memory: 0, cpu: 0, disk: 0, uptime: 0, tx: 0, rx: 0 });
|
||||
|
||||
const status = ServerContext.useStoreState(state => state.status.value);
|
||||
const connected = ServerContext.useStoreState(state => state.socket.connected);
|
||||
const instance = ServerContext.useStoreState(state => state.socket.instance);
|
||||
const limits = ServerContext.useStoreState(state => state.server.data!.limits);
|
||||
const allocation = ServerContext.useStoreState(state => {
|
||||
const match = state.server.data!.allocations.find(allocation => allocation.isDefault);
|
||||
|
||||
return !match ? 'n/a' : `${match.alias || formatIp(match.ip)}:${match.port}`;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected || !instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
instance.send(SocketRequest.SEND_STATS);
|
||||
}, [ instance, connected ]);
|
||||
|
||||
useWebsocketEvent(SocketEvent.STATS, (data) => {
|
||||
let stats: any = {};
|
||||
try {
|
||||
stats = JSON.parse(data);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStats({
|
||||
memory: stats.memory_bytes,
|
||||
cpu: stats.cpu_absolute,
|
||||
disk: stats.disk_bytes,
|
||||
tx: stats.network.tx_bytes,
|
||||
rx: stats.network.rx_bytes,
|
||||
uptime: stats.uptime || 0,
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classNames('grid grid-cols-6 gap-2 md:gap-4', className)}>
|
||||
<StatBlock
|
||||
icon={faClock}
|
||||
title={'Uptime'}
|
||||
color={getBackgroundColor(status === 'running' ? 0 : (status !== 'offline' ? 9 : 10), 10)}
|
||||
>
|
||||
{stats.uptime > 0 ?
|
||||
<UptimeDuration uptime={stats.uptime / 1000}/>
|
||||
:
|
||||
'Offline'
|
||||
}
|
||||
</StatBlock>
|
||||
<StatBlock
|
||||
icon={faMicrochip}
|
||||
title={'CPU'}
|
||||
color={getBackgroundColor(stats.cpu, limits.cpu)}
|
||||
description={limits.memory
|
||||
? `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.'
|
||||
}
|
||||
>
|
||||
{status === 'offline' ?
|
||||
<span className={'text-gray-400'}>Offline</span>
|
||||
:
|
||||
`${stats.cpu.toFixed(2)}%`
|
||||
}
|
||||
</StatBlock>
|
||||
<StatBlock
|
||||
icon={faMemory}
|
||||
title={'Memory'}
|
||||
color={getBackgroundColor(stats.memory / 1024, limits.memory * 1024)}
|
||||
description={limits.memory
|
||||
? `This server is allowed to use up to ${megabytesToHuman(limits.memory)} of memory.`
|
||||
: 'No memory limit has been configured for this server.'
|
||||
}
|
||||
>
|
||||
{status === 'offline' ?
|
||||
<span className={'text-gray-400'}>Offline</span>
|
||||
:
|
||||
bytesToHuman(stats.memory)
|
||||
}
|
||||
</StatBlock>
|
||||
<StatBlock
|
||||
icon={faHdd}
|
||||
title={'Disk'}
|
||||
color={getBackgroundColor(stats.disk / 1024, limits.disk * 1024)}
|
||||
description={limits.disk
|
||||
? `This server is allowed to use up to ${megabytesToHuman(limits.disk)} of disk space.`
|
||||
: 'No disk space limit has been configured for this server.'
|
||||
}
|
||||
>
|
||||
{bytesToHuman(stats.disk)}
|
||||
</StatBlock>
|
||||
<StatBlock
|
||||
icon={faCloudDownloadAlt}
|
||||
title={'Network (Inbound)'}
|
||||
description={'The total amount of network traffic that your server has recieved since it was started.'}
|
||||
>
|
||||
{status === 'offline' ?
|
||||
<span className={'text-gray-400'}>Offline</span>
|
||||
:
|
||||
bytesToHuman(stats.tx)
|
||||
}
|
||||
</StatBlock>
|
||||
<StatBlock
|
||||
icon={faCloudUploadAlt}
|
||||
title={'Network (Outbound)'}
|
||||
description={'The total amount of traffic your server has sent across the internet since it was started.'}
|
||||
>
|
||||
{status === 'offline' ?
|
||||
<span className={'text-gray-400'}>Offline</span>
|
||||
:
|
||||
bytesToHuman(stats.rx)
|
||||
}
|
||||
</StatBlock>
|
||||
<StatBlock
|
||||
icon={faWifi}
|
||||
title={'Address'}
|
||||
description={`You can connect to your server at: ${allocation}`}
|
||||
>
|
||||
{allocation}
|
||||
</StatBlock>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerDetailsBlock;
|
|
@ -3,6 +3,7 @@ import Icon from '@/components/elements/Icon';
|
|||
import { IconDefinition } from '@fortawesome/free-solid-svg-icons';
|
||||
import classNames from 'classnames';
|
||||
import Tooltip from '@/components/elements/tooltip/Tooltip';
|
||||
import styles from './style.module.css';
|
||||
|
||||
interface StatBlockProps {
|
||||
title: string;
|
||||
|
@ -10,33 +11,26 @@ interface StatBlockProps {
|
|||
color?: string | undefined;
|
||||
icon: IconDefinition;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default ({ title, icon, color, description, children }: StatBlockProps) => {
|
||||
export default ({ title, icon, color, description, className, children }: StatBlockProps) => {
|
||||
return (
|
||||
<Tooltip arrow placement={'top'} disabled={!description} content={description || ''}>
|
||||
<div className={'flex items-center space-x-4 bg-gray-600 rounded p-4 shadow-lg'}>
|
||||
<div
|
||||
className={classNames(
|
||||
'transition-colors duration-500',
|
||||
'flex-shrink-0 flex items-center justify-center w-12 h-12 rounded-lg shadow-md',
|
||||
color || 'bg-gray-700',
|
||||
)}
|
||||
>
|
||||
<div className={classNames(styles.stat_block, 'bg-gray-600', className)}>
|
||||
<div className={classNames(styles.status_bar, color || 'bg-gray-700')}/>
|
||||
<div className={classNames(styles.icon, color || 'bg-gray-700')}>
|
||||
<Icon
|
||||
icon={icon}
|
||||
className={classNames(
|
||||
'w-6 h-6 m-auto',
|
||||
{
|
||||
'text-gray-100': !color || color === 'bg-gray-700',
|
||||
'text-gray-50': color && color !== 'bg-gray-700',
|
||||
},
|
||||
)}
|
||||
className={classNames({
|
||||
'text-gray-100': !color || color === 'bg-gray-700',
|
||||
'text-gray-50': color && color !== 'bg-gray-700',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className={'flex flex-col justify-center overflow-hidden'}>
|
||||
<p className={'font-header leading-tight text-sm text-gray-200'}>{title}</p>
|
||||
<p className={'text-xl font-semibold text-gray-50 truncate'}>
|
||||
<p className={'font-header leading-tight text-xs md:text-sm text-gray-200'}>{title}</p>
|
||||
<p className={'text-base md:text-xl font-semibold text-gray-50 truncate'}>
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
|
|
170
resources/scripts/components/server/console/StatGraphs.tsx
Normal file
170
resources/scripts/components/server/console/StatGraphs.tsx
Normal file
|
@ -0,0 +1,170 @@
|
|||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import Chart, { ChartConfiguration } from 'chart.js';
|
||||
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 });
|
||||
};
|
||||
|
||||
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 ` });
|
||||
|
||||
useWebsocketEvent(SocketEvent.STATS, (data: string) => {
|
||||
let stats: any = {};
|
||||
try {
|
||||
stats = 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);
|
||||
|
||||
previous.current = { tx: stats.network.tx_bytes, rx: stats.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>
|
||||
}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
};
|
59
resources/scripts/components/server/console/style.module.css
Normal file
59
resources/scripts/components/server/console/style.module.css
Normal file
|
@ -0,0 +1,59 @@
|
|||
.stat_block {
|
||||
@apply flex items-center rounded shadow-lg relative;
|
||||
@apply col-span-3 md:col-span-2 lg:col-span-6;
|
||||
@apply px-3 py-2 md:p-3 lg:p-4;
|
||||
|
||||
& > .status_bar {
|
||||
@apply w-1 h-full absolute left-0 top-0 rounded-l sm:hidden;
|
||||
}
|
||||
|
||||
& > .icon {
|
||||
@apply hidden flex-shrink-0 items-center justify-center rounded-lg shadow-md w-12 h-12;
|
||||
@apply transition-colors duration-500;
|
||||
@apply sm:flex sm:mr-4;
|
||||
|
||||
& > svg {
|
||||
@apply w-6 h-6 m-auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.terminal {
|
||||
@apply relative h-full flex flex-col;
|
||||
|
||||
& .overflows_container {
|
||||
@apply -ml-4 sm:ml-0;
|
||||
width: calc(100% + 2rem);
|
||||
|
||||
@screen sm {
|
||||
@apply w-full;
|
||||
}
|
||||
}
|
||||
|
||||
& > .container {
|
||||
@apply rounded-t p-1 sm:p-2 bg-black min-h-[16rem] flex-1 font-mono text-sm;
|
||||
|
||||
& #terminal {
|
||||
@apply h-full;
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
@apply w-2;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-900;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .command_icon {
|
||||
@apply flex items-center top-0 left-0 absolute z-10 select-none h-full px-3 transition-colors duration-100;
|
||||
}
|
||||
|
||||
& .command_input {
|
||||
@apply relative bg-gray-900 px-2 text-gray-100 pl-10 pr-4 py-2 w-full font-mono text-sm sm:rounded-b;
|
||||
@apply focus:ring-0 outline-none focus-visible:outline-none;
|
||||
@apply border-0 border-b-2 border-transparent transition-colors duration-100;
|
||||
@apply active:border-cyan-500 focus:border-cyan-500;
|
||||
}
|
||||
}
|
Reference in a new issue