feat: added uptime and improved buttons on console

This commit is contained in:
Naterfute
2026-01-15 04:39:24 -08:00
parent 299898f9ec
commit 45646551f1
7 changed files with 46 additions and 186 deletions

View File

@@ -2,7 +2,7 @@ import { forwardRef } from 'react';
interface ActionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
size?: 'start' | 'sm' | 'md' | 'lg';
children: React.ReactNode;
}
@@ -19,6 +19,7 @@ const ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>(
};
const sizeClasses = {
start: 'h-8 px-2 py-1.5 text-xs',
sm: 'h-8 px-3 py-1.5 text-xs',
md: 'h-10 px-4 py-2 text-sm',
lg: 'h-12 px-6 py-3 text-base',

View File

@@ -0,0 +1,19 @@
export function formatUptime(uptime: number): string {
if (uptime <= 0) {
return 'Offline';
}
const secondsTotal = Math.floor(uptime / 1000);
const days = Math.floor(secondsTotal / 86400);
const hours = Math.floor((secondsTotal % 86400) / 3600);
const minutes = Math.floor((secondsTotal % 3600) / 60);
const seconds = secondsTotal % 60;
if (days > 0) {
return `${days}d ${hours}h ${minutes}m`;
}
return `${hours}h ${minutes}m ${seconds}s`;
}
export default formatUptime;

View File

@@ -1,24 +0,0 @@
const UptimeDuration = ({ uptime }: { uptime: number }) => {
const uptimeDiv = uptime / 1000;
const days = Math.floor(uptimeDiv / (24 * 60 * 60));
const hours = Math.floor((Math.floor(uptimeDiv) / 60 / 60) % 24);
const remainder = Math.floor(uptimeDiv - hours * 60 * 60);
const minutes = Math.floor((remainder / 60) % 60);
const seconds = remainder % 60;
if (days > 0) {
return (
<>
{days}d {hours}h {minutes}m
</>
);
}
return (
<>
{hours}h {minutes}m {seconds}s
</>
);
};
export default UptimeDuration;

View File

@@ -3,6 +3,7 @@ import { HugeiconsIcon } from '@hugeicons/react';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import ActionButton from '@/components/elements/ActionButton';
import Can from '@/components/elements/Can';
import { Dialog } from '@/components/elements/dialog';
import { PowerAction } from '@/components/server/console/ServerConsoleContainer';
@@ -71,9 +72,9 @@ const PowerButtons = ({ className }: PowerButtonProps) => {
Forcibly stopping a server can lead to data corruption.
</Dialog.Confirm>
<Can action={'control.start'}>
<Button
<ActionButton
variant={'secondary'}
size={'sm'}
size={'start'}
className='px-3 gap-1 rounded-full'
disabled={status !== 'offline'}
onClick={onButtonClick.bind(this, 'start')}
@@ -83,31 +84,31 @@ const PowerButtons = ({ className }: PowerButtonProps) => {
<HugeiconsIcon size={16} strokeWidth={2} icon={PlayIcon} className='size-4' />
Start
</div>
</Button>
</ActionButton>
</Can>
<Can action={'control.restart'}>
<Button
<ActionButton
variant={'secondary'}
size={'sm'}
size={'start'}
className='p-1 gap-1 rounded-full size-8'
disabled={!status}
onClick={onButtonClick.bind(this, 'restart')}
aria-label='Restart server'
>
<HugeiconsIcon size={16} icon={Rotate01FreeIcons} />
</Button>
</ActionButton>
</Can>
<Can action={'control.stop'}>
<Button
<ActionButton
variant={'secondary'}
size={'sm'}
size={'start'}
className='p-1 gap-1 rounded-full size-8'
disabled={status === 'offline'}
onClick={onButtonClick.bind(this, killable ? 'kill' : 'stop')}
aria-label={`${killable ? 'Kill' : 'Stop'} server`}
>
<HugeiconsIcon size={16} icon={StopIcon} />
</Button>
</ActionButton>
</Can>
</div>
);

View File

@@ -36,6 +36,7 @@ const ServerConsoleContainer = () => {
<StatusPillHeader />
<span className='xl:max-w-[20vw] min-w-0 truncate'>{name}</span>
</div>
<div className='border-l border-gray-200 h-6' />
<ServerDetailsHeader />
</HeaderCentered>

View File

@@ -1,151 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import StatBlock from '@/components/server/console/StatBlock';
import { SocketEvent, SocketRequest } from '@/components/server/events';
import { bytesToString, ip, mbToBytes } from '@/lib/formatters';
import { cn } from '@/lib/utils';
import { ServerContext } from '@/state/server';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
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;
// };
// @ts-expect-error - Unused parameter in component definition
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const Limit = ({ limit, children }: { limit: string | null; children: React.ReactNode }) => <>{children}</>;
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 textLimits = useMemo(
() => ({
cpu: limits?.cpu ? `${limits.cpu}%` : null,
memory: limits?.memory ? bytesToString(mbToBytes(limits.memory)) : null,
disk: limits?.disk ? bytesToString(mbToBytes(limits.disk)) : null,
}),
[limits],
);
const allocation = ServerContext.useStoreState((state) => {
const match = state.server.data!.allocations.find((allocation) => allocation.isDefault);
return !match ? 'n/a' : `${match.alias || ip(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={cn('flex md:flex-row gap-4 flex-col', className)}>
<div
className='transform-gpu skeleton-anim-2'
style={{
display: 'flex',
width: '100%',
animationDelay: `150ms`,
animationTimingFunction:
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
}}
>
<StatBlock title={'IP Address'} copyOnClick={allocation}>
{allocation}
</StatBlock>
</div>
<div
className='transform-gpu skeleton-anim-2'
style={{
display: 'flex',
width: '100%',
animationDelay: `175ms`,
animationTimingFunction:
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
}}
>
<StatBlock title={'CPU'}>
{status === 'offline' ? (
<span className={'text-zinc-400'}>Offline</span>
) : (
<Limit limit={textLimits.cpu}>{stats.cpu.toFixed(2)}%</Limit>
)}
</StatBlock>
</div>
<div
className='transform-gpu skeleton-anim-2'
style={{
display: 'flex',
width: '100%',
animationDelay: `200ms`,
animationTimingFunction:
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
}}
>
<StatBlock title={'RAM'}>
{status === 'offline' ? (
<span className={'text-zinc-400'}>Offline</span>
) : (
<Limit limit={textLimits.memory}>{bytesToString(stats.memory)}</Limit>
)}
</StatBlock>
</div>
<div
className='transform-gpu skeleton-anim-2'
style={{
display: 'flex',
width: '100%',
animationDelay: `225ms`,
animationTimingFunction:
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
}}
>
<StatBlock title={'Storage'}>
<Limit limit={textLimits.disk}>{bytesToString(stats.disk)}</Limit>
</StatBlock>
</div>
</div>
);
};
export default ServerDetailsBlock;

View File

@@ -1,5 +1,5 @@
import { CloudDownload, CloudUpload } from '@carbon/icons-react';
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Line } from 'react-chartjs-2';
import ChartBlock from '@/components/server/console/ChartBlock';
@@ -14,9 +14,12 @@ import { ServerContext } from '@/state/server';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import formatUptime from '../UptimeDuration';
interface StatsData {
cpu_absolute: number;
memory_bytes: number;
uptime: number;
network: {
tx_bytes: number;
rx_bytes: number;
@@ -29,6 +32,7 @@ const StatGraphs = () => {
const previous = useRef<Record<'tx' | 'rx', number>>({ tx: -1, rx: -1 });
const cpu = useChartTickLabel('CPU', limits.cpu, '%', 2);
const [uptime, setUptime] = useState(0);
const memory = useChartTickLabel('Memory', limits.memory, 'MiB');
const network = useChart('Network', {
sets: 2,
@@ -58,6 +62,7 @@ const StatGraphs = () => {
cpu.clear();
memory.clear();
network.clear();
setUptime(0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status]);
@@ -69,8 +74,10 @@ const StatGraphs = () => {
} catch {
return;
}
setUptime(values.uptime);
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),
@@ -96,6 +103,12 @@ const StatGraphs = () => {
<div className='font-medium'>{allocation}</div>
</div>
</div>
<div>
<div className='group p-4 justify-between relative rounded-xl border-[1px] border-[#ffffff11] bg-[#110f0d] flex gap-4 text-sm'>
<h3 className='font-extrabold'>Uptime</h3>
<div className='font-medium'>{formatUptime(uptime)}</div>
</div>
</div>
<div>
<div className='group p-4 justify-between relative rounded-xl border-[1px] border-[#ffffff11] flex-col bg-[#110f0d] flex gap-4 text-sm'>
<h3 className='font-extrabold'>Description</h3>