Files
EvoBGP/apps/web/src/routes/_auth/dashboard.tsx
T
Denozordec 452f6b2db0 feat: refactor components to utilize SelectField for improved UI consistency
Updated various components to replace traditional select implementations with the new SelectField component. This change enhances the user interface by providing a more consistent layout and improved accessibility. Additionally, refactored the dashboard and operations pages to utilize DataGridCard for better organization of content, streamlining the overall user experience.
2026-07-09 11:53:43 +07:00

275 lines
9.8 KiB
TypeScript

import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQueries } from '@tanstack/react-query'
import {
Boxes,
CheckCircle,
Clock,
GitBranch,
Info,
Radio,
RefreshCw,
Activity,
XCircle,
} from 'lucide-react'
import { useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@evobgp/ui/components/card'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { DataGridCard } from '@/components/data-grid-shell'
import { DashboardNetworkPanel } from '@/components/dashboard/dashboard-network-panel'
import { DashboardQuickActions } from '@/components/dashboard/dashboard-quick-actions'
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
import { PageHeader } from '@/components/page-header'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import {
aggregateNetworkMetrics,
moduleNameById,
overviewHealthQueryOptions,
overviewJobsQueryOptions,
overviewModulesQueryOptions,
overviewPeersQueryOptions,
overviewRevisionsQueryOptions,
overviewSpeakersQueryOptions,
runningJobCount,
} from '@/queries/overview'
export const Route = createFileRoute('/_auth/dashboard')({
component: DashboardComponent,
})
function DashboardComponent() {
const navigate = useNavigate()
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const results = useQueries({
queries: [
overviewHealthQueryOptions(),
overviewModulesQueryOptions(),
overviewPeersQueryOptions(),
overviewSpeakersQueryOptions(),
overviewRevisionsQueryOptions(),
overviewJobsQueryOptions(),
],
})
const [healthQ, modulesQ, peersQ, speakersQ, revisionsQ, jobsQ] = results
const initialLoading =
modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || revisionsQ.isLoading || jobsQ.isLoading
const refreshing = results.some((r) => r.isFetching && !r.isLoading)
if (!lastUpdated && !initialLoading && results.every((r) => r.isSuccess || r.isError)) {
setTimeout(() => setLastUpdated(new Date()), 0)
}
function refetchAll() {
setLastUpdated(null)
results.forEach((r) => r.refetch())
}
const modules = modulesQ.data?.items ?? []
const peers = peersQ.data?.items ?? []
const speakers = speakersQ.data?.items ?? []
const revisions = revisionsQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const modulesHasMore = modulesQ.data?.has_more ?? false
const peersHasMore = peersQ.data?.has_more ?? false
const speakersHasMore = speakersQ.data?.has_more ?? false
const revisionsHasMore = revisionsQ.data?.has_more ?? false
const net = aggregateNetworkMetrics(peers, speakers)
const running = runningJobCount(jobs)
const nameById = moduleNameById(modules)
const countBadge = (n: number, hasMore: boolean, suffix: string) => (hasMore ? '200+' : suffix)
const items: SectionCardItem[] = [
{
label: 'Модули',
value: initialLoading ? '—' : String(modules.length),
icon: <Boxes className="size-4" />,
hint: countBadge(modules.length, modulesHasMore, 'AS, CDN, домены, IP'),
onClick: () => navigate({ to: '/modules' }),
},
{
label: 'Пиры',
value: initialLoading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
icon: <GitBranch className="size-4" />,
hint: countBadge(peers.length, peersHasMore, 'Established / включённых'),
badge: net.peersMismatch > 0 ? `mismatch ${net.peersMismatch}` : undefined,
variant: net.peersMismatch > 0 ? 'warning' : 'default',
onClick: () => navigate({ to: '/network', search: { tab: 'peers' } }),
},
{
label: 'Спикеры',
value: initialLoading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
icon: <Radio className="size-4" />,
hint: countBadge(speakers.length, speakersHasMore, 'online / всего'),
variant: net.speakersOnline < net.speakersTotal ? 'warning' : 'default',
onClick: () => navigate({ to: '/network', search: { tab: 'overview' } }),
},
{
label: 'Ревизии',
value: initialLoading ? '—' : String(revisions.length),
icon: <Activity className="size-4" />,
hint: countBadge(revisions.length, revisionsHasMore, 'configs'),
onClick: () => navigate({ to: '/operations', search: { tab: 'revisions' } }),
},
{
label: 'Активных задач',
value: initialLoading ? '—' : String(running),
icon: <Clock className="size-4" />,
hint: 'queued и running',
onClick: () => navigate({ to: '/operations', search: { tab: 'jobs' } }),
},
]
const activityLoading =
refreshing && jobs.length === 0 && revisions.length === 0 && peers.length === 0 && speakers.length === 0
return (
<div className="flex flex-col gap-5">
<PageHeader
title="Обзор"
description={
lastUpdated
? `Состояние панели управления EvoBGP. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'Состояние панели управления EvoBGP.'
}
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Панель управления EvoBGP</AlertTitle>
<AlertDescription>
Сводка по модулям, сети и фоновым задачам. BGP и ноды «Сеть», префиксы «Модули»,
деплой «Операции», здоровье API «Мониторинг».
</AlertDescription>
</Alert>
<HealthAlert
loading={healthQ.isLoading}
ok={healthQ.data === true}
loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null}
/>
{initialLoading ? <SectionCardsSkeleton count={5} /> : <SectionCards items={items} className="gap-3" />}
<div className="grid gap-4 lg:grid-cols-3">
<DataGridCard
title="Недавние задачи"
description="Последние фоновые операции"
className="h-full"
>
{activityLoading ? (
<Skeleton className="m-3 h-24 w-auto" />
) : (
<DashboardRecentJobsGrid jobs={jobs} nameById={nameById} isLoading={refreshing} />
)}
</DataGridCard>
<DataGridCard
title="Последние ревизии"
description="История конфигураций"
className="h-full"
>
{activityLoading ? (
<Skeleton className="m-3 h-24 w-auto" />
) : (
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
)}
</DataGridCard>
<Card className="h-full gap-0">
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Состояние сети</CardTitle>
<CardDescription>BGP-сессии и спикеры</CardDescription>
</CardHeader>
<CardContent className="p-0">
{activityLoading ? (
<Skeleton className="m-3 h-24 w-auto" />
) : (
<DashboardNetworkPanel peers={peers} speakers={speakers} />
)}
</CardContent>
</Card>
</div>
<Card className="gap-0">
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Быстрые действия</CardTitle>
<CardDescription>Частые переходы к настройке и деплою</CardDescription>
</CardHeader>
<DashboardQuickActions />
</Card>
</div>
)
}
function HealthAlert({
loading,
ok,
loadError,
}: {
loading: boolean
ok: boolean | undefined
loadError: string | null
}) {
if (loading) {
return (
<Alert>
<Skeleton className="size-5 rounded-full" />
<AlertTitle>Проверка API</AlertTitle>
<AlertDescription>
Запрос к <code className="text-xs">/v1/health</code>
</AlertDescription>
</Alert>
)
}
if (ok && !loadError) {
return (
<Alert className="border-success/30 bg-success/5">
<CheckCircle className="text-success" />
<AlertTitle>API работает</AlertTitle>
<AlertDescription>Сервер отвечает на запросы health-check.</AlertDescription>
</Alert>
)
}
if (ok && loadError) {
return (
<Alert className="border-warning/30 bg-warning/5">
<Info className="text-warning" />
<AlertTitle>API доступен, данные не загружены</AlertTitle>
<AlertDescription>{loadError}. Проверьте Bearer-токен в «Настройках».</AlertDescription>
</Alert>
)
}
return (
<Alert variant="destructive" className="border-destructive/30 bg-destructive/5">
<XCircle className="text-destructive" />
<AlertTitle>API недоступен</AlertTitle>
<AlertDescription>
Не удалось получить ответ от сервера. Проверьте, что API запущен (порт 8080) и в dev работает
прокси Vite.
</AlertDescription>
</Alert>
)
}