Compare commits

...
2 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
DenozordecandCursor 54b7ea3bd5 feat(lookup): resolve domain to IPs for membership check
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 1m6s
CI / go (push) Successful in 57s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m6s
Для FQDN после проверки DOMAINS выполняется live DNS (A/AAAA), каждый IP проверяется по IP_RANGES и snapshots; в ответе resolved_ips / resolved_ip, UI KPI и OpenAPI обновлены.

Co-authored-by: Cursor <[email protected]>
2026-07-18 01:01:54 +07:00
13 changed files with 667 additions and 369 deletions
@@ -57,7 +57,11 @@ export function LookupMatchesGrid({
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridPrimaryCell <DataGridPrimaryCell
title={row.original.matched_value} title={row.original.matched_value}
subtitle={row.original.match_kind} subtitle={
row.original.resolved_ip
? `${row.original.match_kind} · via ${row.original.resolved_ip}`
: row.original.match_kind
}
accent="mono" accent="mono"
/> />
), ),
@@ -1,4 +1,4 @@
import { Layers, ListChecks, Radar } from 'lucide-react' import { Globe, Layers, ListChecks, Radar } from 'lucide-react'
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit' import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import type { LookupResponse } from '@/types/api' import type { LookupResponse } from '@/types/api'
@@ -10,6 +10,7 @@ import type { LookupResponse } from '@/types/api'
export function LookupSummaryKpi({ data }: { data: LookupResponse }) { export function LookupSummaryKpi({ data }: { data: LookupResponse }) {
const entryCount = data.matches.filter((m) => m.layer === 'entry').length const entryCount = data.matches.filter((m) => m.layer === 'entry').length
const snapshotCount = data.matches.filter((m) => m.layer === 'snapshot').length const snapshotCount = data.matches.filter((m) => m.layer === 'snapshot').length
const resolvedCount = data.resolved_ips?.length ?? 0
const items: KpiStatItem[] = [ const items: KpiStatItem[] = [
{ {
@@ -41,6 +42,17 @@ export function LookupSummaryKpi({ data }: { data: LookupResponse }) {
}, },
] ]
if (data.query_kind === 'domain') {
items.push({
id: 'resolved',
label: 'DNS IP',
value: resolvedCount,
hint: resolvedCount > 0 ? data.resolved_ips?.slice(0, 3).join(', ') : 'нет A/AAAA',
icon: <Globe aria-hidden />,
iconClassName: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
})
}
return ( return (
<KpiStatGrid <KpiStatGrid
items={items} items={items}
@@ -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>
) )
} }
+2
View File
@@ -169,6 +169,7 @@ export type LookupMatch = {
community_id?: string | null community_id?: string | null
community?: string community?: string
community_title?: string community_title?: string
resolved_ip?: string
} }
export type LookupResponse = { export type LookupResponse = {
@@ -178,6 +179,7 @@ export type LookupResponse = {
matched: boolean matched: boolean
match_count: number match_count: number
matches: LookupMatch[] matches: LookupMatch[]
resolved_ips?: string[]
} }
// ---- Peers ---- // ---- Peers ----
+15 -2
View File
@@ -813,6 +813,11 @@ components:
community_title: community_title:
type: string type: string
description: Человекочитаемое название community. description: Человекочитаемое название community.
resolved_ip:
type: string
description: |
IP, полученный DNS-resolve domain-запроса, из-за которого появился этот матч.
Пусто для прямого IP-запроса и для FQDN entry/snapshot без resolve.
LookupResponse: LookupResponse:
type: object type: object
@@ -842,6 +847,11 @@ components:
type: array type: array
items: items:
$ref: "#/components/schemas/LookupMatch" $ref: "#/components/schemas/LookupMatch"
resolved_ips:
type: array
description: IP-адреса после live DNS resolve (только для query_kind=domain; A/AAAA).
items:
type: string
BgpPeer: BgpPeer:
type: object type: object
@@ -1882,12 +1892,15 @@ paths:
- **IP** — слой `entry` (`IP_RANGES`, `CIDR.Contains`) и слой `snapshot` - **IP** — слой `entry` (`IP_RANGES`, `CIDR.Contains`) и слой `snapshot`
(все module prefix snapshots, `Prefix.Contains`); (все module prefix snapshots, `Prefix.Contains`);
- **Domain** — слой `entry` (нормализованный FQDN в `DOMAINS`) и слой `snapshot` - **Domain** — слой `entry` (нормализованный FQDN в `DOMAINS`) и слой `snapshot`
(префиксы `source=domain` у matched DOMAINS-модулей, если snapshot есть). (префиксы `source=domain` у matched DOMAINS-модулей, если snapshot есть);
затем **live DNS resolve** (A/AAAA через системный резолвер) и проверка
каждого полученного IP так же, как для IP-запроса (ranges + все snapshots).
Community на матче: `entry.community_id || module.default_community_id` (entry) Community на матче: `entry.community_id || module.default_community_id` (entry)
или `PrefixRow.community_id` (snapshot), с join к справочнику communities. или `PrefixRow.community_id` (snapshot), с join к справочнику communities.
Live DoH resolve не выполняется только уже материализованный snapshot. Поля `resolved_ips` / `resolved_ip` заполняются только для domain-запросов
(после успешного DNS). Ошибка DNS не даёт 5xx: FQDN-слой всё равно возвращается.
operationId: lookupMembership operationId: lookupMembership
parameters: parameters:
- $ref: "#/components/parameters/TenantId" - $ref: "#/components/parameters/TenantId"
+1 -1
View File
@@ -24,7 +24,7 @@ func (s *Server) handleLookup(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "query parameter q is required") writeProblem(w, http.StatusBadRequest, "Bad Request", "query parameter q is required")
return return
} }
res, err := lookup.Lookup(s.store, a.TenantID, q) res, err := lookup.Lookup(r.Context(), s.store, a.TenantID, q)
if err != nil { if err != nil {
if errors.Is(err, store.ErrInvalidInput) { if errors.Is(err, store.ErrInvalidInput) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "query must be an IP address or FQDN") writeProblem(w, http.StatusBadRequest, "Bad Request", "query must be an IP address or FQDN")
+89 -10
View File
@@ -3,7 +3,9 @@
package lookup package lookup
import ( import (
"context"
"fmt" "fmt"
"net"
"net/netip" "net/netip"
"strings" "strings"
"unicode" "unicode"
@@ -49,20 +51,37 @@ type Match struct {
CommunityID *string `json:"community_id,omitempty"` CommunityID *string `json:"community_id,omitempty"`
Community string `json:"community,omitempty"` Community string `json:"community,omitempty"`
CommunityTitle string `json:"community_title,omitempty"` CommunityTitle string `json:"community_title,omitempty"`
// ResolvedIP is set when the hit came from a DNS-resolved address of a domain query.
ResolvedIP string `json:"resolved_ip,omitempty"`
} }
// Result is the full lookup response payload. // Result is the full lookup response payload.
type Result struct { type Result struct {
Query string `json:"query"` Query string `json:"query"`
QueryKind QueryKind `json:"query_kind"` QueryKind QueryKind `json:"query_kind"`
Normalized string `json:"normalized"` Normalized string `json:"normalized"`
Matched bool `json:"matched"` Matched bool `json:"matched"`
MatchCount int `json:"match_count"` MatchCount int `json:"match_count"`
Matches []Match `json:"matches"` Matches []Match `json:"matches"`
ResolvedIPs []string `json:"resolved_ips,omitempty"`
} }
// DomainResolver resolves a hostname to IP addresses (A/AAAA).
type DomainResolver func(ctx context.Context, host string) ([]netip.Addr, error)
// Lookup checks whether q (IP or FQDN) is present in tenant lists (entries + snapshots). // Lookup checks whether q (IP or FQDN) is present in tenant lists (entries + snapshots).
func Lookup(st store.Backend, tenantID, q string) (*Result, error) { // For domains, FQDN membership is checked first, then live DNS resolve and IP membership.
func Lookup(ctx context.Context, st store.Backend, tenantID, q string) (*Result, error) {
return LookupWithResolver(ctx, st, tenantID, q, systemDNSResolver)
}
// LookupWithResolver is like Lookup but uses resolve for domain→IP (tests / alternate DNS).
func LookupWithResolver(
ctx context.Context,
st store.Backend,
tenantID, q string,
resolve DomainResolver,
) (*Result, error) {
raw := strings.TrimSpace(q) raw := strings.TrimSpace(q)
if raw == "" { if raw == "" {
return nil, fmt.Errorf("%w: empty query", store.ErrInvalidInput) return nil, fmt.Errorf("%w: empty query", store.ErrInvalidInput)
@@ -87,7 +106,7 @@ func Lookup(st store.Backend, tenantID, q string) (*Result, error) {
if addr, err := netip.ParseAddr(raw); err == nil { if addr, err := netip.ParseAddr(raw); err == nil {
out.QueryKind = KindIP out.QueryKind = KindIP
out.Normalized = addr.String() out.Normalized = addr.String()
if err := lookupIP(st, tenantID, addr, out, commByID); err != nil { if err := lookupIP(st, tenantID, addr, out, commByID, ""); err != nil {
return nil, err return nil, err
} }
} else { } else {
@@ -100,6 +119,12 @@ func Lookup(st store.Backend, tenantID, q string) (*Result, error) {
if err := lookupDomain(st, tenantID, fqdn, out, commByID); err != nil { if err := lookupDomain(st, tenantID, fqdn, out, commByID); err != nil {
return nil, err return nil, err
} }
if resolve == nil {
resolve = systemDNSResolver
}
if err := lookupResolvedIPs(ctx, st, tenantID, fqdn, out, commByID, resolve); err != nil {
return nil, err
}
} }
out.MatchCount = len(out.Matches) out.MatchCount = len(out.Matches)
@@ -107,7 +132,59 @@ func Lookup(st store.Backend, tenantID, q string) (*Result, error) {
return out, nil return out, nil
} }
func lookupIP(st store.Backend, tenantID string, addr netip.Addr, out *Result, commByID map[string]*store.Community) error { func systemDNSResolver(ctx context.Context, host string) ([]netip.Addr, error) {
ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return nil, err
}
return uniqAddrs(ips), nil
}
func uniqAddrs(in []netip.Addr) []netip.Addr {
seen := make(map[netip.Addr]struct{}, len(in))
out := make([]netip.Addr, 0, len(in))
for _, a := range in {
a = a.Unmap()
if _, ok := seen[a]; ok {
continue
}
seen[a] = struct{}{}
out = append(out, a)
}
return out
}
func lookupResolvedIPs(
ctx context.Context,
st store.Backend,
tenantID, fqdn string,
out *Result,
commByID map[string]*store.Community,
resolve DomainResolver,
) error {
ips, err := resolve(ctx, fqdn)
if err != nil {
// DNS failure must not hide FQDN-layer matches already collected.
return nil
}
out.ResolvedIPs = make([]string, 0, len(ips))
for _, ip := range ips {
out.ResolvedIPs = append(out.ResolvedIPs, ip.String())
if err := lookupIP(st, tenantID, ip, out, commByID, ip.String()); err != nil {
return err
}
}
return nil
}
func lookupIP(
st store.Backend,
tenantID string,
addr netip.Addr,
out *Result,
commByID map[string]*store.Community,
resolvedIP string,
) error {
for _, mod := range st.ListModules(tenantID) { for _, mod := range st.ListModules(tenantID) {
if mod == nil { if mod == nil {
continue continue
@@ -137,6 +214,7 @@ func lookupIP(st store.Backend, tenantID string, addr netip.Addr, out *Result, c
MatchedValue: e.Prefix, MatchedValue: e.Prefix,
EntryID: e.ID, EntryID: e.ID,
CommunityID: resolveCommunityID(e.CommunityID, mod.DefaultCommunityID), CommunityID: resolveCommunityID(e.CommunityID, mod.DefaultCommunityID),
ResolvedIP: resolvedIP,
}, commByID)) }, commByID))
} }
} }
@@ -165,6 +243,7 @@ func lookupIP(st store.Backend, tenantID string, addr netip.Addr, out *Result, c
MatchedValue: row.Prefix, MatchedValue: row.Prefix,
Source: row.Source, Source: row.Source,
CommunityID: row.CommunityID, CommunityID: row.CommunityID,
ResolvedIP: resolvedIP,
}, commByID)) }, commByID))
} }
} }
@@ -218,7 +297,7 @@ func lookupDomain(st store.Backend, tenantID, fqdn string, out *Result, commByID
} }
out.Matches = append(out.Matches, decorateMatch(Match{ out.Matches = append(out.Matches, decorateMatch(Match{
Layer: LayerSnapshot, Layer: LayerSnapshot,
ModuleID: mod.ID, ModuleID: mid,
ModuleName: mod.Name, ModuleName: mod.Name,
ModuleType: mod.Type, ModuleType: mod.Type,
MatchKind: MatchPrefix, MatchKind: MatchPrefix,
+63 -7
View File
@@ -1,7 +1,9 @@
package lookup package lookup
import ( import (
"context"
"errors" "errors"
"net/netip"
"testing" "testing"
"evobgp/internal/store" "evobgp/internal/store"
@@ -39,7 +41,7 @@ func TestLookupIPEntryAndSnapshot(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
res, err := Lookup(m, tenant, "203.0.113.10") res, err := Lookup(context.Background(), m, tenant, "203.0.113.10")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -80,7 +82,7 @@ func TestLookupIPCommunityFallback(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
res, err := Lookup(m, tenant, "10.1.2.3") res, err := Lookup(context.Background(), m, tenant, "10.1.2.3")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -134,7 +136,8 @@ func TestLookupDomainEntryAndSnapshot(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
res, err := Lookup(m, tenant, "example.com") noDNS := func(context.Context, string) ([]netip.Addr, error) { return nil, nil }
res, err := LookupWithResolver(context.Background(), m, tenant, "example.com", noDNS)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -165,12 +168,64 @@ func TestLookupDomainEntryAndSnapshot(t *testing.T) {
} }
} }
func TestLookupDomainResolvedIPAgainstRanges(t *testing.T) {
m := store.NewMemory()
m.SeedDemo()
tenant, _, modIP, _, _ := m.DemoIDs()
comms, _ := m.ListCommunities(tenant)
cid := comms[0].ID
if _, err := m.UpdateModule(tenant, modIP, &store.ModulePatch{DefaultCommunityID: &cid}); err != nil {
t.Fatal(err)
}
if _, err := m.CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{
Prefix: "203.0.113.0/24",
CommunityID: &cid,
}); err != nil {
t.Fatal(err)
}
if err := m.SetModulePrefixSnapshot(tenant, modIP, "hash-r", []store.PrefixRow{
{Prefix: "203.0.113.0/24", CommunityID: &cid, Source: "ip_range"},
}); err != nil {
t.Fatal(err)
}
fake := func(_ context.Context, host string) ([]netip.Addr, error) {
if host != "google.com" {
t.Fatalf("unexpected host %q", host)
}
return []netip.Addr{netip.MustParseAddr("203.0.113.50")}, nil
}
res, err := LookupWithResolver(context.Background(), m, tenant, "google.com", fake)
if err != nil {
t.Fatal(err)
}
if res.QueryKind != KindDomain {
t.Fatalf("kind: %+v", res)
}
if len(res.ResolvedIPs) != 1 || res.ResolvedIPs[0] != "203.0.113.50" {
t.Fatalf("resolved_ips: %+v", res.ResolvedIPs)
}
if !res.Matched {
t.Fatalf("expected IP membership via resolve, got %+v", res)
}
var viaResolve bool
for _, hit := range res.Matches {
if hit.ResolvedIP == "203.0.113.50" && hit.MatchedValue == "203.0.113.0/24" {
viaResolve = true
}
}
if !viaResolve {
t.Fatalf("missing resolved-ip match: %+v", res.Matches)
}
}
func TestLookupNoMatch(t *testing.T) { func TestLookupNoMatch(t *testing.T) {
m := store.NewMemory() m := store.NewMemory()
m.SeedDemo() m.SeedDemo()
tenant, _, _, _, _ := m.DemoIDs() tenant, _, _, _, _ := m.DemoIDs()
res, err := Lookup(m, tenant, "192.0.2.1") res, err := Lookup(context.Background(), m, tenant, "192.0.2.1")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -183,16 +238,17 @@ func TestLookupInvalid(t *testing.T) {
m := store.NewMemory() m := store.NewMemory()
m.SeedDemo() m.SeedDemo()
tenant, _, _, _, _ := m.DemoIDs() tenant, _, _, _, _ := m.DemoIDs()
ctx := context.Background()
_, err := Lookup(m, tenant, "") _, err := Lookup(ctx, m, tenant, "")
if !errors.Is(err, store.ErrInvalidInput) { if !errors.Is(err, store.ErrInvalidInput) {
t.Fatalf("empty: %v", err) t.Fatalf("empty: %v", err)
} }
_, err = Lookup(m, tenant, "not a host") _, err = Lookup(ctx, m, tenant, "not a host")
if !errors.Is(err, store.ErrInvalidInput) { if !errors.Is(err, store.ErrInvalidInput) {
t.Fatalf("spaces: %v", err) t.Fatalf("spaces: %v", err)
} }
_, err = Lookup(m, tenant, "localhost") _, err = Lookup(ctx, m, tenant, "localhost")
if !errors.Is(err, store.ErrInvalidInput) { if !errors.Is(err, store.ErrInvalidInput) {
t.Fatalf("single label: %v", err) t.Fatalf("single label: %v", err)
} }