Files
panel/resources/scripts/components/admin/overview/OverviewContainer.tsx
2026-05-28 21:40:17 -04:00

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