Compare commits

..
1 Commits
Author SHA1 Message Date
DenozordecandCursor 2820cff988 refactor(web): streamline /network page with ReUI kit
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / web (push) Successful in 1m14s
CI / release (push) Successful in 4m23s
Сжать экран «Сеть»: KPI (stats-12) и вкладки Пиры/Спикеры через ResourcePage; убраны дубли charts и заглушка control-plane.

Co-authored-by: Cursor <[email protected]>
2026-07-18 01:12:42 +07:00
6 changed files with 479 additions and 347 deletions
@@ -0,0 +1,115 @@
import { AlertTriangle, Network, ServerCog, Share2 } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { KpiStatGrid, type KpiStatCardData } from '@/components/reui-kit'
import { aggregateNetworkMetrics } from '@/queries/overview'
import type { BirdStatus, PeerRow, SpeakerRow } from '@/types/api'
/**
* Network page KPI strip.
* @see https://reui.io/preview/base/stats-12
*/
export function NetworkKpi({
peers,
speakers,
bird,
loading,
}: {
peers: PeerRow[]
speakers: SpeakerRow[]
bird?: BirdStatus
loading?: boolean
}) {
const net = aggregateNetworkMetrics(peers, speakers)
const birdHealthy = bird?.healthy
const birdSessions =
bird != null ? `${bird.bgp_established} / ${bird.bgp_sessions_total}` : '—'
const cards: KpiStatCardData[] = [
{
id: 'peers-established',
icon: <Share2 aria-hidden />,
iconClassName: 'text-success',
label: 'Пиры Established',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
footer: (
<Badge variant="success-light" size="sm">
{loading ? '…' : `${net.peersTotal} в каталоге`}
</Badge>
),
to: '/network',
search: { tab: 'peers' },
},
{
id: 'speakers-online',
icon: <ServerCog aria-hidden />,
iconClassName: 'text-info',
label: 'Спикеры online',
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
footer: (
<Badge
variant={
net.speakersOnline === net.speakersTotal && net.speakersTotal > 0
? 'success-light'
: 'warning-light'
}
size="sm"
>
{loading ? '…' : 'live'}
</Badge>
),
to: '/network',
search: { tab: 'speakers' },
},
{
id: 'mismatches',
icon: <AlertTriangle aria-hidden />,
iconClassName: net.peersMismatch > 0 ? 'text-warning' : 'text-muted-foreground',
label: 'Расхождения',
value: loading ? '—' : String(net.peersMismatch),
variant: net.peersMismatch > 0 ? 'warning' : 'default',
footer: (
<Badge variant={net.peersMismatch > 0 ? 'warning-light' : 'outline'} size="sm">
{loading ? '…' : net.peersMismatch > 0 ? 'проверить' : 'в норме'}
</Badge>
),
to: '/network',
search: { tab: 'peers' },
},
{
id: 'bird',
icon: <Network aria-hidden />,
iconClassName:
birdHealthy === false ? 'text-destructive' : 'text-primary',
label: 'BIRD',
value: loading ? '—' : birdSessions,
variant: birdHealthy === false ? 'destructive' : 'default',
footer: (
<Badge
variant={
birdHealthy === true
? 'success-light'
: birdHealthy === false
? 'destructive-light'
: 'outline'
}
size="sm"
>
{loading
? '…'
: birdHealthy === true
? 'в норме'
: birdHealthy === false
? 'проблема'
: 'н/д'}
</Badge>
),
},
]
return (
<section aria-label="Ключевые метрики сети">
<KpiStatGrid cards={cards} isLoading={loading} skeletonCount={4} />
</section>
)
}
@@ -1,17 +1,76 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Plus } from 'lucide-react' import { Plus, SearchIcon, ActivityIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
import { PeerFormDialog } from '@/components/network/peer-form-dialog' import { PeerFormDialog } from '@/components/network/peer-form-dialog'
import { QueryState } from '@/components/query-state' import {
import { TableSkeleton } from '@/components/skeletons' getPeerFilterFieldValue,
peerColumns,
peerTabFilter,
} from '@/components/network/network-peers-grid'
import {
createFilter,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
import type { PeerRow, SpeakerRow } from '@/types/api' import type { PeerRow, SpeakerRow } from '@/types/api'
type PeerTab = 'all' | 'established' | 'pending' | 'disabled' /**
* BGP peers list — ResourcePage (Frame + tabs + Filters + DataGrid).
* @see https://reui.io/preview/base/data-grid-filtering-2
*/
const PEER_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'established', label: 'Established' },
{ id: 'pending', label: 'Ожидание' },
{ id: 'disabled', label: 'Выключены' },
]
const SESSION_STATE_OPTIONS = [
{ value: 'Established', label: 'Established' },
{ value: 'Idle', label: 'Idle' },
{ value: 'Active', label: 'Active' },
{ value: 'Connect', label: 'Connect' },
{ value: 'OpenSent', label: 'OpenSent' },
{ value: 'OpenConfirm', label: 'OpenConfirm' },
]
function createDefaultPeerFilters(): Filter[] {
return [createFilter('name', 'contains', [''])]
}
const peerFilterFields: FilterFieldConfig[] = [
{
key: 'name',
label: 'Имя',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-48',
placeholder: 'Поиск по имени…',
},
{
key: 'neighbor',
label: 'Сосед',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-48',
placeholder: 'Адрес соседа…',
},
{
key: 'session_state',
label: 'Состояние',
icon: <ActivityIcon className="size-3.5" aria-hidden />,
type: 'select',
searchable: false,
className: 'w-[160px]',
options: SESSION_STATE_OPTIONS,
customValueRenderer: (values) =>
renderSingleSelectedLabel(values, SESSION_STATE_OPTIONS),
},
]
interface NetworkPeersCardProps { interface NetworkPeersCardProps {
items: PeerRow[] items: PeerRow[]
@@ -22,24 +81,6 @@ interface NetworkPeersCardProps {
onRetry: () => void onRetry: () => void
} }
function filterPeers(items: PeerRow[], tab: PeerTab): PeerRow[] {
if (tab === 'all') return items
if (tab === 'disabled') return items.filter((p) => p.enabled === false)
const enabled = items.filter((p) => p.enabled !== false)
if (tab === 'established') return enabled.filter((p) => p.session_state === 'Established')
return enabled.filter((p) => p.session_state !== 'Established')
}
function tabCounts(items: PeerRow[]) {
const enabled = items.filter((p) => p.enabled !== false)
return {
all: items.length,
established: enabled.filter((p) => p.session_state === 'Established').length,
pending: enabled.filter((p) => p.session_state !== 'Established').length,
disabled: items.length - enabled.length,
}
}
export function NetworkPeersCard({ export function NetworkPeersCard({
items, items,
speakers, speakers,
@@ -49,48 +90,44 @@ export function NetworkPeersCard({
onRetry, onRetry,
}: NetworkPeersCardProps) { }: NetworkPeersCardProps) {
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [tab, setTab] = useState<PeerTab>('all') const [filters, setFilters] = useState<Filter[]>(createDefaultPeerFilters)
const counts = useMemo(() => tabCounts(items), [items])
const filtered = useMemo(() => filterPeers(items, tab), [items, tab])
return ( const addButton = useMemo(
<> () => (
<DataGridCard
title="Пиры"
description="BGP-соседи и привязка к спикерам"
actions={
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}> <Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus /> <Plus />
Добавить пира Добавить пира
</Button> </Button>
} ),
> [],
<div className="px-5 pt-3"> )
<Tabs value={tab} onValueChange={(v) => setTab(v as PeerTab)} className="w-full">
<TabsList variant="line" className="w-full justify-start gap-6"> return (
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger> <>
<TabsTrigger value="established">Established ({counts.established})</TabsTrigger> <ResourcePage
<TabsTrigger value="pending">Ожидание ({counts.pending})</TabsTrigger> title="Пиры"
<TabsTrigger value="disabled">Выключены ({counts.disabled})</TabsTrigger> description="BGP-соседи и привязка к спикерам"
</TabsList> tabs={PEER_TABS}
</Tabs> tabFilter={peerTabFilter}
</div> filterFields={peerFilterFields}
<QueryState filters={filters}
data={filtered} onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultPeerFilters())}
getFilterFieldValue={getPeerFilterFieldValue}
columns={peerColumns}
data={items}
getRowId={(row) => row.id}
isLoading={isLoading} isLoading={isLoading}
isError={isError} isError={isError}
error={error} error={error instanceof Error ? error : null}
empty={filtered.length === 0}
emptyTitle="Нет пиров в выборке"
emptyDescription="Измените фильтр или добавьте BGP-соседа."
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={onRetry} onRetry={onRetry}
> primaryAction={addButton}
{(data) => ( emptyState={{
<NetworkPeersGrid items={data} isLoading={isLoading && data.length > 0} /> title: 'Нет пиров',
)} description: 'Добавьте первого BGP-соседа.',
</QueryState> action: addButton,
</DataGridCard> }}
/>
<PeerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} speakers={speakers} /> <PeerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} speakers={speakers} />
</> </>
@@ -1,24 +1,13 @@
import { ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge' import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { bgpSessionStateRu } from '@/lib/ui-labels' import { bgpSessionStateRu } from '@/lib/ui-labels'
import type { PeerRow } from '@/types/api' import type { PeerRow } from '@/types/api'
export function NetworkPeersGrid({ export const peerColumns: ColumnDef<PeerRow, unknown>[] = [
items,
isLoading = false,
}: {
items: PeerRow[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<PeerRow>[]>(
() => [
{ {
id: 'name', id: 'name',
accessorFn: (row) => row.name ?? row.neighbor, accessorFn: (row) => row.name ?? row.neighbor,
@@ -64,27 +53,25 @@ export function NetworkPeersGrid({
), ),
meta: { headerTitle: 'Состояние' }, meta: { headerTitle: 'Состояние' },
}, },
], ]
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({ export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
data: items, switch (field) {
columns, case 'name':
getSearchText: (row) => return item.name ?? item.neighbor
`${row.name ?? ''} ${row.neighbor} ${row.remote_asn ?? ''} ${row.session_state ?? ''}`, case 'neighbor':
getRowId: (row) => row.id, return item.neighbor
}) case 'session_state':
return item.session_state
default:
return undefined
}
}
return ( export function peerTabFilter(item: PeerRow, tabId: string): boolean {
<DataGridSection if (tabId === 'disabled') return item.enabled === false
table={table} const enabled = item.enabled !== false
recordCount={filteredCount} if (tabId === 'established') return enabled && item.session_state === 'Established'
isLoading={isLoading} if (tabId === 'pending') return enabled && item.session_state !== 'Established'
emptyMessage="Нет пиров" return true
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск пиров…"
/>
)
} }
@@ -1,15 +1,65 @@
import { useState } from 'react' import { useMemo, useState } from 'react'
import { Plus } from 'lucide-react' import { Plus, SearchIcon, TagIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid'
import { SpeakerFormDialog } from '@/components/network/speaker-form-dialog' import { SpeakerFormDialog } from '@/components/network/speaker-form-dialog'
import { QueryState } from '@/components/query-state' import {
import { TableSkeleton } from '@/components/skeletons' getSpeakerFilterFieldValue,
speakerColumns,
speakerTabFilter,
} from '@/components/network/network-speakers-grid'
import {
createFilter,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
import type { SpeakerRow } from '@/types/api' import type { SpeakerRow } from '@/types/api'
/**
* BGP speakers list — ResourcePage (Frame + tabs + Filters + DataGrid).
* @see https://reui.io/preview/base/data-grid-filtering-2
*/
const SPEAKER_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'online', label: 'Online' },
{ id: 'offline', label: 'Offline' },
]
const ROLE_OPTIONS = [
{ value: 'primary', label: 'primary' },
{ value: 'secondary', label: 'secondary' },
{ value: 'speaker', label: 'speaker' },
]
function createDefaultSpeakerFilters(): Filter[] {
return [createFilter('endpoint', 'contains', [''])]
}
const speakerFilterFields: FilterFieldConfig[] = [
{
key: 'endpoint',
label: 'Конечная точка',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'endpoint…',
},
{
key: 'role',
label: 'Роль',
icon: <TagIcon className="size-3.5" aria-hidden />,
type: 'select',
searchable: false,
className: 'w-[140px]',
options: ROLE_OPTIONS,
customValueRenderer: (values) =>
renderSingleSelectedLabel(values, ROLE_OPTIONS),
},
]
interface NetworkSpeakersCardProps { interface NetworkSpeakersCardProps {
items: SpeakerRow[] items: SpeakerRow[]
isLoading: boolean isLoading: boolean
@@ -26,35 +76,44 @@ export function NetworkSpeakersCard({
onRetry, onRetry,
}: NetworkSpeakersCardProps) { }: NetworkSpeakersCardProps) {
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [filters, setFilters] = useState<Filter[]>(createDefaultSpeakerFilters)
return ( const addButton = useMemo(
<> () => (
<DataGridCard
title="Спикеры"
description="BIRD-агенты на нодах tenant"
actions={
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}> <Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus /> <Plus />
Добавить спикера Добавить спикера
</Button> </Button>
} ),
> [],
<QueryState )
return (
<>
<ResourcePage
title="Спикеры"
description="BIRD-агенты на нодах tenant"
tabs={SPEAKER_TABS}
tabFilter={speakerTabFilter}
filterFields={speakerFilterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultSpeakerFilters())}
getFilterFieldValue={getSpeakerFilterFieldValue}
columns={speakerColumns}
data={items} data={items}
getRowId={(row) => row.id}
isLoading={isLoading} isLoading={isLoading}
isError={isError} isError={isError}
error={error} error={error instanceof Error ? error : null}
empty={items.length === 0}
emptyTitle="Нет спикеров"
emptyDescription="Добавьте первого BIRD-агента на ноде."
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={onRetry} onRetry={onRetry}
> primaryAction={addButton}
{(data) => ( emptyState={{
<NetworkSpeakersGrid items={data} isLoading={isLoading && data.length > 0} /> title: 'Нет спикеров',
)} description: 'Добавьте первого BIRD-агента на ноде.',
</QueryState> action: addButton,
</DataGridCard> }}
/>
<SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} /> <SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</> </>
@@ -1,25 +1,14 @@
import { ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge' import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { speakerOnlineLabel } from '@/lib/ui-labels' import { speakerOnlineLabel } from '@/lib/ui-labels'
import type { SpeakerRow } from '@/types/api' import type { SpeakerRow } from '@/types/api'
export function NetworkSpeakersGrid({ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
items,
isLoading = false,
}: {
items: SpeakerRow[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<SpeakerRow>[]>(
() => [
{ {
accessorKey: 'endpoint', accessorKey: 'endpoint',
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
@@ -40,9 +29,17 @@ export function NetworkSpeakersGrid({
header: 'Агент', header: 'Агент',
cell: ({ row }) => { cell: ({ row }) => {
const live = row.original.live const live = row.original.live
if (live?.agent_ok === true) return <StatusBadge status="ok" label={speakerOnlineLabel(true)} /> if (live?.agent_ok === true) {
if (live?.agent_ok === false) return <StatusBadge status="error" label={speakerOnlineLabel(false)} /> return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
return <Badge variant="outline" size="sm" radius="full"></Badge> }
if (live?.agent_ok === false) {
return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
}
return (
<Badge variant="outline" size="sm" radius="full">
</Badge>
)
}, },
meta: { headerTitle: 'Агент' }, meta: { headerTitle: 'Агент' },
}, },
@@ -61,27 +58,21 @@ export function NetworkSpeakersGrid({
}, },
meta: { headerTitle: 'BGP' }, meta: { headerTitle: 'BGP' },
}, },
], ]
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({ export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
data: items, switch (field) {
columns, case 'endpoint':
getSearchText: (row) => return item.endpoint
`${row.endpoint} ${row.role} ${row.agent_domain ?? ''} ${row.node_ipv4 ?? ''}`, case 'role':
getRowId: (row) => row.id, return item.role
}) default:
return undefined
}
}
return ( export function speakerTabFilter(item: SpeakerRow, tabId: string): boolean {
<DataGridSection if (tabId === 'online') return item.live?.agent_ok === true
table={table} if (tabId === 'offline') return item.live?.agent_ok !== true
recordCount={filteredCount} return true
isLoading={isLoading}
emptyMessage="Нет спикеров"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск спикеров…"
/>
)
} }
+44 -101
View File
@@ -1,28 +1,38 @@
import { createFileRoute, useSearch } from '@tanstack/react-router' import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { PanelCard } from '@/components/panel-card' import { TabsContent } from '@evobgp/ui/components/tabs'
import { RefreshCw } from 'lucide-react' import { RefreshCw } from 'lucide-react'
import { import { CountedLineTabs } from '@/components/counted-line-tabs'
DashboardNetworkCapacityCard, import { NetworkKpi } from '@/components/network/network-kpi'
NetworkOverviewAnalyticsCard,
} from '@/components/analytics'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { NetworkPeersCard } from '@/components/network/network-peers-card' import { NetworkPeersCard } from '@/components/network/network-peers-card'
import { NetworkSpeakersCard } from '@/components/network/network-speakers-card' import { NetworkSpeakersCard } from '@/components/network/network-speakers-card'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state' import {
import { TableSkeleton } from '@/components/skeletons' networkBirdQueryOptions,
import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network' networkPeersQueryOptions,
import { overviewJobsQueryOptions } from '@/queries/overview' networkSpeakersQueryOptions,
} from '@/queries/network'
type NetworkTab = 'peers' | 'speakers'
function parseNetworkTab(value: unknown): NetworkTab {
if (value === 'speakers') return 'speakers'
// legacy: overview | control-plane → peers
return 'peers'
}
/**
* Network ops page — KPI (stats-12) + peers/speakers ResourcePage lists.
* @see https://reui.io/preview/base/stats-12
* @see https://reui.io/preview/base/data-grid-filtering-2
* @see https://reui.io/preview/base/empty-state-12
*/
export const Route = createFileRoute('/_auth/network')({ export const Route = createFileRoute('/_auth/network')({
component: NetworkComponent, component: NetworkComponent,
validateSearch: (search: Record<string, unknown>) => ({ validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab === 'peers' || search.tab === 'speakers' || search.tab === 'control-plane' tab: parseNetworkTab(search.tab),
? search.tab
: 'overview') as 'overview' | 'peers' | 'speakers' | 'control-plane',
}), }),
}) })
@@ -32,12 +42,10 @@ function NetworkComponent() {
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 }) const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 }) const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 }) const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
const jobsQ = useQuery(overviewJobsQueryOptions())
const refreshing = peersQ.isFetching || speakersQ.isFetching const refreshing = peersQ.isFetching || speakersQ.isFetching || birdQ.isFetching
const peers = peersQ.data?.items ?? [] const peers = peersQ.data?.items ?? []
const speakers = speakersQ.data?.items ?? [] const speakers = speakersQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const overviewLoading = peersQ.isLoading || speakersQ.isLoading const overviewLoading = peersQ.isLoading || speakersQ.isLoading
function refetchAll() { function refetchAll() {
@@ -47,121 +55,56 @@ function NetworkComponent() {
} }
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-4 md:gap-6">
<PageHeader <PageHeader
title="Сеть" title="Сеть"
description="BGP-пиры, спикеры и live-метрики нод" description="BGP-пиры, спикеры и статус BIRD"
actions={ actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}> <Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} /> <RefreshCw className={refreshing ? 'animate-spin' : undefined} />
Обновить Обновить
</Button> </Button>
} }
/> />
<BadgeTabs <NetworkKpi
peers={peers}
speakers={speakers}
bird={birdQ.data}
loading={overviewLoading || birdQ.isLoading}
/>
<CountedLineTabs
value={search.tab} value={search.tab}
onValueChange={(tab) => onValueChange={(tab) =>
navigate({ navigate({ search: { tab: tab as NetworkTab } })
search: {
tab: tab as 'overview' | 'peers' | 'speakers' | 'control-plane',
},
})
} }
items={[ tabs={[
{ value: 'overview', label: 'Обзор' }, { id: 'peers', label: 'Пиры', count: peers.length },
{ value: 'peers', label: 'Пиры', count: peers.length }, { id: 'speakers', label: 'Спикеры', count: speakers.length },
{ value: 'speakers', label: 'Спикеры', count: speakers.length, badgeVariant: 'info-light' },
{ value: 'control-plane', label: 'Плоскость управления' },
]} ]}
> >
<TabsContent value="overview" className="mt-0"> <TabsContent value="peers" className="mt-4">
<div className="grid gap-4 lg:grid-cols-2">
<NetworkOverviewAnalyticsCard
peers={peers}
speakers={speakers}
loading={overviewLoading}
/>
<DashboardNetworkCapacityCard
peers={peers}
speakers={speakers}
jobs={jobs}
loading={overviewLoading}
/>
</div>
<PanelCard
className="mt-4"
title="BIRD (control plane)"
description="Статус birdc на хосте API"
contentClassName="py-4"
>
<QueryState
data={birdQ.data}
isLoading={birdQ.isLoading}
isError={birdQ.isError}
error={birdQ.error}
skeleton={<TableSkeleton rows={3} cols={2} />}
onRetry={() => birdQ.refetch()}
>
{(bird) => <BirdSummary bird={bird} />}
</QueryState>
</PanelCard>
</TabsContent>
<TabsContent value="peers" className="mt-0">
<NetworkPeersCard <NetworkPeersCard
items={peers} items={peers}
speakers={speakers} speakers={speakers}
isLoading={peersQ.isLoading} isLoading={peersQ.isLoading}
isError={peersQ.isError} isError={peersQ.isError}
error={peersQ.error} error={peersQ.error}
onRetry={() => peersQ.refetch()} onRetry={() => void peersQ.refetch()}
/> />
</TabsContent> </TabsContent>
<TabsContent value="speakers" className="mt-0"> <TabsContent value="speakers" className="mt-4">
<NetworkSpeakersCard <NetworkSpeakersCard
items={speakers} items={speakers}
isLoading={speakersQ.isLoading} isLoading={speakersQ.isLoading}
isError={speakersQ.isError} isError={speakersQ.isError}
error={speakersQ.error} error={speakersQ.error}
onRetry={() => speakersQ.refetch()} onRetry={() => void speakersQ.refetch()}
/> />
</TabsContent> </TabsContent>
</CountedLineTabs>
<TabsContent value="control-plane" className="mt-0">
<PanelCard
title="Настройки Control Plane (BIRD)"
description="Конфигурация tenant-level — в разделе «Настройки BIRD»"
contentClassName="py-4 text-sm text-muted-foreground"
>
См. раздел «Настройки BIRD».
</PanelCard>
</TabsContent>
</BadgeTabs>
</div>
)
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{value}</span>
</div>
)
}
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
return (
<div className="flex flex-col gap-2 text-sm">
<Field
label="Состояние"
value={bird.healthy === true ? 'В норме' : bird.healthy === false ? 'Проблема' : 'Н/Д'}
/>
<Field label="Сессий BGP" value={`${bird.bgp_established} / ${bird.bgp_sessions_total}`} />
{bird.message ? <p className="text-xs text-muted-foreground">{bird.message}</p> : null}
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
</div> </div>
) )
} }