Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2820cff988 | ||
|
|
54b7ea3bd5 |
@@ -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="Поиск спикеров…"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user