mirror of
https://github.com/ConvoyPanel/panel.git
synced 2026-06-12 16:33:05 +08:00
370 lines
14 KiB
TypeScript
370 lines
14 KiB
TypeScript
import { bytesToString } from '@/util/helpers'
|
|
import {
|
|
CircleStackIcon,
|
|
CpuChipIcon,
|
|
ExclamationTriangleIcon,
|
|
ServerStackIcon,
|
|
SignalIcon,
|
|
UsersIcon,
|
|
} from '@heroicons/react/24/outline'
|
|
import { Badge, Skeleton } from '@mantine/core'
|
|
import { ComponentType } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { Link } from 'react-router-dom'
|
|
|
|
import useOverviewSWR from '@/api/admin/overview/useOverviewSWR'
|
|
import {
|
|
DashboardMetric,
|
|
DashboardNode,
|
|
} from '@/api/admin/overview/getOverview'
|
|
|
|
import Card from '@/components/elements/Card'
|
|
import MessageBox from '@/components/elements/MessageBox'
|
|
import PageContentBlock from '@/components/elements/PageContentBlock'
|
|
|
|
interface IconProps {
|
|
className?: string
|
|
}
|
|
|
|
interface StatCardProps {
|
|
title: string
|
|
value: number | string
|
|
detail?: string
|
|
icon: ComponentType<IconProps>
|
|
to?: string
|
|
tone?: 'default' | 'warning' | 'error'
|
|
}
|
|
|
|
const toneClasses = {
|
|
default: 'text-accent-600 border-accent-200 bg-accent-100',
|
|
warning: 'text-warning-dark border-warning bg-warning-lighter',
|
|
error: 'text-error border-error-light bg-error-lighter',
|
|
}
|
|
|
|
const StatCard = ({
|
|
title,
|
|
value,
|
|
detail,
|
|
icon: Icon,
|
|
to,
|
|
tone = 'default',
|
|
}: StatCardProps) => {
|
|
const content = (
|
|
<div className='flex items-start justify-between gap-4'>
|
|
<div>
|
|
<p className='dt'>{title}</p>
|
|
<p className='mt-3 text-3xl font-semibold text-foreground'>
|
|
{value}
|
|
</p>
|
|
{detail && <p className='description-small mt-2'>{detail}</p>}
|
|
</div>
|
|
<div className={`rounded-md border p-2 ${toneClasses[tone]}`}>
|
|
<Icon className='h-5 w-5' />
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
if (to) {
|
|
return (
|
|
<Link
|
|
to={to}
|
|
className='col-span-12 block sm:col-span-6 xl:col-span-3'
|
|
>
|
|
<Card className='h-full transition-shadow hover:shadow-lg'>
|
|
{content}
|
|
</Card>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Card className='col-span-12 sm:col-span-6 xl:col-span-3'>
|
|
{content}
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
const UsageBar = ({
|
|
label,
|
|
metric,
|
|
}: {
|
|
label: string
|
|
metric: DashboardMetric
|
|
}) => {
|
|
const { t } = useTranslation('admin.overview')
|
|
|
|
return (
|
|
<div>
|
|
<div className='flex items-center justify-between gap-3'>
|
|
<p className='text-sm font-medium text-foreground'>{label}</p>
|
|
<p className='text-sm text-accent-500'>
|
|
{bytesToString(metric.allocated)} /{' '}
|
|
{bytesToString(metric.total)}
|
|
</p>
|
|
</div>
|
|
<div className='mt-2 h-2 overflow-hidden rounded bg-accent-200'>
|
|
<div
|
|
className={`h-full rounded ${
|
|
metric.percent >= 90
|
|
? 'bg-error'
|
|
: metric.percent >= 75
|
|
? 'bg-warning'
|
|
: 'bg-success'
|
|
}`}
|
|
style={{ width: `${Math.min(metric.percent, 100)}%` }}
|
|
/>
|
|
</div>
|
|
<p className='description-small mt-1'>
|
|
{t('allocated_percent', { percent: metric.percent })}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const NodeRow = ({ node }: { node: DashboardNode }) => {
|
|
const { t } = useTranslation('admin.overview')
|
|
const { t: tStrings } = useTranslation('strings')
|
|
|
|
return (
|
|
<div className='border-b border-accent-200 py-4 last:border-0 last:pb-0'>
|
|
<div className='flex flex-wrap items-start justify-between gap-3'>
|
|
<div>
|
|
<Link
|
|
to={`/admin/nodes/${node.id}`}
|
|
className='link text-foreground'
|
|
>
|
|
{node.name}
|
|
</Link>
|
|
<p className='description-small mt-1'>
|
|
{node.cluster} - {node.fqdn}
|
|
</p>
|
|
</div>
|
|
<Badge variant='outline' color='gray' className='!normal-case'>
|
|
{t('servers_count', { count: node.servers })}
|
|
</Badge>
|
|
</div>
|
|
<div className='mt-4 grid gap-4 lg:grid-cols-2'>
|
|
<UsageBar label={tStrings('memory')} metric={node.memory} />
|
|
<UsageBar label={tStrings('disk')} metric={node.disk} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const OverviewSkeleton = () => (
|
|
<div className='grid grid-cols-12 gap-6'>
|
|
{[1, 2, 3, 4].map(item => (
|
|
<Skeleton
|
|
key={item}
|
|
className='col-span-12 sm:col-span-6 xl:col-span-3'
|
|
height={142}
|
|
/>
|
|
))}
|
|
<Skeleton className='col-span-12 lg:col-span-7' height={420} />
|
|
<Skeleton className='col-span-12 lg:col-span-5' height={420} />
|
|
</div>
|
|
)
|
|
|
|
const OverviewContainer = () => {
|
|
const { t } = useTranslation('admin.overview')
|
|
const { t: tStrings } = useTranslation('strings')
|
|
const { data, error } = useOverviewSWR()
|
|
|
|
return (
|
|
<PageContentBlock title={tStrings('overview') ?? ''}>
|
|
<div className='mb-6 flex flex-wrap items-center justify-between gap-3'>
|
|
<div>
|
|
<h1 className='text-2xl font-semibold text-foreground'>
|
|
{tStrings('overview')}
|
|
</h1>
|
|
<p className='description-small mt-1'>
|
|
{t('description')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<MessageBox
|
|
type='error'
|
|
title={tStrings('error') ?? ''}
|
|
className='mb-6'
|
|
>
|
|
{t('load_error')}
|
|
</MessageBox>
|
|
)}
|
|
|
|
{!data ? (
|
|
<OverviewSkeleton />
|
|
) : (
|
|
<div className='grid grid-cols-12 gap-6'>
|
|
<StatCard
|
|
title={tStrings('server', { count: 2 })}
|
|
value={data.summary.servers}
|
|
detail={t('ready_installing_detail', {
|
|
ready: data.servers.ready,
|
|
installing: data.servers.installing,
|
|
})}
|
|
icon={ServerStackIcon}
|
|
to='/admin/servers'
|
|
/>
|
|
<StatCard
|
|
title={tStrings('node', { count: 2 })}
|
|
value={data.summary.nodes}
|
|
detail={t('nodes_locations_detail', {
|
|
count: data.summary.locations,
|
|
})}
|
|
icon={CpuChipIcon}
|
|
to='/admin/nodes'
|
|
/>
|
|
<StatCard
|
|
title={tStrings('user', { count: 2 })}
|
|
value={data.summary.users}
|
|
icon={UsersIcon}
|
|
to='/admin/users'
|
|
/>
|
|
<StatCard
|
|
title={t('attention')}
|
|
value={data.summary.failedServers}
|
|
detail={t('attention_detail', {
|
|
backups: data.backups.failed,
|
|
deleting: data.servers.deleting,
|
|
})}
|
|
icon={ExclamationTriangleIcon}
|
|
to='/admin/servers'
|
|
tone={
|
|
data.summary.failedServers > 0
|
|
? 'error'
|
|
: 'default'
|
|
}
|
|
/>
|
|
|
|
<Card className='col-span-12 lg:col-span-7'>
|
|
<div className='flex items-center justify-between gap-3'>
|
|
<div>
|
|
<h2 className='h5'>{t('capacity')}</h2>
|
|
<p className='description-small mt-1'>
|
|
{t('capacity_description')}
|
|
</p>
|
|
</div>
|
|
<CircleStackIcon className='h-5 w-5 text-accent-400' />
|
|
</div>
|
|
<div className='mt-6 grid gap-6'>
|
|
<UsageBar
|
|
label={tStrings('memory')}
|
|
metric={data.capacity.memory}
|
|
/>
|
|
<UsageBar
|
|
label={tStrings('disk')}
|
|
metric={data.capacity.disk}
|
|
/>
|
|
</div>
|
|
|
|
<div className='mt-8 grid gap-4 sm:grid-cols-3'>
|
|
<div className='border-l border-accent-200 py-1 pl-4'>
|
|
<p className='dt'>{t('addresses')}</p>
|
|
<p className='mt-2 text-xl font-semibold text-foreground'>
|
|
{data.addresses.assigned} /{' '}
|
|
{data.addresses.total}
|
|
</p>
|
|
<p className='description-small mt-1'>
|
|
{t('addresses_detail', {
|
|
available:
|
|
data.addresses.available,
|
|
pools: data.addresses.pools,
|
|
})}
|
|
</p>
|
|
</div>
|
|
<div className='border-l border-accent-200 py-1 pl-4'>
|
|
<p className='dt'>
|
|
{tStrings('backup', { count: 2 })}
|
|
</p>
|
|
<p className='mt-2 text-xl font-semibold text-foreground'>
|
|
{data.backups.successful} /{' '}
|
|
{data.backups.total}
|
|
</p>
|
|
<p className='description-small mt-1'>
|
|
{t('backup_status_detail', {
|
|
pending: data.backups.pending,
|
|
failed: data.backups.failed,
|
|
})}
|
|
</p>
|
|
</div>
|
|
<div className='border-l border-accent-200 py-1 pl-4'>
|
|
<p className='dt'>
|
|
{tStrings('iso', { count: 2 })}
|
|
</p>
|
|
<p className='mt-2 text-xl font-semibold text-foreground'>
|
|
{data.isos.successful} /{' '}
|
|
{data.isos.total}
|
|
</p>
|
|
<p className='description-small mt-1'>
|
|
{t('iso_status_detail', {
|
|
pending: data.isos.pending,
|
|
})}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className='col-span-12 lg:col-span-5'>
|
|
<div className='flex items-center justify-between gap-3'>
|
|
<div>
|
|
<h2 className='h5'>{t('server_state')}</h2>
|
|
<p className='description-small mt-1'>
|
|
{t('server_state_description')}
|
|
</p>
|
|
</div>
|
|
<SignalIcon className='h-5 w-5 text-accent-400' />
|
|
</div>
|
|
<div className='mt-6 grid grid-cols-2 gap-3'>
|
|
{[
|
|
[t('ready'), data.servers.ready],
|
|
[t('installing'), data.servers.installing],
|
|
[
|
|
tStrings('suspended'),
|
|
data.servers.suspended,
|
|
],
|
|
[t('restoring'), data.servers.restoring],
|
|
[t('deleting'), data.servers.deleting],
|
|
[t('failed'), data.servers.failed],
|
|
].map(([label, value]) => (
|
|
<div
|
|
key={label}
|
|
className='border-l border-accent-200 py-1 pl-4'
|
|
>
|
|
<p className='dt'>{label}</p>
|
|
<p className='mt-2 text-2xl font-semibold text-foreground'>
|
|
{value}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className='col-span-12'>
|
|
<h2 className='h5'>
|
|
{tStrings('node', { count: 2 })}
|
|
</h2>
|
|
<p className='description-small mt-1'>
|
|
{t('nodes_description')}
|
|
</p>
|
|
<div className='mt-2'>
|
|
{data.nodes.length === 0 ? (
|
|
<p className='description-small py-6 text-center'>
|
|
{t('no_nodes')}
|
|
</p>
|
|
) : (
|
|
data.nodes.map(node => (
|
|
<NodeRow key={node.id} node={node} />
|
|
))
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</PageContentBlock>
|
|
)
|
|
}
|
|
|
|
export default OverviewContainer
|