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]) const addButton = useMemo(
() => (
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить пира
</Button>
),
[],
)
return ( return (
<> <>
<DataGridCard <ResourcePage
title="Пиры" title="Пиры"
description="BGP-соседи и привязка к спикерам" description="BGP-соседи и привязка к спикерам"
actions={ tabs={PEER_TABS}
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}> tabFilter={peerTabFilter}
<Plus /> filterFields={peerFilterFields}
Добавить пира filters={filters}
</Button> onFiltersChange={setFilters}
} onClearFilters={() => setFilters(createDefaultPeerFilters())}
> getFilterFieldValue={getPeerFilterFieldValue}
<div className="px-5 pt-3"> columns={peerColumns}
<Tabs value={tab} onValueChange={(v) => setTab(v as PeerTab)} className="w-full"> data={items}
<TabsList variant="line" className="w-full justify-start gap-6"> getRowId={(row) => row.id}
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger> isLoading={isLoading}
<TabsTrigger value="established">Established ({counts.established})</TabsTrigger> isError={isError}
<TabsTrigger value="pending">Ожидание ({counts.pending})</TabsTrigger> error={error instanceof Error ? error : null}
<TabsTrigger value="disabled">Выключены ({counts.disabled})</TabsTrigger> onRetry={onRetry}
</TabsList> primaryAction={addButton}
</Tabs> emptyState={{
</div> title: 'Нет пиров',
<QueryState description: 'Добавьте первого BGP-соседа.',
data={filtered} action: addButton,
isLoading={isLoading} }}
isError={isError} />
error={error}
empty={filtered.length === 0}
emptyTitle="Нет пиров в выборке"
emptyDescription="Измените фильтр или добавьте BGP-соседа."
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={onRetry}
>
{(data) => (
<NetworkPeersGrid items={data} isLoading={isLoading && data.length > 0} />
)}
</QueryState>
</DataGridCard>
<PeerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} speakers={speakers} /> <PeerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} speakers={speakers} />
</> </>
@@ -1,90 +1,77 @@
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, id: 'name',
}: { accessorFn: (row) => row.name ?? row.neighbor,
items: PeerRow[] header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
isLoading?: boolean cell: ({ row }) => (
}) { <DataGridPrimaryCell
const columns = useMemo<ColumnDef<PeerRow>[]>( title={row.original.name ?? row.original.neighbor}
() => [ subtitle={row.original.name ? row.original.neighbor : undefined}
{ accent="primary"
id: 'name', />
accessorFn: (row) => row.name ?? row.neighbor, ),
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />, meta: { headerTitle: 'Имя' },
cell: ({ row }) => ( },
<DataGridPrimaryCell {
title={row.original.name ?? row.original.neighbor} accessorKey: 'neighbor',
subtitle={row.original.name ? row.original.neighbor : undefined} header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
accent="primary" cell: ({ row }) => (
/> <DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
), ),
meta: { headerTitle: 'Имя' }, meta: { headerTitle: 'Адрес соседа' },
}, },
{ {
accessorKey: 'neighbor', accessorKey: 'remote_asn',
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />, header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" /> <span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
), ),
meta: { headerTitle: 'Адрес соседа' }, meta: { headerTitle: 'ASN' },
}, },
{ {
accessorKey: 'remote_asn', accessorKey: 'session_state',
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span> <div className="flex items-center gap-1">
), <StatusBadge
meta: { headerTitle: 'ASN' }, status={row.original.session_state ?? ''}
}, label={bgpSessionStateRu(row.original.session_state)}
{ />
accessorKey: 'session_state', {row.original.session_mismatch ? (
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />, <CategoryBadge tone="warning">расхождение</CategoryBadge>
cell: ({ row }) => ( ) : null}
<div className="flex items-center gap-1"> </div>
<StatusBadge ),
status={row.original.session_state ?? '—'} meta: { headerTitle: 'Состояние' },
label={bgpSessionStateRu(row.original.session_state)} },
/> ]
{row.original.session_mismatch ? (
<CategoryBadge tone="warning">расхождение</CategoryBadge>
) : null}
</div>
),
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)
const addButton = useMemo(
() => (
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить спикера
</Button>
),
[],
)
return ( return (
<> <>
<DataGridCard <ResourcePage
title="Спикеры" title="Спикеры"
description="BIRD-агенты на нодах tenant" description="BIRD-агенты на нодах tenant"
actions={ tabs={SPEAKER_TABS}
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}> tabFilter={speakerTabFilter}
<Plus /> filterFields={speakerFilterFields}
Добавить спикера filters={filters}
</Button> onFiltersChange={setFilters}
} onClearFilters={() => setFilters(createDefaultSpeakerFilters())}
> getFilterFieldValue={getSpeakerFilterFieldValue}
<QueryState columns={speakerColumns}
data={items} data={items}
isLoading={isLoading} getRowId={(row) => row.id}
isError={isError} isLoading={isLoading}
error={error} isError={isError}
empty={items.length === 0} error={error instanceof Error ? error : null}
emptyTitle="Нет спикеров" onRetry={onRetry}
emptyDescription="Добавьте первого BIRD-агента на ноде." primaryAction={addButton}
skeleton={<TableSkeleton rows={6} cols={4} />} emptyState={{
onRetry={onRetry} title: 'Нет спикеров',
> description: 'Добавьте первого BIRD-агента на ноде.',
{(data) => ( action: addButton,
<NetworkSpeakersGrid items={data} isLoading={isLoading && data.length > 0} /> }}
)} />
</QueryState>
</DataGridCard>
<SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} /> <SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</> </>
@@ -1,87 +1,78 @@
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, accessorKey: 'endpoint',
}: { header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
items: SpeakerRow[] cell: ({ row }) => (
isLoading?: boolean <DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
}) { ),
const columns = useMemo<ColumnDef<SpeakerRow>[]>( meta: { headerTitle: 'Конечная точка' },
() => [ },
{ {
accessorKey: 'endpoint', accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => ( cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" /> meta: { headerTitle: 'Роль' },
), },
meta: { headerTitle: 'Конечная точка' }, {
}, id: 'agent',
{ enableSorting: false,
accessorKey: 'role', header: 'Агент',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />, cell: ({ row }) => {
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>, const live = row.original.live
meta: { headerTitle: 'Роль' }, if (live?.agent_ok === true) {
}, return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
{ }
id: 'agent', if (live?.agent_ok === false) {
enableSorting: false, return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
header: 'Агент', }
cell: ({ row }) => { return (
const live = row.original.live <Badge variant="outline" size="sm" radius="full">
if (live?.agent_ok === true) return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
if (live?.agent_ok === false) return <StatusBadge status="error" label={speakerOnlineLabel(false)} /> </Badge>
return <Badge variant="outline" size="sm" radius="full"></Badge> )
}, },
meta: { headerTitle: 'Агент' }, meta: { headerTitle: 'Агент' },
}, },
{ {
id: 'bgp', id: 'bgp',
enableSorting: false, enableSorting: false,
header: 'BGP', header: 'BGP',
cell: ({ row }) => { cell: ({ row }) => {
const live = row.original.live const live = row.original.live
if (!live) return '—' if (!live) return '—'
return ( return (
<span className="text-xs"> <span className="text-xs">
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0} {live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
</span> </span>
) )
}, },
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>
) )
} }