feat(network): implement peer discovery features and UI integration
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 52s
CI / go (push) Successful in 2m22s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m36s

Added functionality for peer discovery, including new API endpoints for listing, approving, and rejecting discovered peers. Updated the network queries and settings to support peer discovery configurations. Enhanced the UI to display discovered peers and integrated related settings in the tenant settings component. Updated OpenAPI documentation to reflect the new endpoints and parameters. This improves the network management capabilities by allowing dynamic peer discovery and management.
This commit is contained in:
Denozordec
2026-07-30 22:31:59 +07:00
parent fd3a217cbe
commit 5fbe9c9b56
29 changed files with 1831 additions and 26 deletions
@@ -0,0 +1,274 @@
import { useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { CheckIcon, SearchIcon, XIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import {
createFilter,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
import { ResourcePage } from '@/components/reui-kit'
import { bgpSessionStateRu } from '@/lib/ui-labels'
import {
useApproveDiscoveredPeerMutation,
useRejectDiscoveredPeerMutation,
} from '@/queries/network'
import type { PeerDiscoveryRow, SpeakerRow } from '@/types/api'
/**
* Pending BGP peer discoveries — approve / reject.
* @see https://reui.io/preview/base/data-grid-filtering-2
* @see https://reui.io/preview/base/components/c-empty-1
*/
function speakerLabel(s: SpeakerRow): string {
if (s.role === 'master') {
const host = s.agent_domain ?? s.endpoint
return host ? `CP · ${host}` : 'CP (master)'
}
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}`
}
function createDefaultFilters(): Filter[] {
return [createFilter('neighbor', 'contains', [''])]
}
const filterFields: FilterFieldConfig[] = [
{
key: 'neighbor',
label: 'Сосед',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-48',
placeholder: 'IP или Neighbor ID…',
},
]
interface NetworkDiscoveredPeersCardProps {
items: PeerDiscoveryRow[]
speakers: SpeakerRow[]
isLoading: boolean
isError: boolean
error: unknown
onRetry: () => void
}
export function NetworkDiscoveredPeersCard({
items,
speakers,
isLoading,
isError,
error,
onRetry,
}: NetworkDiscoveredPeersCardProps) {
const approveMutation = useApproveDiscoveredPeerMutation()
const rejectMutation = useRejectDiscoveredPeerMutation()
const [filters, setFilters] = useState<Filter[]>(createDefaultFilters)
const [approveTarget, setApproveTarget] = useState<PeerDiscoveryRow | null>(null)
const [rejectTarget, setRejectTarget] = useState<PeerDiscoveryRow | null>(null)
const [name, setName] = useState('')
const [speakerId, setSpeakerId] = useState<string | null>(null)
const speakerItems = useMemo(
() => [
{ value: '', label: 'Авто (с ноды обнаружения)' },
...speakers.map((s) => ({ value: s.id, label: speakerLabel(s) })),
],
[speakers],
)
const columns = useMemo<ColumnDef<PeerDiscoveryRow, unknown>[]>(
() => [
{
id: 'neighbor_id',
accessorFn: (row) => row.neighbor_id || row.neighbor,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Neighbor ID" />
),
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.neighbor_id || '—'}
subtitle={row.original.neighbor}
accent="mono"
/>
),
meta: { headerTitle: 'Neighbor ID' },
},
{
accessorKey: 'remote_asn',
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.remote_asn || '—'}</span>
),
meta: { headerTitle: 'ASN' },
},
{
accessorKey: 'session_state',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Состояние" />
),
cell: ({ row }) => (
<StatusBadge
status={row.original.session_state ?? '—'}
label={bgpSessionStateRu(row.original.session_state)}
/>
),
meta: { headerTitle: 'Состояние' },
},
{
id: 'actions',
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-1">
<Button
size="sm"
type="button"
variant="outline"
onClick={() => {
setApproveTarget(row.original)
setName('')
setSpeakerId(row.original.speaker_id ?? null)
}}
>
<CheckIcon />
Одобрить
</Button>
<Button
size="sm"
type="button"
variant="ghost"
onClick={() => setRejectTarget(row.original)}
>
<XIcon />
Отклонить
</Button>
</div>
),
},
],
[],
)
function getFilterFieldValue(item: PeerDiscoveryRow, field: string): unknown {
if (field === 'neighbor') {
return `${item.neighbor_id ?? ''} ${item.neighbor}`
}
return undefined
}
async function confirmApprove() {
if (!approveTarget) return
await approveMutation.mutateAsync({
id: approveTarget.id,
body: {
name: name.trim() || undefined,
bgp_speaker_id: speakerId || null,
},
})
setApproveTarget(null)
}
return (
<>
<ResourcePage
title="На одобрение"
description="Новые BGP-клиенты, подключившиеся к dynamic listener (карантин без export)"
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultFilters())}
getFilterFieldValue={getFilterFieldValue}
columns={columns}
data={items}
getRowId={(row) => row.id}
isLoading={isLoading}
isError={isError}
error={error instanceof Error ? error : null}
onRetry={onRetry}
emptyState={{
title: 'Нет ожидающих пиров',
description:
'Включите peer discovery в параметрах BIRD и задайте CIDR-диапазоны. Новые сессии появятся здесь.',
}}
/>
<FormDrawer
open={!!approveTarget}
onOpenChange={(open) => {
if (!open) setApproveTarget(null)
}}
title="Одобрить пира"
description={
approveTarget
? `Neighbor ID ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
: undefined
}
className="sm:max-w-sm"
footer={
<>
<Button variant="outline" type="button" onClick={() => setApproveTarget(null)}>
Отмена
</Button>
<LoadingButton
type="button"
loading={approveMutation.isPending}
onClick={() => void confirmApprove()}
>
Одобрить
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="disc-name">Имя (опционально)</Label>
<Input
id="disc-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="client-edge-1"
/>
</div>
<SelectField
id="disc-speaker"
label="Спикер"
items={speakerItems}
value={speakerId ?? ''}
onValueChange={(v) => setSpeakerId(v || null)}
placeholder="Авто"
/>
</FormDrawer>
<ConfirmDialog
open={!!rejectTarget}
onOpenChange={(open) => {
if (!open) setRejectTarget(null)
}}
title="Отклонить пира?"
description={
rejectTarget
? `${rejectTarget.neighbor_id || rejectTarget.neighbor} больше не будет появляться в списке.`
: undefined
}
confirmLabel="Отклонить"
destructive
confirmLoading={rejectMutation.isPending}
onConfirm={() => {
if (!rejectTarget) return
void rejectMutation.mutateAsync(rejectTarget.id).then(() => setRejectTarget(null))
}}
/>
</>
)
}
+45
View File
@@ -8,6 +8,9 @@ import type {
BgpSpeakerCreate,
BgpSpeakerPatch,
BirdStatus,
PeerDiscoveryApprove,
PeerDiscoveriesResponse,
PeerDiscoveryRow,
PeerRow,
PeersResponse,
SpeakerRow,
@@ -19,6 +22,7 @@ export const NETWORK_AUTO_REFRESH_MS = 30_000
export const networkKeys = {
all: ['network'] as const,
peers: () => [...networkKeys.all, 'peers'] as const,
discovered: () => [...networkKeys.all, 'discovered'] as const,
speakers: () => [...networkKeys.all, 'speakers'] as const,
bird: () => [...networkKeys.all, 'bird'] as const,
}
@@ -31,6 +35,14 @@ export function networkPeersQueryOptions() {
})
}
export function networkDiscoveredPeersQueryOptions() {
return queryOptions<PeerDiscoveriesResponse>({
queryKey: networkKeys.discovered(),
queryFn: () => apiJSON<PeerDiscoveriesResponse>('/v1/peers/discovered?status=pending'),
staleTime: 10_000,
})
}
export function networkSpeakersQueryOptions() {
return queryOptions<SpeakersResponse>({
queryKey: networkKeys.speakers(),
@@ -91,6 +103,39 @@ export function useDeletePeerMutation() {
})
}
export function useApproveDiscoveredPeerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: string; body?: PeerDiscoveryApprove }) =>
apiMutate<{ peer: PeerRow; discovery: PeerDiscoveryRow }>(
`/v1/peers/discovered/${id}/approve`,
'POST',
body ?? {},
{ idempotent: false },
),
onSuccess: () => {
toast.success('Пир одобрен')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось одобрить пира'),
})
}
export function useRejectDiscoveredPeerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiMutate<PeerDiscoveryRow>(`/v1/peers/discovered/${id}/reject`, 'POST', {}, {
idempotent: false,
}),
onSuccess: () => {
toast.success('Пир отклонён')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отклонить пира'),
})
}
export function useCreateSpeakerMutation() {
const qc = useQueryClient()
return useMutation({
+14 -1
View File
@@ -10,6 +10,10 @@ export const BIRD_SETTING_KEYS = [
'bird_local_asn',
'bird_bgp_source_ipv4',
'bird_bgp_source_ipv6',
'peer_discovery_enabled',
'peer_discovery_ranges_v4',
'peer_discovery_ranges_v6',
'peer_discovery_require_external',
] as const
export const REVISION_SETTING_KEYS = ['revision_retention_minutes'] as const
@@ -38,7 +42,11 @@ export const NUMERIC_SETTING_KEYS = new Set<KnownSettingKey>([
'runtime_logs_max_file_mb',
])
export const BOOLEAN_SETTING_KEYS = new Set<KnownSettingKey>(['runtime_logs_auto_enabled'])
export const BOOLEAN_SETTING_KEYS = new Set<KnownSettingKey>([
'runtime_logs_auto_enabled',
'peer_discovery_enabled',
'peer_discovery_require_external',
])
export const settingsKeys = {
all: ['settings'] as const,
@@ -53,6 +61,11 @@ export function settingsQueryOptions() {
}
export function parseKnownValue(key: KnownSettingKey, value: unknown): string {
if (BOOLEAN_SETTING_KEYS.has(key)) {
if (value === true || value === 1 || value === 'true' || value === '1') return 'true'
if (value === false || value === 0 || value === 'false' || value === '0') return 'false'
return ''
}
if (NUMERIC_SETTING_KEYS.has(key)) {
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
if (typeof value === 'string') return value
+30 -5
View File
@@ -5,29 +5,32 @@ import { TabsContent } from '@evobgp/ui/components/tabs'
import { RefreshCw } from 'lucide-react'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { NetworkDiscoveredPeersCard } from '@/components/network/network-discovered-peers-card'
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 {
networkBirdQueryOptions,
networkDiscoveredPeersQueryOptions,
networkPeersQueryOptions,
networkSpeakersQueryOptions,
} from '@/queries/network'
type NetworkTab = 'peers' | 'speakers'
type NetworkTab = 'peers' | 'discovered' | 'speakers'
function parseNetworkTab(value: unknown): NetworkTab {
if (value === 'speakers') return 'speakers'
if (value === 'discovered') return 'discovered'
// legacy: overview | control-plane → peers
return 'peers'
}
/**
* Network ops page — KPI (stats-12) + peers/speakers ResourcePage lists.
* Network ops page — KPI (stats-12) + peers/discovered/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
* @see https://reui.io/preview/base/components/c-empty-1
*/
export const Route = createFileRoute('/_auth/network')({
component: NetworkComponent,
@@ -40,16 +43,26 @@ function NetworkComponent() {
const search = useSearch({ from: '/_auth/network' })
const navigate = Route.useNavigate()
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
const discoveredQ = useQuery({
...networkDiscoveredPeersQueryOptions(),
refetchInterval: 15_000,
})
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
const refreshing = peersQ.isFetching || speakersQ.isFetching || birdQ.isFetching
const refreshing =
peersQ.isFetching ||
discoveredQ.isFetching ||
speakersQ.isFetching ||
birdQ.isFetching
const peers = peersQ.data?.items ?? []
const discovered = discoveredQ.data?.items ?? []
const speakers = speakersQ.data?.items ?? []
const overviewLoading = peersQ.isLoading || speakersQ.isLoading
function refetchAll() {
void peersQ.refetch()
void discoveredQ.refetch()
void speakersQ.refetch()
void birdQ.refetch()
}
@@ -58,7 +71,7 @@ function NetworkComponent() {
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Сеть"
description="BGP-пиры, спикеры и статус BIRD"
description="BGP-пиры, автообнаружение и спикеры"
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : undefined} />
@@ -81,6 +94,7 @@ function NetworkComponent() {
}
tabs={[
{ id: 'peers', label: 'Пиры', count: peers.length },
{ id: 'discovered', label: 'На одобрение', count: discovered.length },
{ id: 'speakers', label: 'Спикеры', count: speakers.length },
]}
>
@@ -95,6 +109,17 @@ function NetworkComponent() {
/>
</TabsContent>
<TabsContent value="discovered" className="mt-4">
<NetworkDiscoveredPeersCard
items={discovered}
speakers={speakers}
isLoading={discoveredQ.isLoading}
isError={discoveredQ.isError}
error={discoveredQ.error}
onRetry={() => void discoveredQ.refetch()}
/>
</TabsContent>
<TabsContent value="speakers" className="mt-4">
<NetworkSpeakersCard
items={speakers}
+39 -7
View File
@@ -54,6 +54,23 @@ const BIRD_LABELS: Record<BirdSettingKey, string> = {
bird_local_asn: 'Локальный ASN',
bird_bgp_source_ipv4: 'BGP source IPv4',
bird_bgp_source_ipv6: 'BGP source IPv6',
peer_discovery_enabled: 'Автообнаружение пиров',
peer_discovery_ranges_v4: 'Discovery CIDR IPv4',
peer_discovery_ranges_v6: 'Discovery CIDR IPv6',
peer_discovery_require_external: 'Только external ASN',
}
const BIRD_BOOL_ITEMS = [
{ value: 'true', label: 'Вкл' },
{ value: 'false', label: 'Выкл' },
] as const
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
peer_discovery_enabled:
'Dynamic neighbor range в BIRD (карантин import/export none). Требует CIDR.',
peer_discovery_ranges_v4: 'Через запятую или пробел, напр. 198.51.100.0/24 203.0.113.0/24',
peer_discovery_ranges_v6: 'Опционально, напр. 2001:db8::/32',
peer_discovery_require_external: 'neighbor range … external (любой чужой ASN)',
}
function TenantSettingsComponent() {
@@ -143,17 +160,32 @@ function TenantSettingsComponent() {
<SettingsSettingField
key={key}
title={BIRD_LABELS[key]}
description={key}
description={BIRD_HINTS[key] ?? key}
labelFor={key}
badge={{ label: 'BIRD', variant: 'info-light' }}
last={index === BIRD_SETTING_KEYS.length - 1}
>
<Input
id={key}
value={birdForm[key] ?? ''}
onChange={(e) => setBirdForm((s) => ({ ...s, [key]: e.target.value }))}
placeholder={BIRD_LABELS[key]}
/>
{key === 'peer_discovery_enabled' ||
key === 'peer_discovery_require_external' ? (
<SelectField
id={key}
items={[...BIRD_BOOL_ITEMS]}
value={birdForm[key] || 'false'}
onValueChange={(v) =>
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
}
placeholder="Выкл"
/>
) : (
<Input
id={key}
value={birdForm[key] ?? ''}
onChange={(e) =>
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
}
placeholder={BIRD_LABELS[key]}
/>
)}
</SettingsSettingField>
))}
<div className="px-4 py-4">
+26
View File
@@ -221,10 +221,36 @@ export type BgpPeerCreate = {
}
export type BgpPeerPatch = Partial<BgpPeerCreate>
export type PeerDiscoveryRow = {
id: string
speaker_id?: string | null
neighbor_id?: string
neighbor: string
remote_asn?: number
protocol_name?: string
session_state?: string
status: 'pending' | 'approved' | 'rejected' | string
first_seen_at?: string
last_seen_at?: string
approved_peer_id?: string | null
}
export type PeerDiscoveriesResponse = {
items: PeerDiscoveryRow[]
}
export type PeerDiscoveryApprove = {
name?: string
bgp_speaker_id?: string | null
enabled?: boolean
}
// ---- Speakers ----
export type BgpSessionLive = {
name: string
neighbor?: string
neighbor_as?: number
neighbor_id?: string
state: string
}