mirror of
https://github.com/ConvoyPanel/panel.git
synced 2026-07-01 03:14:22 +08:00
Add server statistics
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
9
resources/scripts/components/elements/Card.tsx
Normal file
9
resources/scripts/components/elements/Card.tsx
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
224
resources/scripts/util/chart.ts
Normal file
224
resources/scripts/util/chart.ts
Normal 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 }
|
||||
@@ -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})`;
|
||||
}
|
||||
12
resources/scripts/util/useNotify.ts
Normal file
12
resources/scripts/util/useNotify.ts
Normal 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
|
||||
Reference in New Issue
Block a user