diff --git a/apps/web/src/components/network/network-kpi.tsx b/apps/web/src/components/network/network-kpi.tsx new file mode 100644 index 0000000..0568034 --- /dev/null +++ b/apps/web/src/components/network/network-kpi.tsx @@ -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: , + iconClassName: 'text-success', + label: 'Пиры Established', + value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`, + footer: ( + + {loading ? '…' : `${net.peersTotal} в каталоге`} + + ), + to: '/network', + search: { tab: 'peers' }, + }, + { + id: 'speakers-online', + icon: , + iconClassName: 'text-info', + label: 'Спикеры online', + value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`, + footer: ( + 0 + ? 'success-light' + : 'warning-light' + } + size="sm" + > + {loading ? '…' : 'live'} + + ), + to: '/network', + search: { tab: 'speakers' }, + }, + { + id: 'mismatches', + icon: , + iconClassName: net.peersMismatch > 0 ? 'text-warning' : 'text-muted-foreground', + label: 'Расхождения', + value: loading ? '—' : String(net.peersMismatch), + variant: net.peersMismatch > 0 ? 'warning' : 'default', + footer: ( + 0 ? 'warning-light' : 'outline'} size="sm"> + {loading ? '…' : net.peersMismatch > 0 ? 'проверить' : 'в норме'} + + ), + to: '/network', + search: { tab: 'peers' }, + }, + { + id: 'bird', + icon: , + iconClassName: + birdHealthy === false ? 'text-destructive' : 'text-primary', + label: 'BIRD', + value: loading ? '—' : birdSessions, + variant: birdHealthy === false ? 'destructive' : 'default', + footer: ( + + {loading + ? '…' + : birdHealthy === true + ? 'в норме' + : birdHealthy === false + ? 'проблема' + : 'н/д'} + + ), + }, + ] + + return ( +
+ +
+ ) +} diff --git a/apps/web/src/components/network/network-peers-card.tsx b/apps/web/src/components/network/network-peers-card.tsx index efe130f..99b8234 100644 --- a/apps/web/src/components/network/network-peers-card.tsx +++ b/apps/web/src/components/network/network-peers-card.tsx @@ -1,17 +1,76 @@ 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 { 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 { QueryState } from '@/components/query-state' -import { TableSkeleton } from '@/components/skeletons' +import { + 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' -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: , + type: 'text', + className: 'w-48', + placeholder: 'Поиск по имени…', + }, + { + key: 'neighbor', + label: 'Сосед', + icon: , + type: 'text', + className: 'w-48', + placeholder: 'Адрес соседа…', + }, + { + key: 'session_state', + label: 'Состояние', + icon: , + type: 'select', + searchable: false, + className: 'w-[160px]', + options: SESSION_STATE_OPTIONS, + customValueRenderer: (values) => + renderSingleSelectedLabel(values, SESSION_STATE_OPTIONS), + }, +] interface NetworkPeersCardProps { items: PeerRow[] @@ -22,24 +81,6 @@ interface NetworkPeersCardProps { 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({ items, speakers, @@ -49,48 +90,44 @@ export function NetworkPeersCard({ onRetry, }: NetworkPeersCardProps) { const [dialogOpen, setDialogOpen] = useState(false) - const [tab, setTab] = useState('all') - const counts = useMemo(() => tabCounts(items), [items]) - const filtered = useMemo(() => filterPeers(items, tab), [items, tab]) + const [filters, setFilters] = useState(createDefaultPeerFilters) + + const addButton = useMemo( + () => ( + + ), + [], + ) return ( <> - setDialogOpen(true)}> - - Добавить пира - - } - > -
- setTab(v as PeerTab)} className="w-full"> - - Все ({counts.all}) - Established ({counts.established}) - Ожидание ({counts.pending}) - Выключены ({counts.disabled}) - - -
- } - onRetry={onRetry} - > - {(data) => ( - 0} /> - )} - -
+ tabs={PEER_TABS} + tabFilter={peerTabFilter} + filterFields={peerFilterFields} + filters={filters} + onFiltersChange={setFilters} + onClearFilters={() => setFilters(createDefaultPeerFilters())} + getFilterFieldValue={getPeerFilterFieldValue} + columns={peerColumns} + data={items} + getRowId={(row) => row.id} + isLoading={isLoading} + isError={isError} + error={error instanceof Error ? error : null} + onRetry={onRetry} + primaryAction={addButton} + emptyState={{ + title: 'Нет пиров', + description: 'Добавьте первого BGP-соседа.', + action: addButton, + }} + /> diff --git a/apps/web/src/components/network/network-peers-grid.tsx b/apps/web/src/components/network/network-peers-grid.tsx index 7d0b28a..f064bf8 100644 --- a/apps/web/src/components/network/network-peers-grid.tsx +++ b/apps/web/src/components/network/network-peers-grid.tsx @@ -1,90 +1,77 @@ -import { ColumnDef } from '@tanstack/react-table' -import { useMemo } from 'react' +import type { ColumnDef } from '@tanstack/react-table' import { CategoryBadge } from '@/components/category-badge' import { DataGridPrimaryCell } from '@/components/data-grid-cell' -import { DataGridSection } from '@/components/data-grid-shell' import { StatusBadge } from '@/components/status-badge' 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 type { PeerRow } from '@/types/api' -export function NetworkPeersGrid({ - items, - isLoading = false, -}: { - items: PeerRow[] - isLoading?: boolean -}) { - const columns = useMemo[]>( - () => [ - { - id: 'name', - accessorFn: (row) => row.name ?? row.neighbor, - header: ({ column }) => , - cell: ({ row }) => ( - - ), - meta: { headerTitle: 'Имя' }, - }, - { - accessorKey: 'neighbor', - header: ({ column }) => , - cell: ({ row }) => ( - - ), - meta: { headerTitle: 'Адрес соседа' }, - }, - { - accessorKey: 'remote_asn', - header: ({ column }) => , - cell: ({ row }) => ( - {row.original.remote_asn ?? '—'} - ), - meta: { headerTitle: 'ASN' }, - }, - { - accessorKey: 'session_state', - header: ({ column }) => , - cell: ({ row }) => ( -
- - {row.original.session_mismatch ? ( - расхождение - ) : null} -
- ), - meta: { headerTitle: 'Состояние' }, - }, - ], - [], - ) +export const peerColumns: ColumnDef[] = [ + { + id: 'name', + accessorFn: (row) => row.name ?? row.neighbor, + header: ({ column }) => , + cell: ({ row }) => ( + + ), + meta: { headerTitle: 'Имя' }, + }, + { + accessorKey: 'neighbor', + header: ({ column }) => , + cell: ({ row }) => ( + + ), + meta: { headerTitle: 'Адрес соседа' }, + }, + { + accessorKey: 'remote_asn', + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.remote_asn ?? '—'} + ), + meta: { headerTitle: 'ASN' }, + }, + { + accessorKey: 'session_state', + header: ({ column }) => , + cell: ({ row }) => ( +
+ + {row.original.session_mismatch ? ( + расхождение + ) : null} +
+ ), + meta: { headerTitle: 'Состояние' }, + }, +] - const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({ - data: items, - columns, - getSearchText: (row) => - `${row.name ?? ''} ${row.neighbor} ${row.remote_asn ?? ''} ${row.session_state ?? ''}`, - getRowId: (row) => row.id, - }) +export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown { + switch (field) { + case 'name': + return item.name ?? item.neighbor + case 'neighbor': + return item.neighbor + case 'session_state': + return item.session_state + default: + return undefined + } +} - return ( - - ) +export function peerTabFilter(item: PeerRow, tabId: string): boolean { + if (tabId === 'disabled') return item.enabled === false + const enabled = item.enabled !== false + if (tabId === 'established') return enabled && item.session_state === 'Established' + if (tabId === 'pending') return enabled && item.session_state !== 'Established' + return true } diff --git a/apps/web/src/components/network/network-speakers-card.tsx b/apps/web/src/components/network/network-speakers-card.tsx index 288ad3f..4bca06f 100644 --- a/apps/web/src/components/network/network-speakers-card.tsx +++ b/apps/web/src/components/network/network-speakers-card.tsx @@ -1,15 +1,65 @@ -import { useState } from 'react' -import { Plus } from 'lucide-react' +import { useMemo, useState } from 'react' +import { Plus, SearchIcon, TagIcon } from 'lucide-react' 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 { QueryState } from '@/components/query-state' -import { TableSkeleton } from '@/components/skeletons' +import { + 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' +/** + * 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: , + type: 'text', + className: 'w-52', + placeholder: 'endpoint…', + }, + { + key: 'role', + label: 'Роль', + icon: , + type: 'select', + searchable: false, + className: 'w-[140px]', + options: ROLE_OPTIONS, + customValueRenderer: (values) => + renderSingleSelectedLabel(values, ROLE_OPTIONS), + }, +] + interface NetworkSpeakersCardProps { items: SpeakerRow[] isLoading: boolean @@ -26,35 +76,44 @@ export function NetworkSpeakersCard({ onRetry, }: NetworkSpeakersCardProps) { const [dialogOpen, setDialogOpen] = useState(false) + const [filters, setFilters] = useState(createDefaultSpeakerFilters) + + const addButton = useMemo( + () => ( + + ), + [], + ) return ( <> - setDialogOpen(true)}> - - Добавить спикера - - } - > - } - onRetry={onRetry} - > - {(data) => ( - 0} /> - )} - - + tabs={SPEAKER_TABS} + tabFilter={speakerTabFilter} + filterFields={speakerFilterFields} + filters={filters} + onFiltersChange={setFilters} + onClearFilters={() => setFilters(createDefaultSpeakerFilters())} + getFilterFieldValue={getSpeakerFilterFieldValue} + columns={speakerColumns} + data={items} + getRowId={(row) => row.id} + isLoading={isLoading} + isError={isError} + error={error instanceof Error ? error : null} + onRetry={onRetry} + primaryAction={addButton} + emptyState={{ + title: 'Нет спикеров', + description: 'Добавьте первого BIRD-агента на ноде.', + action: addButton, + }} + /> diff --git a/apps/web/src/components/network/network-speakers-grid.tsx b/apps/web/src/components/network/network-speakers-grid.tsx index f17cc9e..1713bae 100644 --- a/apps/web/src/components/network/network-speakers-grid.tsx +++ b/apps/web/src/components/network/network-speakers-grid.tsx @@ -1,87 +1,78 @@ -import { ColumnDef } from '@tanstack/react-table' -import { useMemo } from 'react' +import type { ColumnDef } from '@tanstack/react-table' import { CategoryBadge } from '@/components/category-badge' import { DataGridPrimaryCell } from '@/components/data-grid-cell' -import { DataGridSection } from '@/components/data-grid-shell' import { StatusBadge } from '@/components/status-badge' import { Badge } from '@/components/reui/badge' 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 type { SpeakerRow } from '@/types/api' -export function NetworkSpeakersGrid({ - items, - isLoading = false, -}: { - items: SpeakerRow[] - isLoading?: boolean -}) { - const columns = useMemo[]>( - () => [ - { - accessorKey: 'endpoint', - header: ({ column }) => , - cell: ({ row }) => ( - - ), - meta: { headerTitle: 'Конечная точка' }, - }, - { - accessorKey: 'role', - header: ({ column }) => , - cell: ({ row }) => {row.original.role}, - meta: { headerTitle: 'Роль' }, - }, - { - id: 'agent', - enableSorting: false, - header: 'Агент', - cell: ({ row }) => { - const live = row.original.live - if (live?.agent_ok === true) return - if (live?.agent_ok === false) return - return - }, - meta: { headerTitle: 'Агент' }, - }, - { - id: 'bgp', - enableSorting: false, - header: 'BGP', - cell: ({ row }) => { - const live = row.original.live - if (!live) return '—' - return ( - - {live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0} - - ) - }, - meta: { headerTitle: 'BGP' }, - }, - ], - [], - ) +export const speakerColumns: ColumnDef[] = [ + { + accessorKey: 'endpoint', + header: ({ column }) => , + cell: ({ row }) => ( + + ), + meta: { headerTitle: 'Конечная точка' }, + }, + { + accessorKey: 'role', + header: ({ column }) => , + cell: ({ row }) => {row.original.role}, + meta: { headerTitle: 'Роль' }, + }, + { + id: 'agent', + enableSorting: false, + header: 'Агент', + cell: ({ row }) => { + const live = row.original.live + if (live?.agent_ok === true) { + return + } + if (live?.agent_ok === false) { + return + } + return ( + + — + + ) + }, + meta: { headerTitle: 'Агент' }, + }, + { + id: 'bgp', + enableSorting: false, + header: 'BGP', + cell: ({ row }) => { + const live = row.original.live + if (!live) return '—' + return ( + + {live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0} + + ) + }, + meta: { headerTitle: 'BGP' }, + }, +] - const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({ - data: items, - columns, - getSearchText: (row) => - `${row.endpoint} ${row.role} ${row.agent_domain ?? ''} ${row.node_ipv4 ?? ''}`, - getRowId: (row) => row.id, - }) +export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown { + switch (field) { + case 'endpoint': + return item.endpoint + case 'role': + return item.role + default: + return undefined + } +} - return ( - - ) +export function speakerTabFilter(item: SpeakerRow, tabId: string): boolean { + if (tabId === 'online') return item.live?.agent_ok === true + if (tabId === 'offline') return item.live?.agent_ok !== true + return true } diff --git a/apps/web/src/routes/_auth/network.tsx b/apps/web/src/routes/_auth/network.tsx index 9c44579..6f10465 100644 --- a/apps/web/src/routes/_auth/network.tsx +++ b/apps/web/src/routes/_auth/network.tsx @@ -1,28 +1,38 @@ import { createFileRoute, useSearch } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' 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 { - DashboardNetworkCapacityCard, - NetworkOverviewAnalyticsCard, -} from '@/components/analytics' -import { BadgeTabs, TabsContent } from '@/components/badge-tabs' +import { CountedLineTabs } from '@/components/counted-line-tabs' +import { NetworkKpi } from '@/components/network/network-kpi' import { NetworkPeersCard } from '@/components/network/network-peers-card' import { NetworkSpeakersCard } from '@/components/network/network-speakers-card' import { PageHeader } from '@/components/page-header' -import { QueryState } from '@/components/query-state' -import { TableSkeleton } from '@/components/skeletons' -import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network' -import { overviewJobsQueryOptions } from '@/queries/overview' +import { + networkBirdQueryOptions, + networkPeersQueryOptions, + 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')({ component: NetworkComponent, validateSearch: (search: Record) => ({ - tab: (search.tab === 'peers' || search.tab === 'speakers' || search.tab === 'control-plane' - ? search.tab - : 'overview') as 'overview' | 'peers' | 'speakers' | 'control-plane', + tab: parseNetworkTab(search.tab), }), }) @@ -32,12 +42,10 @@ function NetworkComponent() { const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 }) const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), 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 speakers = speakersQ.data?.items ?? [] - const jobs = jobsQ.data?.items ?? [] const overviewLoading = peersQ.isLoading || speakersQ.isLoading function refetchAll() { @@ -47,121 +55,56 @@ function NetworkComponent() { } return ( -
+
- + Обновить } /> - + + - navigate({ - search: { - tab: tab as 'overview' | 'peers' | 'speakers' | 'control-plane', - }, - }) + navigate({ search: { tab: tab as NetworkTab } }) } - items={[ - { value: 'overview', label: 'Обзор' }, - { value: 'peers', label: 'Пиры', count: peers.length }, - { value: 'speakers', label: 'Спикеры', count: speakers.length, badgeVariant: 'info-light' }, - { value: 'control-plane', label: 'Плоскость управления' }, + tabs={[ + { id: 'peers', label: 'Пиры', count: peers.length }, + { id: 'speakers', label: 'Спикеры', count: speakers.length }, ]} > - -
- - -
- - } - onRetry={() => birdQ.refetch()} - > - {(bird) => } - - -
- - + peersQ.refetch()} + onRetry={() => void peersQ.refetch()} /> - + speakersQ.refetch()} + onRetry={() => void speakersQ.refetch()} /> - - - - См. раздел «Настройки BIRD». - - -
-
- ) -} - -function Field({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ) -} - -function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) { - return ( -
- - - {bird.message ?

{bird.message}

: null} - {bird.error ?

{bird.error}

: null} +
) }