Add server statistics

This commit is contained in:
Eric Wang
2022-11-06 16:19:43 +00:00
parent 1aba32bbb5
commit 7446fd56d3
14 changed files with 416 additions and 65 deletions

View File

@@ -18,7 +18,7 @@ export interface Server {
status: EloquentStatus
node_id: number
usages: {
bandwidthUsage: number // bytes
bandwidth: number // bytes
}
limits: {
cpu: number
@@ -56,7 +56,7 @@ export const rawDataToServerObject = (data: FractalResponseData): Server => ({
: null
: null,
usages: {
bandwidthUsage: data.usages.bandwidth_usage,
bandwidth: data.usages.bandwidth,
},
limits: {
cpu: data.limits.cpu,

View File

@@ -3,7 +3,7 @@
@tailwind utilities;
body {
@apply bg-stone-100 dark:bg-primary dark:text-stone-400;
@apply bg-[#fafafa] dark:bg-primary dark:text-stone-400;
}
.description {
@@ -38,6 +38,10 @@ body {
@apply border-[#eaeaea] dark:border-[#333333] hover:dark:border-white;
}
.h5 {
@apply text-base font-semibold text-black dark:text-white;
}
.fade-enter,
.fade-exit,
.fade-appear {

View File

@@ -1,9 +1,7 @@
import { useStoreState } from '@/state'
import {
createEmotionCache,
MantineProvider,
} from '@mantine/core'
import { createEmotionCache, MantineProvider } from '@mantine/core'
import { ReactNode, useEffect } from 'react'
import { NotificationsProvider } from '@mantine/notifications'
const emotionCache = createEmotionCache({
key: 'mantine',
@@ -32,7 +30,7 @@ const ThemeProvider = ({ children }: Props) => {
colorScheme: theme === 'dark' ? 'dark' : 'light',
}}
>
{children}
<NotificationsProvider>{children}</NotificationsProvider>
</MantineProvider>
)
}

View File

@@ -24,7 +24,7 @@ const ServerCard = ({ server }: Props) => {
const disk = useMemo(() => formatBytes(server.limits.disk, 0), [server])
return (
<Link to={`/servers/${server.id}`} className='bg-auto p-6 shadow hover:shadow-lg border border-colors border-colors-hover dark:shadow-none dark:hover:shadow-none transition-shadow rounded-lg'>
<Link to={`/servers/${server.id}`} className='bg-auto p-6 shadow-light hover:shadow-lg border border-colors border-colors-hover dark:shadow-none dark:hover:shadow-none transition-shadow rounded-lg'>
<div className='flex items-center space-x-3'>
<Avatar color='blue' size='md' radius='xl'>
{getInitials(server.name, ' ', 2)}

View File

@@ -0,0 +1,9 @@
import styled from '@emotion/styled'
import tw from 'twin.macro'
const Card = styled.div`
${tw`border border-[#eaeaea] dark:border-[#333333] p-6 bg-white dark:bg-black rounded shadow-light dark:shadow-none`}
`
export default Card

View File

@@ -126,7 +126,7 @@ const NavigationBar = ({ routes, breadcrumb }: Props) => {
<NavigationDropdown logout={logout} visible={menuVisible} />
<div
ref={bottomBar}
className='bg-white pt-1.5 shadow-none transition-shadow dark:bg-black flex w-full dark:border-b border-colors z-[2000]'
className='bg-white pt-1.5 shadow-none transition-shadow dark:bg-black flex w-full border-b border-colors z-[2000]'
>
<ContentContainer className='flex w-full'>
<div

View File

@@ -1,12 +1,21 @@
import { ServerContext } from '@/state/server'
import { capitalize, convertTimeToSmallest, formatBytes } from '@/util/helpers'
import {
capitalize,
convertTimeToSmallest,
formatBytes,
Sizes,
} from '@/util/helpers'
import useNotify from '@/util/useNotify'
import styled from '@emotion/styled'
import { Skeleton } from '@mantine/core'
import { Badge, RingProgress, Skeleton } from '@mantine/core'
import { useEffect, useMemo, useRef } from 'react'
import tw from 'twin.macro'
import Card from '@/components/elements/Card'
import { Line } from 'react-chartjs-2'
import { useChartTickLabel } from '@/util/chart'
export const StatRow = styled.div`
${tw`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 border border-[#eaeaea] dark:border-[#333333] shadow dark:shadow-none rounded-lg bg-white dark:bg-black`}
${tw`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 border border-[#eaeaea] dark:border-[#333333] shadow-light dark:shadow-none rounded bg-white dark:bg-black`}
&>div {
${tw`border-[#eaeaea] dark:border-[#333333] p-6`}
@@ -39,14 +48,13 @@ export const StatRow = styled.div`
const ServerDetailsBlock = () => {
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid)
const memoryLimit = ServerContext.useStoreState(
(state) => state.server.data!.limits.memory
)
const server = ServerContext.useStoreState((state) => state.server.data!)
const status = ServerContext.useStoreState((state) => state.status.data)
const getStatus = ServerContext.useStoreActions(
(actions) => actions.status.getStatus
)
const isUpdating = useRef(true)
const notify = useNotify()
useEffect(() => {
isUpdating.current = true
@@ -54,7 +62,19 @@ const ServerDetailsBlock = () => {
const update = async () => {
if (!isUpdating.current) return
await getStatus(uuid)
try {
await getStatus(uuid)
} catch {
notify({
title: 'Error',
message: 'Failed to update server status. Retrying in 5 seconds.',
color: 'red',
})
setTimeout(update, 5000)
return
}
setTimeout(update, 1000)
}
@@ -69,7 +89,7 @@ const ServerDetailsBlock = () => {
const memory = useMemo(
() => ({
used: formatBytes(status?.memory || 0),
total: formatBytes(memoryLimit),
total: formatBytes(server.limits.memory),
}),
[status]
)
@@ -79,54 +99,123 @@ const ServerDetailsBlock = () => {
[status]
)
const bandwidth = useMemo(
() => ({
used: formatBytes(server.usages.bandwidth),
total: server.limits.bandwidth
? formatBytes(server.limits.bandwidth)
: undefined,
percentage: server.limits.bandwidth
? Math.floor(
(server.usages.bandwidth / server.limits.bandwidth) * 10000
) / 100
: 0,
}),
[server]
)
const cpuGraph = useChartTickLabel('CPU', 100, '%', 2)
const memoryGraph = useChartTickLabel(
'Memory',
memory.total.size,
memory.total.unit,
2
)
useEffect(() => {
if (status) {
cpuGraph.push(Math.floor(status.cpu * 10000) / 100)
memoryGraph.push(
formatBytes(status.memory, 2, memory.total.unit as Sizes).size
)
}
}, [status])
return (
<div>
<div className='grid grid-cols-10 gap-6'>
{!status ? (
<Skeleton className='w-full !h-[421px] md:!h-[211px] lg:!h-[106px]' />
<Skeleton className='w-full col-span-10 !h-[421px] md:!h-[211px] lg:!h-[106px]' />
) : (
<StatRow>
<div>
<p>Server State</p>
<div className='flex space-x-2 items-center mt-1'>
<div className='grid place-items-center h-full'>
<div
className={`w-3 h-3 rounded-full ${
status.state === 'running' ? 'bg-green-500' : 'bg-red-500'
}`}
></div>
<>
<StatRow className='col-span-10'>
<div>
<p>Server State</p>
<div className='flex space-x-2 items-center mt-1'>
<div className='grid place-items-center h-full'>
<div
className={`w-3 h-3 rounded-full ${
status.state === 'running' ? 'bg-green-500' : 'bg-red-500'
}`}
></div>
</div>
<p className='text-2xl font-semibold dark:text-white'>
{capitalize(status.state)}
</p>
</div>
<p className='text-2xl font-semibold dark:text-white'>
{capitalize(status.state)}
</p>
</div>
</div>
<div>
<p>CPU Usage</p>
<p>{Math.floor(status.cpu * 100)}%</p>
</div>
<div>
<p>Memory Usage</p>
<div className='flex space-x-2 items-end mt-1'>
<p className='text-2xl font-semibold dark:text-white'>
{memory.used.size} {memory.used.unit}
</p>
<p className='text-sm font-semibold description mb-[0.3rem]'>
/ {memory.total.size} {memory.total.unit}
</p>
<div>
<p>CPU Usage</p>
<p>{Math.floor(status.cpu * 100)}%</p>
</div>
</div>
<div>
<p>Uptime</p>
<div className='flex space-x-2 items-end mt-1'>
<p className='text-2xl font-semibold dark:text-white'>
{Math.floor(uptime.time)}
</p>
<p className='text-sm font-semibold description mb-[0.3rem]'>
{uptime.unit}
</p>
<div>
<p>Memory Usage</p>
<div className='flex space-x-2 items-end mt-1'>
<p className='text-2xl font-semibold dark:text-white'>
{memory.used.size} {memory.used.unit}
</p>
<p className='text-sm font-semibold description mb-[0.3rem]'>
/ {memory.total.size} {memory.total.unit}
</p>
</div>
</div>
</div>
</StatRow>
<div>
<p>Uptime</p>
<div className='flex space-x-2 items-end mt-1'>
<p className='text-2xl font-semibold dark:text-white'>
{Math.floor(uptime.time)}
</p>
<p className='text-sm font-semibold description mb-[0.3rem]'>
{uptime.unit}
</p>
</div>
</div>
</StatRow>
<Card className='flex flex-col justify-between items-center col-span-10 lg:col-span-2'>
<h5 className='h5'>Bandwidth Usage</h5>
<div className='grid place-items-center mt-5'>
<h4 className='absolute text-3xl font-semibold text-auto'>
{Math.floor(bandwidth.percentage)}
</h4>
<RingProgress
size={128}
thickness={12}
roundCaps
sections={[{ value: bandwidth.percentage, color: 'green' }]}
/>
</div>
<Badge
className='!normal-case'
size='lg'
color='gray'
variant='outline'
>
{bandwidth.used.size} {bandwidth.used.unit} /{' '}
{bandwidth.total
? `${bandwidth.total.size} ${bandwidth.total.unit}`
: 'unlimited'}
</Badge>
</Card>
<Card className='flex flex-col space-y-5 col-span-10 md:col-span-5 lg:col-span-4'>
<h5 className='h5'>CPU</h5>
<Line {...cpuGraph.props} />
</Card>
<Card className='flex flex-col space-y-5 col-span-10 md:col-span-5 lg:col-span-4'>
<h5 className='h5'>Memory</h5>
<Line {...memoryGraph.props} />
</Card>
</>
)}
</div>
)

View File

@@ -0,0 +1,224 @@
import {
Chart as ChartJS,
ChartData,
ChartDataset,
ChartOptions,
Filler,
LinearScale,
LineElement,
PointElement,
} from 'chart.js'
import { DeepPartial } from 'ts-essentials'
import { useEffect, useState } from 'react'
import { deepmerge, deepmergeCustom } from 'deepmerge-ts'
import { theme } from 'twin.macro'
import { hexToRgba } from '@/util/helpers'
import { useStoreState } from '@/state'
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.stone.300'),
drawBorder: false,
},
ticks: {
display: true,
count: 3,
color: theme('colors.stone.900'),
font: {
family: theme('fontFamily.sans'),
size: 11,
weight: '400',
},
},
},
},
elements: {
point: {
radius: 0,
},
line: {
tension: 0.15,
},
},
}
function getOptions(
opts?: DeepPartial<ChartOptions<'line'>> | 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(-5),
borderColor: theme('colors.blue.400'),
backgroundColor: hexToRgba(theme('colors.blue.700'), 0.5),
},
index
)
),
}
}
const merge = deepmergeCustom({ mergeArrays: false })
interface UseChartOptions {
sets: number
options?: DeepPartial<ChartOptions<'line'>> | number | undefined
callback?: ChartDatasetCallback | undefined
}
function useChart(label: string, opts?: UseChartOptions) {
const themeMode = useStoreState((state) => state.settings.data?.theme)
const [options, setOptions] = useState(
getOptions(
typeof opts?.options === 'number'
? { scales: { y: { min: 0, suggestedMax: opts.options } } }
: opts?.options
)
)
useEffect(() => {
if (themeMode === 'dark') {
setOptions(
merge(options, {
scales: {
y: {
grid: {
color: theme('colors.stone.700'),
},
ticks: {
color: theme('colors.stone.300'),
},
},
},
})
)
} else {
setOptions(
merge(options, {
scales: {
y: {
grid: {
color: theme('colors.stone.300'),
},
ticks: {
color: theme('colors.stone.900'),
},
},
},
})
)
}
}, [themeMode])
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(
typeof item === 'number' ? Number(item.toFixed(2)) : item
),
})
),
})
)
const clear = () =>
setData((state) =>
merge(state, {
datasets: state.datasets.map((value) => ({
...value,
data: Array(20).fill(-5),
})),
})
)
return { props: { data, options }, push, clear, setOptions }
}
function useChartTickLabel(
label: string,
max: number,
tickLabel: string,
roundTo?: number
) {
return useChart(label, {
sets: 1,
options: {
scales: {
y: {
suggestedMax: max,
ticks: {
callback(value) {
return `${
roundTo ? Number(value).toFixed(roundTo) : value
}${tickLabel}`
},
},
},
},
},
})
}
export { useChart, useChartTickLabel, getOptions, getEmptyData }

View File

@@ -29,7 +29,7 @@ export interface FormattedBytes {
unit: string
}
export type Sizes = 'B' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB' | 'EB' | 'ZB' | 'YB'
export type Sizes = 'B' | 'KiB' | 'MiB' | 'GiB' | 'TiB' | 'PiB' | 'EiB' | 'ZiB' | 'YiB'
export function formatBytes(
bytes: number,
@@ -47,7 +47,7 @@ export function formatBytes(
return {
size,
unit: sizes[i],
unit: sizes[i] as Sizes,
}
}
@@ -73,4 +73,16 @@ export const convertTimeToSmallest = (seconds: number) => {
time: seconds / (bestUnit[0] as number),
unit: bestUnit[1] as string,
}
}
export const hexToRgba = (hex: string, alpha = 1): string => {
// noinspection RegExpSimplifiable
if (!/#?([a-fA-F0-9]{2}){3}/.test(hex)) {
return hex;
}
// noinspection RegExpSimplifiable
const [r, g, b] = hex.match(/[a-fA-F0-9]{2}/g)!.map((v) => parseInt(v, 16));
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

View File

@@ -0,0 +1,12 @@
import { NotificationProps, showNotification } from '@mantine/notifications'
const useNotify = () => {
return (props: Omit<NotificationProps, 'style'>) => showNotification({
...props,
style: {
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
},
})
}
export default useNotify