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.
275 lines
9.8 KiB
TypeScript
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>
|
|
)
|
|
}
|