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
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:
@@ -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))
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user